15 Commits
Author SHA1 Message Date
x3 3957bdcf68 Docs: Homebrew dependency table (sshpass, freerdp, builtins) 2026-09-22 10:26:47 +02:00
x3 c657ec2825 Full interactive terminal via SwiftTerm, About sheet, English docs 2026-09-22 10:19:25 +02:00
x3 d0855ab45d Popup-Fix: sheet(item) statt if-let (leeres Sheet), Keyscan im Hintergrund, Doku 2026-09-22 09:39:26 +02:00
x3 4209c481dc Host-Key-Popup mit Fingerprint + ctty-run-Helper (Ctty fuer ssh-Prompts), Doku 2026-09-22 09:31:08 +02:00
x3 8511e4fdae Docs: #17 Vorab-Check-Timeout ist Server-Firewall (22 DROP, 443 ok) 2026-09-22 08:52:50 +02:00
x3 7a792fe680 Fix stale Sessions: Fingerprint-Reconfig bei Profil-Aenderung, Doku 2026-09-22 08:46:45 +02:00
x3 69f9e9952c Crash-Haertung: Delegat bei Programm-Sets abhaengen, send()-Pointer safe, Doku 2026-09-21 23:52:23 +02:00
x3 ed6e96b3c1 SSH-Agent-Socket an ssh durchreichen (wie Terminal), Banner-Tipp, Doku 2026-09-21 23:47:01 +02:00
x3 37a70edbab Fix Permission-denied: gehaertetes Terminal-Eingabefeld ohne Text-Ersetzung, Banner-Tipp, Doku 2026-09-21 23:38:52 +02:00
x3 ebb838b6b1 Editor: Passwort unter Benutzername, Live-Validierung, Fehlerbericht kopieren, Doku 2026-09-21 23:29:39 +02:00
x3 4fd711474c Passwort nutzen: Auto-Login via sshpass + Keychain, Editor mit Anzeige/Loeschen, Doku 2026-09-21 23:21:07 +02:00
x3 7dc6ff75d2 Fix: stderr-Pipe gegen Instant-Exit-Datenverlust, TCP-Vorab-Check, Fehler-Banner; Icon (Dracula) im Bundle 2026-09-21 23:14:08 +02:00
x3 1828a96a2b Terminal-Bugfix: In-App-PTY-Terminal im Dracula-Theme, .command-Fallback, ANSI-Parser-Fix, Doku 2026-09-21 22:59:17 +02:00
x3 8fe8c4925e Installer: RemminaMac.app-Bundle + Drag-and-drop-DMG (Programme-Link), Doku, Fehlerprotokoll 2026-09-21 22:49:13 +02:00
x3 c5ecb273c2 Docs: Gitea-401 als behoben markiert (Push + Release v0.1.0 ok) 2026-09-21 22:46:34 +02:00
30 changed files with 2177 additions and 257 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
"version" : "1.8.2"
}
},
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm",
"state" : {
"revision" : "5d14406844143538cd8f8851d2d8a67c1fe443e5",
"version" : "1.20.0"
}
}
],
"version" : 2
}
+11 -1
View File
@@ -7,12 +7,22 @@ let package = Package(
.macOS(.v14)
],
products: [
.executable(name: "RemminaMac", targets: ["RemminaMac"])
.executable(name: "RemminaMac", targets: ["RemminaMac"]),
.executable(name: "ctty-run", targets: ["ctty-run"])
],
dependencies: [
.package(url: "https://github.com/migueldeicaza/SwiftTerm", from: "1.20.0")
],
targets: [
.executableTarget(
name: "RemminaMac",
dependencies: ["SwiftTerm"],
path: "Sources/RemminaMac"
),
.executableTarget(
name: "ctty-run",
path: "Sources/ctty-run",
sources: ["ctty-run.c"]
)
]
)
+72 -59
View File
@@ -1,61 +1,69 @@
# RemminaMac — SSH / RDP Client für macOS
# RemminaMac — SSH / RDP Client for macOS
Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI.
![RemminaMac Icon](docs/icon-preview.png)
![Plattform](https://img.shields.io/badge/plattform-macOS%2014%2B-blue)
![Sprache](https://img.shields.io/badge/sprache-Swift%205.9-orange)
Remmina-inspired connection manager for macOS, native Swift + SwiftUI.
![Platform](https://img.shields.io/badge/platform-macOS%2014%2B-blue)
![Language](https://img.shields.io/badge/language-Swift%205.9-orange)
![UI](https://img.shields.io/badge/UI-SwiftUI-green)
Layout wie Remmina: **links Profil-Liste mit Suche + Gruppen, rechts Verbindungs-Tabs, unten Statusbar.**
Remmina-style layout: **profile list with search + groups on the left, connection tabs on the right, status bar at the bottom.**
## Inhaltsverzeichnis
## Contents
- [Features](#features)
- [Screenshots / Layout](#screenshots--layout)
- [Schnellstart](#schnellstart)
- [Layout](#layout)
- [Quick start](#quick-start)
- [Installation](docs/INSTALL.md)
- [Benutzung](docs/USAGE.md)
- [Architektur](docs/ARCHITECTURE.md)
- [SSH-Keys & Sicherheit](#ssh-keys--sicherheit)
- [RDP / VNC unter macOS](#rdp--vnc-unter-macos)
- [Profile-Format](#profile-format)
- [Release-Binary](#release-binary)
- [Fehlerprotokoll](docs/ERRORS.md)
- [Usage](docs/USAGE.md)
- [Architecture](docs/ARCHITECTURE.md)
- [SSH keys & security](#ssh-keys--security)
- [RDP / VNC on macOS](#rdp--vnc-on-macos)
- [Profile format](#profile-format)
- [Release binaries](#release-binaries)
- [Error log](docs/ERRORS.md)
- [Roadmap](#roadmap)
- [Lizenz](#lizenz)
- [License](#license)
## Features
- GUI im Remmina-Layout (`Views/ContentView.swift`):
- Sidebar: Gruppen-Picker, Suche, Profil-Liste, Doppelklick = Verbinden
- Detail: Tab-Ansicht offener Sitzungen, Verbindungsdetails, Statusbar
- Toolbar: Neu / Bearbeiten / Duplizieren / Verbinden / Quick-Connect / SSH-Keys
- Profile laden / speichern / duplizieren / löschen
- GUI in Remmina layout (`Views/ContentView.swift`):
- Sidebar: group picker, search, profile list, double-click = connect
- Detail: tabbed open sessions, connection details, status bar
- Toolbar: New / Edit / Duplicate / Connect / Quick-Connect / SSH keys / Info
- Load / save / duplicate / delete profiles
- JSON in `~/Library/Application Support/RemminaMac/profiles.json`
- Export / Import (siehe Benutzung)
- Protokolle: SSH, SFTP, RDP, VNC (`Models/ConnectionProfile.swift:3`)
- SSH-Keys speichern & verwalten (`Views/SSHKeysView.swift`):
- Import aus `~/.ssh`, Datei-Picker, `ssh-keygen`-Wrapper (ed25519)
- Passphrases in macOS Keychain (`Security/KeychainHelper.swift`)
- Passwörter in Keychain, nie im Klartext-JSON
- SSH via System-`ssh` in Terminal.app (`Services/ConnectionServices.swift:6`)
- RDP via FreeRDP (`xfreerdp`) oder Fallback Microsoft Remote Desktop
- VNC via `vnc://`-Scheme
- Validation + trimming in the editor, copyable error reports
- Protocols: SSH, SFTP, RDP, VNC (`Models/ConnectionProfile.swift`)
- Full interactive terminal per tab (SwiftTerm emulator, Dracula themed):
cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
full-screen apps (vim/htop). Real PTY + controlling terminal via `ctty-run`.
- Host-key fingerprint popup (accept/decline, MITM warning on change),
shared `~/.ssh/known_hosts` with Terminal.app
- SSH key management (`Views/SSHKeysView.swift`):
import from `~/.ssh`, file picker, `ssh-keygen` wrapper (ed25519)
- Passwords/passphrases in macOS Keychain only, never in JSON
- Auto-login via `sshpass` (if installed) or interactive prompt
- TCP pre-check with plain-language diagnostics before connecting
- RDP via FreeRDP (`xfreerdp`) or Microsoft Remote Desktop fallback
- VNC via `vnc://` scheme
- About sheet with contributor + repository link
## Screenshots / Layout
## Layout
```
+-------------------------------------------------------------+
| Toolbar: [+][Edit][Copy][Key] | [▶ Verbinden][⚡Quick] |
| Toolbar: [+][Edit][Copy][Key][i] | [▶ Connect][⚡Quick] |
+------+-------------------------------+--------------------+
| Gruppe | Tab: Webserver | Büro-PC | ... | Detail |
| Suche +-------------------------------+ |
| - Webserver | Verbindungsdetails (Befehl)| |
| - Büro-PC | Hinweis | |
| - Dateiserver+-------------------------------+ |
| Group | Tab: Webserver | Office-PC | ... | Detail |
| Search +-------------------------------+ |
| - Webserver | Full terminal (SwiftTerm) | |
| - Office-PC | Connection details (cmd) | |
| - Fileserver +-------------------------------+ |
+------+-------------------------------+--------------------+
| Statusbar: ● Bereit ... N Profile · M Sitzungen |
| Statusbar: ● Ready ... N profiles · M sessions |
+-------------------------------------------------------------+
```
## Schnellstart
## Quick start
```bash
cd RemminaMac
swift build
@@ -65,19 +73,21 @@ open Package.swift
# Target RemminaMac → My Mac → Run
```
## SSH-Keys & Sicherheit
- Private-Key-Pfade stehen in `sshkeys.json`, Passphrases nur in Keychain.
- Service-Name: `RemminaMac`, Accounts: `profile-<UUID>`, `sshkey-<UUID>`.
- Keychain lesen/schreiben: `Security/KeychainHelper.swift:5`.
- Es werden keine Secrets ins Git-Repo geschrieben (siehe `.gitignore`).
## SSH keys & security
- Private key paths live in `sshkeys.json`, passphrases in Keychain only.
- Service name: `RemminaMac`, accounts: `profile-<UUID>`, `sshkey-<UUID>`.
- Keychain access: `Security/KeychainHelper.swift`.
- No secrets are committed to the repo (see `.gitignore`).
- Passwords reach ssh via `SSHPASS` env (with sshpass) or the interactive
terminal prompt — never via command line or logs.
## RDP / VNC unter macOS
## RDP / VNC on macOS
- Optional: `brew install freerdp`
- Mit FreeRDP: `xfreerdp /v:HOST:PORT /u:USER [/size:WxH | /f]`
- Ohne: `rdc://` → Microsoft Remote Desktop (aus App Store installieren)
- With FreeRDP: `xfreerdp /v:HOST:PORT /u:USER [/size:WxH | /f]`
- Without: `rdc://` → Microsoft Remote Desktop (install from App Store)
- VNC: `open vnc://host:port`
## Profile-Format
## Profile format
```json
[
{
@@ -89,7 +99,7 @@ open Package.swift
"username": "admin",
"group": "Server",
"colorTag": "blue",
"resolution": "Automatisch",
"resolution": "Automatic",
"sshKeyID": null,
"passwordAccount": "profile-UUID",
"extraArgs": "",
@@ -98,21 +108,24 @@ open Package.swift
]
```
## Release-Binary
Fertige Binaries liegen unter Git-Releases (`dist/` wird lokal gebaut, nicht committet):
## Release binaries
Prebuilt binaries on Git releases (`dist/` is built locally, never committed):
```bash
./scripts/build-release.sh
./scripts/build-release.sh 0.7.0
./scripts/build-app.sh 0.7.0 # RemminaMac.app for /Applications
./scripts/build-dmg.sh 0.7.0 # DMG with drag-and-drop to Applications
# → dist/RemminaMac-<version>-macos-arm64.zip + .sha256
# → dist/RemminaMac-<version>-macos-arm64.dmg + .sha256
ls dist/
```
Details: [docs/INSTALL.md](docs/INSTALL.md), [docs/RELEASING.md](docs/RELEASING.md).
## Roadmap
- [ ] Eingebettetes Terminal (SwiftTerm + swift-nio-ssh)
- [ ] Echte RDP-Einbettung (FreeRDP-Bridge) statt externem Prozess
- [ ] SFTP-Browser integriert
- [ ] Export/Import-Buttons in der GUI
- [ ] Tray / Favoriten / Ordner-Badges wie Remmina
- [x] Embedded full terminal (SwiftTerm) — done in 0.7.0
- [ ] Embedded RDP (FreeRDP bridge) instead of external process
- [ ] Integrated SFTP browser
- [ ] Export/Import buttons in the GUI
- [ ] Tray / favorites / folder badges like Remmina
## Lizenz
MIT — siehe `LICENSE`.
## License
MIT — see `LICENSE`.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleDisplayName</key>
<string>RemminaMac</string>
<key>CFBundleExecutable</key>
<string>RemminaMac</string>
<key>CFBundleIdentifier</key>
<string>xyz.sysdaemon.remmina-mac</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>RemminaMac</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT — RemminaMac Contributors</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
</dict>
</plist>
@@ -0,0 +1,38 @@
import Foundation
/// Eingabe-Prüfung + Bereinigung für Profile (reine Funktionen, testbar).
/// Fängt die häufigsten „Verbindung falsch"-Ursachen ab: Leerzeichen,
/// user@host im Host-Feld, Protokoll-Präfix, leerer Benutzername.
enum ProfileValidation {
static func cleaned(_ profile: ConnectionProfile) -> ConnectionProfile {
var p = profile
p.name = p.name.trimmingCharacters(in: .whitespacesAndNewlines)
p.host = p.host.trimmingCharacters(in: .whitespacesAndNewlines)
p.username = p.username.trimmingCharacters(in: .whitespacesAndNewlines)
p.group = p.group.trimmingCharacters(in: .whitespacesAndNewlines)
p.resolution = p.resolution.trimmingCharacters(in: .whitespacesAndNewlines)
p.extraArgs = p.extraArgs.trimmingCharacters(in: .whitespacesAndNewlines)
return p
}
static func warnings(for profile: ConnectionProfile) -> [String] {
var w: [String] = []
let h = profile.host.trimmingCharacters(in: .whitespacesAndNewlines)
if h.contains("@") {
w.append("Host enthält „@“ — den Benutzernamen ins Feld darunter eintragen, hier nur Hostname/IP.")
}
if h.contains("://") {
w.append("Host enthält „://“ — nur Hostname/IP, kein Protokoll (das steht oben).")
}
if h.contains(where: { $0.isWhitespace }) {
w.append("Host enthält Leerzeichen.")
}
if profile.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
w.append("Benutzername fehlt.")
}
if !(1...65535).contains(profile.port) {
w.append("Port ungültig (1–65535).")
}
return w
}
}
@@ -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..<min(i + len, bytes.count)]
chunk += String(decoding: slice, as: UTF8.self)
i += slice.count
}
}
}
flush()
return events
}
private mutating func parseCSI(bytes: [UInt8], from start: Int, chunk: inout String) -> (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
}
}
@@ -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?) {
@@ -35,6 +39,67 @@ enum SSHService {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(cmd, forType: .string)
}
// MARK: - Passwort / Auto-Login
/// SSH-Agent-Socket für GUI-Prozesse auflösen (Finder-Apps erben SSH_AUTH_SOCK
/// nicht, Terminal schon — ohne diesen Unterschied nutzt Terminal evtl. einen
/// Agent-Key, während die App nach dem Passwort fragt und scheitert).
static func agentSocket() -> String? {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
p.arguments = ["getenv", "SSH_AUTH_SOCK"]
let out = Pipe()
let err = Pipe()
p.standardOutput = out
p.standardError = err
do {
try p.run()
p.waitUntilExit()
} catch {
return nil
}
let path = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !path.isEmpty,
FileManager.default.fileExists(atPath: path) else { return nil }
return path
}
/// Gespeichertes Login-Secret: erst Profil-Passwort, dann Key-Passphrase (Keychain, nie JSON).
static func loginSecret(for profile: ConnectionProfile, key: StoredSSHKey?) -> String? {
if let acct = profile.passwordAccount,
let pw = KeychainHelper.load(account: acct), !pw.isEmpty {
return pw
}
if let key, let acct = key.passphraseAccount,
let pp = KeychainHelper.load(account: acct), !pp.isEmpty {
return pp
}
return nil
}
static func findSshpass() -> String? {
for p in ["/opt/homebrew/bin/sshpass", "/usr/local/bin/sshpass", "/usr/bin/sshpass"] {
if FileManager.default.isExecutableFile(atPath: p) { return p }
}
return nil
}
/// true = beim Verbinden wird ohne Tippen eingeloggt (sshpass + Keychain-Secret).
static func willAutoLogin(profile: ConnectionProfile, key: StoredSSHKey?) -> Bool {
loginSecret(for: profile, key: key) != nil && findSshpass() != nil
}
/// Reine Funktion (testbar): mit Secret+sshpass -> sshpass -e … + SSHPASS-Env,
/// sonst direkter Aufruf. Passwort steht NIE in der Befehlszeile (nur im Env).
static func sessionConfig(executable: String, args: [String], password: String?, sshpassPath: String?)
-> (executable: String, arguments: [String], extraEnv: [String: String], autoLogin: Bool) {
if let password, !password.isEmpty, let sshpass = sshpassPath, !sshpass.isEmpty {
return (sshpass, ["-e", executable] + args, ["SSHPASS": password], true)
}
return (executable, args, [:], false)
}
}
enum RDPService {
@@ -0,0 +1,148 @@
import Foundation
/// Host-Key-Prüfung OHNE interaktiven Yes/No-Prompt (der kommt ohne echtes
/// Terminal/Ctty nicht an — ssh bricht dann mit „Host key verification failed." ab).
/// Ablauf: ssh-keyscan → Fingerprint → Popup (akzeptieren/ablehnen) → known_hosts.
/// Nutzt bewusst ~/.ssh/known_hosts gemeinsam mit Terminal.app.
enum HostKeyStatus: Equatable {
case known
case unknown(HostKeyInfo)
case changed(HostKeyInfo)
case unreachable(String)
}
struct HostKeyInfo: Equatable {
var host: String
var port: Int
var keyType: String
var fingerprintSHA256: String
var fingerprintMD5: String
var allKeyLines: [String]
}
enum HostKeyService {
static var knownHostsPath = NSHomeDirectory() + "/.ssh/known_hosts"
static var scanTimeoutSeconds = "5"
static func canonicalHost(_ host: String, port: Int) -> String {
port == 22 ? host : "[\(host)]:\(port)"
}
@discardableResult
static func run(_ exe: String, _ args: [String]) -> (Int32, String) {
let p = Process()
p.executableURL = URL(fileURLWithPath: exe)
p.arguments = args
let out = Pipe()
p.standardOutput = out
p.standardError = Pipe()
do {
try p.run()
} catch {
return (-1, "")
}
p.waitUntilExit()
let data = out.fileHandleForReading.readDataToEndOfFile()
return (p.terminationStatus, String(data: data, encoding: .utf8) ?? "")
}
/// Rohe Key-Zeilen vom Server (ohne #-Kommentare).
static func keyscan(host: String, port: Int) -> [String] {
let (code, out) = run("/usr/bin/ssh-keyscan", ["-T", scanTimeoutSeconds, "-p", "\(port)", host])
guard code == 0 else { return [] }
return out.split(separator: "\n").map(String.init).filter { line in
let t = line.trimmingCharacters(in: .whitespaces)
return !t.isEmpty && !t.hasPrefix("#") && t.contains(" ")
}
}
static func knownHostsContent() -> String {
(try? String(contentsOfFile: knownHostsPath, encoding: .utf8)) ?? ""
}
static func status(host: String, port: Int) -> HostKeyStatus {
let h = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !h.isEmpty else { return .unreachable("Kein Host.") }
let lines = keyscan(host: h, port: port)
guard !lines.isEmpty else {
return .unreachable("Host-Key nicht abfragbar (ssh-keyscan ohne Antwort).")
}
guard let info = info(host: h, port: port, lines: lines) else {
return .unreachable("Fingerprint nicht berechenbar.")
}
let (fcode, fout) = run("/usr/bin/ssh-keygen", ["-F", canonicalHost(h, port: port), "-f", knownHostsPath])
let hasEntry = fcode == 0 && fout.contains("found:")
if hasEntry {
let knownKeys = lines.compactMap { $0.split(separator: " ").last.map(String.init) }
if knownKeys.contains(where: { knownHostsContent().contains($0) }) {
return .known
}
return .changed(info)
}
return .unknown(info)
}
/// Bevorzugter Key (ed25519 > ecdsa > Rest) + Fingerprints via ssh-keygen.
static func info(host: String, port: Int, lines: [String]) -> HostKeyInfo? {
func rank(_ l: String) -> Int {
if l.contains("ssh-ed25519") { return 0 }
if l.contains("ecdsa-") { return 1 }
return 2
}
guard let line = lines.sorted(by: { rank($0) < rank($1) }).first else { return nil }
let parts = line.split(separator: " ")
guard parts.count >= 3 else { return nil }
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("rmm-fp-\(UUID().uuidString)")
do {
try (line + "\n").write(to: tmp, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmp) }
let (_, sha) = run("/usr/bin/ssh-keygen", ["-l", "-f", tmp.path])
let (_, md5) = run("/usr/bin/ssh-keygen", ["-l", "-E", "md5", "-f", tmp.path])
return HostKeyInfo(host: host, port: port, keyType: String(parts[1]),
fingerprintSHA256: parseFP(sha), fingerprintMD5: parseFP(md5),
allKeyLines: lines)
} catch {
return nil
}
}
/// Aus `256 SHA256:XYZ host (ED25519)` wird `SHA256:XYZ`.
static func parseFP(_ out: String) -> String {
let parts = out.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ")
guard parts.count >= 2 else { return out.trimmingCharacters(in: .whitespacesAndNewlines) }
return String(parts[1])
}
/// Fehlende Key-Zeilen an known_hosts anhängen (0600, ~/.ssh 0700).
static func accept(_ info: HostKeyInfo) -> Bool {
let dir = (knownHostsPath as NSString).deletingLastPathComponent
do {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
let existing = knownHostsContent()
let missing = info.allKeyLines.filter { line in
let key = line.split(separator: " ").last.map(String.init) ?? line
return !existing.contains(key)
}
guard !missing.isEmpty else { return true }
if !FileManager.default.fileExists(atPath: knownHostsPath) {
FileManager.default.createFile(atPath: knownHostsPath, contents: nil,
attributes: [.posixPermissions: 0o600])
}
let fh = try FileHandle(forWritingTo: URL(fileURLWithPath: knownHostsPath))
defer { try? fh.close() }
try fh.seekToEnd()
fh.write(((missing.joined(separator: "\n")) + "\n").data(using: .utf8)!)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: knownHostsPath)
return true
} catch {
return false
}
}
/// Alle Einträge für Host entfernen (ssh-keygen -R).
static func revoke(host: String, port: Int) -> Bool {
let (code, _) = run("/usr/bin/ssh-keygen", ["-R", canonicalHost(host, port: port), "-f", knownHostsPath])
return code == 0
}
}
@@ -0,0 +1,404 @@
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).
/// - stdin/stdout: PTY-Slave (interaktiv, TTY bleibt erhalten)
/// - stderr: PIPE — macOS verwirft ungelesene PTY-Daten beim Slave-Close,
/// dadurch gingen Fehlermeldungen bei Instant-Exit (z. B. Code 255) verloren.
/// Pipes liefern gepufferte Daten garantiert aus (Bugfix, siehe docs/ERRORS.md #8).
///
/// FD-Regeln: masterFD wird NUR auf dem Main-Thread vergeben/geschlossen,
/// auf ioQueue nur gelesen (EBADF-tolerant). Kein Close auf ioQueue.
final class PTYSession: ObservableObject {
@Published private(set) var lines: [[StyledText]] = [[]]
@Published private(set) var state: PTYState = .never
@Published private(set) var autoLogin = false
var executable = ""
var arguments: [String] = []
var customEnv: [String: String] = [:]
/// Fingerprint der Konfiguration (TerminalManager) — bei Änderung wird neu konfiguriert.
var configTag = ""
private var masterFD: Int32 = -1
private var process: Process?
private var readSource: DispatchSourceRead?
private var errPipe: Pipe?
private let ioQueue = DispatchQueue(label: "RemminaMac.pty")
/// Raw output hook for the terminal emulator (SwiftTerm feeds on this).
/// Called on ioQueue for every chunk from pty master and stderr pipe.
var onData: ((Data) -> Void)?
private var parser = AnsiParser()
private var finished = false
private var epoch = 0 // nur Main-Thread
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 setAutoLogin(_ value: Bool) {
autoLogin = value
}
func start() {
cleanupSync()
epoch += 1
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
// Ctty-Helper (gleiches Verzeichnis wie dieses Binary — .app wie .build):
// Gibt ssh ein echtes Controlling-Terminal, damit Passwort- und
// Yes/No-Prompts funktionieren (/dev/tty statt stiller Abbrüche).
// Ohne Helper: Legacy-Direktstart (Prompts teils stumm).
let pipe = Pipe()
let p = Process()
if let helper = Self.cttyHelperURL() {
p.executableURL = helper
p.arguments = [slavePath, executable] + arguments
} else {
p.executableURL = URL(fileURLWithPath: executable)
p.arguments = arguments
DispatchQueue.main.async { [weak self] in
self?.note("Hinweis: ohne ctty-run-Helper (eingeschränkte Prompts).")
}
}
var env = ProcessInfo.processInfo.environment
env["TERM"] = "xterm-256color"
if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" }
for (k, v) in customEnv { env[k] = v }
p.environment = env
// closeOnDealloc=false: Parent schließt slave explizit nach run().
p.standardInput = FileHandle(fileDescriptor: slave)
p.standardOutput = FileHandle(fileDescriptor: slave)
p.standardError = pipe.fileHandleForWriting
let myEpoch = epoch
p.terminationHandler = { [weak self] proc in
DispatchQueue.main.async { [weak self] in
guard let self, self.epoch == myEpoch else { return }
self.ioQueue.async { [weak self] in
self?.finalFlush(code: proc.terminationStatus, myEpoch: myEpoch)
}
}
}
do {
try p.run()
} catch {
close(slave); close(master); masterFD = -1
errPipe = nil
finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil)
return
}
process = p
close(slave) // Parent behält nur Master
errPipe = pipe
pipe.fileHandleForReading.readabilityHandler = { [weak self] h in
let data = h.availableData
guard let self else { return }
if data.isEmpty {
h.readabilityHandler = nil // EOF
return
}
self.onData?(data)
self.ioQueue.async { [weak self] in
guard let self else { return }
let events = self.parser.feed(data)
DispatchQueue.main.async { self.apply(events) }
}
}
let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue)
source.setEventHandler { [weak self] in self?.drain() }
// Kein Close hier (Eigentum: Main-Thread) — nur Cancel.
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 data = Data(buf[..<n])
onData?(data)
let events = parser.feed(data)
DispatchQueue.main.async { [weak self] in self?.apply(events) }
} else {
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
}
}
/// Letzte Reste vom PTY-Master einsammeln (non-blocking, EBADF-safe).
/// Pipe-Reste kommen garantiert über den readabilityHandler.
private func finalFlush(code: Int32, myEpoch: Int) {
var events: [TerminalEvent] = []
let fd = masterFD
if fd >= 0 {
var buf = [UInt8](repeating: 0, count: 65536)
while true {
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
events += parser.feed(Data(buf[..<n]))
}
}
DispatchQueue.main.async { [weak self] in
guard let self, self.epoch == myEpoch, !self.finished else { return }
self.apply(events)
self.finish(message: "Sitzung beendet (Code \(code)).", code: code)
}
}
private func apply(_ events: [TerminalEvent]) {
for ev in events {
switch ev {
case .text(let seg):
if lines.isEmpty { lines.append([]) }
if var last = lines[lines.count - 1].last, last.style == seg.style {
last.text += seg.text
lines[lines.count - 1][lines[lines.count - 1].count - 1] = last
} else {
lines[lines.count - 1].append(seg)
}
case .newline:
lines.append([])
case .carriageReturn:
// Overwrite-Modus (Fortschrittsbalken): aktuelle Zeile leeren
if !lines.isEmpty { lines[lines.count - 1] = [] }
case .backspace:
guard !lines.isEmpty, !lines[lines.count - 1].isEmpty else { break }
var row = lines[lines.count - 1]
if var last = row.last, !last.text.isEmpty {
last.text.removeLast()
if last.text.isEmpty { row.removeLast() } else { row[row.count - 1] = last }
lines[lines.count - 1] = row
}
case .clearScreen:
lines = [[]]
case .clearLine:
if !lines.isEmpty { lines[lines.count - 1] = [] }
case .bell:
NSSound.beep()
}
}
if lines.count > maxLines {
lines.removeFirst(lines.count - maxLines)
}
}
func send(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
sendBytes(data)
}
/// Raw bytes from the terminal emulator (keyboard) to the pty master.
func sendBytes(_ data: Data) {
let fd = masterFD
guard fd >= 0, !data.isEmpty else { return }
ioQueue.async {
data.withUnsafeBytes { ptr in
guard let base = ptr.baseAddress else { return }
var written = 0
while written < data.count {
let n = write(fd, base.advanced(by: written), data.count - written)
if n <= 0 { break }
written += n
}
}
}
}
/// Terminal size to the pty (TIOCSWINSZ) so vim etc. use the full area.
func setWinsize(cols: Int, rows: Int) {
let fd = masterFD
guard fd >= 0, cols > 0, rows > 0 else { return }
var ws = winsize(ws_row: UInt16(min(rows, 65535)),
ws_col: UInt16(min(cols, 65535)),
ws_xpixel: 0, ws_ypixel: 0)
_ = ioctl(fd, TIOCSWINSZ, &ws)
}
func clear() {
lines = [[]]
}
/// ctty-run liegt neben diesem Binary (Contents/MacOS wie .build/debug).
static func cttyHelperURL() -> URL? {
let sibling = URL(fileURLWithPath: CommandLine.arguments[0])
.deletingLastPathComponent()
.appendingPathComponent("ctty-run")
if FileManager.default.isExecutableFile(atPath: sibling.path) {
return sibling
}
return nil
}
func note(_ message: String) {
lines.append([StyledText(text: message, style: TermStyle(fg: 8))])
}
/// Vorab-Check-Fehlschlag (ohne Spawn): rote Notiz + done.
func fail(_ message: String) {
cleanupSync()
epoch += 1
finished = true
state = .done(exitCode: nil, message: message)
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 1))])
lines.append([])
}
func stop() {
let wasRunning = isRunning
killTree()
cleanupSync()
if wasRunning {
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
finished = true
state = .done(exitCode: nil, message: "Getrennt.")
lines.append([StyledText(text: "— Getrennt. —", style: TermStyle(fg: 8))])
lines.append([])
}
}
/// Beendet Kind + Enkel (ctty-run-Wächter + ssh teilen die Prozessgruppe).
private func killTree() {
if let pid = process?.processIdentifier, pid > 0 {
Darwin.kill(-pid, SIGTERM)
}
process?.terminate()
}
/// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen.
/// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst.
private func cleanupSync() {
readSource?.cancel()
readSource = nil
if masterFD >= 0 {
close(masterFD)
masterFD = -1
}
if let pipe = errPipe {
pipe.fileHandleForReading.readabilityHandler = nil
errPipe = nil
}
process = nil
}
private func finish(message: String, code: Int32?) {
guard !finished else { return }
finished = true
cleanupSync()
state = .done(exitCode: code, message: message)
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 8))])
lines.append([])
}
deinit {
if let pid = process?.processIdentifier, pid > 0 {
Darwin.kill(-pid, SIGTERM)
}
process?.terminate()
readSource?.cancel()
if masterFD >= 0 { close(masterFD) }
}
}
/// 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 {
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert
// und sshpass-Verfügbarkeit): Ändert der User Host/Passwort/Key oder wird
// sshpass (nach-)installiert, wird neu konfiguriert statt stale weiterzuwählen.
let secret = SSHService.loginSecret(for: profile, key: key)
let sshpass = SSHService.findSshpass()
let tag = Self.configTag(profile: profile, keyPath: key?.privateKeyPath,
secret: secret, sshpass: sshpass)
if let existing = sessions[profile.id] {
// Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen.
if existing.isRunning || existing.configTag == tag { return existing }
existing.stop()
configure(existing, profile: profile, key: key, secret: secret, sshpass: sshpass)
existing.configTag = tag
return existing
}
let s = PTYSession()
configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass)
s.configTag = tag
sessions[profile.id] = s
return s
}
/// Pure tag builder (testable): any change here must reconfigure.
nonisolated static func configTag(profile: ConnectionProfile, keyPath: String?, secret: String?, sshpass: String?) -> String {
[profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
profile.extraArgs, keyPath ?? "", secret ?? "", sshpass ?? ""].joined(separator: "|")
}
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?, sshpass: String?) {
let baseExe: String
let baseArgs: [String]
if profile.proto == .sftp {
baseExe = "/usr/bin/sftp"
baseArgs = ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]
} else {
baseExe = "/usr/bin/ssh"
baseArgs = SSHService.sshArguments(for: profile, key: key)
}
// Auto-Login nur mit Keychain-Secret + sshpass, sonst interaktiver Prompt im Terminal.
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
password: secret,
sshpassPath: sshpass)
s.configure(executable: cfg.executable, arguments: cfg.arguments)
var env = cfg.extraEnv
if let sock = SSHService.agentSocket() {
env["SSH_AUTH_SOCK"] = sock
}
s.customEnv = env
s.setAutoLogin(cfg.autoLogin)
}
func close(_ profile: ConnectionProfile) {
sessions[profile.id]?.stop()
sessions.removeValue(forKey: profile.id)
}
}
@@ -0,0 +1,65 @@
import Foundation
import Network
/// Schneller TCP-Vorab-Check, BEVOR ssh gestartet wird.
/// Falscher Port / falscher Host / Firewall → sofort klare Meldung
/// statt „Sitzung beendet (Code 255)" ohne Erklärung.
enum TCPCheck {
private final class Flag {
var done = false
}
/// - completion: nil bei Erfolg, sonst deutsche Fehlermeldung.
static func check(host: String, port: Int, timeout: TimeInterval = 4.0, completion: @escaping (String?) -> Void) {
let h = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !h.isEmpty else { completion("Kein Host angegeben — Profil bearbeiten."); return }
guard (1...65535).contains(port), let p = NWEndpoint.Port(rawValue: UInt16(port)) else {
completion("Ungültiger Port \(port)."); return
}
let conn = NWConnection(host: NWEndpoint.Host(h), port: p, using: .tcp)
let lock = NSLock()
let flag = Flag()
func finish(_ message: String?) {
lock.lock()
defer { lock.unlock() }
guard !flag.done else { return }
flag.done = true
conn.cancel()
completion(message)
}
conn.stateUpdateHandler = { state in
switch state {
case .ready:
finish(nil)
case .failed(let err):
finish(describe(err, host: h, port: port))
default:
break
}
}
conn.start(queue: .global(qos: .userInitiated))
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
finish("Keine Antwort in \(Int(timeout)) s — Hostname/IP, Netzwerk/VPN oder Firewall prüfen.")
}
}
static func describe(_ error: NWError, host: String, port: Int) -> String {
switch error {
case .posix(let code):
switch code.rawValue {
case 61:
return "Port \(port) auf \(host) verweigert die Verbindung — läuft SSH dort? Stimmt der Port?"
case 51, 64, 65:
return "Host \(host) nicht erreichbar — Netzwerk/VPN prüfen."
case 60:
return "Zeitüberschreitung — Host/Port/Firewall prüfen."
default:
return "Netzwerkfehler (\(code.rawValue)): \(error.localizedDescription)"
}
case .dns:
return "Hostname \(host) kann nicht aufgelöst werden — Tippfehler? DNS/VPN prüfen."
default:
return error.localizedDescription
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
import AppKit
import SwiftTerm
/// Offizielle Dracula-Palette (https://draculatheme.com/contribute).
enum Dracula {
static let background = SwiftUI.Color(hex: "282A36")
static let currentLine = SwiftUI.Color(hex: "44475A")
static let foreground = SwiftUI.Color(hex: "F8F8F2")
static let comment = SwiftUI.Color(hex: "6272A4")
static let cyan = SwiftUI.Color(hex: "8BE9FD")
static let green = SwiftUI.Color(hex: "50FA7B")
static let orange = SwiftUI.Color(hex: "FFB86C")
static let pink = SwiftUI.Color(hex: "FF79C6")
static let purple = SwiftUI.Color(hex: "BD93F9")
static let red = SwiftUI.Color(hex: "FF5555")
static let yellow = SwiftUI.Color(hex: "F1FA8C")
/// ANSI-Index 0-15 -> Dracula-Farbe.
static func color(for index: Int?) -> SwiftUI.Color {
guard let index else { return foreground }
switch index {
case 0: return SwiftUI.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 SwiftUI.Color(hex: "FF6E6E")
case 10: return SwiftUI.Color(hex: "69FF94")
case 11: return SwiftUI.Color(hex: "FFFFA5")
case 12: return SwiftUI.Color(hex: "D6ACFF")
case 13: return SwiftUI.Color(hex: "FF92DF")
case 14: return SwiftUI.Color(hex: "A4FFFF")
default: return SwiftUI.Color(hex: "FFFFFF")
}
}
static func nsColor(for index: Int?) -> NSColor {
NSColor(color(for: index))
}
static let nsBackground = NSColor(SwiftUI.Color(hex: "282A36"))
static let nsSelection = NSColor(SwiftUI.Color(hex: "44475A"))
/// 16 ANSI colors for the terminal emulator (SwiftTerm palette).
static var ansiPalette: [SwiftTerm.Color] {
["21222C", "FF5555", "50FA7B", "F1FA8C", "BD93F9", "FF79C6", "8BE9FD", "F8F8F2",
"6272A4", "FF6E6E", "69FF94", "FFFFA5", "D6ACFF", "FF92DF", "A4FFFF", "FFFFFF"]
.compactMap { SwiftTerm.Color.parse("#\($0)") }
}
}
extension SwiftUI.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)
}
}
+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)
}
}
+23 -5
View File
@@ -3,12 +3,14 @@ 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?
@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 {
@@ -58,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: {
@@ -68,7 +72,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) }
}
@@ -98,7 +107,7 @@ struct ContentView: View {
}
}
.sheet(item: $editingProfile) { profile in
ProfileEditorView(profile: profile) { saved in
ProfileEditorView(profile: profile, isNew: !store.profiles.contains(where: { $0.id == profile.id })) { saved in
if store.profiles.contains(where: { $0.id == saved.id }) {
store.update(saved)
} else {
@@ -110,6 +119,9 @@ struct ContentView: View {
.sheet(isPresented: $showingKeys) {
SSHKeysView().environmentObject(store)
}
.sheet(isPresented: $showingAbout) {
AboutView()
}
}
var selectedProfile: ConnectionProfile? {
@@ -143,11 +155,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 +168,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) }
@@ -0,0 +1,199 @@
import SwiftUI
import AppKit
/// 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 checking = false
@State private var hostKeyRequest: HostKeyRequest?
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)
}
// Full terminal emulator (click to focus, type directly).
SwiftTermView(term: term)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.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(item: $hostKeyRequest) { req in
HostKeySheet(info: req.info, isChange: req.isChange,
onAccept: { acceptHostKey(req) },
onCancel: {
hostKeyRequest = nil
checking = false
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).
// Läuft im Hintergrund: ssh-keyscan blockiert Sekunden (kein UI-Freeze).
let host = profile.host
let port = profile.port
DispatchQueue.global(qos: .userInitiated).async {
let hk = HostKeyService.status(host: host, port: port)
DispatchQueue.main.async {
switch hk {
case .known, .unreachable:
checking = false
term.start()
case .unknown(let info):
hostKeyRequest = HostKeyRequest(info: info, isChange: false)
case .changed(let info):
hostKeyRequest = HostKeyRequest(info: info, isChange: true)
}
}
}
}
}
}
func acceptHostKey(_ req: HostKeyRequest) {
hostKeyRequest = nil
if req.isChange {
_ = HostKeyService.revoke(host: profile.host, port: profile.port)
}
if HostKeyService.accept(req.info) {
checking = false
term.note("Host-Key akzeptiert (\(req.info.keyType), \(req.info.fingerprintSHA256.prefix(19))…).")
term.start()
} else {
checking = false
term.fail("Host-Key konnte nicht in known_hosts gespeichert werden.")
}
}
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).")
}
}
}
}
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
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
}
}
}
@@ -0,0 +1,65 @@
import SwiftUI
/// Sheet-Request (Identifiable): .sheet(item:) rendert IMMER mit Wert —
/// ein `if let` im Sheet kann den Empty-Zweig nehmen und bleibt dann leer.
struct HostKeyRequest: Identifiable {
let id = UUID()
var info: HostKeyInfo
var isChange: Bool
}
/// Popup bei unbekanntem/geändertem Host-Key: Fingerprint prüfen, dann
/// akzeptieren (→ known_hosts + verbinden) oder abbrechen.
struct HostKeySheet: View {
var info: HostKeyInfo
var isChange: Bool
var onAccept: () -> Void
var onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: isChange ? "exclamationmark.octagon.fill" : "key.fill")
.font(.title)
.foregroundStyle(isChange ? Dracula.red : Dracula.orange)
VStack(alignment: .leading) {
Text(isChange ? "Host-Key GEÄNDERT" : "Neuer Host-Key")
.font(.headline)
Text("\(info.host):\(info.port) · \(info.keyType)")
.font(.subheadline).foregroundStyle(.secondary)
}
}
if isChange {
Text("Warnung: Der gespeicherte Schlüssel passt NICHT zum Server. Das kann ein neu aufgesetzter Server sein — oder ein Angriff (MITM). Nur fortfahren, wenn du die Änderung erwartest!")
.font(.callout).foregroundStyle(Dracula.red)
} else {
Text("Erstverbindung: Dieser Server ist noch unbekannt. Fingerprint prüfen (z. B. mit dem Server-Admin oder einer Terminal-Verbindung vergleichen), dann akzeptieren.")
.font(.callout).foregroundStyle(.secondary)
}
GroupBox("SHA256-Fingerprint") {
Text(info.fingerprintSHA256)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox("MD5-Fingerprint") {
Text(info.fingerprintMD5)
.font(.system(.callout, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
Button("Abbrechen", role: .cancel, action: onCancel)
Spacer()
Button(isChange ? "Trotzdem ersetzen & verbinden" : "Akzeptieren & verbinden", action: onAccept)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 480)
}
}
@@ -4,18 +4,23 @@ struct ProfileEditorView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var store: ProfileStore
@State var profile: ConnectionProfile
var isNew: Bool
var onSave: (ConnectionProfile) -> Void
@State private var password: String = ""
@State private var savePassword: Bool = false
@State private var storedPasswordPresent: Bool = false
init(profile: ConnectionProfile, onSave: @escaping (ConnectionProfile) -> Void) {
init(profile: ConnectionProfile, isNew: Bool, onSave: @escaping (ConnectionProfile) -> Void) {
_profile = State(initialValue: profile)
self.isNew = isNew
self.onSave = onSave
}
var liveWarnings: [String] { ProfileValidation.warnings(for: profile) }
var body: some View {
VStack(spacing: 0) {
Text(profile.id.uuidString.prefix(8) == ConnectionProfile().id.uuidString.prefix(8) ? "Profil bearbeiten" : "Profil").font(.headline).padding()
Text(isNew ? "Neues Profil" : "Profil bearbeiten").font(.headline).padding()
Form {
Section("Allgemein") {
TextField("Name", text: $profile.name)
@@ -34,18 +39,38 @@ struct ProfileEditorView: View {
}
Section("Verbindung") {
TextField("Host", text: $profile.host)
.autocorrectionDisabled()
TextField("Port", value: $profile.port, format: .number)
TextField("Benutzername", text: $profile.username)
.autocorrectionDisabled()
// Passwort direkt unter dem Benutzernamen (Keychain-gespeichert)
if storedPasswordPresent && password.isEmpty {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text("Passwort gespeichert")
Spacer()
Button("Löschen", role: .destructive) { deleteStoredPassword() }
}
}
SecureField(storedPasswordPresent ? "Neues Passwort …" : "Passwort", text: $password)
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
TextField("Auflösung (RDP, z. B. 1920x1080 oder Automatisch)", text: $profile.resolution)
TextField("Extra-Argumente", text: $profile.extraArgs)
if !liveWarnings.isEmpty {
ForEach(liveWarnings, id: \.self) { w in
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange)
Text(w).font(.caption).foregroundStyle(.orange)
}
Section("Authentifizierung") {
}
}
}
Section("SSH-Key (optional)") {
Picker("SSH-Key", selection: $profile.sshKeyID) {
Text("Kein Key").tag(UUID?.none)
Text("Kein Key (Passwort-Login)").tag(UUID?.none)
ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) }
}
SecureField("Passwort (optional, Keychain)", text: $password)
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
Text(sshpassHint).font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
@@ -54,7 +79,7 @@ struct ProfileEditorView: View {
Button("Abbrechen", role: .cancel) { dismiss() }
Spacer()
Button("Speichern") {
var toSave = profile
var toSave = ProfileValidation.cleaned(profile)
if toSave.port == 0 { toSave.port = toSave.proto.defaultPort }
if toSave.passwordAccount == nil { toSave.passwordAccount = "profile-\(toSave.id.uuidString)" }
if savePassword, !password.isEmpty, let acct = toSave.passwordAccount {
@@ -64,16 +89,35 @@ struct ProfileEditorView: View {
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(profile.host.isEmpty || profile.name.isEmpty)
.disabled(profile.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| profile.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
}
.frame(width: 520, height: 620)
.frame(width: 520, height: 680)
.onAppear {
if let acct = profile.passwordAccount,
let stored = KeychainHelper.load(account: acct), !stored.isEmpty {
storedPasswordPresent = true
savePassword = true
}
}
}
var sshpassHint: String {
if SSHService.findSshpass() != nil {
return "sshpass gefunden → Auto-Login mit Keychain-Passwort aktiv, kein Tippen nötig."
} else {
return "Ohne sshpass: Passwort wird beim Verbinden im Terminal abgefragt. Für Auto-Login: brew install sshpass."
}
}
func deleteStoredPassword() {
if let acct = profile.passwordAccount {
password = KeychainHelper.load(account: acct) ?? ""
savePassword = !password.isEmpty
}
KeychainHelper.delete(account: acct)
}
password = ""
savePassword = false
storedPasswordPresent = false
}
}
+20 -7
View File
@@ -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, profile: profile, 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!))."
@@ -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) {}
}
}
+58
View File
@@ -0,0 +1,58 @@
// ctty-run: gibt dem Kindprozess ein Controlling-Terminal auf dem PTY-Slave.
// Usage: ctty-run <slave-pfad> <programm> [args...]
// stdin/stdout zeigen dabei bereits auf den Slave (vom Parent gesetzt),
// stderr bleibt unberührt (Eltern-Pipe für garantierte Fehlerausgabe).
//
// Warum Double-Fork: Apples Process startet Kinder in eigener Prozessgruppe,
// daher schlägt setsid() im direkten Kind mit EPERM fehl. Der Wächter forkt
// einmal weiter (Enkel = kein Leader mehr), wartet auf ihn und übernimmt
// dessen Exit-Code — Lifecycle/Exit-Codes bleiben für den Parent korrekt.
// Der Enkel: setsid() -> Slave einmal öffnen (wird dadurch Ctty) -> exec.
// Ohne Ctty scheitern ssh-Passwort- und Yes/No-Prompts still (/dev/tty ENXIO).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/wait.h>
int
main(int argc, char **argv)
{
if (argc < 3) {
dprintf(STDERR_FILENO, "usage: ctty-run <slave> <prog> [args...]\n");
return 2;
}
pid_t pid = fork();
if (pid < 0) {
dprintf(STDERR_FILENO, "ctty-run: fork: %s\n", strerror(errno));
return 1;
}
if (pid > 0) {
/* Wächter: keine FDs offen halten (sonst nie EOF), aufs Kind warten. */
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR)
;
if (WIFEXITED(st))
return WEXITSTATUS(st);
return 128;
}
/* Enkel: eigene Session; danach Slave-Open = Ctty. */
if (setsid() < 0) {
dprintf(STDERR_FILENO, "ctty-run: setsid: %s (weiter ohne Ctty)\n",
strerror(errno));
}
int fd = open(argv[1], O_RDWR);
if (fd < 0) {
dprintf(STDERR_FILENO, "ctty-run: open slave: %s\n", strerror(errno));
_exit(1);
}
close(fd); /* Ctty bleibt der Session erhalten */
execvp(argv[2], &argv[2]);
dprintf(STDERR_FILENO, "ctty-run: execvp: %s\n", strerror(errno));
_exit(127);
}
+53 -25
View File
@@ -1,33 +1,61 @@
# Architektur
# Architecture
```
Sources/RemminaMac/
RemminaMacApp.swift # @main, WindowGroup, lädt ProfileStore
RemminaMacApp.swift # @main, WindowGroup, loads ProfileStore
Models/ConnectionProfile.swift # ProtocolType + ConnectionProfile + StoredSSHKey (Codable)
Stores/ProfileStore.swift # ObservableObject, JSON-Persistenz, Filter/Suche/Gruppen
Security/KeychainHelper.swift# SecItemAdd/Copy/Delete Wrapper
Services/ConnectionServices.swift # SSHService (ssh-Befehl) + RDPService (freerdp/rdc/vnc)
Views/ContentView.swift # NavigationSplitView: Sidebar + Tabs + Statusbar
Views/SessionView.swift # Detail pro Sitzung
Views/ProfileEditorView.swift# Formular Neu/Bearbeiten + Keychain-Anbindung
Views/SSHKeysView.swift # Key-Liste, Import, ssh-keygen
Models/ProfileValidation.swift # input checks + trimming (pure, tested)
Stores/ProfileStore.swift # ObservableObject, JSON persistence, filter/search/groups
Security/KeychainHelper.swift # SecItemAdd/Copy/Delete wrapper
Services/ConnectionServices.swift # SSHService (ssh args, secrets, sshpass, agent) + RDPService
Services/PTYSession.swift # real PTY session (posix_openpt), stderr pipe, TerminalManager
Services/AnsiParser.swift # ANSI/SGR parser for diagnostics buffer (pure, tested)
Services/TCPCheck.swift # TCP pre-check via Network.framework
Services/HostKeyService.swift # keyscan/fingerprint/accept/revoke via system ssh tools
Services/TerminalTheme.swift # Dracula palette (SwiftUI + NSColor + SwiftTerm)
Views/ContentView.swift # NavigationSplitView: sidebar + tabs + status bar
Views/SessionView.swift # per-session detail
Views/SwiftTermView.swift # full terminal emulator view (SwiftTerm, Dracula)
Views/ProfileEditorView.swift # new/edit form + Keychain wiring + validation
Views/SSHKeysView.swift # key list, import, ssh-keygen
Views/HostKeySheet.swift # fingerprint popup (accept/decline, MITM warning)
Views/AboutView.swift # about sheet (contributor, repo link)
Sources/ctty-run/
ctty-run.c # gives the ssh child a controlling terminal
# (double-fork: setsid + slave open + exec)
```
## Datenfluss
1. `RemminaMacApp` erstellt `ProfileStore` als `@StateObject`, injiziert als `environmentObject`.
2. `ProfileStore.load()` liest `profiles.json` + `sshkeys.json`, sonst Sample-Daten.
3. `ContentView` filtert via `searchText`/`selectedGroup`, `selection` steuert Detail.
4. `connect(_:)` setzt `lastConnected`, öffnet Tab, delegiert an `SSHService`/`RDPService`.
5. Secrets nur via `KeychainHelper`, JSON enthält nur Account-Referenzen.
## Data flow
1. `RemminaMacApp` creates `ProfileStore` as `@StateObject`, injects as `environmentObject`.
2. `ProfileStore.load()` reads `profiles.json` + `sshkeys.json`, else sample data.
3. `ContentView` filters via `searchText`/`selectedGroup`; `selection` drives detail.
4. `connect(_:)` stamps `lastConnected`, opens a tab. The terminal view runs:
TCP pre-check → host-key status (popup if unknown/changed) → `PTYSession.start()`.
5. `PTYSession` spawns via `ctty-run` (controlling terminal): stdin/stdout on the
pty slave, stderr on a loss-free pipe. Output feeds SwiftTerm (`onData`) and
the diagnostics buffer (`lines`, used by the error banner + report).
6. Keyboard flows from SwiftTerm's delegate straight into the pty master
(`sendBytes`); window resizes propagate via `TIOCSWINSZ` (`setWinsize`).
7. Secrets only via `KeychainHelper`; JSON holds account references.
`TerminalManager` caches one session per profile and reconfigures on any
config change (fingerprint incl. secret + sshpass presence).
## Wichtige Dateien
- `Stores/ProfileStore.swift:13` — Pfade + `load()`/`save()`
- `Models/ConnectionProfile.swift:3` — Protokoll-Defaults (Ports, Icons)
- `Services/ConnectionServices.swift:6` — `sshArguments(for:key:)`
- `Views/ContentView.swift` — Remmina-Layout
## Key files
- `Stores/ProfileStore.swift` — paths + `load()`/`save()`
- `Models/ConnectionProfile.swift` — protocol defaults (ports, icons)
- `Services/PTYSession.swift` — spawn, lifecycle, `TerminalManager`
- `Services/HostKeyService.swift` — fingerprint flow
- `Views/SwiftTermView.swift` — emulator wiring + Dracula theme
- `Views/ContentView.swift` — Remmina layout
## Nächste Ausbaustufen
1. SwiftTerm-View statt externem Terminal + `swift-nio-ssh` für echte SSH-Sessions.
2. FreeRDP als XPC-Service einbetten statt `Process`.
3. SFTP-Browser (NIO SFTP oder libssh2-Bridge).
4. Export/Import-Buttons + Drag&Drop für Keys.
## Verification strategy
Logic is verified with throwaway `swiftc` harnesses in `/tmp` (never committed):
ANSI parser, PTY roundtrips, real `ssh` runs, Keychain roundtrips, TCP checks,
host-key flow against a local paramiko test server, SwiftTerm core headless
(cursor addressing, alt-screen, clear), view-layer stress. See `docs/ERRORS.md`.
## Next steps
1. Embedded RDP (FreeRDP bridge) instead of `Process`.
2. Integrated SFTP browser.
3. Export/Import buttons + drag & drop for keys.
4. Tray / favorites / folder badges like Remmina.
+157 -86
View File
@@ -1,102 +1,173 @@
# Fehlerprotokoll (ERRORS.md)
# Error log (ERRORS.md)
Alle Fehler, die beim Erstellen / Bauen / Releasen von RemminaMac aufgetreten sind — mit Ursache, Fix und Status. Wird bei jedem Build fortgeschrieben.
Every error hit while building/releasing RemminaMac — cause, fix, status. Updated with every build.
Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+
As of 2026-09-22 · version 0.7.0 · macOS arm64 · Swift 5.9+ · Xcode 15+
## Übersicht
| # | Zeitpunkt | Phase | Fehler | Schwere | Status |
|---|-----------|-------|--------|---------|--------|
| 1 | 2026-09-21 | `swift build` (debug) | `WindowGroup<some View> has no member 'windowTitle'` | blockierend | ✅ behoben |
| 2 | 2026-09-21 | `swift build -c release` | keine Fehler, nur lange Link-Zeit (~40 s) | Info | ✅ ok |
| 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` (Host `git.sysdaemon.xyz`, v1.27.3 erreichbar) | blockierend (Push/Release) | ⬜ offen — Credentials prüfen |
## Overview
| # | Date | Phase | Error | Severity | Status |
|---|------|-------|-------|----------|--------|
| 1 | 2026-09-21 | `swift build` (debug) | `WindowGroup<some View> has no member 'windowTitle'` | blocking | ✅ fixed |
| 2 | 2026-09-21 | `swift build -c release` | no errors, just slow first link (~40 s) | info | ✅ ok |
| 3 | 2026-09-21 | release script | `dist/` missing from `.gitignore` → binary almost committed | medium | ✅ fixed (ignored) |
| 4 | 2026-09-21 | Gitea upload | `GET /api/v1/user` → `401` with old credentials | blocking | ✅ fixed — new credentials, push + v0.1.0 live |
| 5 | 2026-09-21 | app bundle + DMG | no errors: signing ok, UDZO DMG, mount verified | info | ✅ ok |
| 6 | 2026-09-21 | SSH connect | no terminal opened: `NSAppleScript` failed silently (no AppleEvents permission/handling) | blocking | ✅ fixed — in-app PTY terminal (Dracula) + `.command` fallback |
| 7 | 2026-09-21 | ANSI parser (test) | `feed()` flushed chunk AFTER `parseCSI` → wrong style | medium | ✅ fixed — flush before style switch |
| 8 | 2026-09-21 | SSH session dies instantly, no message | stderr shared the PTY: macOS drops unread master data on slave close → bare "code 255" | blocking | ✅ fixed — stderr→pipe, TCP pre-check, error banner |
| 9 | 2026-09-21 | `swift build` | `Result<Void, String>`: String is not Error (follow-on DispatchQueue errors) | blocking | ✅ fixed — completion as `(String?) -> Void` |
| 10 | 2026-09-21 | icon generator (`RenderIcon.swift`) | 3 Swift errors: non-optional `guard let`, `Int * CGFloat`, wrong preview path | small | ✅ fixed |
| 11 | 2026-09-21 | password feature | request: store passwords + keys AND use them (stored but never used) | feature | ✅ done — sshpass auto-login, else interactive |
| 12 | 2026-09-21 | "always connection wrong" | user report without details | open | ⬜ partly: editor validation + copyable error report |
| 13 | 2026-09-21 | Permission denied with correct password | text substitution in input field mangled special chars | high | ✅ fixed — hardened input field (all substitutions off, 9/9) |
| 14 | 2026-09-21 | Terminal works, app doesn't (same password) | SSH agent socket missing for GUI processes → maybe key login in Terminal | high | ✅ app forwards agent socket; user confirmation open |
| 15 | 2026-09-21 | crash on type + send | cause unclear (not reproducible headless) | critical | ⬜ hardened (v0.5.3); crash log requested |
| 16 | 2026-09-22 | profile edits had no effect | session cache ignored host/password/key corrections | high | ✅ fixed — fingerprint reconfig (9/9) |
| 17 | 2026-09-22 | rzsv: pre-check timeout | port 22 dropped server-side (443 ok) — not an app bug | external | ⬜ server side: fail2ban/VPN/firewall |
| 18 | 2026-09-22 | host-key yes/no impossible | ssh without controlling terminal: /dev/tty ENXIO → silent abort | critical | ✅ ctty-run helper (double-fork) + fingerprint popup |
| 19 | 2026-09-22 | empty popup (see screenshot) | `if let` in sheet takes empty branch with no update path | medium | ✅ `.sheet(item:)` + background keyscan |
| 20 | 2026-09-22 | terminal not fully interactive | single-line input, no completion, no vim | feature | ✅ done — SwiftTerm emulator (0.7.0) |
| 21 | 2026-09-22 | yes-asked-twice + password ignored | popup-accept race; sshpass installs went stale | medium | ✅ verified no re-ask; sshpass in fingerprint |
---
## #1 — `windowTitle` existiert nicht auf `WindowGroup`
**Phase:** erster `swift build`
**Log (gekürzt):**
```
Sources/RemminaMac/RemminaMacApp.swift:14:10: error: value of type 'WindowGroup<some View>' has no member 'windowTitle'
14 | .windowTitle("RemminaMac")
```
**Ursache:** `windowTitle(_:)` ist kein Modifier für `WindowGroup`. Der Fenstertitel wird im `WindowGroup`-Init übergeben.
**Fix:** `Sources/RemminaMac/RemminaMacApp.swift`:
```swift
// vorher (falsch):
WindowGroup { ... }.windowTitle("RemminaMac")
// nachher (korrekt):
WindowGroup("RemminaMac") { ... }
```
**Verifikation:** `swift build` → `Build complete! (1.31s)`.
**Status:** ✅ behoben, kein Re-Check offen.
## #2 — Release-Build langsam, aber fehlerfrei
**Phase:** `bash scripts/build-release.sh 0.1.0` → `swift build -c release`
**Log:**
```
Building for production...
[4/5] Linking RemminaMac
Build complete! (40.54s)
```
**Ursache:** keine — Release-Optimierung + Link brauchen beim ersten Mal ~40 s.
**Fix:** keiner nötig. Bei wiederholten Builds ist es schneller (Cache in `.build/`).
**Artefakte:**
```
dist/RemminaMac-0.1.0-macos-arm64.zip (186K)
dist/RemminaMac-0.1.0-macos-arm64.zip.sha256 (c476bb3c…)
```
**Status:** ✅ ok.
## #3 — `dist/` fast committet
**Phase:** Release-Vorbereitung
**Ursache:** `dist/` enthielt ZIP + entpacktes Binary, `.gitignore` kannte es noch nicht.
**Fix:** `.gitignore` ergänzt:
```
.build/
dist/
*.zip
*.sha256
```
**Verifikation:** `git status --short` zeigt kein `dist/` mehr.
**Status:** ✅ behoben.
## Vorlage für neue Fehler
## Template for new errors
```markdown
## #N — Titel
## #N — Title
**Phase:** ...
**Log:** ...
**Ursache:** ...
**Cause:** ...
**Fix:** ...
**Verifikation:** ...
**Status:** ⬜ offen / ✅ behoben
**Verified:** ...
**Status:** ⬜ open / ✅ fixed
```
## 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.
## How errors get here
- Every `swift build` / `swift build -c release` / script run with an error → log excerpt + exit code goes here.
- Gitea push/release errors likewise.
- New tests: `AnsiParser` via `swiftc` harness, `PTYSession` smoke tests, Keychain roundtrips, TCP checks, host-key flow vs local paramiko server, SwiftTerm core headless.
## #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)
## #1 — `windowTitle` doesn't exist on `WindowGroup`
**Phase:** first `swift build`
**Log:**
```
GET /user -> 401
{"message":"invalid username, password or token","url":"https://git.sysdaemon.xyz/api/swagger"}
RemminaMacApp.swift:14:10: error: value of type 'WindowGroup<some View>' has no member 'windowTitle'
```
Getestet mit Login `x3` und `x3@sysdaemon.xyz` — beide 401. Kein Quote-/Encoding-Problem (Basic-Auth via Python, kein Shell-Escaping).
**Mögliche Ursachen:**
1. Passwort falsch/ablaufen bzw. Sonderzeichen-Typo
2. Account braucht App-Token statt Passwort (Gitea: User → Einstellungen → Anwendungen → Token erzeugen)
3. Falscher Owner-/Repo-Name
**Fix (manuell, sobald Credentials stimmen):**
```bash
cd RemminaMac
git remote add origin https://<user>@git.sysdaemon.xyz/<owner>/remmina-mac.git
git push -u origin main
export GITEA_HOST="https://git.sysdaemon.xyz" GITEA_OWNER="<owner>" GITEA_REPO="remmina-mac" GITEA_TOKEN="<token>" VERSION="0.1.0"
./scripts/publish-gitea-release.sh
```
Oder mit Token als Passwort: `git push https://<owner>:<token>@git.sysdaemon.xyz/<owner>/remmina-mac.git main`.
**Lokaler Stand:** Commit `cd834dc` + `dist/RemminaMac-0.1.0-macos-arm64.zip` sind fertig und warteten nur auf den Push.
**Status:** ⬜ offen — bitte gültige Credentials / Token liefern, dann pushe ich erneut.
**Cause:** `windowTitle(_:)` is not a `WindowGroup` modifier; the title goes in the `WindowGroup` init.
**Fix:** `WindowGroup("RemminaMac") { ... }`.
**Status:** ✅ fixed.
## #2 — Slow but clean release build
**Phase:** `bash scripts/build-release.sh 0.1.0` → `swift build -c release` (~40 s first link).
**Cause:** none — release optimization + link just take a while cold; faster on rebuilds (`.build/` cache).
**Status:** ✅ ok.
## #3 — `dist/` almost committed
**Cause:** `dist/` held ZIP + unpacked binary, `.gitignore` didn't know it.
**Fix:** ignore `.build/`, `dist/`, `*.zip`, `*.sha256`.
**Status:** ✅ fixed.
## #4 — Gitea upload: 401 despite reachable server
**Phase:** `POST /api/v1/user/repos` + `GET /api/v1/user` on `https://git.sysdaemon.xyz` (v1.27.3 confirmed via `/api/v1/version`).
**Log:** `GET /user -> 401 {"message":"invalid username, password or token"}` — tried logins `x3` and `x3@sysdaemon.xyz`, both 401 (Basic auth via Python, no shell-quoting issue).
**Fix:** with corrected credentials: `GET /api/v1/user → 200 (login x3)`, repo `x3/remmina-mac` created (201), push ok, release `v0.1.0` + assets uploaded.
**Status:** ✅ fixed.
## #6 — SSH click opens no terminal (user bug report)
**Cause:** `SSHService.openInTerminal` used `NSAppleScript(tell application "Terminal" …)` — fails silently without `NSAppleEventsUsageDescription` and with no error handling.
**Fix:** `Services/PTYSession.swift` (real PTY via `posix_openpt`), `Views/EmbeddedTerminalView.swift` (in-app Dracula terminal, auto-connect), external fallback via `.command` file + `NSWorkspace` (no automation rights needed).
**Verified:** build ok; parser 8/8; PTY smoke (`/bin/echo` → output + exit 0); `ssh -G` through PTY ok.
**Status:** ✅ fixed.
## #7 — ANSI parser: text got the wrong style
**Phase:** `swiftc` harness during #6. Log: `text("rot", fg: nil)` instead of `fg: 1`.
**Cause:** chunk flushed only after the SGR switch.
**Fix:** `flush()` before `parseCSI`. Harness 8/8 green.
**Status:** ✅ fixed.
## #8 — Terminal connects then dies instantly, no message (user bug report)
**Repro (harness, exact app path `ssh -p 1 nobody@127.0.0.1`):** exit 255, output shows only the session note — ssh's error lost.
**Ground truth (Python pty):** even `/bin/echo hello` yields `b''` when read only AFTER child exit; reading promptly yields `b'hello-py\r\n'` → macOS drops unread PTY master data on slave close (then EIO). Our `DispatchSourceRead` loses that race for instant-exit children — and ssh writes everything (incl. "Connection refused") to stderr, which shared the PTY.
**Fix:** stderr → `Pipe` (buffered data survives child exit), stdin/stdout stay on PTY; non-blocking `finalFlush` + epoch-guarded lifecycle; TCP pre-check (Network.framework, 4 s) with plain-language messages; red error banner + Diagnose/Retry buttons.
**Verified:** refused harness now shows the error (PASS); `cat` roundtrip, `ssh -G`, TCP harness (closed/open/empty) PASS.
**Status:** ✅ fixed.
## #9 — Build: `Result<Void, String>` invalid
**Log:** `error: type 'String' does not conform to protocol 'Error'` (+ follow-on errors).
**Fix:** `TCPCheck.check` uses `(String?) -> Void` (nil = ok, else message).
**Status:** ✅ fixed.
## #10 — Icon generator build errors
**Log:** non-optional `guard let` (`??` already unwraps); `Int * CGFloat`; preview landed in `.iconset/` instead of `/tmp/`.
**Fix:** assign `??` fallback directly; `CGFloat(...)`; path corrected.
**Status:** ✅ fixed. Icon: `Resources/AppIcon.icns` (reproducible via `scripts/generate-icon.sh`), preview `docs/icon-preview.png`.
## #11 — Store AND use passwords (user request)
**State:** passwords/passphrases were stored in Keychain but never used for login (ssh always asked interactively).
**Done:** `SSHService.loginSecret()` (profile password → key passphrase), pure/testable `sessionConfig()`: with secret + sshpass → `sshpass -e ssh/sftp …` + `SSHPASS` env (never command line/logs); otherwise direct call + interactive prompt. `PTYSession.customEnv` + auto-login flag; status shows auto-login state. Editor: saved indicator (✓), delete button, sshpass hint.
**Verified:** config harness 9/9 (incl. "password never on command line"); real Keychain roundtrip 7/7.
**Note:** without `sshpass` (`brew install sshpass`) the interactive prompt remains.
**Status:** ✅ done.
## #12 — "Always connection wrong" (user report, details open)
**State:** reported without target host, banner text or credentials — not reproducible remotely.
**Checklist:** `user@host` in host field or `ssh://` prefix → live editor warnings now; whitespace trimmed; wrong password / missing key → banner + Diagnose; server blocks (fail2ban, key-only) → check server logs.
**Done:** `Models/ProfileValidation.swift` (7/7), password right below username, "Copy report" in banner (no secrets).
**Status:** ⬜ waiting for error report.
## #13 — Permission denied with correct password (user error report)
**Report:** `administrator@sysdaemon.xyz:22`, ssh asks 3x → `Permission denied (publickey,password)`. Same password works in Terminal.app.
**Cause:** the terminal input was a stock TextField — macOS silently substitutes characters (`"` → “”, `--` → —, autocorrect). A password with special chars arrives mangled; Terminal shows nothing wrong, ssh just denies. Verified: stock `NSTextField` has these switches on, and can't even disable dash substitution (compiler proof).
**Fix (`Views/TerminalInputField.swift`, later superseded by SwiftTerm):** NSTextView-based field with all substitutions explicitly off; Secure variant via `NSSecureTextField`; Enter → send, paste normalization.
**Verified:** harness 9/9 (all flags off).
**Status:** ✅ fixed — retest with v0.5.1+.
## #14 — Terminal.app logs in, RemminaMac doesn't (same password)
**User answers:** v0.5.1 installed, password login, Terminal.app works with the same data.
**Analysis:** app side fully verified → difference must be environmental. Main suspect: **SSH agent** — Finder-launched GUI processes don't inherit `SSH_AUTH_SOCK`. If Terminal silently uses an agent key (not the password), the app fails with the password. Verified: `launchctl getenv SSH_AUTH_SOCK` returns the socket.
**Fix:** `SSHService.agentSocket()` → `TerminalManager` injects `SSH_AUTH_SOCK` into the ssh env (coexists with SSHPASS).
**Verified:** harness 4/4. User confirmation check: `ssh-add -l` + `ssh -o IdentitiesOnly=yes administrator@sysdaemon.xyz`.
**Status:** ✅ app side done — waiting for confirmation.
## #15 — Crash on type + send (user report)
**State:** "crashes as soon as I type and send." Not reproducible headless — every layer green alone.
**Ruled out by test:** SIGPIPE on write (macOS returns EIO, no signal; send-after-stop survives); view layer (real coordinator + NSTextView: umlauts/quotes/paste/Enter, 200 programmatic sets); PTY roundtrip, refused, Keychain, TCP harnesses.
**Hardened (v0.5.3, unproven as THE crash):** detach delegate during programmatic sets (no state mutation mid-render); safe pointer in `send()`.
**Still needed if it recurs:** Console.app → Crash Reports → newest `RemminaMac` entry → `Exception Type`, `Termination Reason`, crashed thread.
**Status:** ⬜ waiting for feedback / crash log.
## #16 — Profile edits had no effect ("still doesn't work")
**State:** user kept reporting failure after fixes. Code review (`TerminalManager.session`): a once-created session was reused forever — host/password/key corrections never reached it.
**Fix:** config fingerprint (proto/host/port/user/extraArgs/key path/secret value) per session; on mismatch with a non-running session: stop + reconfigure (same object, tabs stay). Running sessions are never reconfigured underfoot (disconnect first).
**Verified:** harness 9/9.
**Status:** ✅ fixed — retest with v0.5.4 (re-save profile once).
## #17 — rzsv: pre-check timeout (not an app bug, screenshot proof)
**Picture:** tab rzsv (`administrator@sysdaemon.xyz:22`), banner "pre-check: no answer in 4 s", ssh correctly NOT started.
**Measured (same machine):** `sysdaemon.xyz` → `185.117.250.190`; port 443 ok in 0.02 s, port 22 dropped (timeout, no refused). Server reachable, SSH port dropped from this network. No client can connect from here — the pre-check was right.
**Likely causes:** fail2ban ban after the many denied attempts (`fail2ban-client status sshd` → unban); VPN on/off mismatch; firewall allows 22 only from certain networks.
**Update from user:** port 22 is blocked on the company network; hotspot works. Confirmed.
**Status:** ⬜ server/network side.
## #18 — Host-key yes/no impossible (user screenshot: Terminal works, app only says "Host key verification failed.")
**Analysis:** Terminal shows the `Are you sure...?` prompt (with fingerprint), app aborts without prompt. Proof via `-v`: `read_passphrase: can't open /dev/tty: Device not configured` — ssh prompts need a controlling terminal; our PTY slave wasn't one (GUI app has none, child inherits none). Affects password AND yes/no prompts.
**Fix:** `Sources/ctty-run/ctty-run.c` (C helper, SPM target): double-fork (needed because Apple's Process starts children in their own process group → direct setsid = EPERM) → setsid → slave open (= ctty) → exec ssh. stderr stays on the parent pipe (loss-free). `stop()` kills the process group (no orphans, verified). Plus `Views/HostKeySheet.swift` flow: `ssh-keyscan` fingerprint upfront → popup (SHA256/MD5, type) → accept (→ shared `~/.ssh/known_hosts`) / decline; red MITM warning + `ssh-keygen -R` path on changed keys.
**Verified (local paramiko server):** bug reproduced (no prompt, exit 255) → after fix: ctty proven via `ps` (ttys001), yes/no visible + answerable, password accepted, shell active. HostKeyService: unknown/accept/known/changed/revoke/unreachable green.
**Status:** ✅ fixed (v0.6.0).
## #19 — Fingerprint popup appears empty (user screenshot)
**Picture:** sheet window present but tiny and contentless; status stuck on "checking …".
**Cause:** `.sheet(isPresented:)` + `if let info` content: if evaluation takes the empty branch at presentation time, the empty branch has no dependency on the state var → the sheet never updates, stays empty. Classic SwiftUI trap.
**Fix:** `.sheet(item: $hostKeyRequest)` with `HostKeyRequest: Identifiable` — content structurally always exists. Plus `HostKeyService.status` (blocking keyscan) moved to a background queue — the UI used to freeze during "checking …".
**Status:** ✅ fixed (v0.6.1).
## #20 — Terminal not fully interactive (user request)
**State:** single-line input row, no Tab completion, no vim/full-screen apps.
**Done (0.7.0):** SwiftTerm emulator (v1.20.0 SPM dep) as `Views/SwiftTermView.swift`: cursor addressing, colors, scrollback, selection, direct keyboard (completion, arrows, Ctrl+C, Esc), vim/htop. Dracula palette via `installColors` + native fg/bg. PTY layer feeds raw bytes (`onData`); keystrokes go straight to the pty master (`sendBytes`); resizes propagate via `TIOCSWINSZ` (`setWinsize`). Old input row + `TerminalInputField.swift` removed (SwiftTerm handles keys natively, immune to macOS substitutions).
**Verified:** headless emulator core 6/6 (text, absolute cursor addressing, alt-screen on/off, clear, 16-color palette); app-path login + shell still green.
**Status:** ✅ done.
## #21 — Asked for yes twice + password ignored (user reports)
**Analysis:** (a) empty-popup era (#19) forced ssh's own yes/no prompt — fixed by the popup fix; verified no re-ask after popup accept (harness asserts no "Are you sure" post-accept). (b) `sshpass` installs went stale: the session fingerprint didn't include sshpass presence, so installing sshpass later never switched to auto-login. Fixed by including it in `TerminalManager.configTag` (now `nonisolated` pure function).
**Verified:** no-ask + login green; tag changes on sshpass variance.
**Status:** ✅ fixed.
+54 -14
View File
@@ -1,40 +1,80 @@
# Installation
## Voraussetzungen
## Requirements
- macOS 14+
- Xcode 15+ (für GUI-Run) oder nur Swift-Toolchain 5.9+ (für CLI-Build)
- Xcode 15+ (for GUI runs) or Swift toolchain 5.9+ (for CLI builds)
## Variante A: Xcode (empfohlen für GUI)
## Dependencies (via Homebrew)
One command covers everything optional:
```bash
brew install sshpass freerdp
```
| Tool | Purpose | Required? | Install |
|------|---------|-----------|---------|
| Xcode Command Line Tools | building (`swift build`, C helper) | build only | `xcode-select --install` |
| `sshpass` | password auto-login from Keychain (no typing) | optional | `brew install sshpass` |
| `xfreerdp` (FreeRDP) | native RDP sessions | optional | `brew install freerdp` |
| `ssh`, `sftp`, `ssh-keyscan`, `ssh-keygen` | SSH core, host-key fingerprints | built into macOS | — |
| SwiftTerm | terminal emulator | bundled via SPM | — |
| Microsoft Remote Desktop | RDP fallback without FreeRDP | optional | App Store |
Without `sshpass`: passwords are asked interactively in the terminal tab.
Without FreeRDP: RDP profiles open Microsoft Remote Desktop instead.
## Option A: Xcode (recommended for GUI)
1. `open Package.swift` in `RemminaMac/`
2. Target `RemminaMac` wählen, Destination `My Mac`
3. `Cmd+R` → App startet
4. Erster Start legt an:
2. Select target `RemminaMac`, destination `My Mac`
3. `Cmd+R` → app launches
4. First launch creates:
- `~/Library/Application Support/RemminaMac/profiles.json`
- `~/Library/Application Support/RemminaMac/sshkeys.json`
## Variante B: Swift CLI
## Option B: Swift CLI
```bash
cd RemminaMac
swift build
.build/debug/RemminaMac
```
## Variante C: Release-Binary (ohne Xcode)
## Option C: Release binary (no Xcode)
```bash
./scripts/build-release.sh
unzip dist/RemminaMac-*-macos-arm64.zip -d /tmp/rmm
/tmp/rmm/RemminaMac
```
## RDP-Voraussetzungen
## Option D: Installer DMG (recommended for end users)
1. Download `RemminaMac-<version>-macos-arm64.dmg` from a Gitea release
(or build locally: `./scripts/build-app.sh <version> && ./scripts/build-dmg.sh <version>`)
2. Double-click the DMG → Finder window with `RemminaMac.app` + `Applications` link
3. Drag `RemminaMac.app` onto `Applications` → `/Applications/RemminaMac.app`
4. Eject, launch from Applications. First launch: right-click → Open
(ad-hoc signature, no Apple notarization).
```bash
# Für volle RDP-Unterstützung:
# Verify:
spctl -a -vv dist/RemminaMac.app
hdiutil verify dist/RemminaMac-*-macos-arm64.dmg
```
## RDP requirements
```bash
# For full RDP support (see dependency table above):
brew install freerdp
which xfreerdp
# Ohne FreeRDP: Microsoft Remote Desktop aus dem App Store installieren
# Without FreeRDP: install Microsoft Remote Desktop from the App Store
```
## SSH auto-login (optional)
```bash
brew install sshpass
# Enables password auto-login from Keychain (no typing).
# Without sshpass: the password prompt appears inside the terminal tab.
# Full list: dependencies table in [docs/INSTALL.md](docs/INSTALL.md#dependencies-via-homebrew).
```
## Troubleshooting
- `swift build` schlägt fehl → `swift --version` prüfen (>= 5.9), Xcode Command Line Tools: `xcode-select --install`
- Keychain-Prompt → einmal erlauben, Service `RemminaMac`
- `xfreerdp` nicht gefunden → Fallback nutzt `rdc://`, braucht MS Remote Desktop
- `swift build` fails → check `swift --version` (>= 5.9), Xcode Command Line Tools: `xcode-select --install`
- Keychain prompt → allow once, service `RemminaMac`
- `xfreerdp` not found → fallback uses `rdc://`, needs MS Remote Desktop
+29 -18
View File
@@ -1,36 +1,47 @@
# Releasing
## Lokal bauen
## Build locally
```bash
./scripts/build-release.sh [version]
# Beispiel:
./scripts/build-release.sh 0.1.0
./scripts/build-release.sh [version] # ZIP with binary + docs
./scripts/build-app.sh [version] # RemminaMac.app (bundle, ad-hoc signed, icon + ctty-run)
./scripts/build-dmg.sh [version] # DMG with app + Applications link
./scripts/generate-icon.sh # re-render AppIcon.icns (Dracula terminal)
# Example:
./scripts/build-release.sh 0.7.0
./scripts/build-app.sh 0.7.0
./scripts/build-dmg.sh 0.7.0
# Output:
# dist/RemminaMac-0.1.0-macos-arm64.zip
# dist/RemminaMac-0.1.0-macos-arm64.zip.sha256
# dist/RemminaMac-0.1.0-macos-arm64/RemminaMac (Binary)
# dist/RemminaMac-0.7.0-macos-arm64.zip (+ .sha256)
# dist/RemminaMac.app/ (with ctty-run helper + icon)
# dist/RemminaMac-0.7.0-macos-arm64.dmg (+ .sha256)
```
## Was das Skript macht
1. `swift build -c release`
2. Binary nach `dist/RemminaMac-<ver>-macos-arm64/` kopieren
3. `README.md` + `docs/` + `LICENSE` dazulegen
4. ZIP + SHA256 erzeugen
Bump `AppInfo.version` in `Views/AboutView.swift` with each release.
## Gitea-Release erstellen
## What the scripts do
1. `swift build -c release` (builds both `RemminaMac` and `ctty-run`)
2. Copy binaries into `dist/RemminaMac-<ver>-macos-arm64/`
3. Add `README.md` + `docs/` + `LICENSE`
4. ZIP + SHA256
`build-app.sh` also copies `AppIcon.icns` and `ctty-run` into the bundle and
signs ad-hoc (`codesign --deep`). `build-dmg.sh` creates the drag-and-drop DMG
via `hdiutil` (no extra tools).
## Publish a Gitea release
```bash
export GITEA_HOST="https://git.sysdaemon.xyz"
export GITEA_OWNER="<owner>"
export GITEA_REPO="remmina-mac"
export GITEA_TOKEN="<token>" # statt Passwort: User → Settings → Applications → Token
export VERSION="0.1.0"
export GITEA_TOKEN="<token>" # not password: user → Settings → Applications → token
export VERSION="0.7.0"
./scripts/publish-gitea-release.sh
```
Das Skript erstellt (falls nötig) das Release `v0.1.0` und lädt ZIP + SHA256 als Attachments hoch.
The script creates the release (if needed) and uploads ZIP + SHA256 as attachments.
## Manuell via API
## Manual API
```bash
curl -u "user:pass" -H "Content-Type: application/json" \
-d '{"tag_name":"v0.1.0","name":"v0.1.0","body":"Erstes Release"}' \
-d '{"tag_name":"v0.7.0","name":"v0.7.0","body":"..."}' \
"$GITEA_HOST/api/v1/repos/$OWNER/$REPO/releases"
```
+36 -23
View File
@@ -1,33 +1,46 @@
# Benutzung
# Usage
## Layout (wie Remmina)
- **Links:** Gruppen-Dropdown, Suche, Profil-Liste. Rechtsklick: Verbinden / Duplizieren / Bearbeiten / Löschen. Doppelklick verbindet.
- **Rechts:** Tabs aller offenen Sitzungen + Verbindungsdetails (kopierbarer Befehl) + Hinweis-Box.
- **Unten:** Statusbar mit Profil-/Sitzungs-Zähler.
- **Toolbar:** `+` Neu, Stift Bearbeiten, Kopie-Symbol Duplizieren, Schlüssel Keys, `▶` Verbinden, `⚡` Quick-Connect.
## Layout (Remmina-style)
- **Left:** group dropdown, search, profile list. Right-click: Connect / Duplicate / Edit / Delete. Double-click connects.
- **Right:** tabs for open sessions + connection details (copyable command) + info box.
- **Bottom:** status bar with profile/session counts.
- **Toolbar:** `+` New, pencil Edit, copy Duplicate, key SSH keys, `ⓘ` About, `▶` Connect, `⚡` Quick-Connect.
## Profil anlegen
1. `+` → Name, Gruppe, Protokoll (SSH/SFTP/RDP/VNC), Host, Port, User ausfüllen.
2. SSH-Key wählen (vorher unter Schlüssel-Icon importieren).
3. Optional Passwort → Haken „in Keychain speichern“.
4. Speichern → liegt sofort in `profiles.json`.
## Creating a profile
1. `+` → name, group, protocol (SSH/SFTP/RDP/VNC), host, port, username, **password right below** (check Keychain to save it).
2. The editor warns live about common mistakes: `user@host` in the host field, `ssh://` prefix, whitespace, empty username, invalid port. Values are trimmed on save.
3. Pick an SSH key (optional, section below — import it first via the key icon).
4. Save → stored immediately in `profiles.json`.
5. If a connection fails: red banner → **Copy report** and send it in for debugging (contains no password).
## 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.
- RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop.
- VNC: öffnet `vnc://host:port`.
## Connecting
- Select a profile → `▶ Connect` or double-click.
- SSH/SFTP: **full terminal tab** (SwiftTerm, Dracula theme). Flow: TCP pre-check (wrong port/host → instant plain-language message instead of a cryptic exit code) → host-key fingerprint popup on first connect → auto-connect. Type directly in the terminal: Tab completion, arrows, Ctrl+C, copy/paste, scrollback, vim/htop all work. Buttons: Disconnect/Reconnect, Clear, Close tab. On failure: red banner with last message + Diagnose button.
- Alternative: `Open in Terminal.app` (opens a `.command` file, no automation permission needed) or `Copy command`.
- RDP: launches `xfreerdp` (if present) or opens Microsoft Remote Desktop.
- VNC: opens `vnc://host:port`.
## SSH-Keys verwalten (Schlüssel-Icon)
- `Import aus ~/.ssh`: übernimmt alle privaten Keys (ohne `*.pub`, `known_hosts`, `config`).
- `Wählen…`: Picker für Key-Datei.
- `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>` auf.
## Managing SSH keys (key icon)
- `Import from ~/.ssh`: picks up all private keys (skips `*.pub`, `known_hosts`, `config`).
- `Choose…`: file picker for a key file.
- `Generate new key`: runs `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>`.
- Passphrase → Keychain (`sshkey-<UUID>`).
## Quick-Connect
- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.
## Host-key verification (popup)
- On first connect the app shows a popup with the SHA256/MD5 fingerprint (fetched via `ssh-keyscan`) — compare it with Terminal output or the server admin's value, then Accept & connect (stored in `~/.ssh/known_hosts`, shared with Terminal.app).
- On CHANGED key: red warning (possible MITM!) — only replace if expected (freshly provisioned server). Manual reset: `ssh-keygen -R HOST`.
- Tech note: `Sources/ctty-run/ctty-run.c` gives ssh a controlling terminal (without it all interactive prompts fail silently).
## Export / Import (manuell, bis GUI-Buttons kommen)
## Saving passwords & auto-login
- Profile editor: enter password + check Keychain. A saved password is indicated (✓) and can be deleted. `profiles.json` holds only the reference, never the password.
- Order at connect time: profile password first, key passphrase second.
- **Auto-login without typing:** needs `sshpass` (`brew install sshpass`). ssh logs in with the Keychain secret (password only in the `SSHPASS` process env, never on the command line or in logs). Status shows auto-login state.
- **Without sshpass:** password/key passphrase is asked interactively in the terminal.
## Quick-Connect
- With selection: connects directly. Without: opens the editor with a “Quick-Connect” template.
## Export / Import (manual until GUI buttons land)
```bash
PROFILE_DIR=~/Library/Application\ Support/RemminaMac
cp "$PROFILE_DIR/profiles.json" ~/Desktop/remminamac-backup.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env swift
// Generiert das RemminaMac App-Icon (Dracula-Terminal) als PNG-Set + .iconset.
// Aufruf: swift scripts/RenderIcon.swift <output-dir>
// Braucht nur macOS-Bordmittel (AppKit). Kein Netzwerk, keine Deps.
import AppKit
import Foundation
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/RemminaMac.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
// Dracula-Palette
func c(_ hex: String, _ alpha: CGFloat = 1) -> NSColor {
var v: UInt64 = 0
Scanner(string: hex).scanHexInt64(&v)
return NSColor(red: CGFloat((v >> 16) & 0xFF) / 255,
green: CGFloat((v >> 8) & 0xFF) / 255,
blue: CGFloat(v & 0xFF) / 255, alpha: alpha)
}
let bg1 = c("3A3D4F"), bg2 = c("23242F"), bar = c("44475A")
let green = c("50FA7B"), pink = c("FF79C6"), purple = c("BD93F9")
let red = c("FF5555"), yellow = c("F1FA8C")
func drawIcon(size: CGFloat) -> NSImage {
let img = NSImage(size: NSSize(width: size, height: size))
img.lockFocus()
let s = size / 1024.0 // Skalierung relativ zu 1024er-Master
NSGraphicsContext.current?.imageInterpolation = .high
// Hintergrund: abgerundetes Quadrat mit Verlauf
let rect = NSRect(x: 0, y: 0, width: size, height: size)
let radius = 232 * s
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
let grad = NSGradient(colors: [bg1, bg2])!
grad.draw(in: path, angle: 90)
// feine Kontur
bar.withAlphaComponent(0.9).setStroke()
path.lineWidth = max(2, 6 * s)
path.stroke()
// Dezenter Glow hinter der Glyphe
let glow = NSShadow()
glow.shadowColor = purple.withAlphaComponent(0.55)
glow.shadowBlurRadius = 90 * s
glow.shadowOffset = .zero
// Traffic-Lights (Fenster-Look)
for (i, col) in [red, yellow, green].enumerated() {
let r = 40 * s
let cx = CGFloat(150 + i * 108) * s
let cy = size - 158.0 * s
col.setFill()
NSBezierPath(ovalIn: NSRect(x: cx - r, y: cy - r, width: 2 * r, height: 2 * r)).fill()
}
// Prompt „>" + Block-Cursor (Menlo Bold)
let fontSize: CGFloat = 470 * s
let font: NSFont = NSFont(name: "Menlo-Bold", size: fontSize)
?? NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: green]
let glyph = NSAttributedString(string: ">", attributes: attrs)
let gw = glyph.size()
// Cursor-Block rechts vom „>"
let gap = 44 * s
let cursorW = 128 * s, cursorH = 96 * s
let totalW = gw.width + gap + cursorW
let startX = (size - totalW) / 2
let baseY = size * 0.30
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setShadow(offset: .zero, blur: 90 * s, color: purple.withAlphaComponent(0.55).cgColor)
glyph.draw(at: NSPoint(x: startX, y: baseY))
NSGraphicsContext.restoreGraphicsState()
let cursor = NSBezierPath(roundedRect: NSRect(x: startX + gw.width + gap, y: baseY + 30 * s, width: cursorW, height: cursorH), xRadius: 14 * s, yRadius: 14 * s)
pink.setFill()
cursor.fill()
img.unlockFocus()
return img
}
func save(_ img: NSImage, _ name: String) {
guard let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) else {
fputs("FEHLER bei \(name)\n", stderr); exit(1)
}
try! png.write(to: URL(fileURLWithPath: "\(outDir)/\(name)"))
}
// iconset: je Größe 1x + 2x
for base in [16, 32, 128, 256, 512] {
save(drawIcon(size: CGFloat(base)), "icon_\(base)x\(base).png")
save(drawIcon(size: CGFloat(base * 2)), "icon_\(base)x\(base)@2x.png")
}
// Vorschau in groß
try? FileManager.default.createDirectory(atPath: "/tmp", withIntermediateDirectories: true)
save(drawIcon(size: 512), "RemminaMac-preview-tmp.png")
print("iconset fertig: \(outDir)")
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Baut RemminaMac.app (Drag-and-drop-fähiges Bundle) aus dem Release-Binary.
# Usage: ./scripts/build-app.sh [version]
set -euo pipefail
VERSION="${1:-0.1.0}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="$ROOT/dist"
BIN="$ROOT/.build/release/RemminaMac"
APP="$DIST/RemminaMac.app"
echo "[build-app] Version: $VERSION"
if [ ! -x "$BIN" ]; then
echo "[build-app] Kein Release-Binary, baue..."
swift build -c release --package-path "$ROOT"
fi
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/RemminaMac"
chmod +x "$APP/Contents/MacOS/RemminaMac"
# Ctty-Helper (gibt ssh ein Controlling-Terminal für Prompts)
if [ -x "$ROOT/.build/release/ctty-run" ]; then
cp "$ROOT/.build/release/ctty-run" "$APP/Contents/MacOS/"
else
echo "[build-app] WARNUNG: ctty-run fehlt (swift build lief ohne C-Target?)"
fi
cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/" 2>/dev/null || echo "[build-app] Hinweis: kein AppIcon.icns (./scripts/generate-icon.sh)"
# Info.plist mit Version versehen
sed "s|<string>0.1.0</string>|<string>$VERSION</string>|" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
echo -n "APPL????" > "$APP/Contents/PkgInfo"
# Ad-hoc-Signatur (nötig für Apple Silicon / Gatekeeper-Basis)
codesign --force --deep --sign - "$APP" 2>&1 | tail -n 3 || true
codesign --verify --verbose=2 "$APP" 2>&1 | tail -n 5 || true
echo "[build-app] Fertig:"
ls -ld "$APP"
du -sh "$APP"
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Baut ein Drag-and-drop-DMG: RemminaMac.app + Programme-Symlink.
# Nur Bordmittel (hdiutil). Usage: ./scripts/build-dmg.sh [version]
set -euo pipefail
VERSION="${1:-0.1.0}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="$ROOT/dist"
APP="$DIST/RemminaMac.app"
VOL="RemminaMac $VERSION"
RW_DMG="$DIST/.rw.RemminaMac-$VERSION.dmg"
FINAL_DMG="$DIST/RemminaMac-$VERSION-macos-arm64.dmg"
echo "[build-dmg] Version: $VERSION"
[ -d "$APP" ] || { echo "[build-dmg] $APP fehlt — erst ./scripts/build-app.sh $VERSION"; exit 1; }
rm -f "$RW_DMG" "$FINAL_DMG"
# 1. Beschreibbares DMG mit App + Programme-Link erzeugen
hdiutil create -volname "$VOL" -srcfolder "$APP" -ov -format UDRW "$RW_DMG" > /dev/null
MOUNT="$(hdiutil attach -readwrite -nobrowse "$RW_DMG" | grep -E '/Volumes/' | sed 's/.*\/Volumes\//\/Volumes\//')"
echo "[build-dmg] Gemountet: $MOUNT"
ln -s /Applications "$MOUNT/Programme" || true
ln -s /Applications "$MOUNT/Applications" || true
# Optionales Fenster-Layout (schlägt headless ggf. fehl — nicht fatal)
osascript <<OSA || true
tell application "Finder"
tell disk "$VOL"
open
set current view of container window to icon view
set toolbar visible of container window to false
set the bounds of container window to {100, 100, 640, 420}
delay 1
end tell
end tell
OSA
hdiutil detach "$MOUNT" > /dev/null
# 2. Komprimieren + Checksumme
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -o "$FINAL_DMG" > /dev/null
rm -f "$RW_DMG"
shasum -a 256 "$FINAL_DMG" > "$FINAL_DMG.sha256"
echo "[build-dmg] Fertig:"
ls -lh "$FINAL_DMG" "$FINAL_DMG.sha256"
cat "$FINAL_DMG.sha256"
hdiutil imageinfo "$FINAL_DMG" 2>/dev/null | grep -E 'Format|Size' | head -n 4 || true
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Generiert AppIcon.icns + Doku-Vorschau aus scripts/RenderIcon.swift (nur Bordmittel).
# Usage: ./scripts/generate-icon.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ICONSET="/tmp/RemminaMac.iconset"
rm -rf "$ICONSET"
swift "$ROOT/scripts/RenderIcon.swift" "$ICONSET"
cp "$ICONSET/RemminaMac-preview-tmp.png" "$ROOT/docs/icon-preview.png"
iconutil -c icns "$ICONSET" -o "$ROOT/Resources/AppIcon.icns"
echo "[icon] Fertig:"
ls -lh "$ROOT/Resources/AppIcon.icns" "$ROOT/docs/icon-preview.png"