import Foundation import Combine import Darwin import AppKit enum PTYState: Equatable { case never case running case done(exitCode: Int32?, message: String) } /// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY). /// - stdin/stdout: PTY-Slave (interaktiv, TTY bleibt erhalten) /// - stderr: PIPE — macOS verwirft ungelesene PTY-Daten beim Slave-Close, /// dadurch gingen Fehlermeldungen bei Instant-Exit (z. B. Code 255) verloren. /// Pipes liefern gepufferte Daten garantiert aus (Bugfix, siehe docs/ERRORS.md #8). /// /// FD-Regeln: masterFD wird NUR auf dem Main-Thread vergeben/geschlossen, /// auf ioQueue nur gelesen (EBADF-tolerant). Kein Close auf ioQueue. final class PTYSession: ObservableObject { @Published private(set) var lines: [[StyledText]] = [[]] @Published private(set) var state: PTYState = .never @Published private(set) var autoLogin = false var executable = "" var arguments: [String] = [] var customEnv: [String: String] = [:] /// Fingerprint der Konfiguration (TerminalManager) — bei Änderung wird neu konfiguriert. var configTag = "" private var masterFD: Int32 = -1 private var process: Process? private var readSource: DispatchSourceRead? private var errPipe: Pipe? private let ioQueue = DispatchQueue(label: "RemminaMac.pty") /// Raw output hook for the terminal emulator (SwiftTerm feeds on this). /// Called on ioQueue for every chunk from pty master and stderr pipe. var onData: ((Data) -> Void)? private var parser = AnsiParser() private var finished = false private var epoch = 0 // nur Main-Thread private let maxLines = 2000 var isRunning: Bool { if case .running = state { return true } return false } var plainText: String { lines.map { $0.map(\.text).joined() }.joined(separator: "\n") } func configure(executable: String, arguments: [String]) { self.executable = executable self.arguments = arguments } func setAutoLogin(_ value: Bool) { autoLogin = value } func start() { cleanupSync() epoch += 1 finished = false parser = AnsiParser() state = .running lines = [[]] let master = posix_openpt(O_RDWR | O_NOCTTY) guard master >= 0, grantpt(master) == 0, unlockpt(master) == 0, let cName = ptsname(master), !String(cString: cName).isEmpty else { finish(message: "PTY konnte nicht geöffnet werden (errno \(errno)).", code: nil) return } let slavePath = String(cString: cName) let slave = open(slavePath, O_RDWR | O_NOCTTY) guard slave >= 0 else { close(master) finish(message: "PTY-Slave \(slavePath) ließ sich nicht öffnen.", code: nil) return } masterFD = master // Ctty-Helper (gleiches Verzeichnis wie dieses Binary — .app wie .build): // Gibt ssh ein echtes Controlling-Terminal, damit Passwort- und // Yes/No-Prompts funktionieren (/dev/tty statt stiller Abbrüche). // Ohne Helper: Legacy-Direktstart (Prompts teils stumm). let pipe = Pipe() let p = Process() if let helper = Self.cttyHelperURL() { p.executableURL = helper p.arguments = [slavePath, executable] + arguments } else { p.executableURL = URL(fileURLWithPath: executable) p.arguments = arguments DispatchQueue.main.async { [weak self] in self?.note("Hinweis: ohne ctty-run-Helper (eingeschränkte Prompts).") } } var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" } for (k, v) in customEnv { env[k] = v } p.environment = env // closeOnDealloc=false: Parent schließt slave explizit nach run(). p.standardInput = FileHandle(fileDescriptor: slave) p.standardOutput = FileHandle(fileDescriptor: slave) p.standardError = pipe.fileHandleForWriting let myEpoch = epoch p.terminationHandler = { [weak self] proc in DispatchQueue.main.async { [weak self] in guard let self, self.epoch == myEpoch else { return } self.ioQueue.async { [weak self] in self?.finalFlush(code: proc.terminationStatus, myEpoch: myEpoch) } } } do { try p.run() } catch { close(slave); close(master); masterFD = -1 errPipe = nil finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil) return } process = p close(slave) // Parent behält nur Master errPipe = pipe pipe.fileHandleForReading.readabilityHandler = { [weak self] h in let data = h.availableData guard let self else { return } if data.isEmpty { h.readabilityHandler = nil // EOF return } self.onData?(data) self.ioQueue.async { [weak self] in guard let self else { return } let events = self.parser.feed(data) DispatchQueue.main.async { self.apply(events) } } } let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue) source.setEventHandler { [weak self] in self?.drain() } // Kein Close hier (Eigentum: Main-Thread) — nur Cancel. readSource = source source.resume() } private func drain() { let fd = masterFD guard fd >= 0 else { return } var buf = [UInt8](repeating: 0, count: 8192) let n = read(fd, &buf, buf.count) if n > 0 { let data = Data(buf[..= 0 { var buf = [UInt8](repeating: 0, count: 65536) while true { let n = read(fd, &buf, buf.count) if n <= 0 { break } events += parser.feed(Data(buf[.. maxLines { lines.removeFirst(lines.count - maxLines) } } func send(_ string: String) { guard let data = string.data(using: .utf8) else { return } sendBytes(data) } /// Raw bytes from the terminal emulator (keyboard) to the pty master. func sendBytes(_ data: Data) { let fd = masterFD guard fd >= 0, !data.isEmpty else { return } ioQueue.async { data.withUnsafeBytes { ptr in guard let base = ptr.baseAddress else { return } var written = 0 while written < data.count { let n = write(fd, base.advanced(by: written), data.count - written) if n <= 0 { break } written += n } } } } /// Terminal size to the pty (TIOCSWINSZ) so vim etc. use the full area. func setWinsize(cols: Int, rows: Int) { let fd = masterFD guard fd >= 0, cols > 0, rows > 0 else { return } var ws = winsize(ws_row: UInt16(min(rows, 65535)), ws_col: UInt16(min(cols, 65535)), ws_xpixel: 0, ws_ypixel: 0) _ = ioctl(fd, TIOCSWINSZ, &ws) } func clear() { lines = [[]] } /// ctty-run liegt neben diesem Binary (Contents/MacOS wie .build/debug). static func cttyHelperURL() -> URL? { let sibling = URL(fileURLWithPath: CommandLine.arguments[0]) .deletingLastPathComponent() .appendingPathComponent("ctty-run") if FileManager.default.isExecutableFile(atPath: sibling.path) { return sibling } return nil } func note(_ message: String) { lines.append([StyledText(text: message, style: TermStyle(fg: 8))]) } /// Vorab-Check-Fehlschlag (ohne Spawn): rote Notiz + done. func fail(_ message: String) { cleanupSync() epoch += 1 finished = true state = .done(exitCode: nil, message: message) lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 1))]) lines.append([]) } func stop() { let wasRunning = isRunning killTree() cleanupSync() if wasRunning { epoch += 1 // späte terminationHandler-Ausläufer verwerfen finished = true state = .done(exitCode: nil, message: "Getrennt.") lines.append([StyledText(text: "— Getrennt. —", style: TermStyle(fg: 8))]) lines.append([]) } } /// Beendet Kind + Enkel (ctty-run-Wächter + ssh teilen die Prozessgruppe). private func killTree() { if let pid = process?.processIdentifier, pid > 0 { Darwin.kill(-pid, SIGTERM) } process?.terminate() } /// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen. /// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst. private func cleanupSync() { readSource?.cancel() readSource = nil if masterFD >= 0 { close(masterFD) masterFD = -1 } if let pipe = errPipe { pipe.fileHandleForReading.readabilityHandler = nil errPipe = nil } process = nil } private func finish(message: String, code: Int32?) { guard !finished else { return } finished = true cleanupSync() state = .done(exitCode: code, message: message) lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 8))]) lines.append([]) } deinit { if let pid = process?.processIdentifier, pid > 0 { Darwin.kill(-pid, SIGTERM) } process?.terminate() readSource?.cancel() if masterFD >= 0 { close(masterFD) } } } /// Hält pro Profil genau eine PTYSession, damit Tab-Wechsel die Session nicht killen. @MainActor final class TerminalManager: ObservableObject { private var sessions: [UUID: PTYSession] = [:] func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession { // Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert // und sshpass-Verfügbarkeit): Ändert der User Host/Passwort/Key oder wird // sshpass (nach-)installiert, wird neu konfiguriert statt stale weiterzuwählen. let secret = SSHService.loginSecret(for: profile, key: key) let sshpass = SSHService.findSshpass() let tag = Self.configTag(profile: profile, keyPath: key?.privateKeyPath, secret: secret, sshpass: sshpass) if let existing = sessions[profile.id] { // Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen. if existing.isRunning || existing.configTag == tag { return existing } existing.stop() configure(existing, profile: profile, key: key, secret: secret, sshpass: sshpass) existing.configTag = tag return existing } let s = PTYSession() configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass) s.configTag = tag sessions[profile.id] = s return s } /// Pure tag builder (testable): any change here must reconfigure. nonisolated static func configTag(profile: ConnectionProfile, keyPath: String?, secret: String?, sshpass: String?) -> String { [profile.proto.rawValue, profile.host, "\(profile.port)", profile.username, profile.extraArgs, keyPath ?? "", secret ?? "", sshpass ?? ""].joined(separator: "|") } private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?, sshpass: String?) { let baseExe: String let baseArgs: [String] if profile.proto == .sftp { baseExe = "/usr/bin/sftp" baseArgs = ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"] } else { baseExe = "/usr/bin/ssh" baseArgs = SSHService.sshArguments(for: profile, key: key) } // Auto-Login nur mit Keychain-Secret + sshpass, sonst interaktiver Prompt im Terminal. // SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app. let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs, password: secret, sshpassPath: sshpass) s.configure(executable: cfg.executable, arguments: cfg.arguments) var env = cfg.extraEnv if let sock = SSHService.agentSocket() { env["SSH_AUTH_SOCK"] = sock } s.customEnv = env s.setAutoLogin(cfg.autoLogin) } func close(_ profile: ConnectionProfile) { sessions[profile.id]?.stop() sessions.removeValue(forKey: profile.id) } }