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 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, auf Tastaturlayout (z. B. Z/Y) achten, Keychain-Eintrag prüfen.") .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 } } } func connect() { guard canConnect, !term.isRunning, !checking else { return } checking = true TCPCheck.check(host: profile.host, port: profile.port) { message in DispatchQueue.main.async { checking = false if let message { term.fail("Vorab-Check: \(message)") } else { term.start() } } } } 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) } }