Files

405 lines
15 KiB
Swift

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[..<n])
onData?(data)
let events = parser.feed(data)
DispatchQueue.main.async { [weak self] in self?.apply(events) }
} else {
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
}
}
/// Letzte Reste vom PTY-Master einsammeln (non-blocking, EBADF-safe).
/// Pipe-Reste kommen garantiert über den readabilityHandler.
private func finalFlush(code: Int32, myEpoch: Int) {
var events: [TerminalEvent] = []
let fd = masterFD
if fd >= 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[..<n]))
}
}
DispatchQueue.main.async { [weak self] in
guard let self, self.epoch == myEpoch, !self.finished else { return }
self.apply(events)
self.finish(message: "Sitzung beendet (Code \(code)).", code: code)
}
}
private func apply(_ events: [TerminalEvent]) {
for ev in events {
switch ev {
case .text(let seg):
if lines.isEmpty { lines.append([]) }
if var last = lines[lines.count - 1].last, last.style == seg.style {
last.text += seg.text
lines[lines.count - 1][lines[lines.count - 1].count - 1] = last
} else {
lines[lines.count - 1].append(seg)
}
case .newline:
lines.append([])
case .carriageReturn:
// Overwrite-Modus (Fortschrittsbalken): aktuelle Zeile leeren
if !lines.isEmpty { lines[lines.count - 1] = [] }
case .backspace:
guard !lines.isEmpty, !lines[lines.count - 1].isEmpty else { break }
var row = lines[lines.count - 1]
if var last = row.last, !last.text.isEmpty {
last.text.removeLast()
if last.text.isEmpty { row.removeLast() } else { row[row.count - 1] = last }
lines[lines.count - 1] = row
}
case .clearScreen:
lines = [[]]
case .clearLine:
if !lines.isEmpty { lines[lines.count - 1] = [] }
case .bell:
NSSound.beep()
}
}
if lines.count > 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)
}
}