Files
remmina-mac/Sources/RemminaMac/Views/EmbeddedTerminalView.swift
T

305 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
/// Verbindet erst nach TCP-Vorab-Check (falscher Port/Host -> sofort klare Meldung).
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
struct EmbeddedTerminalView: View {
@ObservedObject var term: PTYSession
let profile: ConnectionProfile
var canConnect: Bool
@State private var input = ""
@State private var secureInput = false
@State private var checking = false
@State private var hostKeyInfo: HostKeyInfo?
@State private var hostKeyChange = false
@State private var showingHostKey = false
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)
}
// Ausgabe im Dracula-Theme
DraculaTextView(lines: term.lines)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Eingabezeile
HStack(spacing: 6) {
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
// Gehärtetes Feld: keine macOS-Text-Ersetzungen (Passwort-Sonderzeichen!)
ZStack(alignment: .leading) {
if input.isEmpty {
Text(secureInput ? "Eingabe (versteckt, z. B. Passwort) …" : "Eingabe …")
.font(.system(.body, design: .monospaced))
.foregroundStyle(Dracula.comment.opacity(0.7))
.allowsHitTesting(false)
}
TerminalInputField(text: $input, isSecure: secureInput, onSubmit: submit)
.id(secureInput)
.frame(height: 22)
}
Button(action: { secureInput.toggle() }) {
Image(systemName: secureInput ? "eye.slash" : "eye")
}
.buttonStyle(.plain).foregroundStyle(Dracula.comment)
.help("Passwort-Modus (versteckte Eingabe)")
Button("Senden") { submit() }
.disabled(input.isEmpty || !term.isRunning)
ControlButton(label: "^C", tip: "SIGINT (Strg+C)") { term.send("\u{03}") }
ControlButton(label: "^D", tip: "EOF (Strg+D)") { term.send("\u{04}") }
}
.padding(8)
.background(Dracula.background)
}
.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(isPresented: $showingHostKey) {
if let info = hostKeyInfo {
HostKeySheet(info: info, isChange: hostKeyChange,
onAccept: acceptHostKey,
onCancel: {
showingHostKey = false
checking = false
hostKeyInfo = nil
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)
let hk = HostKeyService.status(host: profile.host, port: profile.port)
switch hk {
case .known, .unreachable:
checking = false
term.start()
case .unknown(let info):
hostKeyInfo = info
hostKeyChange = false
showingHostKey = true
case .changed(let info):
hostKeyInfo = info
hostKeyChange = true
showingHostKey = true
}
}
}
}
func acceptHostKey() {
guard let info = hostKeyInfo else { return }
showingHostKey = false
if hostKeyChange {
_ = HostKeyService.revoke(host: profile.host, port: profile.port)
}
if HostKeyService.accept(info) {
checking = false
term.note("Host-Key akzeptiert (\(info.keyType), \(info.fingerprintSHA256.prefix(19))…).")
term.start()
} else {
checking = false
term.fail("Host-Key konnte nicht in known_hosts gespeichert werden.")
}
hostKeyInfo = nil
}
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).")
}
}
}
}
func submit() {
guard term.isRunning, !input.isEmpty else { return }
term.send(input + "\n")
input = ""
}
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
/// Hinweis: Wer das Passwort sichtbar eingetippt hat (nicht Auge-Modus),
/// löscht die Ausgabe vorher per „Leeren".
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
}
}
}
struct ControlButton: View {
var label: String
var tip: String
var action: () -> Void
var body: some View {
Button(label, action: action)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Dracula.orange)
.buttonStyle(.plain)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Dracula.currentLine.opacity(0.6), in: RoundedRectangle(cornerRadius: 5))
.help(tip)
}
}
/// Read-only NSTextView mit Dracula-Farben, baut NSAttributedString aus StyledText-Zeilen.
struct DraculaTextView: NSViewRepresentable {
var lines: [[StyledText]]
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.drawsBackground = true
scroll.backgroundColor = Dracula.nsBackground
let tv = NSTextView()
tv.isEditable = false
tv.isSelectable = true
tv.backgroundColor = Dracula.nsBackground
tv.textColor = Dracula.nsColor(for: nil)
tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
tv.textContainerInset = NSSize(width: 8, height: 8)
tv.isAutomaticQuoteSubstitutionEnabled = false
scroll.documentView = tv
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let tv = scroll.documentView as? NSTextView else { return }
let attr = NSMutableAttributedString()
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
for (idx, row) in lines.enumerated() {
if row.isEmpty {
attr.append(NSAttributedString(string: idx < lines.count - 1 ? "\n" : ""))
continue
}
for seg in row {
let attrs: [NSAttributedString.Key: Any] = [
.font: seg.style.bold ? boldFont : font,
.foregroundColor: Dracula.nsColor(for: seg.style.fg),
.backgroundColor: seg.style.bg.map { Dracula.nsColor(for: $0) } ?? Dracula.nsBackground
]
attr.append(NSAttributedString(string: seg.text, attributes: attrs))
}
if idx < lines.count - 1 { attr.append(NSAttributedString(string: "\n")) }
}
// Nur neu setzen wenn geändert (Performance bei viel Output)
if tv.textStorage?.string != attr.string {
tv.textStorage?.setAttributedString(attr)
}
tv.scrollToEndOfDocument(nil)
}
}