322 lines
11 KiB
Swift
322 lines
11 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] = [:]
|
|
|
|
private var masterFD: Int32 = -1
|
|
private var process: Process?
|
|
private var readSource: DispatchSourceRead?
|
|
private var errPipe: Pipe?
|
|
private let ioQueue = DispatchQueue(label: "RemminaMac.pty")
|
|
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
|
|
|
|
let pipe = Pipe()
|
|
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" }
|
|
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.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 events = parser.feed(Data(buf[..<n]))
|
|
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) {
|
|
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 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
|
|
process?.terminate()
|
|
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([])
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
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 {
|
|
if let existing = sessions[profile.id] { return existing }
|
|
let s = PTYSession()
|
|
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: SSHService.loginSecret(for: profile, key: key),
|
|
sshpassPath: SSHService.findSshpass())
|
|
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)
|
|
sessions[profile.id] = s
|
|
return s
|
|
}
|
|
|
|
func close(_ profile: ConnectionProfile) {
|
|
sessions[profile.id]?.stop()
|
|
sessions.removeValue(forKey: profile.id)
|
|
}
|
|
}
|