diff --git a/Sources/RemminaMac/Services/AnsiParser.swift b/Sources/RemminaMac/Services/AnsiParser.swift new file mode 100644 index 0000000..1bb1f3f --- /dev/null +++ b/Sources/RemminaMac/Services/AnsiParser.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Reine ANSI/SGR-Logik ohne UI-Abhängigkeiten (unit-testbar). +/// Versteht: SGR-Farben (30-37, 90-97, 38;5;n, 38;2;r;g;b + BG-Varianten), +/// Clear Screen (ESC[2J), Clear Line (ESC[K]), CR/LF/Tab/Backspace/Bell. +/// Alles andere (Cursor-Moves etc.) wird ignoriert. + +/// Vorder-/Hintergrund als Dracula-Index 0-15, nil = Standard. +struct TermStyle: Equatable { + var fg: Int? = nil + var bg: Int? = nil + var bold: Bool = false +} + +struct StyledText: Equatable { + var text: String + var style: TermStyle +} + +enum TerminalEvent: Equatable { + case text(StyledText) + case newline + case carriageReturn + case backspace + case clearScreen + case clearLine + case bell +} + +struct AnsiParser { + private(set) var style = TermStyle() + + mutating func feed(_ data: Data) -> [TerminalEvent] { + var events: [TerminalEvent] = [] + var chunk = "" + func flush() { + if !chunk.isEmpty { + events.append(.text(StyledText(text: chunk, style: style))) + chunk = "" + } + } + let bytes = [UInt8](data) + var i = 0 + while i < bytes.count { + let b = bytes[i] + switch b { + case 0x07: // BEL + flush(); events.append(.bell); i += 1 + case 0x08: // BS + flush(); events.append(.backspace); i += 1 + case 0x09: // TAB -> 8er-Stopp angenähert mit Spaces + chunk += " "; i += 1 + case 0x0A: // LF + flush(); events.append(.newline); i += 1 + case 0x0D: // CR (+ LF folgt -> nur Newline, sonst Overwrite) + if i + 1 < bytes.count && bytes[i + 1] == 0x0A { + i += 1 // CR vor LF schlucken, LF handled es + } else { + flush(); events.append(.carriageReturn) + i += 1 + } + case 0x1B: // ESC + if i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "[") { + flush() // pending Text gehört noch zum alten Stil + let (consumed, evs) = parseCSI(bytes: bytes, from: i, chunk: &chunk) + events.append(contentsOf: evs) + i += consumed + } else if i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "]") { + // OSC (z. B. Fenstertitel) bis BEL oder ESC\ skippen + flush() + i += 2 + while i < bytes.count { + if bytes[i] == 0x07 { i += 1; break } + if bytes[i] == 0x1B && i + 1 < bytes.count && bytes[i + 1] == UInt8(ascii: "\\") { i += 2; break } + i += 1 + } + } else { + i += 2 // sonstige ESC-Sequenz ignorieren + } + default: + if b < 0x20 { + i += 1 // übrige Steuerzeichen ignorieren + } else { + // UTF-8: Folgebytes mitnehmen + let len: Int + if b & 0x80 == 0 { len = 1 } + else if b & 0xE0 == 0xC0 { len = 2 } + else if b & 0xF0 == 0xE0 { len = 3 } + else { len = 4 } + let slice = bytes[i.. (Int, [TerminalEvent]) { + var i = start + 2 // hinter ESC[ + var params = "" + while i < bytes.count && !(bytes[i] >= 0x40 && bytes[i] <= 0x7E) { + params.append(Character(UnicodeScalar(bytes[i]))) + i += 1 + } + guard i < bytes.count else { return (bytes.count - start, []) } + let final = bytes[i] + i += 1 + switch final { + case UInt8(ascii: "m"): + applySGR(params) + return (i - start, []) + case UInt8(ascii: "J"): + if params == "2" || params == "3" { return (i - start, [.clearScreen]) } + return (i - start, []) + case UInt8(ascii: "K"): + return (i - start, [.clearLine]) + case UInt8(ascii: "H"), UInt8(ascii: "f"): + return (i - start, [.carriageReturn]) + default: + return (i - start, []) // Cursor-Moves etc. ignorieren + } + } + + private mutating func applySGR(_ params: String) { + let codes = params.isEmpty ? [0] : params.split(separator: ";").compactMap { Int($0) } + var i = 0 + while i < codes.count { + let c = codes[i] + switch c { + case 0: style = TermStyle() + case 1: style.bold = true + case 22: style.bold = false + case 30...37: style.fg = c - 30 + case 39: style.fg = nil + case 40...47: style.bg = c - 40 + case 49: style.bg = nil + case 90...97: style.fg = c - 90 + 8 + case 100...107: style.bg = c - 100 + 8 + case 38, 48: + let isFg = (c == 38) + if i + 1 < codes.count && codes[i + 1] == 5 && i + 2 < codes.count { + let mapped = Self.map256(codes[i + 2]) + if isFg { style.fg = mapped } else { style.bg = mapped } + i += 2 + } else if i + 1 < codes.count && codes[i + 1] == 2 && i + 4 < codes.count { + let mapped = Self.nearestDracula(r: codes[i + 2], g: codes[i + 3], b: codes[i + 4]) + if isFg { style.fg = mapped } else { style.bg = mapped } + i += 4 + } + default: break + } + i += 1 + } + } + + /// 256-Farben grob auf Dracula 0-15 mappen. + static func map256(_ n: Int) -> Int { + if n < 16 { return n } + if n >= 232 { + if n < 238 { return 0 } + if n < 248 { return 8 } + return 15 + } + let x = n - 16 + let r = (x / 36) % 6, g = (x / 6) % 6, b = x % 6 + let bright = (r > 2 || g > 2 || b > 2) ? 8 : 0 + if r >= g && r >= b { return 1 + bright } + if g >= b { return 2 + bright } + if b == g && b > r { return 6 + bright } + return 4 + bright + } + + /// Truecolor -> nächstliegende Dracula-Farbe (vereinfachte Distanz). + static func nearestDracula(r: Int, g: Int, b: Int) -> Int { + let palette = [(33, 34, 44), (255, 85, 85), (80, 250, 123), (241, 250, 140), + (189, 147, 249), (255, 121, 198), (139, 233, 253), (248, 248, 242), + (98, 114, 164), (255, 110, 110), (105, 255, 148), (255, 255, 165), + (214, 172, 255), (255, 146, 223), (164, 255, 255), (255, 255, 255)] + var best = 7, bestDist = Int.max + for (idx, (pr, pg, pb)) in palette.enumerated() { + let d = (r - pr) * (r - pr) + (g - pg) * (g - pg) + (b - pb) * (b - pb) + if d < bestDist { bestDist = d; best = idx } + } + return best + } +} diff --git a/Sources/RemminaMac/Services/ConnectionServices.swift b/Sources/RemminaMac/Services/ConnectionServices.swift index c14e76c..d83bd4d 100644 --- a/Sources/RemminaMac/Services/ConnectionServices.swift +++ b/Sources/RemminaMac/Services/ConnectionServices.swift @@ -20,14 +20,18 @@ enum SSHService { return "ssh " + escaped.joined(separator: " ") } - /// Öffnet die SSH-Session in Terminal.app + /// Öffnet die SSH-Session in Terminal.app — ohne AppleScript (das scheiterte + /// still an fehlender Automation-Berechtigung), sondern über eine .command-Datei. static func openInTerminal(profile: ConnectionProfile, key: StoredSSHKey?) { let cmd = sshCommandString(for: profile, key: key) - let script = "tell application \"Terminal\" to do script \"\(cmd.replacingOccurrences(of: "\"", with: "\\\""))\"" - var err: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&err) - } + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("RemminaMac", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("launch-\(profile.id.uuidString).command") + // exec bash hält das Fenster nach Sitzungsende offen + try? "#!/bin/bash\n\(cmd)\nexec bash\n".write(to: url, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + NSWorkspace.shared.open(url) } static func copyCommandToClipboard(profile: ConnectionProfile, key: StoredSSHKey?) { diff --git a/Sources/RemminaMac/Services/PTYSession.swift b/Sources/RemminaMac/Services/PTYSession.swift new file mode 100644 index 0000000..1fdf636 --- /dev/null +++ b/Sources/RemminaMac/Services/PTYSession.swift @@ -0,0 +1,225 @@ +import Foundation +import Combine +import Darwin +import AppKit + +enum PTYState: Equatable { + case never + case running + case done(exitCode: Int32?, message: String) +} + +/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY), damit interaktive +/// Prompts (Passwort, Host-Key) funktionieren. Ausgabe wird via AnsiParser +/// in gestylte Zeilen für die Dracula-Ansicht übersetzt. +final class PTYSession: ObservableObject { + @Published private(set) var lines: [[StyledText]] = [[]] + @Published private(set) var state: PTYState = .never + + var executable = "" + var arguments: [String] = [] + + private var masterFD: Int32 = -1 + private var process: Process? + private var readSource: DispatchSourceRead? + private let ioQueue = DispatchQueue(label: "RemminaMac.pty") + private var parser = AnsiParser() + private var finished = false + private let maxLines = 2000 + + var isRunning: Bool { + if case .running = state { return true } + return false + } + + var plainText: String { + lines.map { $0.map(\.text).joined() }.joined(separator: "\n") + } + + func configure(executable: String, arguments: [String]) { + self.executable = executable + self.arguments = arguments + } + + func start() { + stop() + finished = false + parser = AnsiParser() + state = .running + lines = [[]] + + let master = posix_openpt(O_RDWR | O_NOCTTY) + guard master >= 0, grantpt(master) == 0, unlockpt(master) == 0, + let cName = ptsname(master), + !String(cString: cName).isEmpty else { + finish(message: "PTY konnte nicht geöffnet werden (errno \(errno)).", code: nil) + return + } + let slavePath = String(cString: cName) + let slave = open(slavePath, O_RDWR | O_NOCTTY) + guard slave >= 0 else { + close(master) + finish(message: "PTY-Slave \(slavePath) ließ sich nicht öffnen.", code: nil) + return + } + masterFD = master + + let p = Process() + p.executableURL = URL(fileURLWithPath: executable) + p.arguments = arguments + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" } + p.environment = env + // closeOnDealloc=false: Parent schließt slave explizit nach run(). + p.standardInput = FileHandle(fileDescriptor: slave) + p.standardOutput = FileHandle(fileDescriptor: slave) + p.standardError = FileHandle(fileDescriptor: slave) + p.terminationHandler = { [weak self] proc in + DispatchQueue.main.async { + self?.finish(message: "Sitzung beendet (Code \(proc.terminationStatus)).", code: proc.terminationStatus) + } + } + do { + try p.run() + } catch { + close(slave); close(master); masterFD = -1 + finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil) + return + } + process = p + close(slave) // Parent behält nur Master + + let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue) + source.setEventHandler { [weak self] in self?.drain() } + source.setCancelHandler { [master] in close(master) } + readSource = source + source.resume() + } + + private func drain() { + let fd = masterFD + guard fd >= 0 else { return } + var buf = [UInt8](repeating: 0, count: 8192) + let n = read(fd, &buf, buf.count) + if n > 0 { + let events = parser.feed(Data(buf[.. maxLines { + lines.removeFirst(lines.count - maxLines) + } + } + + func send(_ string: String) { + let fd = masterFD + guard fd >= 0 else { return } + guard let data = string.data(using: .utf8) else { return } + ioQueue.async { + data.withUnsafeBytes { ptr in + var written = 0 + while written < data.count { + let n = write(fd, ptr.baseAddress!.advanced(by: written), data.count - written) + if n <= 0 { break } + written += n + } + } + } + } + + func clear() { + lines = [[]] + } + + func stop() { + process?.terminate() + process = nil + if !finished { + finished = true + readSource?.cancel() + readSource = nil + masterFD = -1 + if case .running = state { + state = .done(exitCode: nil, message: "Getrennt.") + } + } + } + + private func finish(message: String, code: Int32?) { + guard !finished else { return } + finished = true + readSource?.cancel() + readSource = nil + masterFD = -1 + process = nil + state = .done(exitCode: code, message: message) + let note = StyledText(text: "— \(message) —", style: TermStyle(fg: 8)) + lines.append([note]) + lines.append([]) + } + + deinit { + process?.terminate() + readSource?.cancel() + } +} + +/// Hält pro Profil genau eine PTYSession, damit Tab-Wechsel die Session nicht killen. +@MainActor +final class TerminalManager: ObservableObject { + private var sessions: [UUID: PTYSession] = [:] + + func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession { + if let existing = sessions[profile.id] { return existing } + let s = PTYSession() + if profile.proto == .sftp { + s.configure(executable: "/usr/bin/sftp", arguments: ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]) + } else { + s.configure(executable: "/usr/bin/ssh", arguments: SSHService.sshArguments(for: profile, key: key)) + } + sessions[profile.id] = s + return s + } + + func close(_ profile: ConnectionProfile) { + sessions[profile.id]?.stop() + sessions.removeValue(forKey: profile.id) + } +} diff --git a/Sources/RemminaMac/Services/TerminalTheme.swift b/Sources/RemminaMac/Services/TerminalTheme.swift new file mode 100644 index 0000000..bcf5df8 --- /dev/null +++ b/Sources/RemminaMac/Services/TerminalTheme.swift @@ -0,0 +1,59 @@ +import SwiftUI +import AppKit + +/// Offizielle Dracula-Palette (https://draculatheme.com/contribute). +enum Dracula { + static let background = Color(hex: "282A36") + static let currentLine = Color(hex: "44475A") + static let foreground = Color(hex: "F8F8F2") + static let comment = Color(hex: "6272A4") + static let cyan = Color(hex: "8BE9FD") + static let green = Color(hex: "50FA7B") + static let orange = Color(hex: "FFB86C") + static let pink = Color(hex: "FF79C6") + static let purple = Color(hex: "BD93F9") + static let red = Color(hex: "FF5555") + static let yellow = Color(hex: "F1FA8C") + + /// ANSI-Index 0-15 -> Dracula-Farbe. + static func color(for index: Int?) -> Color { + guard let index else { return foreground } + switch index { + case 0: return Color(hex: "21222C") + case 1: return red + case 2: return green + case 3: return yellow + case 4: return purple + case 5: return pink + case 6: return cyan + case 7: return foreground + case 8: return comment + case 9: return Color(hex: "FF6E6E") + case 10: return Color(hex: "69FF94") + case 11: return Color(hex: "FFFFA5") + case 12: return Color(hex: "D6ACFF") + case 13: return Color(hex: "FF92DF") + case 14: return Color(hex: "A4FFFF") + default: return Color(hex: "FFFFFF") + } + } + + static func nsColor(for index: Int?) -> NSColor { + NSColor(color(for: index)) + } + + static let nsBackground = NSColor(Color(hex: "282A36")) + static let nsSelection = NSColor(Color(hex: "44475A")) +} + +extension Color { + init(hex: String) { + let clean = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: clean).scanHexInt64(&value) + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} diff --git a/Sources/RemminaMac/Views/ContentView.swift b/Sources/RemminaMac/Views/ContentView.swift index a19ae08..878278c 100644 --- a/Sources/RemminaMac/Views/ContentView.swift +++ b/Sources/RemminaMac/Views/ContentView.swift @@ -3,6 +3,7 @@ import AppKit struct ContentView: View { @EnvironmentObject var store: ProfileStore + @StateObject private var terminals = TerminalManager() @State private var selection: ConnectionProfile.ID? @State private var openSessions: [ConnectionProfile] = [] @State private var activeSessionID: ConnectionProfile.ID? @@ -68,7 +69,12 @@ struct ContentView: View { } else { TabView(selection: $activeSessionID) { ForEach(openSessions) { session in - SessionView(profile: session, statusMessage: $statusMessage) + SessionView( + profile: session, + statusMessage: $statusMessage, + term: terminals.session(for: session, key: store.key(for: session)), + onClose: { closeSession(session) } + ) .tag(Optional(session.id)) .tabItem { Text(session.name) } } @@ -143,11 +149,10 @@ struct ContentView: View { openSessions.append(updated) } activeSessionID = p.id - let key = store.key(for: p) switch p.proto { case .ssh, .sftp: - SSHService.openInTerminal(profile: p, key: key) - statusMessage = "SSH: \(SSHService.sshCommandString(for: p, key: key))" + // In-App-Terminal verbindet automatisch (onAppear) — kein externes Fenster nötig + statusMessage = "SSH-Terminal zu \(p.username)@\(p.host) …" case .rdp: RDPService.connect(profile: p) statusMessage = "RDP zu \(p.host):\(p.port) gestartet." @@ -157,6 +162,13 @@ struct ContentView: View { } } + func closeSession(_ p: ConnectionProfile) { + terminals.close(p) + openSessions.removeAll { $0.id == p.id } + if activeSessionID == p.id { activeSessionID = openSessions.last?.id } + statusMessage = "Sitzung '\(p.name)' geschlossen." + } + func quickConnect() { // Simple Quick-Connect: nimmt Auswahl oder leeres SSH-Profil if let p = selectedProfile { connect(p) } diff --git a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift new file mode 100644 index 0000000..706b3b9 --- /dev/null +++ b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift @@ -0,0 +1,157 @@ +import SwiftUI +import AppKit + +/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile. +/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon. +struct EmbeddedTerminalView: View { + @ObservedObject var term: PTYSession + var canConnect: Bool + @State private var input = "" + @State private var secureInput = 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 { + Button("Verbinden") { term.start() }.buttonStyle(.link).font(.caption) + } + Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Dracula.currentLine.opacity(0.35)) + + // 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, canConnect { + term.start() // Auto-Connect beim Öffnen des Tabs (Bugfix: kein externes Terminal mehr nötig) + } + } + } + + func submit() { + guard term.isRunning, !input.isEmpty else { return } + term.send(input + "\n") + input = "" + } + + var statusDot: some View { + Group { + if term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) } + else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(Dracula.red) } + else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) } + }.font(.caption2) + } + + var statusText: String { + switch term.state { + case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt." + case .running: return "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) + } +} diff --git a/Sources/RemminaMac/Views/SessionView.swift b/Sources/RemminaMac/Views/SessionView.swift index c103829..224abb4 100644 --- a/Sources/RemminaMac/Views/SessionView.swift +++ b/Sources/RemminaMac/Views/SessionView.swift @@ -4,7 +4,11 @@ struct SessionView: View { let profile: ConnectionProfile @Binding var statusMessage: String @EnvironmentObject var store: ProfileStore - @State private var log: String = "" + @ObservedObject var term: PTYSession + var onClose: () -> Void + + var isSSH: Bool { profile.proto == .ssh || profile.proto == .sftp } + var canConnect: Bool { !profile.host.isEmpty && !profile.username.isEmpty } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -16,8 +20,8 @@ struct SessionView: View { .font(.subheadline).foregroundStyle(.secondary) } Spacer() - if profile.proto == .ssh || profile.proto == .sftp { - Button("In Terminal öffnen") { + if isSSH { + Button("In Terminal.app öffnen") { SSHService.openInTerminal(profile: profile, key: store.key(for: profile)) } Button("Befehl kopieren") { @@ -27,12 +31,20 @@ struct SessionView: View { } else if profile.proto == .rdp { Button("RDP erneut verbinden") { RDPService.connect(profile: profile) } } + Button("Tab schließen") { onClose() } + } + .padding(.horizontal) + + if isSSH { + // In-App-Terminal (Dracula) — kein externes Fenster mehr nötig + EmbeddedTerminalView(term: term, canConnect: canConnect) + .frame(minHeight: 380) + .padding(.horizontal) } - .padding() GroupBox("Verbindungsdetails") { let key = store.key(for: profile) - let cmd = profile.proto == .ssh || profile.proto == .sftp + let cmd = isSSH ? SSHService.sshCommandString(for: profile, key: key) : "\(profile.proto.rawValue) \(profile.host):\(profile.port)" Text(cmd) @@ -54,14 +66,15 @@ struct SessionView: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 8) } var hintText: String { switch profile.proto { case .ssh: - return "SSH wird in Terminal.app geöffnet (System-ssh). Für ein eingebettetes Terminal als nächsten Schritt SwiftTerm + swift-nio-ssh einbinden." + return "In-App-Terminal (Dracula-Theme) — läuft direkt in diesem Tab über ein PTY. Alternativ: In Terminal.app öffnen." case .sftp: - return "SFTP nutzt denselben SSH-Zugang. Befehl kopieren und z. B. mit Cyberduck / Transmit / sftp im Terminal nutzen: sftp -P \(profile.port) \(profile.username)@\(profile.host)." + return "SFTP-Sitzung läuft im Terminal oben. Alternativ extern nutzen: sftp -P \(profile.port) \(profile.username)@\(profile.host)." case .rdp: return RDPService.freeRDPPath != nil ? "Verbunden über xfreerdp (\(RDPService.freeRDPPath!))." diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 404b0c1..5f2132b 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -12,6 +12,8 @@ Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+ | 3 | 2026-09-21 | Release-Skript | `dist/` war nicht in `.gitignore` → Binary drohte committet zu werden | mittel | ✅ behoben (ignoriert) | | 4 | 2026-09-21 | Gitea-Upload | `GET /api/v1/user` → `401 invalid username, password or token` mit alten Credentials | blockierend (Push/Release) | ✅ behoben — neue Credentials ok, Push + Release v0.1.0 hochgeladen | | 5 | 2026-09-21 | App-Bundle + DMG | keine Fehler: `build-app.sh` (Signatur ok), `build-dmg.sh` (UDZO, 200K), Mount-Verifikation ok (App + Applications/Programme-Links) | Info | ✅ ok | +| 6 | 2026-09-21 | SSH verbinden | kein Terminal öffnete sich: `SSHService.openInTerminal` nutzte `NSAppleScript` → scheitert still (keine `NSAppleEventsUsageDescription`, kein Error-Handling) | blockierend | ✅ behoben — In-App-PTY-Terminal (Dracula) + `.command`-Fallback | +| 7 | 2026-09-21 | ANSI-Parser (Test) | `feed()` flusht Chunk erst NACH `parseCSI` → Text bekam den neuen statt alten Stil (`rot` mit fg=nil statt 1) | mittel | ✅ behoben — Flush vor Stilwechsel | --- @@ -77,6 +79,26 @@ dist/ ## Wie Fehler hierher kommen - Jeder `swift build` / `swift build -c release` / Skript-Lauf mit Fehler → Log-Ausschnitt + Exit-Code hier eintragen. - Gitea-Push/Release-Fehler (z. B. 401, Repo existiert schon, Asset-Upload schlägt fehl) ebenfalls hier dokumentieren. +- Neue Tests: `AnsiParser` per `swiftc`-Harness (`/tmp`, SGR/CRLF/Clear/Map), `PTYSession` per Smoke-Test (`/bin/echo` + `ssh -G` durchs echte PTY). + +## #6 — SSH-Klick öffnet kein Terminal (User-Bugreport) +**Phase:** Laufzeit, SSH-Profil → Verbinden +**Ursache:** `Services/ConnectionServices.swift` nutzte `NSAppleScript(tell application "Terminal" to do script …)`. Ohne `NSAppleEventsUsageDescription` und ohne Error-Auswertung scheitert das still — es passiert schlicht nichts. +**Fix:** +- Neu: `Services/PTYSession.swift` — echte PTY-Session (`posix_openpt`, `ssh` mit TTY), `TerminalManager` pro Profil (überlebt Tab-Wechsel). +- Neu: `Views/EmbeddedTerminalView.swift` — In-App-Terminal im Dracula-Theme (`Services/TerminalTheme.swift`, `Services/AnsiParser.swift`), Auto-Connect per `onAppear`, Eingabezeile + Passwort-Modus + `^C`/`^D`. +- `SessionView` bettet das Terminal ein (min. 380 pt), `ContentView.connect()` öffnet nur noch den Tab. +- Extern-Fallback umgebaut auf `.command`-Datei via `NSWorkspace` (keine Automation-Rechte nötig). +**Verifikation:** `swift build` ok; Parser-Harness 8/8; PTY-Smoke (`/bin/echo` → Output + Exit 0); `ssh -G example.com` durchs PTY ok. +**Status:** ✅ behoben. + +## #7 — ANSI-Parser: Text bekam falschen Stil +**Phase:** `swiftc`-Harness während #6 +**Log:** `text("rot", fg: nil)` statt `fg: 1` +**Ursache:** Chunk wurde erst nach dem SGR-Wechsel geflusht. +**Fix:** `Services/AnsiParser.swift` — `flush()` vor `parseCSI`. +**Verifikation:** Harness 8/8 grün. +**Status:** ✅ behoben. ## #4 — Gitea-Upload: 401 trotz erreichbarem Server **Phase:** `POST /api/v1/user/repos` + `GET /api/v1/user` auf `https://git.sysdaemon.xyz` (Version 1.27.3, per `/api/v1/version` bestätigt) diff --git a/docs/USAGE.md b/docs/USAGE.md index 8504d7b..fd7d39a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -14,7 +14,8 @@ ## Verbinden - Profil wählen → `▶ Verbinden` oder Doppelklick. -- SSH/SFTP: öffnet Terminal.app mit `ssh -p PORT -i KEY user@host`. Befehl steht zusätzlich kopierbar in der Detailansicht. +- SSH/SFTP: **In-App-Terminal (Dracula-Theme) direkt im Tab** — verbindet automatisch, kein externes Fenster nötig. Eingabezeile unten (Auge-Icon = Passwort-Modus), `^C`/`^D`-Buttons, `Leeren`, `Trennen`/`Verbinden`, `Tab schließen`. +- Alternativ: `In Terminal.app öffnen` (öffnet eine `.command`-Datei, braucht keine Automation-Berechtigung) oder `Befehl kopieren`. - RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop. - VNC: öffnet `vnc://host:port`.