Terminal-Bugfix: In-App-PTY-Terminal im Dracula-Theme, .command-Fallback, ANSI-Parser-Fix, Doku
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user