Full interactive terminal via SwiftTerm, About sheet, English docs

This commit is contained in:
x3
2026-09-22 10:19:25 +02:00
parent d0855ab45d
commit c657ec2825
15 changed files with 584 additions and 650 deletions
+40 -11
View File
@@ -33,6 +33,9 @@ final class PTYSession: ObservableObject {
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
@@ -132,6 +135,7 @@ final class PTYSession: ObservableObject {
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)
@@ -152,7 +156,9 @@ final class PTYSession: ObservableObject {
var buf = [UInt8](repeating: 0, count: 8192)
let n = read(fd, &buf, buf.count)
if n > 0 {
let events = parser.feed(Data(buf[..<n]))
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
@@ -217,9 +223,14 @@ final class PTYSession: ObservableObject {
}
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 else { return }
guard let data = string.data(using: .utf8), !data.isEmpty else { return }
guard fd >= 0, !data.isEmpty else { return }
ioQueue.async {
data.withUnsafeBytes { ptr in
guard let base = ptr.baseAddress else { return }
@@ -233,6 +244,16 @@ final class PTYSession: ObservableObject {
}
}
/// 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 = [[]]
}
@@ -324,27 +345,35 @@ 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!):
// Ändert der User Host/Passwort/Key, wird neu konfiguriert statt stale weiterzuwählen.
// 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 tag = [profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
profile.extraArgs, key?.privateKeyPath ?? "", secret ?? ""].joined(separator: "|")
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)
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)
configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass)
s.configTag = tag
sessions[profile.id] = s
return s
}
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?) {
/// 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 {
@@ -358,7 +387,7 @@ final class TerminalManager: ObservableObject {
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
password: secret,
sshpassPath: SSHService.findSshpass())
sshpassPath: sshpass)
s.configure(executable: cfg.executable, arguments: cfg.arguments)
var env = cfg.extraEnv
if let sock = SSHService.agentSocket() {