254 lines
10 KiB
Swift
254 lines
10 KiB
Swift
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)
|
||
}
|
||
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)
|
||
if secureInput {
|
||
SecureField("Eingabe (versteckt, z. B. Passwort) …", text: $input, onCommit: submit)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(.body, design: .monospaced))
|
||
.foregroundStyle(Dracula.foreground)
|
||
} else {
|
||
TextField("Eingabe …", text: $input, onCommit: submit)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(.body, design: .monospaced))
|
||
.foregroundStyle(Dracula.foreground)
|
||
}
|
||
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)
|
||
}
|
||
}
|