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), damit interaktive /// Prompts (Passwort, Host-Key) funktionieren. Ausgabe wird via AnsiParser /// in gestylte Zeilen für die Dracula-Ansicht übersetzt. final class PTYSession: ObservableObject { @Published private(set) var lines: [[StyledText]] = [[]] @Published private(set) var state: PTYState = .never var executable = "" var arguments: [String] = [] private var masterFD: Int32 = -1 private var process: Process? private var readSource: DispatchSourceRead? private let ioQueue = DispatchQueue(label: "RemminaMac.pty") private var parser = AnsiParser() private var finished = false 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 start() { stop() 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 let p = Process() p.executableURL = URL(fileURLWithPath: executable) p.arguments = arguments var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" } p.environment = env // closeOnDealloc=false: Parent schließt slave explizit nach run(). p.standardInput = FileHandle(fileDescriptor: slave) p.standardOutput = FileHandle(fileDescriptor: slave) p.standardError = FileHandle(fileDescriptor: slave) p.terminationHandler = { [weak self] proc in DispatchQueue.main.async { self?.finish(message: "Sitzung beendet (Code \(proc.terminationStatus)).", code: proc.terminationStatus) } } do { try p.run() } catch { close(slave); close(master); masterFD = -1 finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil) return } process = p close(slave) // Parent behält nur Master let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue) source.setEventHandler { [weak self] in self?.drain() } source.setCancelHandler { [master] in close(master) } 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 events = parser.feed(Data(buf[.. maxLines { lines.removeFirst(lines.count - maxLines) } } func send(_ string: String) { let fd = masterFD guard fd >= 0 else { return } guard let data = string.data(using: .utf8) else { return } ioQueue.async { data.withUnsafeBytes { ptr in var written = 0 while written < data.count { let n = write(fd, ptr.baseAddress!.advanced(by: written), data.count - written) if n <= 0 { break } written += n } } } } func clear() { lines = [[]] } func stop() { process?.terminate() process = nil if !finished { finished = true readSource?.cancel() readSource = nil masterFD = -1 if case .running = state { state = .done(exitCode: nil, message: "Getrennt.") } } } private func finish(message: String, code: Int32?) { guard !finished else { return } finished = true readSource?.cancel() readSource = nil masterFD = -1 process = nil state = .done(exitCode: code, message: message) let note = StyledText(text: "— \(message) —", style: TermStyle(fg: 8)) lines.append([note]) lines.append([]) } deinit { process?.terminate() readSource?.cancel() } } /// 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 { if let existing = sessions[profile.id] { return existing } let s = PTYSession() if profile.proto == .sftp { s.configure(executable: "/usr/bin/sftp", arguments: ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]) } else { s.configure(executable: "/usr/bin/ssh", arguments: SSHService.sshArguments(for: profile, key: key)) } sessions[profile.id] = s return s } func close(_ profile: ConnectionProfile) { sessions[profile.id]?.stop() sessions.removeValue(forKey: profile.id) } }