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 } } }