Full interactive terminal via SwiftTerm, About sheet, English docs

This commit is contained in:
x3
2026-09-22 10:19:25 +02:00
parent d0855ab45d
commit c657ec2825
15 changed files with 584 additions and 650 deletions
+45
View File
@@ -0,0 +1,45 @@
import SwiftUI
enum AppInfo {
static let version = "0.7.0"
static let repoURL = URL(string: "https://git.sysdaemon.xyz/x3/remmina-mac")!
}
/// About sheet: contributor, repository link, license, credits.
struct AboutView: View {
var body: some View {
VStack(spacing: 14) {
Image(systemName: "terminal.fill")
.font(.system(size: 56))
.foregroundStyle(Dracula.green)
.padding(.top, 8)
Text("RemminaMac")
.font(.largeTitle).bold()
Text("Version \(AppInfo.version)")
.font(.subheadline).foregroundStyle(.secondary)
Text("Remmina-style connection manager for macOS — SSH, SFTP, RDP and VNC profiles with an integrated Dracula terminal.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Divider()
VStack(spacing: 6) {
Text("Contributor")
.font(.caption).foregroundStyle(.secondary)
Text("x3")
.font(.headline)
Link(destination: AppInfo.repoURL) {
Label("git.sysdaemon.xyz/x3/remmina-mac", systemImage: "link")
}
}
Divider()
Text("MIT License · Built with SwiftUI, SwiftTerm and a custom PTY layer.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(28)
.frame(width: 420)
}
}
@@ -10,6 +10,7 @@ struct ContentView: View {
@State private var showingEditor = false
@State private var editingProfile: ConnectionProfile?
@State private var showingKeys = false
@State private var showingAbout = false
@State private var statusMessage = "Bereit."
var body: some View {
@@ -59,6 +60,8 @@ struct ContentView: View {
.help("Duplizieren").disabled(selection == nil)
Button(action: { showingKeys = true }) { Image(systemName: "key") }
.help("SSH-Keys verwalten")
Button(action: { showingAbout = true }) { Image(systemName: "info.circle") }
.help("Info")
}
}
} detail: {
@@ -116,6 +119,9 @@ struct ContentView: View {
.sheet(isPresented: $showingKeys) {
SSHKeysView().environmentObject(store)
}
.sheet(isPresented: $showingAbout) {
AboutView()
}
}
var selectedProfile: ConnectionProfile? {
@@ -1,15 +1,14 @@
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.
/// 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 input = ""
@State private var secureInput = false
@State private var checking = false
@State private var hostKeyRequest: HostKeyRequest?
@@ -59,37 +58,9 @@ struct EmbeddedTerminalView: View {
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
}
// Ausgabe im Dracula-Theme
DraculaTextView(lines: term.lines)
// Full terminal emulator (click to focus, type directly).
SwiftTermView(term: term)
.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)
@@ -173,15 +144,7 @@ struct EmbeddedTerminalView: View {
}
}
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"
@@ -234,67 +197,3 @@ struct EmbeddedTerminalView: View {
}
}
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)
}
}
@@ -0,0 +1,65 @@
import SwiftUI
import AppKit
import SwiftTerm
/// Full terminal emulator view (SwiftTerm): cursor addressing, colors,
/// scrollback, selection, and full-screen apps like vim/htop.
/// Keyboard goes straight to the pty (Tab completion, arrows, Ctrl+C, Esc);
/// pty output is fed in via PTYSession.onData. Dracula themed.
struct SwiftTermView: NSViewRepresentable {
@ObservedObject var term: PTYSession
func makeCoordinator() -> Coordinator {
Coordinator(term: term)
}
func makeNSView(context: Context) -> TerminalView {
let view = TerminalView(frame: .zero,
font: .monospacedSystemFont(ofSize: 13, weight: .regular))
view.terminalDelegate = context.coordinator
view.nativeForegroundColor = Dracula.nsColor(for: nil)
view.nativeBackgroundColor = Dracula.nsBackground
let palette = Dracula.ansiPalette
if palette.count == 16 {
view.installColors(palette)
}
context.coordinator.view = view
term.onData = { [weak view] data in
let bytes = Array(data)
DispatchQueue.main.async {
view?.feed(byteArray: bytes[...])
}
}
// Sync current size once the view exists (later resizes via delegate).
DispatchQueue.main.async {
term.setWinsize(cols: view.terminal.cols, rows: view.terminal.rows)
}
return view
}
func updateNSView(_ view: TerminalView, context: Context) {
context.coordinator.term = term
}
final class Coordinator: NSObject, TerminalViewDelegate {
var term: PTYSession?
weak var view: TerminalView?
init(term: PTYSession) {
self.term = term
}
func send(source: TerminalView, data: ArraySlice<UInt8>) {
term?.sendBytes(Data(data))
}
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
term?.setWinsize(cols: newCols, rows: newRows)
}
func setTerminalTitle(source: TerminalView, title: String) {}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
}
}
@@ -1,119 +0,0 @@
import SwiftUI
import AppKit
/// Eingabefeld ohne macOS-Text-Ersetzungen (Anführungszeichen, Gedankenstriche,
/// Auto-Korrektur, Punkt-Verdopplung, Vervollständigung, Links/Daten-Erkennung).
/// Hintergrund: Normale Textfelder ersetzen still z. B. `"` durch „“ und `--`
/// durch — (je nach Tastatur-Einstellung). Im Terminal fällt das nicht auf, aber
/// ein Passwort mit Sonderzeichen kommt falsch bei ssh an → „Permission denied",
/// obwohl das Passwort „stimmt" (docs/ERRORS.md #13).
/// NSTextField kann das nicht alles abschalten, daher NSTextView-Basis.
/// Secure-Modus nutzt NSSecureTextField (per Design ohne Ersetzungen).
struct TerminalInputField: NSViewRepresentable {
@Binding var text: String
var isSecure: Bool
var onSubmit: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, onSubmit: onSubmit)
}
func makeNSView(context: Context) -> NSView {
if isSecure {
let f = NSSecureTextField()
f.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
f.textColor = Dracula.nsColor(for: nil)
f.backgroundColor = .clear
f.isBordered = false
f.focusRingType = .none
f.delegate = context.coordinator
return f
} else {
let tv = NSTextView()
Self.configure(tv)
tv.delegate = context.coordinator
return tv
}
}
func updateNSView(_ view: NSView, context: Context) {
context.coordinator.onSubmit = onSubmit
// Delegat während Programm-Sets abhängen: sonst feuert textDidChange synchron
// zurück ins SwiftUI-State — mitten im Render -> Crash ("Modifying state
// during view update"). User-Tippen läuft weiter über den Delegaten.
if let f = view as? NSTextField {
if f.stringValue != text {
f.delegate = nil
f.stringValue = text
f.delegate = context.coordinator
}
} else if let tv = view as? NSTextView, tv.string != text {
tv.delegate = nil
tv.string = text
tv.delegate = context.coordinator
}
}
/// Alle Ersetzungen aus. Auch als Harness-Test nutzbar (reine Flags, headless).
static func configure(_ tv: NSTextView) {
tv.isRichText = false
tv.importsGraphics = false
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticTextCompletionEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = false
tv.isContinuousSpellCheckingEnabled = false
tv.isAutomaticDataDetectionEnabled = false
tv.isAutomaticLinkDetectionEnabled = false
tv.isAutomaticTextReplacementEnabled = false
tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
tv.textColor = Dracula.nsColor(for: nil)
tv.backgroundColor = .clear
tv.drawsBackground = false
tv.isVerticallyResizable = false
tv.isHorizontallyResizable = true
tv.textContainer?.widthTracksTextView = false
tv.textContainer?.containerSize = NSSize(width: 100000, height: 24)
}
final class Coordinator: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
var text: Binding<String>
var onSubmit: () -> Void
init(text: Binding<String>, onSubmit: @escaping () -> Void) {
self.text = text
self.onSubmit = onSubmit
}
func controlTextDidChange(_ obj: Notification) {
if let f = obj.object as? NSTextField {
text.wrappedValue = f.stringValue
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
onSubmit()
return true
}
return false
}
func textDidChange(_ notification: Notification) {
if let tv = notification.object as? NSTextView {
// Einzeiler: harte Umbrüche sofort entfernen (Paste mit \n)
let single = tv.string.replacingOccurrences(of: "\n", with: "")
if single != tv.string { tv.string = single }
text.wrappedValue = single
}
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
onSubmit()
return true
}
return false
}
}
}