226 lines
7.5 KiB
Swift
226 lines
7.5 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), 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[..<n]))
|
|
DispatchQueue.main.async { [weak self] in self?.apply(events) }
|
|
} else {
|
|
readSource?.cancel()
|
|
masterFD = -1
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|