import AppKit import SwiftTerm import Combine /// Wraps a single SwiftTerm TerminalView + LocalProcess pair. @MainActor class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate { let id = UUID() let terminalView: TerminalView private var process: LocalProcess? @Published var title: String = "shell" @Published var isRunning: Bool = true @Published var currentDirectory: String? init(fontSize: CGFloat) { terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) super.init() terminalView.terminalDelegate = self // Solid black — matches every other element in the notch. // The single `.opacity(notchOpacity)` on ContentView makes // everything uniformly transparent. terminalView.nativeBackgroundColor = .black terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0) let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) terminalView.font = font startShell() } // MARK: - Shell management private func startShell() { let shellPath = resolveShell() let shellName = (shellPath as NSString).lastPathComponent let loginExecName = "-\(shellName)" FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory()) let proc = LocalProcess(delegate: self) // Launch as a login shell so user startup files initialize PATH/tools. proc.startProcess( executable: shellPath, args: ["-l"], environment: nil, execName: loginExecName ) process = proc title = shellName } private func resolveShell() -> String { let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? "" if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) { return custom } return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" } func updateFontSize(_ size: CGFloat) { terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } func terminate() { process?.terminate() process = nil isRunning = false } // MARK: - LocalProcessDelegate nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) { Task { @MainActor in self.isRunning = false } } nonisolated func dataReceived(slice: ArraySlice) { let data = slice Task { @MainActor in self.terminalView.feed(byteArray: data) } } nonisolated func getWindowSize() -> winsize { var ws = winsize() ws.ws_col = 80 ws.ws_row = 24 return ws } // MARK: - TerminalViewDelegate func send(source: TerminalView, data: ArraySlice) { process?.send(data: data) } func setTerminalTitle(source: TerminalView, title: String) { self.title = title.isEmpty ? "shell" : title } func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { guard newCols > 0, newRows > 0 else { return } guard let proc = process else { return } let fd = proc.childfd guard fd >= 0 else { return } var ws = winsize() ws.ws_col = UInt16(newCols) ws.ws_row = UInt16(newRows) _ = ioctl(fd, TIOCSWINSZ, &ws) } func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { currentDirectory = directory } func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} func clipboardCopy(source: TerminalView, content: Data) { NSPasteboard.general.clearContents() NSPasteboard.general.setData(content, forType: .string) } func requestOpenLink(source: TerminalView, link: String, params: [String : String]) { if let url = URL(string: link) { NSWorkspace.shared.open(url) } } func bell(source: TerminalView) { NSSound.beep() } func iTermContent(source: TerminalView, content: ArraySlice) {} }