7 Commits
19 changed files with 994 additions and 597 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"]
)
]
)
+69 -61
View File
@@ -1,63 +1,69 @@
# RemminaMac — SSH / RDP Client für macOS
# RemminaMac — SSH / RDP Client for macOS
![RemminaMac Icon](docs/icon-preview.png)
Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI.
Remmina-inspired connection manager for macOS, native Swift + SwiftUI.
![Plattform](https://img.shields.io/badge/plattform-macOS%2014%2B-blue)
![Sprache](https://img.shields.io/badge/sprache-Swift%205.9-orange)
![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
@@ -67,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
[
{
@@ -91,7 +99,7 @@ open Package.swift
"username": "admin",
"group": "Server",
"colorTag": "blue",
"resolution": "Automatisch",
"resolution": "Automatic",
"sshKeyID": null,
"passwordAccount": "profile-UUID",
"extraArgs": "",
@@ -100,12 +108,12 @@ 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-app.sh # RemminaMac.app für /Applications
./scripts/build-dmg.sh # DMG mit Drag-and-drop auf Programme
./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/
@@ -113,11 +121,11 @@ 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`.
@@ -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
}
}
+93 -10
View File
@@ -25,12 +25,17 @@ final class PTYSession: ObservableObject {
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
@@ -78,10 +83,22 @@ final class PTYSession: ObservableObject {
}
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" }
@@ -118,6 +135,7 @@ final class PTYSession: ObservableObject {
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)
@@ -138,7 +156,9 @@ final class PTYSession: ObservableObject {
var buf = [UInt8](repeating: 0, count: 8192)
let n = read(fd, &buf, buf.count)
if n > 0 {
let events = parser.feed(Data(buf[..<n]))
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
@@ -203,14 +223,20 @@ final class PTYSession: ObservableObject {
}
func send(_ string: String) {
let fd = masterFD
guard fd >= 0 else { return }
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, ptr.baseAddress!.advanced(by: written), data.count - written)
let n = write(fd, base.advanced(by: written), data.count - written)
if n <= 0 { break }
written += n
}
@@ -218,10 +244,31 @@ final class PTYSession: ObservableObject {
}
}
/// 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))])
}
@@ -238,7 +285,7 @@ final class PTYSession: ObservableObject {
func stop() {
let wasRunning = isRunning
process?.terminate()
killTree()
cleanupSync()
if wasRunning {
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
@@ -249,6 +296,14 @@ final class PTYSession: ObservableObject {
}
}
/// 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() {
@@ -275,6 +330,9 @@ final class PTYSession: ObservableObject {
}
deinit {
if let pid = process?.processIdentifier, pid > 0 {
Darwin.kill(-pid, SIGTERM)
}
process?.terminate()
readSource?.cancel()
if masterFD >= 0 { close(masterFD) }
@@ -287,8 +345,35 @@ final class TerminalManager: ObservableObject {
private var sessions: [UUID: PTYSession] = [:]
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
if let existing = sessions[profile.id] { return existing }
// 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 {
@@ -301,8 +386,8 @@ final class TerminalManager: ObservableObject {
// 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: SSHService.loginSecret(for: profile, key: key),
sshpassPath: SSHService.findSshpass())
password: secret,
sshpassPath: sshpass)
s.configure(executable: cfg.executable, arguments: cfg.arguments)
var env = cfg.extraEnv
if let sock = SSHService.agentSocket() {
@@ -310,8 +395,6 @@ final class TerminalManager: ObservableObject {
}
s.customEnv = env
s.setAutoLogin(cfg.autoLogin)
sessions[profile.id] = s
return s
}
func close(_ profile: ConnectionProfile) {
+31 -23
View File
@@ -1,25 +1,26 @@
import SwiftUI
import AppKit
import SwiftTerm
/// Offizielle Dracula-Palette (https://draculatheme.com/contribute).
enum Dracula {
static let background = Color(hex: "282A36")
static let currentLine = Color(hex: "44475A")
static let foreground = Color(hex: "F8F8F2")
static let comment = Color(hex: "6272A4")
static let cyan = Color(hex: "8BE9FD")
static let green = Color(hex: "50FA7B")
static let orange = Color(hex: "FFB86C")
static let pink = Color(hex: "FF79C6")
static let purple = Color(hex: "BD93F9")
static let red = Color(hex: "FF5555")
static let yellow = Color(hex: "F1FA8C")
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?) -> Color {
static func color(for index: Int?) -> SwiftUI.Color {
guard let index else { return foreground }
switch index {
case 0: return Color(hex: "21222C")
case 0: return SwiftUI.Color(hex: "21222C")
case 1: return red
case 2: return green
case 3: return yellow
@@ -28,13 +29,13 @@ enum Dracula {
case 6: return cyan
case 7: return foreground
case 8: return comment
case 9: return Color(hex: "FF6E6E")
case 10: return Color(hex: "69FF94")
case 11: return Color(hex: "FFFFA5")
case 12: return Color(hex: "D6ACFF")
case 13: return Color(hex: "FF92DF")
case 14: return Color(hex: "A4FFFF")
default: return Color(hex: "FFFFFF")
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")
}
}
@@ -42,11 +43,18 @@ enum Dracula {
NSColor(color(for: index))
}
static let nsBackground = NSColor(Color(hex: "282A36"))
static let nsSelection = NSColor(Color(hex: "44475A"))
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 Color {
extension SwiftUI.Color {
init(hex: String) {
let clean = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var value: UInt64 = 0
+45
View File
@@ -0,0 +1,45 @@
import SwiftUI
enum AppInfo {
static let version = "0.7.0"
static let repoURL = URL(string: "https://git.sysdaemon.xyz/x3/remmina-mac")!
}
/// About sheet: contributor, repository link, license, credits.
struct AboutView: View {
var body: some View {
VStack(spacing: 14) {
Image(systemName: "terminal.fill")
.font(.system(size: 56))
.foregroundStyle(Dracula.green)
.padding(.top, 8)
Text("RemminaMac")
.font(.largeTitle).bold()
Text("Version \(AppInfo.version)")
.font(.subheadline).foregroundStyle(.secondary)
Text("Remmina-style connection manager for macOS — SSH, SFTP, RDP and VNC profiles with an integrated Dracula terminal.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Divider()
VStack(spacing: 6) {
Text("Contributor")
.font(.caption).foregroundStyle(.secondary)
Text("x3")
.font(.headline)
Link(destination: AppInfo.repoURL) {
Label("git.sysdaemon.xyz/x3/remmina-mac", systemImage: "link")
}
}
Divider()
Text("MIT License · Built with SwiftUI, SwiftTerm and a custom PTY layer.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(28)
.frame(width: 420)
}
}
@@ -10,6 +10,7 @@ struct ContentView: View {
@State private var showingEditor = false
@State private var editingProfile: ConnectionProfile?
@State private var showingKeys = false
@State private var showingAbout = false
@State private var statusMessage = "Bereit."
var body: some View {
@@ -59,6 +60,8 @@ struct ContentView: View {
.help("Duplizieren").disabled(selection == nil)
Button(action: { showingKeys = true }) { Image(systemName: "key") }
.help("SSH-Keys verwalten")
Button(action: { showingAbout = true }) { Image(systemName: "info.circle") }
.help("Info")
}
}
} detail: {
@@ -116,6 +119,9 @@ struct ContentView: View {
.sheet(isPresented: $showingKeys) {
SSHKeysView().environmentObject(store)
}
.sheet(isPresented: $showingAbout) {
AboutView()
}
}
var selectedProfile: ConnectionProfile? {
@@ -1,16 +1,16 @@
import SwiftUI
import AppKit
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
/// Verbindet erst nach TCP-Vorab-Check (falscher Port/Host -> sofort klare Meldung).
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
/// Full interactive terminal (SwiftTerm emulator, Dracula themed):
/// cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
/// full-screen apps (vim/htop). Keyboard goes straight to the pty.
/// Connects after TCP pre-check + host-key approval (popup).
struct EmbeddedTerminalView: View {
@ObservedObject var term: PTYSession
let profile: ConnectionProfile
var canConnect: Bool
@State private var input = ""
@State private var secureInput = false
@State private var checking = false
@State private var hostKeyRequest: HostKeyRequest?
var body: some View {
VStack(spacing: 0) {
@@ -58,37 +58,9 @@ struct EmbeddedTerminalView: View {
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
}
// Ausgabe im Dracula-Theme
DraculaTextView(lines: term.lines)
// Full terminal emulator (click to focus, type directly).
SwiftTermView(term: term)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Eingabezeile
HStack(spacing: 6) {
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
// Gehärtetes Feld: keine macOS-Text-Ersetzungen (Passwort-Sonderzeichen!)
ZStack(alignment: .leading) {
if input.isEmpty {
Text(secureInput ? "Eingabe (versteckt, z. B. Passwort) …" : "Eingabe …")
.font(.system(.body, design: .monospaced))
.foregroundStyle(Dracula.comment.opacity(0.7))
.allowsHitTesting(false)
}
TerminalInputField(text: $input, isSecure: secureInput, onSubmit: submit)
.id(secureInput)
.frame(height: 22)
}
Button(action: { secureInput.toggle() }) {
Image(systemName: secureInput ? "eye.slash" : "eye")
}
.buttonStyle(.plain).foregroundStyle(Dracula.comment)
.help("Passwort-Modus (versteckte Eingabe)")
Button("Senden") { submit() }
.disabled(input.isEmpty || !term.isRunning)
ControlButton(label: "^C", tip: "SIGINT (Strg+C)") { term.send("\u{03}") }
ControlButton(label: "^D", tip: "EOF (Strg+D)") { term.send("\u{04}") }
}
.padding(8)
.background(Dracula.background)
}
.background(Dracula.background)
.cornerRadius(10)
@@ -98,6 +70,15 @@ struct EmbeddedTerminalView: View {
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() {
@@ -105,15 +86,47 @@ struct EmbeddedTerminalView: View {
checking = true
TCPCheck.check(host: profile.host, port: profile.port) { message in
DispatchQueue.main.async {
checking = false
if let message {
checking = false
term.fail("Vorab-Check: \(message)")
} else {
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 }
@@ -131,15 +144,7 @@ struct EmbeddedTerminalView: View {
}
}
func submit() {
guard term.isRunning, !input.isEmpty else { return }
term.send(input + "\n")
input = ""
}
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
/// Hinweis: Wer das Passwort sichtbar eingetippt hat (nicht Auge-Modus),
/// löscht die Ausgabe vorher per „Leeren".
func copyReport() {
var r = "RemminaMac Fehlerbericht\n"
r += "Profil: \(profile.name) | \(profile.proto.rawValue) | \(profile.username)@\(profile.host):\(profile.port)\n"
@@ -192,67 +197,3 @@ struct EmbeddedTerminalView: View {
}
}
struct ControlButton: View {
var label: String
var tip: String
var action: () -> Void
var body: some View {
Button(label, action: action)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Dracula.orange)
.buttonStyle(.plain)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Dracula.currentLine.opacity(0.6), in: RoundedRectangle(cornerRadius: 5))
.help(tip)
}
}
/// Read-only NSTextView mit Dracula-Farben, baut NSAttributedString aus StyledText-Zeilen.
struct DraculaTextView: NSViewRepresentable {
var lines: [[StyledText]]
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.drawsBackground = true
scroll.backgroundColor = Dracula.nsBackground
let tv = NSTextView()
tv.isEditable = false
tv.isSelectable = true
tv.backgroundColor = Dracula.nsBackground
tv.textColor = Dracula.nsColor(for: nil)
tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
tv.textContainerInset = NSSize(width: 8, height: 8)
tv.isAutomaticQuoteSubstitutionEnabled = false
scroll.documentView = tv
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let tv = scroll.documentView as? NSTextView else { return }
let attr = NSMutableAttributedString()
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
for (idx, row) in lines.enumerated() {
if row.isEmpty {
attr.append(NSAttributedString(string: idx < lines.count - 1 ? "\n" : ""))
continue
}
for seg in row {
let attrs: [NSAttributedString.Key: Any] = [
.font: seg.style.bold ? boldFont : font,
.foregroundColor: Dracula.nsColor(for: seg.style.fg),
.backgroundColor: seg.style.bg.map { Dracula.nsColor(for: $0) } ?? Dracula.nsBackground
]
attr.append(NSAttributedString(string: seg.text, attributes: attrs))
}
if idx < lines.count - 1 { attr.append(NSAttributedString(string: "\n")) }
}
// Nur neu setzen wenn geändert (Performance bei viel Output)
if tv.textStorage?.string != attr.string {
tv.textStorage?.setAttributedString(attr)
}
tv.scrollToEndOfDocument(nil)
}
}
@@ -0,0 +1,65 @@
import SwiftUI
/// 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)
}
}
@@ -0,0 +1,65 @@
import SwiftUI
import AppKit
import SwiftTerm
/// Full terminal emulator view (SwiftTerm): cursor addressing, colors,
/// scrollback, selection, and full-screen apps like vim/htop.
/// Keyboard goes straight to the pty (Tab completion, arrows, Ctrl+C, Esc);
/// pty output is fed in via PTYSession.onData. Dracula themed.
struct SwiftTermView: NSViewRepresentable {
@ObservedObject var term: PTYSession
func makeCoordinator() -> Coordinator {
Coordinator(term: term)
}
func makeNSView(context: Context) -> TerminalView {
let view = TerminalView(frame: .zero,
font: .monospacedSystemFont(ofSize: 13, weight: .regular))
view.terminalDelegate = context.coordinator
view.nativeForegroundColor = Dracula.nsColor(for: nil)
view.nativeBackgroundColor = Dracula.nsBackground
let palette = Dracula.ansiPalette
if palette.count == 16 {
view.installColors(palette)
}
context.coordinator.view = view
term.onData = { [weak view] data in
let bytes = Array(data)
DispatchQueue.main.async {
view?.feed(byteArray: bytes[...])
}
}
// Sync current size once the view exists (later resizes via delegate).
DispatchQueue.main.async {
term.setWinsize(cols: view.terminal.cols, rows: view.terminal.rows)
}
return view
}
func updateNSView(_ view: TerminalView, context: Context) {
context.coordinator.term = term
}
final class Coordinator: NSObject, TerminalViewDelegate {
var term: PTYSession?
weak var view: TerminalView?
init(term: PTYSession) {
self.term = term
}
func send(source: TerminalView, data: ArraySlice<UInt8>) {
term?.sendBytes(Data(data))
}
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
term?.setWinsize(cols: newCols, rows: newRows)
}
func setTerminalTitle(source: TerminalView, title: String) {}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
}
}
@@ -1,110 +0,0 @@
import SwiftUI
import AppKit
/// Eingabefeld ohne macOS-Text-Ersetzungen (Anführungszeichen, Gedankenstriche,
/// Auto-Korrektur, Punkt-Verdopplung, Vervollständigung, Links/Daten-Erkennung).
/// Hintergrund: Normale Textfelder ersetzen still z. B. `"` durch „“ und `--`
/// durch — (je nach Tastatur-Einstellung). Im Terminal fällt das nicht auf, aber
/// ein Passwort mit Sonderzeichen kommt falsch bei ssh an → „Permission denied",
/// obwohl das Passwort „stimmt" (docs/ERRORS.md #13).
/// NSTextField kann das nicht alles abschalten, daher NSTextView-Basis.
/// Secure-Modus nutzt NSSecureTextField (per Design ohne Ersetzungen).
struct TerminalInputField: NSViewRepresentable {
@Binding var text: String
var isSecure: Bool
var onSubmit: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, onSubmit: onSubmit)
}
func makeNSView(context: Context) -> NSView {
if isSecure {
let f = NSSecureTextField()
f.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
f.textColor = Dracula.nsColor(for: nil)
f.backgroundColor = .clear
f.isBordered = false
f.focusRingType = .none
f.delegate = context.coordinator
return f
} else {
let tv = NSTextView()
Self.configure(tv)
tv.delegate = context.coordinator
return tv
}
}
func updateNSView(_ view: NSView, context: Context) {
context.coordinator.onSubmit = onSubmit
if let f = view as? NSTextField, f.stringValue != text {
f.stringValue = text
} else if let tv = view as? NSTextView, tv.string != text {
tv.string = text
}
}
/// Alle Ersetzungen aus. Auch als Harness-Test nutzbar (reine Flags, headless).
static func configure(_ tv: NSTextView) {
tv.isRichText = false
tv.importsGraphics = false
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticTextCompletionEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = false
tv.isContinuousSpellCheckingEnabled = false
tv.isAutomaticDataDetectionEnabled = false
tv.isAutomaticLinkDetectionEnabled = false
tv.isAutomaticTextReplacementEnabled = false
tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
tv.textColor = Dracula.nsColor(for: nil)
tv.backgroundColor = .clear
tv.drawsBackground = false
tv.isVerticallyResizable = false
tv.isHorizontallyResizable = true
tv.textContainer?.widthTracksTextView = false
tv.textContainer?.containerSize = NSSize(width: 100000, height: 24)
}
final class Coordinator: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
var text: Binding<String>
var onSubmit: () -> Void
init(text: Binding<String>, onSubmit: @escaping () -> Void) {
self.text = text
self.onSubmit = onSubmit
}
func controlTextDidChange(_ obj: Notification) {
if let f = obj.object as? NSTextField {
text.wrappedValue = f.stringValue
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
onSubmit()
return true
}
return false
}
func textDidChange(_ notification: Notification) {
if let tv = notification.object as? NSTextView {
// Einzeiler: harte Umbrüche sofort entfernen (Paste mit \n)
let single = tv.string.replacingOccurrences(of: "\n", with: "")
if single != tv.string { tv.string = single }
text.wrappedValue = single
}
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
onSubmit()
return true
}
return false
}
}
}
+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.
+154 -181
View File
@@ -1,200 +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` mit alten Credentials | blockierend (Push/Release) | ✅ behoben — neue Credentials ok, Push + Release v0.1.0 hochgeladen |
| 5 | 2026-09-21 | App-Bundle + DMG | keine Fehler: `build-app.sh` (Signatur ok), `build-dmg.sh` (UDZO, 200K), Mount-Verifikation ok (App + Applications/Programme-Links) | Info | ✅ ok |
| 6 | 2026-09-21 | SSH verbinden | kein Terminal öffnete sich: `SSHService.openInTerminal` nutzte `NSAppleScript` → scheitert still (keine `NSAppleEventsUsageDescription`, kein Error-Handling) | blockierend | ✅ behoben — In-App-PTY-Terminal (Dracula) + `.command`-Fallback |
| 7 | 2026-09-21 | ANSI-Parser (Test) | `feed()` flusht Chunk erst NACH `parseCSI` → Text bekam den neuen statt alten Stil (`rot` mit fg=nil statt 1) | mittel | ✅ behoben — Flush vor Stilwechsel |
| 8 | 2026-09-21 | SSH-Session stirbt instant ohne Meldung | stderr hing mit am PTY: macOS verwirft ungelesene Master-Daten bei Slave-Close → „Code 255" ohne Text | blockierend | ✅ behoben — stderr→Pipe, TCP-Vorab-Check, Fehler-Banner |
| 9 | 2026-09-21 | `swift build` | `Result<Void, String>`: String conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` |
| 10 | 2026-09-21 | Icon-Generator (`RenderIcon.swift`) | 3 Swift-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben |
| 11 | 2026-09-21 | Passwort-Feature | Wunsch: Passwort + Keys speichern UND nutzen (bisher nur gespeichert, nie benutzt) | Feature | ✅ umgesetzt — Auto-Login via sshpass, sonst interaktiv |
| 12 | 2026-09-21 | „Immer Verbindung falsch" | User-Report ohne Details (Ziel/Credentials unbekannt) | offen | ⬜ Teils: Editor-Validierung + kopierbarer Fehlerbericht; warte auf Bericht |
| 13 | 2026-09-21 | Permission denied trotz richtigem Passwort | Text-Ersetzung im Eingabefeld verfälscht Sonderzeichen | hoch | ✅ behoben — gehärtetes Eingabefeld (alle Ersetzungen aus, 9/9 Tests) |
| 14 | 2026-09-21 | Terminal geht, App nicht (gleiches Passwort) | SSH-Agent-Socket fehlt GUI-Prozessen → evtl. Key-Login in Terminal | hoch | ✅ App reicht Agent jetzt durch; Gegenprobe beim User offen |
## 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.
- Neue Tests: `AnsiParser` per `swiftc`-Harness (`/tmp`, SGR/CRLF/Clear/Map), `PTYSession` per Smoke-Test (`/bin/echo` + `ssh -G` durchs echte PTY).
## 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.
## #6 — SSH-Klick öffnet kein Terminal (User-Bugreport)
**Phase:** Laufzeit, SSH-Profil → Verbinden
**Ursache:** `Services/ConnectionServices.swift` nutzte `NSAppleScript(tell application "Terminal" to do script …)`. Ohne `NSAppleEventsUsageDescription` und ohne Error-Auswertung scheitert das still — es passiert schlicht nichts.
**Fix:**
- Neu: `Services/PTYSession.swift` — echte PTY-Session (`posix_openpt`, `ssh` mit TTY), `TerminalManager` pro Profil (überlebt Tab-Wechsel).
- Neu: `Views/EmbeddedTerminalView.swift` — In-App-Terminal im Dracula-Theme (`Services/TerminalTheme.swift`, `Services/AnsiParser.swift`), Auto-Connect per `onAppear`, Eingabezeile + Passwort-Modus + `^C`/`^D`.
- `SessionView` bettet das Terminal ein (min. 380 pt), `ContentView.connect()` öffnet nur noch den Tab.
- Extern-Fallback umgebaut auf `.command`-Datei via `NSWorkspace` (keine Automation-Rechte nötig).
**Verifikation:** `swift build` ok; Parser-Harness 8/8; PTY-Smoke (`/bin/echo` → Output + Exit 0); `ssh -G example.com` durchs PTY ok.
**Status:** ✅ behoben.
## #8 — Terminal verbindet nicht, „direkt gekappt" (User-Bugreport)
**Phase:** Laufzeit, SSH-Profil → Tab öffnet, sofort „Sitzung beendet (Code 255)." — ohne jede Meldung
**Reproduktion (Harness, exakter App-Pfad `ssh -p 1 nobody@127.0.0.1`):**
```
STATE: done(exitCode: Optional(255), message: "Sitzung beendet (Code 255).")
TEXT: "\n\n— Sitzung beendet (Code 255). —\n" # <- Fehlermeldung fehlt!
```
**Ground Truth (Python-pty):** Selbst `/bin/echo hello` liefert `b''`, wenn man erst NACH Child-Exit liest; bei sofortigem Lesen kommt `b'hello-py\r\n'`. → macOS verwirft ungelesene PTY-Master-Daten beim Slave-Close (danach EIO). Unser `DispatchSourceRead` verliert dieses Rennen bei Instant-Exit-Kindern — und ssh schreibt ALLES (inkl. „Connection refused") nach stderr, das mit am PTY hing.
**Fix (`Services/PTYSession.swift`, `Services/TCPCheck.swift`, `Views/EmbeddedTerminalView.swift`):**
- stderr → `Pipe` (gepufferte Daten überleben Child-Exit garantiert), stdin/stdout bleiben am PTY (TTY erhalten für interaktive Prompts)
- `finalFlush` (non-blocking Rest-Drain) + Epoch-guarded Lifecycle (kein State-/FD-Durcheinander bei Trennen→Neuverbinden)
- TCP-Vorab-Check (Network.framework, 4 s Timeout) mit deutschen Klartext-Meldungen (refused/DNS/Timeout/Port)
- Rotes Fehler-Banner mit letzter Meldung + Diagnose- und Erneut-Buttons
**Verifikation:** Refused-Harness zeigt jetzt `ssh: connect to host 127.0.0.1 port 1: Connection refused` (PASS); Roundtrip (`cat`) PASS; `ssh -G` PASS; TCP-Harness (closed/open/empty) PASS.
**Status:** ✅ behoben.
## #9 — Build: `Result<Void, String>` ungültig
**Log:** `error: type 'String' does not conform to protocol 'Error'` (+ Folgefehler an `DispatchQueue.main.async`)
**Fix:** `TCPCheck.check` nutzt `(String?) -> Void` (nil = ok, sonst deutsche Meldung).
**Status:** ✅ behoben.
## #11 — Passwort speichern UND benutzen (User-Wunsch)
**Stand:** Passwort/Key-Passphrase wurden in der Keychain gespeichert, aber nie zum Einloggen benutzt (ssh fragte immer interaktiv).
**Umsetzung:**
- `SSHService.loginSecret()` (Profil-Passwort → Key-Passphrase), `sessionConfig()` (rein, testbar): mit Secret + sshpass → `sshpass -e ssh/sftp …` + `SSHPASS`-Env (nie Befehlszeile/Logs); sonst direkter Aufruf + interaktiver Prompt.
- `PTYSession.customEnv` + `autoLogin`-Flag, `TerminalManager` verdrahtet alles; Status zeigt „Auto-Login via Keychain".
- Editor: Gespeichert-Anzeige (✓), Löschen-Button, sshpass-Hinweis; Titel-Bug (`UUID`-Vergleich mit neuem Random-Objekt) via `isNew` ersetzt.
**Verifikation:** Config-Harness 9/9 (inkl. „Passwort nie in Befehlszeile"); echter Keychain-Roundtrip 7/7 (save/load/delete + Priorität + Fallback).
**Hinweis:** Ohne `sshpass` (`brew install sshpass`) bleibt es beim interaktiven Prompt — hier auf dem Build-Mac ebenfalls nicht installiert, getestet wurde der interaktive Pfad + Logik.
**Status:** ✅ umgesetzt.
## #10 — Icon-Generator Buildfehler
**Log:** `guard let` auf nicht-optionalem Ausdruck (`??` unwrapt schon); `Int * CGFloat`; Preview lag in `.iconset/` statt `/tmp/`.
**Fix:** `??`-Fallback direkt zuweisen; `CGFloat(...)`; Pfad korrigiert.
**Status:** ✅ behoben. Icon: `Resources/AppIcon.icns` (per `scripts/generate-icon.sh` reproduzierbar), Vorschau `docs/icon-preview.png`.
## #7 — ANSI-Parser: Text bekam falschen Stil
**Phase:** `swiftc`-Harness während #6
**Log:** `text("rot", fg: nil)` statt `fg: 1`
**Ursache:** Chunk wurde erst nach dem SGR-Wechsel geflusht.
**Fix:** `Services/AnsiParser.swift` — `flush()` vor `parseCSI`.
**Verifikation:** Harness 8/8 grün.
**Status:** ✅ behoben.
## #4 — Gitea-Upload: 401 trotz erreichbarem Server
**Phase:** `POST /api/v1/user/repos` + `GET /api/v1/user` auf `https://git.sysdaemon.xyz` (Version 1.27.3, per `/api/v1/version` bestätigt)
## #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.
**Nachtrag 2026-09-21:** ✅ behoben — mit korrigierten Credentials: `GET /api/v1/user → 200 (login x3)`, Repo `x3/remmina-mac` erstellt (201), `git push -u origin main` ok, Release `v0.1.0` (id 1) + 2 Assets (ZIP + SHA256) hochgeladen.
**Status:** ✅ behoben.
**Cause:** `windowTitle(_:)` is not a `WindowGroup` modifier; the title goes in the `WindowGroup` init.
**Fix:** `WindowGroup("RemminaMac") { ... }`.
**Status:** ✅ fixed.
## #12 — „Immer Verbindung falsch" (User-Report, Details offen)
**Stand:** Gemeldet ohne Ziel-Host, Banner-Text oder Credentials — aus der Ferne nicht reproduzierbar. Systematische App-Fehler als Ursache weitgehend ausgeschlossen (PTY/ssh-Pfad, Fehlertext-Anzeige, TCP-Check und Keychain-Fluss sind per Harness verifiziert).
**Häufigste reale Ursachen (Checkliste):**
1. `user@host` im Host-Feld oder `ssh://`-Präfix → jetzt Live-Warnung im Editor
2. Leerzeichen/Vertipper in Host/User → wird jetzt getrimmt + gewarnt
3. Falsches Passwort / Key ohne Passphrase-Eintrag → Banner + Diagnose nutzen
4. Server blockt (Fail2ban, nur Key-Login erlaubt) → Server-Logs prüfen
**Umgesetzt:** `Models/ProfileValidation.swift` (trimmen + Warnungen, Harness 7/7), Passwort direkt unter Benutzername, „Bericht kopieren" im Fehler-Banner (ohne Secrets).
**Offen:** Bitte Banner-Text oder kopierten Bericht hierher schicken — dann steht die Ursache in einer Zeile.
**Status:** ⬜ wartet auf Fehlerbericht.
## #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.
## #13 — Permission denied trotz richtigem Passwort (User-Fehlerbericht)
**Bericht:** `administrator@sysdaemon.xyz:22`, ssh fragt 3x Passwort → `Permission denied (publickey,password)`. Gleiches Passwort im Terminal.app funktioniert.
**Ursache:** Das Terminal-Eingabefeld war ein Standard-TextField. macOS ersetzt dort je nach Einstellung still Zeichen (`"` → „“, `--` → —, Auto-Korrektur). Ein Passwort mit Sonderzeichen kommt dadurch falsch bei ssh an — im Terminal fällt nichts auf, ssh sagt nur „denied". Verifiziert: Standard-`NSTextField` hat diese Schalter an bzw. `NSTextField` kann Dash-Ersetzung gar nicht abschalten (Compiler-Beweis).
**Fix (`Views/TerminalInputField.swift`):** Eigenes Eingabefeld auf `NSTextView`-Basis mit allen Ersetzungen explizit aus (Quotes, Dashes, Completion, Spelling, Data/Link-Erkennung, Text-Replacement, Plain-Text); Secure-Variante via `NSSecureTextField` (per Design ersetzungsfrei); Enter → Senden, Paste-Normalisierung (keine Umbrüche).
**Verifikation:** Harness 9/9 (alle Flags aus). Hinweis: Restrisiko Tastaturlayout (Z/Y) bleibt — Banner-Tipp ergänzt.
**Status:** ✅ behoben — bitte mit v0.5.1 erneut testen.
## #3 — `dist/` almost committed
**Cause:** `dist/` held ZIP + unpacked binary, `.gitignore` didn't know it.
**Fix:** ignore `.build/`, `dist/`, `*.zip`, `*.sha256`.
**Status:** ✅ fixed.
## #14 — Terminal.app loggt ein, RemminaMac nicht (gleiches Passwort)
**Stand (User-Antworten):** v0.5.1 installiert, Passwort-Login, Terminal.app funktioniert mit denselben Daten.
**Analyse:** App-seitig ist alles verifiziert (PTY, Fehleranzeige, Passwort-Loop mit Sonderzeichen, Eingabe-Ersetzungen aus). Gleiches Binary + gleiche Config → Unterschied muss aus der Umgebung kommen. Hauptkandidat: **SSH-Agent** — Finder-gestartete GUI-Prozesse erben `SSH_AUTH_SOCK` nicht. Nutzt Terminal unbemerkt einen Agent-Key (statt Passwort), scheitert die App mit Passwort. Verifiziert: `launchctl getenv SSH_AUTH_SOCK` liefert den Socket, Shell-Env wäre ohne Abfrage leer für GUI-Apps.
**Fix:** `SSHService.agentSocket()` (launchctl + Existenzprüfung) → `TerminalManager` injiziert `SSH_AUTH_SOCK` ins ssh-Env (koexistiert mit SSHPASS). Banner-Tipp ergänzt.
**Verifikation:** Harness 4/4 (Auflösung 0.07 s, Pfad existiert, `ssh -G` ok mit echtem + kaputtem Socket).
**Gegenprobe beim User (bitte ausführen):**
```bash
ssh-add -l
ssh -o IdentitiesOnly=yes administrator@sysdaemon.xyz
```
Zweite Zeile erzwingt Passwort-Login ohne Agent — scheitert sie auch im Terminal, war es immer der Agent-Key.
**Status:** ✅ App-Seite umgesetzt — warte auf Gegenprobe.
## #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.
+50 -21
View File
@@ -1,51 +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
```
## Variante D: Installer-DMG (Empfohlen für Endnutzer)
1. `RemminaMac-0.1.0-macos-arm64.dmg` aus dem Gitea-Release laden (oder lokal: `./scripts/build-app.sh 0.1.0 && ./scripts/build-dmg.sh 0.1.0`)
2. DMG doppelklicken → Finder-Fenster mit `RemminaMac.app` + `Programme`-Link
3. `RemminaMac.app` auf `Programme` ziehen → liegt in `/Applications/RemminaMac.app`
4. Auswerfen, aus Programme starten. Beim ersten Start: Rechtsklick → Öffnen (ad-hoc-Signatur, kein Apple-Notarization).
## 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
# Prüfen:
# Verify:
spctl -a -vv dist/RemminaMac.app
hdiutil verify dist/RemminaMac-0.1.0-macos-arm64.dmg
hdiutil verify dist/RemminaMac-*-macos-arm64.dmg
```
## RDP-Voraussetzungen
## RDP requirements
```bash
# Für volle RDP-Unterstützung:
# 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 -23
View File
@@ -1,41 +1,47 @@
# Releasing
## Lokal bauen
## Build locally
```bash
./scripts/build-release.sh [version] # ZIP mit Binary + Docs
./scripts/build-app.sh [version] # RemminaMac.app (Bundle, ad-hoc-signiert, mit Icon)
./scripts/build-dmg.sh [version] # DMG mit App + Programme-Link
./scripts/generate-icon.sh # AppIcon.icns neu rendern (Dracula-Terminal)
# Beispiel:
./scripts/build-release.sh 0.1.0
./scripts/build-app.sh 0.1.0
./scripts/build-dmg.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 (+ .sha256)
# dist/RemminaMac.app/
# dist/RemminaMac-0.1.0-macos-arm64.dmg (+ .sha256)
# 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"
```
+34 -29
View File
@@ -1,41 +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, Benutzername, **Passwort direkt darunter** ausfüllen (Keychain-Haken setzen zum Speichern).
2. Der Editor warnt live bei typischen Fehlern: `user@host` im Host-Feld, `ssh://`-Präfix, Leerzeichen, leerer Benutzername, ungültiger Port. Beim Speichern wird getrimmt.
3. SSH-Key wählen (optional, Bereich darunter — vorher unter Schlüssel-Icon importieren).
4. Speichern → liegt sofort in `profiles.json`.
5. Schlägt eine Verbindung fehl: rotes Banner → **Bericht kopieren** und zur Fehlersuche mitschicken (enthält kein Passwort).
## 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: **In-App-Terminal (Dracula-Theme) direkt im Tab** — erst TCP-Vorab-Check (falscher Port/Host → sofort klare Meldung statt kryptischem Exit-Code), dann Auto-Connect. Eingabezeile unten (Auge-Icon = Passwort-Modus), `^C`/`^D`-Buttons, `Leeren`, `Trennen`/`Verbinden`, `Tab schließen`. Bei Fehlschlag: rotes Banner mit letzter Meldung + Diagnose-Button.
- Alternativ: `In Terminal.app öffnen` (öffnet eine `.command`-Datei, braucht keine Automation-Berechtigung) oder `Befehl kopieren`.
- RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop.
- VNC: öffnet `vnc://host:port`.
## 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>`).
## Passwort speichern & Auto-Login
- Im Profil-Editor: Passwort eingeben + „in Keychain speichern“. Gespeichertes Passwort wird angezeigt (✓) und kann gelöscht werden. In `profiles.json` steht nur die Referenz, nie das Passwort.
- Reihenfolge beim Verbinden: Profil-Passwort zuerst, sonst Key-Passphrase.
- **Auto-Login ohne Tippen:** braucht `sshpass` (`brew install sshpass`). Dann loggt ssh via Keychain-Secret ein (Passwort nur im Prozess-Env `SSHPASS`, nie in Befehlszeile/Logs). Status zeigt „Auto-Login via Keychain".
- **Ohne sshpass:** Passwort/Key-Passphrase wird interaktiv im Terminal abgefragt (Passwort-Modus: Auge-Icon). Bei Sonderzeichen im Passwort immer Auge-Modus nutzen (keine Text-Ersetzung).
## 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).
## 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
- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.
- With selection: connects directly. Without: opens the editor with a “Quick-Connect” template.
## Export / Import (manuell, bis GUI-Buttons kommen)
## Export / Import (manual until GUI buttons land)
```bash
PROFILE_DIR=~/Library/Application\ Support/RemminaMac
cp "$PROFILE_DIR/profiles.json" ~/Desktop/remminamac-backup.json
+6
View File
@@ -18,6 +18,12 @@ 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