200 lines
8.5 KiB
Swift
200 lines
8.5 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// Full interactive terminal (SwiftTerm emulator, Dracula themed):
|
|
/// cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
|
|
/// full-screen apps (vim/htop). Keyboard goes straight to the pty.
|
|
/// Connects after TCP pre-check + host-key approval (popup).
|
|
struct EmbeddedTerminalView: View {
|
|
@ObservedObject var term: PTYSession
|
|
let profile: ConnectionProfile
|
|
var canConnect: Bool
|
|
@State private var checking = false
|
|
@State private var hostKeyRequest: HostKeyRequest?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Statuszeile
|
|
HStack(spacing: 8) {
|
|
statusDot
|
|
Text(statusText).font(.caption).foregroundStyle(.secondary)
|
|
Spacer()
|
|
if term.isRunning {
|
|
Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption)
|
|
} else if canConnect && !checking {
|
|
Button("Verbinden") { connect() }.buttonStyle(.link).font(.caption)
|
|
}
|
|
Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Dracula.currentLine.opacity(0.35))
|
|
|
|
// Fehler-Banner bei Fehlschlag (statt nur „Sitzung beendet")
|
|
if failed {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(Dracula.red)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Verbindung fehlgeschlagen\(exitSuffix)")
|
|
.font(.callout).bold().foregroundStyle(Dracula.red)
|
|
Text(lastMessage)
|
|
.font(.caption).foregroundStyle(Dracula.foreground)
|
|
.lineLimit(3)
|
|
if lastMessage.localizedCaseInsensitiveContains("permission denied") {
|
|
Text("Tipp: Passwort im Auge-Modus eingeben, Tastaturlayout prüfen. Falls Terminal.app ohne Passwort einloggt (Agent-Key: ssh-add -l), Key in der App hinterlegen oder Agent nutzen.")
|
|
.font(.caption).foregroundStyle(Dracula.orange)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button("Bericht kopieren") { copyReport() }
|
|
.buttonStyle(.link).font(.caption)
|
|
.help("Kopiert Profil (ohne Passwort), Status und letzte Ausgabe — hierher schicken zur Fehlersuche")
|
|
Button("Diagnose") { diagnose() }.buttonStyle(.link).font(.caption)
|
|
Button("Erneut") { connect() }.buttonStyle(.link).font(.caption)
|
|
}
|
|
.padding(8)
|
|
.background(Dracula.red.opacity(0.12))
|
|
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
|
|
}
|
|
|
|
// Full terminal emulator (click to focus, type directly).
|
|
SwiftTermView(term: term)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.background(Dracula.background)
|
|
.cornerRadius(10)
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1))
|
|
.onAppear {
|
|
if case .never = term.state {
|
|
connect() // Auto-Connect beim Öffnen des Tabs
|
|
}
|
|
}
|
|
.sheet(item: $hostKeyRequest) { req in
|
|
HostKeySheet(info: req.info, isChange: req.isChange,
|
|
onAccept: { acceptHostKey(req) },
|
|
onCancel: {
|
|
hostKeyRequest = nil
|
|
checking = false
|
|
term.fail("Host-Key nicht akzeptiert — Verbindung abgebrochen.")
|
|
})
|
|
}
|
|
}
|
|
|
|
func connect() {
|
|
guard canConnect, !term.isRunning, !checking else { return }
|
|
checking = true
|
|
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
|
DispatchQueue.main.async {
|
|
if let message {
|
|
checking = false
|
|
term.fail("Vorab-Check: \(message)")
|
|
return
|
|
}
|
|
// Host-Key vorab per keyscan prüfen (Yes/No-Prompt käme ohne Ctty nicht an).
|
|
// Läuft im Hintergrund: ssh-keyscan blockiert Sekunden (kein UI-Freeze).
|
|
let host = profile.host
|
|
let port = profile.port
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let hk = HostKeyService.status(host: host, port: port)
|
|
DispatchQueue.main.async {
|
|
switch hk {
|
|
case .known, .unreachable:
|
|
checking = false
|
|
term.start()
|
|
case .unknown(let info):
|
|
hostKeyRequest = HostKeyRequest(info: info, isChange: false)
|
|
case .changed(let info):
|
|
hostKeyRequest = HostKeyRequest(info: info, isChange: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func acceptHostKey(_ req: HostKeyRequest) {
|
|
hostKeyRequest = nil
|
|
if req.isChange {
|
|
_ = HostKeyService.revoke(host: profile.host, port: profile.port)
|
|
}
|
|
if HostKeyService.accept(req.info) {
|
|
checking = false
|
|
term.note("Host-Key akzeptiert (\(req.info.keyType), \(req.info.fingerprintSHA256.prefix(19))…).")
|
|
term.start()
|
|
} else {
|
|
checking = false
|
|
term.fail("Host-Key konnte nicht in known_hosts gespeichert werden.")
|
|
}
|
|
}
|
|
|
|
func diagnose() {
|
|
guard !checking else { return }
|
|
checking = true
|
|
term.note("Diagnose: TCP \(profile.host):\(profile.port) …")
|
|
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
|
DispatchQueue.main.async {
|
|
checking = false
|
|
if let message {
|
|
term.note("Diagnose: \(message)")
|
|
} else {
|
|
term.note("Diagnose: Port offen — ssh selbst lehnt ab (User/Key/Passwort/Hostkey prüfen).")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
|
|
func copyReport() {
|
|
var r = "RemminaMac Fehlerbericht\n"
|
|
r += "Profil: \(profile.name) | \(profile.proto.rawValue) | \(profile.username)@\(profile.host):\(profile.port)\n"
|
|
r += "Auto-Login: \(term.autoLogin ? "ja" : "nein") | sshpass: \(SSHService.findSshpass() ?? "fehlt")\n"
|
|
r += "Status: \(statusText)\n"
|
|
let tail = term.lines.suffix(20).map { $0.map(\.text).joined() }.joined(separator: "\n")
|
|
r += "--- letzte Ausgabe ---\n\(tail)\n"
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(r, forType: .string)
|
|
}
|
|
|
|
var failed: Bool {
|
|
if case .done(let code, _) = term.state {
|
|
return (code ?? 1) != 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
var exitSuffix: String {
|
|
if case .done(let code, _) = term.state, let code {
|
|
return " (Exit \(code))"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var lastMessage: String {
|
|
for row in term.lines.reversed() {
|
|
let t = row.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !t.isEmpty && !t.hasPrefix("—") { return t }
|
|
}
|
|
if case .done(_, let msg) = term.state { return msg }
|
|
return "Unbekannter Fehler."
|
|
}
|
|
|
|
var statusDot: some View {
|
|
Group {
|
|
if checking || term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) }
|
|
else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(failed ? Dracula.red : Dracula.comment) }
|
|
else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) }
|
|
}.font(.caption2)
|
|
}
|
|
|
|
var statusText: String {
|
|
if checking { return "Prüfe \(profile.host):\(profile.port) …" }
|
|
switch term.state {
|
|
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
|
case .running: return term.autoLogin ? "Verbunden (Auto-Login via Keychain)." : "Verbunden (PTY, TERM=xterm-256color)."
|
|
case .done(_, let msg): return msg
|
|
}
|
|
}
|
|
}
|
|
|