Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3957bdcf68 | ||
|
|
c657ec2825 | ||
|
|
d0855ab45d | ||
|
|
4209c481dc | ||
|
|
8511e4fdae |
@@ -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
@@ -7,12 +7,22 @@ let package = Package(
|
|||||||
.macOS(.v14)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
products: [
|
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: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "RemminaMac",
|
name: "RemminaMac",
|
||||||
|
dependencies: ["SwiftTerm"],
|
||||||
path: "Sources/RemminaMac"
|
path: "Sources/RemminaMac"
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "ctty-run",
|
||||||
|
path: "Sources/ctty-run",
|
||||||
|
sources: ["ctty-run.c"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,63 +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.
|
Remmina-inspired connection manager for macOS, native Swift + SwiftUI.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
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)
|
- [Features](#features)
|
||||||
- [Screenshots / Layout](#screenshots--layout)
|
- [Layout](#layout)
|
||||||
- [Schnellstart](#schnellstart)
|
- [Quick start](#quick-start)
|
||||||
- [Installation](docs/INSTALL.md)
|
- [Installation](docs/INSTALL.md)
|
||||||
- [Benutzung](docs/USAGE.md)
|
- [Usage](docs/USAGE.md)
|
||||||
- [Architektur](docs/ARCHITECTURE.md)
|
- [Architecture](docs/ARCHITECTURE.md)
|
||||||
- [SSH-Keys & Sicherheit](#ssh-keys--sicherheit)
|
- [SSH keys & security](#ssh-keys--security)
|
||||||
- [RDP / VNC unter macOS](#rdp--vnc-unter-macos)
|
- [RDP / VNC on macOS](#rdp--vnc-on-macos)
|
||||||
- [Profile-Format](#profile-format)
|
- [Profile format](#profile-format)
|
||||||
- [Release-Binary](#release-binary)
|
- [Release binaries](#release-binaries)
|
||||||
- [Fehlerprotokoll](docs/ERRORS.md)
|
- [Error log](docs/ERRORS.md)
|
||||||
- [Roadmap](#roadmap)
|
- [Roadmap](#roadmap)
|
||||||
- [Lizenz](#lizenz)
|
- [License](#license)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- GUI im Remmina-Layout (`Views/ContentView.swift`):
|
- GUI in Remmina layout (`Views/ContentView.swift`):
|
||||||
- Sidebar: Gruppen-Picker, Suche, Profil-Liste, Doppelklick = Verbinden
|
- Sidebar: group picker, search, profile list, double-click = connect
|
||||||
- Detail: Tab-Ansicht offener Sitzungen, Verbindungsdetails, Statusbar
|
- Detail: tabbed open sessions, connection details, status bar
|
||||||
- Toolbar: Neu / Bearbeiten / Duplizieren / Verbinden / Quick-Connect / SSH-Keys
|
- Toolbar: New / Edit / Duplicate / Connect / Quick-Connect / SSH keys / Info
|
||||||
- Profile laden / speichern / duplizieren / löschen
|
- Load / save / duplicate / delete profiles
|
||||||
- JSON in `~/Library/Application Support/RemminaMac/profiles.json`
|
- JSON in `~/Library/Application Support/RemminaMac/profiles.json`
|
||||||
- Export / Import (siehe Benutzung)
|
- Validation + trimming in the editor, copyable error reports
|
||||||
- Protokolle: SSH, SFTP, RDP, VNC (`Models/ConnectionProfile.swift:3`)
|
- Protocols: SSH, SFTP, RDP, VNC (`Models/ConnectionProfile.swift`)
|
||||||
- SSH-Keys speichern & verwalten (`Views/SSHKeysView.swift`):
|
- Full interactive terminal per tab (SwiftTerm emulator, Dracula themed):
|
||||||
- Import aus `~/.ssh`, Datei-Picker, `ssh-keygen`-Wrapper (ed25519)
|
cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
|
||||||
- Passphrases in macOS Keychain (`Security/KeychainHelper.swift`)
|
full-screen apps (vim/htop). Real PTY + controlling terminal via `ctty-run`.
|
||||||
- Passwörter in Keychain, nie im Klartext-JSON
|
- Host-key fingerprint popup (accept/decline, MITM warning on change),
|
||||||
- SSH via System-`ssh` in Terminal.app (`Services/ConnectionServices.swift:6`)
|
shared `~/.ssh/known_hosts` with Terminal.app
|
||||||
- RDP via FreeRDP (`xfreerdp`) oder Fallback Microsoft Remote Desktop
|
- SSH key management (`Views/SSHKeysView.swift`):
|
||||||
- VNC via `vnc://`-Scheme
|
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 |
|
| Group | Tab: Webserver | Office-PC | ... | Detail |
|
||||||
| Suche +-------------------------------+ |
|
| Search +-------------------------------+ |
|
||||||
| - Webserver | Verbindungsdetails (Befehl)| |
|
| - Webserver | Full terminal (SwiftTerm) | |
|
||||||
| - Büro-PC | Hinweis | |
|
| - Office-PC | Connection details (cmd) | |
|
||||||
| - Dateiserver+-------------------------------+ |
|
| - Fileserver +-------------------------------+ |
|
||||||
+------+-------------------------------+--------------------+
|
+------+-------------------------------+--------------------+
|
||||||
| Statusbar: ● Bereit ... N Profile · M Sitzungen |
|
| Statusbar: ● Ready ... N profiles · M sessions |
|
||||||
+-------------------------------------------------------------+
|
+-------------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
## Schnellstart
|
## Quick start
|
||||||
```bash
|
```bash
|
||||||
cd RemminaMac
|
cd RemminaMac
|
||||||
swift build
|
swift build
|
||||||
@@ -67,19 +73,21 @@ open Package.swift
|
|||||||
# Target RemminaMac → My Mac → Run
|
# Target RemminaMac → My Mac → Run
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSH-Keys & Sicherheit
|
## SSH keys & security
|
||||||
- Private-Key-Pfade stehen in `sshkeys.json`, Passphrases nur in Keychain.
|
- Private key paths live in `sshkeys.json`, passphrases in Keychain only.
|
||||||
- Service-Name: `RemminaMac`, Accounts: `profile-<UUID>`, `sshkey-<UUID>`.
|
- Service name: `RemminaMac`, accounts: `profile-<UUID>`, `sshkey-<UUID>`.
|
||||||
- Keychain lesen/schreiben: `Security/KeychainHelper.swift:5`.
|
- Keychain access: `Security/KeychainHelper.swift`.
|
||||||
- Es werden keine Secrets ins Git-Repo geschrieben (siehe `.gitignore`).
|
- 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`
|
- Optional: `brew install freerdp`
|
||||||
- Mit FreeRDP: `xfreerdp /v:HOST:PORT /u:USER [/size:WxH | /f]`
|
- With FreeRDP: `xfreerdp /v:HOST:PORT /u:USER [/size:WxH | /f]`
|
||||||
- Ohne: `rdc://` → Microsoft Remote Desktop (aus App Store installieren)
|
- Without: `rdc://` → Microsoft Remote Desktop (install from App Store)
|
||||||
- VNC: `open vnc://host:port`
|
- VNC: `open vnc://host:port`
|
||||||
|
|
||||||
## Profile-Format
|
## Profile format
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -91,7 +99,7 @@ open Package.swift
|
|||||||
"username": "admin",
|
"username": "admin",
|
||||||
"group": "Server",
|
"group": "Server",
|
||||||
"colorTag": "blue",
|
"colorTag": "blue",
|
||||||
"resolution": "Automatisch",
|
"resolution": "Automatic",
|
||||||
"sshKeyID": null,
|
"sshKeyID": null,
|
||||||
"passwordAccount": "profile-UUID",
|
"passwordAccount": "profile-UUID",
|
||||||
"extraArgs": "",
|
"extraArgs": "",
|
||||||
@@ -100,12 +108,12 @@ open Package.swift
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release-Binary
|
## Release binaries
|
||||||
Fertige Binaries liegen unter Git-Releases (`dist/` wird lokal gebaut, nicht committet):
|
Prebuilt binaries on Git releases (`dist/` is built locally, never committed):
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh 0.7.0
|
||||||
./scripts/build-app.sh # RemminaMac.app für /Applications
|
./scripts/build-app.sh 0.7.0 # RemminaMac.app for /Applications
|
||||||
./scripts/build-dmg.sh # DMG mit Drag-and-drop auf Programme
|
./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.zip + .sha256
|
||||||
# → dist/RemminaMac-<version>-macos-arm64.dmg + .sha256
|
# → dist/RemminaMac-<version>-macos-arm64.dmg + .sha256
|
||||||
ls dist/
|
ls dist/
|
||||||
@@ -113,11 +121,11 @@ ls dist/
|
|||||||
Details: [docs/INSTALL.md](docs/INSTALL.md), [docs/RELEASING.md](docs/RELEASING.md).
|
Details: [docs/INSTALL.md](docs/INSTALL.md), [docs/RELEASING.md](docs/RELEASING.md).
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
- [ ] Eingebettetes Terminal (SwiftTerm + swift-nio-ssh)
|
- [x] Embedded full terminal (SwiftTerm) — done in 0.7.0
|
||||||
- [ ] Echte RDP-Einbettung (FreeRDP-Bridge) statt externem Prozess
|
- [ ] Embedded RDP (FreeRDP bridge) instead of external process
|
||||||
- [ ] SFTP-Browser integriert
|
- [ ] Integrated SFTP browser
|
||||||
- [ ] Export/Import-Buttons in der GUI
|
- [ ] Export/Import buttons in the GUI
|
||||||
- [ ] Tray / Favoriten / Ordner-Badges wie Remmina
|
- [ ] Tray / favorites / folder badges like Remmina
|
||||||
|
|
||||||
## Lizenz
|
## License
|
||||||
MIT — siehe `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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ final class PTYSession: ObservableObject {
|
|||||||
private var readSource: DispatchSourceRead?
|
private var readSource: DispatchSourceRead?
|
||||||
private var errPipe: Pipe?
|
private var errPipe: Pipe?
|
||||||
private let ioQueue = DispatchQueue(label: "RemminaMac.pty")
|
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 parser = AnsiParser()
|
||||||
private var finished = false
|
private var finished = false
|
||||||
private var epoch = 0 // nur Main-Thread
|
private var epoch = 0 // nur Main-Thread
|
||||||
@@ -80,10 +83,22 @@ final class PTYSession: ObservableObject {
|
|||||||
}
|
}
|
||||||
masterFD = master
|
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 pipe = Pipe()
|
||||||
let p = Process()
|
let p = Process()
|
||||||
|
if let helper = Self.cttyHelperURL() {
|
||||||
|
p.executableURL = helper
|
||||||
|
p.arguments = [slavePath, executable] + arguments
|
||||||
|
} else {
|
||||||
p.executableURL = URL(fileURLWithPath: executable)
|
p.executableURL = URL(fileURLWithPath: executable)
|
||||||
p.arguments = arguments
|
p.arguments = arguments
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.note("Hinweis: ohne ctty-run-Helper (eingeschränkte Prompts).")
|
||||||
|
}
|
||||||
|
}
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["TERM"] = "xterm-256color"
|
env["TERM"] = "xterm-256color"
|
||||||
if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" }
|
if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" }
|
||||||
@@ -120,6 +135,7 @@ final class PTYSession: ObservableObject {
|
|||||||
h.readabilityHandler = nil // EOF
|
h.readabilityHandler = nil // EOF
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
self.onData?(data)
|
||||||
self.ioQueue.async { [weak self] in
|
self.ioQueue.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let events = self.parser.feed(data)
|
let events = self.parser.feed(data)
|
||||||
@@ -140,7 +156,9 @@ final class PTYSession: ObservableObject {
|
|||||||
var buf = [UInt8](repeating: 0, count: 8192)
|
var buf = [UInt8](repeating: 0, count: 8192)
|
||||||
let n = read(fd, &buf, buf.count)
|
let n = read(fd, &buf, buf.count)
|
||||||
if n > 0 {
|
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) }
|
DispatchQueue.main.async { [weak self] in self?.apply(events) }
|
||||||
} else {
|
} else {
|
||||||
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
|
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
|
||||||
@@ -205,9 +223,14 @@ final class PTYSession: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ string: String) {
|
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
|
let fd = masterFD
|
||||||
guard fd >= 0 else { return }
|
guard fd >= 0, !data.isEmpty else { return }
|
||||||
guard let data = string.data(using: .utf8), !data.isEmpty else { return }
|
|
||||||
ioQueue.async {
|
ioQueue.async {
|
||||||
data.withUnsafeBytes { ptr in
|
data.withUnsafeBytes { ptr in
|
||||||
guard let base = ptr.baseAddress else { return }
|
guard let base = ptr.baseAddress else { return }
|
||||||
@@ -221,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() {
|
func clear() {
|
||||||
lines = [[]]
|
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) {
|
func note(_ message: String) {
|
||||||
lines.append([StyledText(text: message, style: TermStyle(fg: 8))])
|
lines.append([StyledText(text: message, style: TermStyle(fg: 8))])
|
||||||
}
|
}
|
||||||
@@ -241,7 +285,7 @@ final class PTYSession: ObservableObject {
|
|||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
let wasRunning = isRunning
|
let wasRunning = isRunning
|
||||||
process?.terminate()
|
killTree()
|
||||||
cleanupSync()
|
cleanupSync()
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
|
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
|
||||||
@@ -252,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.
|
/// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen.
|
||||||
/// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst.
|
/// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst.
|
||||||
private func cleanupSync() {
|
private func cleanupSync() {
|
||||||
@@ -278,6 +330,9 @@ final class PTYSession: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
if let pid = process?.processIdentifier, pid > 0 {
|
||||||
|
Darwin.kill(-pid, SIGTERM)
|
||||||
|
}
|
||||||
process?.terminate()
|
process?.terminate()
|
||||||
readSource?.cancel()
|
readSource?.cancel()
|
||||||
if masterFD >= 0 { close(masterFD) }
|
if masterFD >= 0 { close(masterFD) }
|
||||||
@@ -290,27 +345,35 @@ final class TerminalManager: ObservableObject {
|
|||||||
private var sessions: [UUID: PTYSession] = [:]
|
private var sessions: [UUID: PTYSession] = [:]
|
||||||
|
|
||||||
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
|
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
|
||||||
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert!):
|
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert
|
||||||
// Ändert der User Host/Passwort/Key, wird neu konfiguriert statt stale weiterzuwählen.
|
// 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 secret = SSHService.loginSecret(for: profile, key: key)
|
||||||
let tag = [profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
|
let sshpass = SSHService.findSshpass()
|
||||||
profile.extraArgs, key?.privateKeyPath ?? "", secret ?? ""].joined(separator: "|")
|
let tag = Self.configTag(profile: profile, keyPath: key?.privateKeyPath,
|
||||||
|
secret: secret, sshpass: sshpass)
|
||||||
if let existing = sessions[profile.id] {
|
if let existing = sessions[profile.id] {
|
||||||
// Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen.
|
// Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen.
|
||||||
if existing.isRunning || existing.configTag == tag { return existing }
|
if existing.isRunning || existing.configTag == tag { return existing }
|
||||||
existing.stop()
|
existing.stop()
|
||||||
configure(existing, profile: profile, key: key, secret: secret)
|
configure(existing, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||||
existing.configTag = tag
|
existing.configTag = tag
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
let s = PTYSession()
|
let s = PTYSession()
|
||||||
configure(s, profile: profile, key: key, secret: secret)
|
configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||||
s.configTag = tag
|
s.configTag = tag
|
||||||
sessions[profile.id] = s
|
sessions[profile.id] = s
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?) {
|
/// 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 baseExe: String
|
||||||
let baseArgs: [String]
|
let baseArgs: [String]
|
||||||
if profile.proto == .sftp {
|
if profile.proto == .sftp {
|
||||||
@@ -324,7 +387,7 @@ final class TerminalManager: ObservableObject {
|
|||||||
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
|
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
|
||||||
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
|
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
|
||||||
password: secret,
|
password: secret,
|
||||||
sshpassPath: SSHService.findSshpass())
|
sshpassPath: sshpass)
|
||||||
s.configure(executable: cfg.executable, arguments: cfg.arguments)
|
s.configure(executable: cfg.executable, arguments: cfg.arguments)
|
||||||
var env = cfg.extraEnv
|
var env = cfg.extraEnv
|
||||||
if let sock = SSHService.agentSocket() {
|
if let sock = SSHService.agentSocket() {
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftTerm
|
||||||
|
|
||||||
/// Offizielle Dracula-Palette (https://draculatheme.com/contribute).
|
/// Offizielle Dracula-Palette (https://draculatheme.com/contribute).
|
||||||
enum Dracula {
|
enum Dracula {
|
||||||
static let background = Color(hex: "282A36")
|
static let background = SwiftUI.Color(hex: "282A36")
|
||||||
static let currentLine = Color(hex: "44475A")
|
static let currentLine = SwiftUI.Color(hex: "44475A")
|
||||||
static let foreground = Color(hex: "F8F8F2")
|
static let foreground = SwiftUI.Color(hex: "F8F8F2")
|
||||||
static let comment = Color(hex: "6272A4")
|
static let comment = SwiftUI.Color(hex: "6272A4")
|
||||||
static let cyan = Color(hex: "8BE9FD")
|
static let cyan = SwiftUI.Color(hex: "8BE9FD")
|
||||||
static let green = Color(hex: "50FA7B")
|
static let green = SwiftUI.Color(hex: "50FA7B")
|
||||||
static let orange = Color(hex: "FFB86C")
|
static let orange = SwiftUI.Color(hex: "FFB86C")
|
||||||
static let pink = Color(hex: "FF79C6")
|
static let pink = SwiftUI.Color(hex: "FF79C6")
|
||||||
static let purple = Color(hex: "BD93F9")
|
static let purple = SwiftUI.Color(hex: "BD93F9")
|
||||||
static let red = Color(hex: "FF5555")
|
static let red = SwiftUI.Color(hex: "FF5555")
|
||||||
static let yellow = Color(hex: "F1FA8C")
|
static let yellow = SwiftUI.Color(hex: "F1FA8C")
|
||||||
|
|
||||||
/// ANSI-Index 0-15 -> Dracula-Farbe.
|
/// 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 }
|
guard let index else { return foreground }
|
||||||
switch index {
|
switch index {
|
||||||
case 0: return Color(hex: "21222C")
|
case 0: return SwiftUI.Color(hex: "21222C")
|
||||||
case 1: return red
|
case 1: return red
|
||||||
case 2: return green
|
case 2: return green
|
||||||
case 3: return yellow
|
case 3: return yellow
|
||||||
@@ -28,13 +29,13 @@ enum Dracula {
|
|||||||
case 6: return cyan
|
case 6: return cyan
|
||||||
case 7: return foreground
|
case 7: return foreground
|
||||||
case 8: return comment
|
case 8: return comment
|
||||||
case 9: return Color(hex: "FF6E6E")
|
case 9: return SwiftUI.Color(hex: "FF6E6E")
|
||||||
case 10: return Color(hex: "69FF94")
|
case 10: return SwiftUI.Color(hex: "69FF94")
|
||||||
case 11: return Color(hex: "FFFFA5")
|
case 11: return SwiftUI.Color(hex: "FFFFA5")
|
||||||
case 12: return Color(hex: "D6ACFF")
|
case 12: return SwiftUI.Color(hex: "D6ACFF")
|
||||||
case 13: return Color(hex: "FF92DF")
|
case 13: return SwiftUI.Color(hex: "FF92DF")
|
||||||
case 14: return Color(hex: "A4FFFF")
|
case 14: return SwiftUI.Color(hex: "A4FFFF")
|
||||||
default: return Color(hex: "FFFFFF")
|
default: return SwiftUI.Color(hex: "FFFFFF")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +43,18 @@ enum Dracula {
|
|||||||
NSColor(color(for: index))
|
NSColor(color(for: index))
|
||||||
}
|
}
|
||||||
|
|
||||||
static let nsBackground = NSColor(Color(hex: "282A36"))
|
static let nsBackground = NSColor(SwiftUI.Color(hex: "282A36"))
|
||||||
static let nsSelection = NSColor(Color(hex: "44475A"))
|
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) {
|
init(hex: String) {
|
||||||
let clean = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let clean = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
var value: UInt64 = 0
|
var value: UInt64 = 0
|
||||||
|
|||||||
@@ -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 showingEditor = false
|
||||||
@State private var editingProfile: ConnectionProfile?
|
@State private var editingProfile: ConnectionProfile?
|
||||||
@State private var showingKeys = false
|
@State private var showingKeys = false
|
||||||
|
@State private var showingAbout = false
|
||||||
@State private var statusMessage = "Bereit."
|
@State private var statusMessage = "Bereit."
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -59,6 +60,8 @@ struct ContentView: View {
|
|||||||
.help("Duplizieren").disabled(selection == nil)
|
.help("Duplizieren").disabled(selection == nil)
|
||||||
Button(action: { showingKeys = true }) { Image(systemName: "key") }
|
Button(action: { showingKeys = true }) { Image(systemName: "key") }
|
||||||
.help("SSH-Keys verwalten")
|
.help("SSH-Keys verwalten")
|
||||||
|
Button(action: { showingAbout = true }) { Image(systemName: "info.circle") }
|
||||||
|
.help("Info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
@@ -116,6 +119,9 @@ struct ContentView: View {
|
|||||||
.sheet(isPresented: $showingKeys) {
|
.sheet(isPresented: $showingKeys) {
|
||||||
SSHKeysView().environmentObject(store)
|
SSHKeysView().environmentObject(store)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAbout) {
|
||||||
|
AboutView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedProfile: ConnectionProfile? {
|
var selectedProfile: ConnectionProfile? {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
|
/// Full interactive terminal (SwiftTerm emulator, Dracula themed):
|
||||||
/// Verbindet erst nach TCP-Vorab-Check (falscher Port/Host -> sofort klare Meldung).
|
/// cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
|
||||||
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
|
/// full-screen apps (vim/htop). Keyboard goes straight to the pty.
|
||||||
|
/// Connects after TCP pre-check + host-key approval (popup).
|
||||||
struct EmbeddedTerminalView: View {
|
struct EmbeddedTerminalView: View {
|
||||||
@ObservedObject var term: PTYSession
|
@ObservedObject var term: PTYSession
|
||||||
let profile: ConnectionProfile
|
let profile: ConnectionProfile
|
||||||
var canConnect: Bool
|
var canConnect: Bool
|
||||||
@State private var input = ""
|
|
||||||
@State private var secureInput = false
|
|
||||||
@State private var checking = false
|
@State private var checking = false
|
||||||
|
@State private var hostKeyRequest: HostKeyRequest?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -58,37 +58,9 @@ struct EmbeddedTerminalView: View {
|
|||||||
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
|
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ausgabe im Dracula-Theme
|
// Full terminal emulator (click to focus, type directly).
|
||||||
DraculaTextView(lines: term.lines)
|
SwiftTermView(term: term)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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)
|
.background(Dracula.background)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
@@ -98,6 +70,15 @@ struct EmbeddedTerminalView: View {
|
|||||||
connect() // Auto-Connect beim Öffnen des Tabs
|
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() {
|
func connect() {
|
||||||
@@ -105,15 +86,47 @@ struct EmbeddedTerminalView: View {
|
|||||||
checking = true
|
checking = true
|
||||||
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
checking = false
|
|
||||||
if let message {
|
if let message {
|
||||||
|
checking = false
|
||||||
term.fail("Vorab-Check: \(message)")
|
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()
|
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() {
|
func diagnose() {
|
||||||
guard !checking else { return }
|
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).
|
/// 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() {
|
func copyReport() {
|
||||||
var r = "RemminaMac Fehlerbericht\n"
|
var r = "RemminaMac Fehlerbericht\n"
|
||||||
r += "Profil: \(profile.name) | \(profile.proto.rawValue) | \(profile.username)@\(profile.host):\(profile.port)\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,119 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
/// Eingabefeld ohne macOS-Text-Ersetzungen (Anführungszeichen, Gedankenstriche,
|
|
||||||
/// Auto-Korrektur, Punkt-Verdopplung, Vervollständigung, Links/Daten-Erkennung).
|
|
||||||
/// Hintergrund: Normale Textfelder ersetzen still z. B. `"` durch „“ und `--`
|
|
||||||
/// durch — (je nach Tastatur-Einstellung). Im Terminal fällt das nicht auf, aber
|
|
||||||
/// ein Passwort mit Sonderzeichen kommt falsch bei ssh an → „Permission denied",
|
|
||||||
/// obwohl das Passwort „stimmt" (docs/ERRORS.md #13).
|
|
||||||
/// NSTextField kann das nicht alles abschalten, daher NSTextView-Basis.
|
|
||||||
/// Secure-Modus nutzt NSSecureTextField (per Design ohne Ersetzungen).
|
|
||||||
struct TerminalInputField: NSViewRepresentable {
|
|
||||||
@Binding var text: String
|
|
||||||
var isSecure: Bool
|
|
||||||
var onSubmit: () -> Void
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(text: $text, onSubmit: onSubmit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
|
||||||
if isSecure {
|
|
||||||
let f = NSSecureTextField()
|
|
||||||
f.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
||||||
f.textColor = Dracula.nsColor(for: nil)
|
|
||||||
f.backgroundColor = .clear
|
|
||||||
f.isBordered = false
|
|
||||||
f.focusRingType = .none
|
|
||||||
f.delegate = context.coordinator
|
|
||||||
return f
|
|
||||||
} else {
|
|
||||||
let tv = NSTextView()
|
|
||||||
Self.configure(tv)
|
|
||||||
tv.delegate = context.coordinator
|
|
||||||
return tv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ view: NSView, context: Context) {
|
|
||||||
context.coordinator.onSubmit = onSubmit
|
|
||||||
// Delegat während Programm-Sets abhängen: sonst feuert textDidChange synchron
|
|
||||||
// zurück ins SwiftUI-State — mitten im Render -> Crash ("Modifying state
|
|
||||||
// during view update"). User-Tippen läuft weiter über den Delegaten.
|
|
||||||
if let f = view as? NSTextField {
|
|
||||||
if f.stringValue != text {
|
|
||||||
f.delegate = nil
|
|
||||||
f.stringValue = text
|
|
||||||
f.delegate = context.coordinator
|
|
||||||
}
|
|
||||||
} else if let tv = view as? NSTextView, tv.string != text {
|
|
||||||
tv.delegate = nil
|
|
||||||
tv.string = text
|
|
||||||
tv.delegate = context.coordinator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alle Ersetzungen aus. Auch als Harness-Test nutzbar (reine Flags, headless).
|
|
||||||
static func configure(_ tv: NSTextView) {
|
|
||||||
tv.isRichText = false
|
|
||||||
tv.importsGraphics = false
|
|
||||||
tv.isAutomaticQuoteSubstitutionEnabled = false
|
|
||||||
tv.isAutomaticDashSubstitutionEnabled = false
|
|
||||||
tv.isAutomaticTextCompletionEnabled = false
|
|
||||||
tv.isAutomaticSpellingCorrectionEnabled = false
|
|
||||||
tv.isContinuousSpellCheckingEnabled = false
|
|
||||||
tv.isAutomaticDataDetectionEnabled = false
|
|
||||||
tv.isAutomaticLinkDetectionEnabled = false
|
|
||||||
tv.isAutomaticTextReplacementEnabled = false
|
|
||||||
tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
||||||
tv.textColor = Dracula.nsColor(for: nil)
|
|
||||||
tv.backgroundColor = .clear
|
|
||||||
tv.drawsBackground = false
|
|
||||||
tv.isVerticallyResizable = false
|
|
||||||
tv.isHorizontallyResizable = true
|
|
||||||
tv.textContainer?.widthTracksTextView = false
|
|
||||||
tv.textContainer?.containerSize = NSSize(width: 100000, height: 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Coordinator: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
|
|
||||||
var text: Binding<String>
|
|
||||||
var onSubmit: () -> Void
|
|
||||||
|
|
||||||
init(text: Binding<String>, onSubmit: @escaping () -> Void) {
|
|
||||||
self.text = text
|
|
||||||
self.onSubmit = onSubmit
|
|
||||||
}
|
|
||||||
|
|
||||||
func controlTextDidChange(_ obj: Notification) {
|
|
||||||
if let f = obj.object as? NSTextField {
|
|
||||||
text.wrappedValue = f.stringValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
||||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
||||||
onSubmit()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
|
||||||
if let tv = notification.object as? NSTextView {
|
|
||||||
// Einzeiler: harte Umbrüche sofort entfernen (Paste mit \n)
|
|
||||||
let single = tv.string.replacingOccurrences(of: "\n", with: "")
|
|
||||||
if single != tv.string { tv.string = single }
|
|
||||||
text.wrappedValue = single
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
||||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
||||||
onSubmit()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,33 +1,61 @@
|
|||||||
# Architektur
|
# Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Sources/RemminaMac/
|
Sources/RemminaMac/
|
||||||
RemminaMacApp.swift # @main, WindowGroup, lädt ProfileStore
|
RemminaMacApp.swift # @main, WindowGroup, loads ProfileStore
|
||||||
Models/ConnectionProfile.swift # ProtocolType + ConnectionProfile + StoredSSHKey (Codable)
|
Models/ConnectionProfile.swift # ProtocolType + ConnectionProfile + StoredSSHKey (Codable)
|
||||||
Stores/ProfileStore.swift # ObservableObject, JSON-Persistenz, Filter/Suche/Gruppen
|
Models/ProfileValidation.swift # input checks + trimming (pure, tested)
|
||||||
Security/KeychainHelper.swift# SecItemAdd/Copy/Delete Wrapper
|
Stores/ProfileStore.swift # ObservableObject, JSON persistence, filter/search/groups
|
||||||
Services/ConnectionServices.swift # SSHService (ssh-Befehl) + RDPService (freerdp/rdc/vnc)
|
Security/KeychainHelper.swift # SecItemAdd/Copy/Delete wrapper
|
||||||
Views/ContentView.swift # NavigationSplitView: Sidebar + Tabs + Statusbar
|
Services/ConnectionServices.swift # SSHService (ssh args, secrets, sshpass, agent) + RDPService
|
||||||
Views/SessionView.swift # Detail pro Sitzung
|
Services/PTYSession.swift # real PTY session (posix_openpt), stderr pipe, TerminalManager
|
||||||
Views/ProfileEditorView.swift# Formular Neu/Bearbeiten + Keychain-Anbindung
|
Services/AnsiParser.swift # ANSI/SGR parser for diagnostics buffer (pure, tested)
|
||||||
Views/SSHKeysView.swift # Key-Liste, Import, ssh-keygen
|
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
|
## Data flow
|
||||||
1. `RemminaMacApp` erstellt `ProfileStore` als `@StateObject`, injiziert als `environmentObject`.
|
1. `RemminaMacApp` creates `ProfileStore` as `@StateObject`, injects as `environmentObject`.
|
||||||
2. `ProfileStore.load()` liest `profiles.json` + `sshkeys.json`, sonst Sample-Daten.
|
2. `ProfileStore.load()` reads `profiles.json` + `sshkeys.json`, else sample data.
|
||||||
3. `ContentView` filtert via `searchText`/`selectedGroup`, `selection` steuert Detail.
|
3. `ContentView` filters via `searchText`/`selectedGroup`; `selection` drives detail.
|
||||||
4. `connect(_:)` setzt `lastConnected`, öffnet Tab, delegiert an `SSHService`/`RDPService`.
|
4. `connect(_:)` stamps `lastConnected`, opens a tab. The terminal view runs:
|
||||||
5. Secrets nur via `KeychainHelper`, JSON enthält nur Account-Referenzen.
|
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
|
## Key files
|
||||||
- `Stores/ProfileStore.swift:13` — Pfade + `load()`/`save()`
|
- `Stores/ProfileStore.swift` — paths + `load()`/`save()`
|
||||||
- `Models/ConnectionProfile.swift:3` — Protokoll-Defaults (Ports, Icons)
|
- `Models/ConnectionProfile.swift` — protocol defaults (ports, icons)
|
||||||
- `Services/ConnectionServices.swift:6` — `sshArguments(for:key:)`
|
- `Services/PTYSession.swift` — spawn, lifecycle, `TerminalManager`
|
||||||
- `Views/ContentView.swift` — Remmina-Layout
|
- `Services/HostKeyService.swift` — fingerprint flow
|
||||||
|
- `Views/SwiftTermView.swift` — emulator wiring + Dracula theme
|
||||||
|
- `Views/ContentView.swift` — Remmina layout
|
||||||
|
|
||||||
## Nächste Ausbaustufen
|
## Verification strategy
|
||||||
1. SwiftTerm-View statt externem Terminal + `swift-nio-ssh` für echte SSH-Sessions.
|
Logic is verified with throwaway `swiftc` harnesses in `/tmp` (never committed):
|
||||||
2. FreeRDP als XPC-Service einbetten statt `Process`.
|
ANSI parser, PTY roundtrips, real `ssh` runs, Keychain roundtrips, TCP checks,
|
||||||
3. SFTP-Browser (NIO SFTP oder libssh2-Bridge).
|
host-key flow against a local paramiko test server, SwiftTerm core headless
|
||||||
4. Export/Import-Buttons + Drag&Drop für Keys.
|
(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.
|
||||||
|
|||||||
+152
-199
@@ -1,220 +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
|
## Overview
|
||||||
| # | Zeitpunkt | Phase | Fehler | Schwere | Status |
|
| # | Date | Phase | Error | Severity | Status |
|
||||||
|---|-----------|-------|--------|---------|--------|
|
|---|------|-------|-------|----------|--------|
|
||||||
| 1 | 2026-09-21 | `swift build` (debug) | `WindowGroup<some View> has no member 'windowTitle'` | blockierend | ✅ behoben |
|
| 1 | 2026-09-21 | `swift build` (debug) | `WindowGroup<some View> has no member 'windowTitle'` | blocking | ✅ fixed |
|
||||||
| 2 | 2026-09-21 | `swift build -c release` | keine Fehler, nur lange Link-Zeit (~40 s) | Info | ✅ ok |
|
| 2 | 2026-09-21 | `swift build -c release` | no errors, just slow first link (~40 s) | info | ✅ ok |
|
||||||
| 3 | 2026-09-21 | Release-Skript | `dist/` war nicht in `.gitignore` → Binary drohte committet zu werden | mittel | ✅ behoben (ignoriert) |
|
| 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 invalid username, password or token` mit alten Credentials | blockierend (Push/Release) | ✅ behoben — neue Credentials ok, Push + Release v0.1.0 hochgeladen |
|
| 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 | keine Fehler: `build-app.sh` (Signatur ok), `build-dmg.sh` (UDZO, 200K), Mount-Verifikation ok (App + Applications/Programme-Links) | Info | ✅ ok |
|
| 5 | 2026-09-21 | app bundle + DMG | no errors: signing ok, UDZO DMG, mount verified | 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 |
|
| 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()` flusht Chunk erst NACH `parseCSI` → Text bekam den neuen statt alten Stil (`rot` mit fg=nil statt 1) | mittel | ✅ behoben — Flush vor Stilwechsel |
|
| 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 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 |
|
| 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 conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` |
|
| 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-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben |
|
| 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 | Passwort-Feature | Wunsch: Passwort + Keys speichern UND nutzen (bisher nur gespeichert, nie benutzt) | Feature | ✅ umgesetzt — Auto-Login via sshpass, sonst interaktiv |
|
| 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 | „Immer Verbindung falsch" | User-Report ohne Details (Ziel/Credentials unbekannt) | offen | ⬜ Teils: Editor-Validierung + kopierbarer Fehlerbericht; warte auf Bericht |
|
| 12 | 2026-09-21 | "always connection wrong" | user report without details | open | ⬜ partly: editor validation + copyable error report |
|
||||||
| 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) |
|
| 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 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 |
|
| 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 bei Tippen + Senden | Ursache unklar (headless nicht reproduzierbar) | kritisch | ⬜ Gehärtet (v0.5.3); Crash-Log angefragt |
|
| 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 | Profil-Änderungen wirkungslos | Session-Cache ignorierte Host-/Passwort-/Key-Korrekturen | hoch | ✅ behoben — Fingerprint-Reconfig (9/9 Tests) |
|
| 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`
|
## Template for new errors
|
||||||
**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
|
|
||||||
```markdown
|
```markdown
|
||||||
## #N — Titel
|
## #N — Title
|
||||||
**Phase:** ...
|
**Phase:** ...
|
||||||
**Log:** ...
|
**Log:** ...
|
||||||
**Ursache:** ...
|
**Cause:** ...
|
||||||
**Fix:** ...
|
**Fix:** ...
|
||||||
**Verifikation:** ...
|
**Verified:** ...
|
||||||
**Status:** ⬜ offen / ✅ behoben
|
**Status:** ⬜ open / ✅ fixed
|
||||||
```
|
```
|
||||||
|
|
||||||
## Wie Fehler hierher kommen
|
## How errors get here
|
||||||
- Jeder `swift build` / `swift build -c release` / Skript-Lauf mit Fehler → Log-Ausschnitt + Exit-Code hier eintragen.
|
- Every `swift build` / `swift build -c release` / script run with an error → log excerpt + exit code goes here.
|
||||||
- Gitea-Push/Release-Fehler (z. B. 401, Repo existiert schon, Asset-Upload schlägt fehl) ebenfalls hier dokumentieren.
|
- Gitea push/release errors likewise.
|
||||||
- Neue Tests: `AnsiParser` per `swiftc`-Harness (`/tmp`, SGR/CRLF/Clear/Map), `PTYSession` per Smoke-Test (`/bin/echo` + `ssh -G` durchs echte PTY).
|
- 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)
|
## #1 — `windowTitle` doesn't exist on `WindowGroup`
|
||||||
**Phase:** Laufzeit, SSH-Profil → Verbinden
|
**Phase:** first `swift build`
|
||||||
**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)
|
|
||||||
**Log:**
|
**Log:**
|
||||||
```
|
```
|
||||||
GET /user -> 401
|
RemminaMacApp.swift:14:10: error: value of type 'WindowGroup<some View>' has no member 'windowTitle'
|
||||||
{"message":"invalid username, password or token","url":"https://git.sysdaemon.xyz/api/swagger"}
|
|
||||||
```
|
```
|
||||||
Getestet mit Login `x3` und `x3@sysdaemon.xyz` — beide 401. Kein Quote-/Encoding-Problem (Basic-Auth via Python, kein Shell-Escaping).
|
**Cause:** `windowTitle(_:)` is not a `WindowGroup` modifier; the title goes in the `WindowGroup` init.
|
||||||
**Mögliche Ursachen:**
|
**Fix:** `WindowGroup("RemminaMac") { ... }`.
|
||||||
1. Passwort falsch/ablaufen bzw. Sonderzeichen-Typo
|
**Status:** ✅ fixed.
|
||||||
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.
|
|
||||||
|
|
||||||
## #12 — „Immer Verbindung falsch" (User-Report, Details offen)
|
## #2 — Slow but clean release build
|
||||||
**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).
|
**Phase:** `bash scripts/build-release.sh 0.1.0` → `swift build -c release` (~40 s first link).
|
||||||
**Häufigste reale Ursachen (Checkliste):**
|
**Cause:** none — release optimization + link just take a while cold; faster on rebuilds (`.build/` cache).
|
||||||
1. `user@host` im Host-Feld oder `ssh://`-Präfix → jetzt Live-Warnung im Editor
|
**Status:** ✅ ok.
|
||||||
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.
|
|
||||||
|
|
||||||
## #13 — Permission denied trotz richtigem Passwort (User-Fehlerbericht)
|
## #3 — `dist/` almost committed
|
||||||
**Bericht:** `administrator@sysdaemon.xyz:22`, ssh fragt 3x Passwort → `Permission denied (publickey,password)`. Gleiches Passwort im Terminal.app funktioniert.
|
**Cause:** `dist/` held ZIP + unpacked binary, `.gitignore` didn't know it.
|
||||||
**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:** ignore `.build/`, `dist/`, `*.zip`, `*.sha256`.
|
||||||
**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).
|
**Status:** ✅ fixed.
|
||||||
**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.
|
|
||||||
|
|
||||||
## #14 — Terminal.app loggt ein, RemminaMac nicht (gleiches Passwort)
|
## #4 — Gitea upload: 401 despite reachable server
|
||||||
**Stand (User-Antworten):** v0.5.1 installiert, Passwort-Login, Terminal.app funktioniert mit denselben Daten.
|
**Phase:** `POST /api/v1/user/repos` + `GET /api/v1/user` on `https://git.sysdaemon.xyz` (v1.27.3 confirmed via `/api/v1/version`).
|
||||||
**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.
|
**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:** `SSHService.agentSocket()` (launchctl + Existenzprüfung) → `TerminalManager` injiziert `SSH_AUTH_SOCK` ins ssh-Env (koexistiert mit SSHPASS). Banner-Tipp ergänzt.
|
**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.
|
||||||
**Verifikation:** Harness 4/4 (Auflösung 0.07 s, Pfad existiert, `ssh -G` ok mit echtem + kaputtem Socket).
|
**Status:** ✅ fixed.
|
||||||
**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.
|
|
||||||
|
|
||||||
## #15 — Crash bei Tippen + Senden (User-Report)
|
## #6 — SSH click opens no terminal (user bug report)
|
||||||
**Stand:** „Sobald ich was eintippe und absende, crasht das Programm." Headless nicht reproduzierbar — alle Ebenen einzeln grün.
|
**Cause:** `SSHService.openInTerminal` used `NSAppleScript(tell application "Terminal" …)` — fails silently without `NSAppleEventsUsageDescription` and with no error handling.
|
||||||
**Ausgeschlossen per Test:**
|
**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).
|
||||||
- SIGPIPE beim Schreiben (Harness: macOS liefert EIO statt Signal; `send` nach `stop()` überlebt) — Hypothese widerlegt
|
**Verified:** build ok; parser 8/8; PTY smoke (`/bin/echo` → output + exit 0); `ssh -G` through PTY ok.
|
||||||
- View-Schicht (echter Coordinator + NSTextView: Tippen mit Umlauten/Quotes/Paste, Enter, 200 Programm-Sets) — grün
|
**Status:** ✅ fixed.
|
||||||
- PTY-Roundtrip, Refused-, Keychain-, TCP-Harness — grün
|
|
||||||
**Gehärtet (v0.5.3, ohne Beweis dass es DER Crash war):**
|
|
||||||
- `TerminalInputField.updateNSView`: Delegat bei Programm-Sets abgehängt — sonst feuert `textDidChange` synchron zurück ins State während SwiftUI rendert („Modifying state during view update" → Crash-Kandidat Nr. 1, genau beim Senden: Eingabe wird geleert)
|
|
||||||
- `PTYSession.send`: Force-Unwrap `baseAddress!` → Guard, Leer-Check
|
|
||||||
**Weiter nötig bei erneutem Crash:** Console.app → Absturzberichte → neuesten `RemminaMac`-Eintrag öffnen → `Exception Type`, `Termination Reason` und crashed Thread hierher schicken. Damit steht die Stelle fest.
|
|
||||||
**Status:** ⬜ wartet auf Rückmeldung / Crash-Log.
|
|
||||||
|
|
||||||
## #16 — Profil-Änderungen wirkungslos („geht immer noch nicht")
|
## #7 — ANSI parser: text got the wrong style
|
||||||
**Stand:** Nach mehreren Fixes meldet der User weiterhin pauschal Misserfolg. Code-Review (`TerminalManager.session`, Zeile 291): Eine einmal erstellte Session wurde für immer wiederverwendet — Host-/Passwort-/Key-Korrekturen im Editor erreichten die laufende/stale Session nie. Jede Korrektur lief ins Leere.
|
**Phase:** `swiftc` harness during #6. Log: `text("rot", fg: nil)` instead of `fg: 1`.
|
||||||
**Fix:** Konfigurations-Fingerprint (Proto/Host/Port/User/ExtraArgs/Key-Pfad/Secret-Wert) pro Session. Bei Abweichung und nicht-laufender Session: stoppen + neu konfigurieren (gleiches Objekt, Tabs bleiben). Laufende Sessions werden nie unter den Füßen wegkonfiguriert ( erst Trennen).
|
**Cause:** chunk flushed only after the SGR switch.
|
||||||
**Verifikation:** Harness 9/9 (Host-Wechsel reconfiguriert, Secret-Wechsel erkannt, laufende ssh-Session an Banner-lesendem Socket unangetastet).
|
**Fix:** `flush()` before `parseCSI`. Harness 8/8 green.
|
||||||
**Status:** ✅ behoben — bitte mit v0.5.4 erneut testen (Profil ggf. einmal neu speichern).
|
**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
@@ -1,51 +1,80 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
## Voraussetzungen
|
## Requirements
|
||||||
- macOS 14+
|
- 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/`
|
1. `open Package.swift` in `RemminaMac/`
|
||||||
2. Target `RemminaMac` wählen, Destination `My Mac`
|
2. Select target `RemminaMac`, destination `My Mac`
|
||||||
3. `Cmd+R` → App startet
|
3. `Cmd+R` → app launches
|
||||||
4. Erster Start legt an:
|
4. First launch creates:
|
||||||
- `~/Library/Application Support/RemminaMac/profiles.json`
|
- `~/Library/Application Support/RemminaMac/profiles.json`
|
||||||
- `~/Library/Application Support/RemminaMac/sshkeys.json`
|
- `~/Library/Application Support/RemminaMac/sshkeys.json`
|
||||||
|
|
||||||
## Variante B: Swift CLI
|
## Option B: Swift CLI
|
||||||
```bash
|
```bash
|
||||||
cd RemminaMac
|
cd RemminaMac
|
||||||
swift build
|
swift build
|
||||||
.build/debug/RemminaMac
|
.build/debug/RemminaMac
|
||||||
```
|
```
|
||||||
|
|
||||||
## Variante C: Release-Binary (ohne Xcode)
|
## Option C: Release binary (no Xcode)
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
unzip dist/RemminaMac-*-macos-arm64.zip -d /tmp/rmm
|
unzip dist/RemminaMac-*-macos-arm64.zip -d /tmp/rmm
|
||||||
/tmp/rmm/RemminaMac
|
/tmp/rmm/RemminaMac
|
||||||
```
|
```
|
||||||
|
|
||||||
## Variante D: Installer-DMG (Empfohlen für Endnutzer)
|
## Option D: Installer DMG (recommended for end users)
|
||||||
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`)
|
1. Download `RemminaMac-<version>-macos-arm64.dmg` from a Gitea release
|
||||||
2. DMG doppelklicken → Finder-Fenster mit `RemminaMac.app` + `Programme`-Link
|
(or build locally: `./scripts/build-app.sh <version> && ./scripts/build-dmg.sh <version>`)
|
||||||
3. `RemminaMac.app` auf `Programme` ziehen → liegt in `/Applications/RemminaMac.app`
|
2. Double-click the DMG → Finder window with `RemminaMac.app` + `Applications` link
|
||||||
4. Auswerfen, aus Programme starten. Beim ersten Start: Rechtsklick → Öffnen (ad-hoc-Signatur, kein Apple-Notarization).
|
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
|
```bash
|
||||||
# Prüfen:
|
# Verify:
|
||||||
spctl -a -vv dist/RemminaMac.app
|
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
|
```bash
|
||||||
# Für volle RDP-Unterstützung:
|
# For full RDP support (see dependency table above):
|
||||||
brew install freerdp
|
brew install freerdp
|
||||||
which xfreerdp
|
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
|
## Troubleshooting
|
||||||
- `swift build` schlägt fehl → `swift --version` prüfen (>= 5.9), Xcode Command Line Tools: `xcode-select --install`
|
- `swift build` fails → check `swift --version` (>= 5.9), Xcode Command Line Tools: `xcode-select --install`
|
||||||
- Keychain-Prompt → einmal erlauben, Service `RemminaMac`
|
- Keychain prompt → allow once, service `RemminaMac`
|
||||||
- `xfreerdp` nicht gefunden → Fallback nutzt `rdc://`, braucht MS Remote Desktop
|
- `xfreerdp` not found → fallback uses `rdc://`, needs MS Remote Desktop
|
||||||
|
|||||||
+29
-23
@@ -1,41 +1,47 @@
|
|||||||
# Releasing
|
# Releasing
|
||||||
|
|
||||||
## Lokal bauen
|
## Build locally
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-release.sh [version] # ZIP mit Binary + Docs
|
./scripts/build-release.sh [version] # ZIP with binary + docs
|
||||||
./scripts/build-app.sh [version] # RemminaMac.app (Bundle, ad-hoc-signiert, mit Icon)
|
./scripts/build-app.sh [version] # RemminaMac.app (bundle, ad-hoc signed, icon + ctty-run)
|
||||||
./scripts/build-dmg.sh [version] # DMG mit App + Programme-Link
|
./scripts/build-dmg.sh [version] # DMG with app + Applications link
|
||||||
./scripts/generate-icon.sh # AppIcon.icns neu rendern (Dracula-Terminal)
|
./scripts/generate-icon.sh # re-render AppIcon.icns (Dracula terminal)
|
||||||
# Beispiel:
|
# Example:
|
||||||
./scripts/build-release.sh 0.1.0
|
./scripts/build-release.sh 0.7.0
|
||||||
./scripts/build-app.sh 0.1.0
|
./scripts/build-app.sh 0.7.0
|
||||||
./scripts/build-dmg.sh 0.1.0
|
./scripts/build-dmg.sh 0.7.0
|
||||||
# Output:
|
# Output:
|
||||||
# dist/RemminaMac-0.1.0-macos-arm64.zip (+ .sha256)
|
# dist/RemminaMac-0.7.0-macos-arm64.zip (+ .sha256)
|
||||||
# dist/RemminaMac.app/
|
# dist/RemminaMac.app/ (with ctty-run helper + icon)
|
||||||
# dist/RemminaMac-0.1.0-macos-arm64.dmg (+ .sha256)
|
# dist/RemminaMac-0.7.0-macos-arm64.dmg (+ .sha256)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Was das Skript macht
|
Bump `AppInfo.version` in `Views/AboutView.swift` with each release.
|
||||||
1. `swift build -c release`
|
|
||||||
2. Binary nach `dist/RemminaMac-<ver>-macos-arm64/` kopieren
|
|
||||||
3. `README.md` + `docs/` + `LICENSE` dazulegen
|
|
||||||
4. ZIP + SHA256 erzeugen
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
export GITEA_HOST="https://git.sysdaemon.xyz"
|
export GITEA_HOST="https://git.sysdaemon.xyz"
|
||||||
export GITEA_OWNER="<owner>"
|
export GITEA_OWNER="<owner>"
|
||||||
export GITEA_REPO="remmina-mac"
|
export GITEA_REPO="remmina-mac"
|
||||||
export GITEA_TOKEN="<token>" # statt Passwort: User → Settings → Applications → Token
|
export GITEA_TOKEN="<token>" # not password: user → Settings → Applications → token
|
||||||
export VERSION="0.1.0"
|
export VERSION="0.7.0"
|
||||||
./scripts/publish-gitea-release.sh
|
./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
|
```bash
|
||||||
curl -u "user:pass" -H "Content-Type: application/json" \
|
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"
|
"$GITEA_HOST/api/v1/repos/$OWNER/$REPO/releases"
|
||||||
```
|
```
|
||||||
|
|||||||
+34
-29
@@ -1,41 +1,46 @@
|
|||||||
# Benutzung
|
# Usage
|
||||||
|
|
||||||
## Layout (wie Remmina)
|
## Layout (Remmina-style)
|
||||||
- **Links:** Gruppen-Dropdown, Suche, Profil-Liste. Rechtsklick: Verbinden / Duplizieren / Bearbeiten / Löschen. Doppelklick verbindet.
|
- **Left:** group dropdown, search, profile list. Right-click: Connect / Duplicate / Edit / Delete. Double-click connects.
|
||||||
- **Rechts:** Tabs aller offenen Sitzungen + Verbindungsdetails (kopierbarer Befehl) + Hinweis-Box.
|
- **Right:** tabs for open sessions + connection details (copyable command) + info box.
|
||||||
- **Unten:** Statusbar mit Profil-/Sitzungs-Zähler.
|
- **Bottom:** status bar with profile/session counts.
|
||||||
- **Toolbar:** `+` Neu, Stift Bearbeiten, Kopie-Symbol Duplizieren, Schlüssel Keys, `▶` Verbinden, `⚡` Quick-Connect.
|
- **Toolbar:** `+` New, pencil Edit, copy Duplicate, key SSH keys, `ⓘ` About, `▶` Connect, `⚡` Quick-Connect.
|
||||||
|
|
||||||
## Profil anlegen
|
## Creating a profile
|
||||||
1. `+` → Name, Gruppe, Protokoll (SSH/SFTP/RDP/VNC), Host, Port, Benutzername, **Passwort direkt darunter** ausfüllen (Keychain-Haken setzen zum Speichern).
|
1. `+` → name, group, protocol (SSH/SFTP/RDP/VNC), host, port, username, **password right below** (check Keychain to save it).
|
||||||
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.
|
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. SSH-Key wählen (optional, Bereich darunter — vorher unter Schlüssel-Icon importieren).
|
3. Pick an SSH key (optional, section below — import it first via the key icon).
|
||||||
4. Speichern → liegt sofort in `profiles.json`.
|
4. Save → stored immediately in `profiles.json`.
|
||||||
5. Schlägt eine Verbindung fehl: rotes Banner → **Bericht kopieren** und zur Fehlersuche mitschicken (enthält kein Passwort).
|
5. If a connection fails: red banner → **Copy report** and send it in for debugging (contains no password).
|
||||||
|
|
||||||
## Verbinden
|
## Connecting
|
||||||
- Profil wählen → `▶ Verbinden` oder Doppelklick.
|
- Select a profile → `▶ Connect` or double-click.
|
||||||
- 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.
|
- 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.
|
||||||
- Alternativ: `In Terminal.app öffnen` (öffnet eine `.command`-Datei, braucht keine Automation-Berechtigung) oder `Befehl kopieren`.
|
- Alternative: `Open in Terminal.app` (opens a `.command` file, no automation permission needed) or `Copy command`.
|
||||||
- RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop.
|
- RDP: launches `xfreerdp` (if present) or opens Microsoft Remote Desktop.
|
||||||
- VNC: öffnet `vnc://host:port`.
|
- VNC: opens `vnc://host:port`.
|
||||||
|
|
||||||
## SSH-Keys verwalten (Schlüssel-Icon)
|
## Managing SSH keys (key icon)
|
||||||
- `Import aus ~/.ssh`: übernimmt alle privaten Keys (ohne `*.pub`, `known_hosts`, `config`).
|
- `Import from ~/.ssh`: picks up all private keys (skips `*.pub`, `known_hosts`, `config`).
|
||||||
- `Wählen…`: Picker für Key-Datei.
|
- `Choose…`: file picker for a key file.
|
||||||
- `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>` auf.
|
- `Generate new key`: runs `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>`.
|
||||||
- Passphrase → Keychain (`sshkey-<UUID>`).
|
- Passphrase → Keychain (`sshkey-<UUID>`).
|
||||||
|
|
||||||
## Passwort speichern & Auto-Login
|
## Host-key verification (popup)
|
||||||
- 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.
|
- 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).
|
||||||
- Reihenfolge beim Verbinden: Profil-Passwort zuerst, sonst Key-Passphrase.
|
- On CHANGED key: red warning (possible MITM!) — only replace if expected (freshly provisioned server). Manual reset: `ssh-keygen -R HOST`.
|
||||||
- **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".
|
- Tech note: `Sources/ctty-run/ctty-run.c` gives ssh a controlling terminal (without it all interactive prompts fail silently).
|
||||||
- **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).
|
|
||||||
|
## 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
|
## 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
|
```bash
|
||||||
PROFILE_DIR=~/Library/Application\ Support/RemminaMac
|
PROFILE_DIR=~/Library/Application\ Support/RemminaMac
|
||||||
cp "$PROFILE_DIR/profiles.json" ~/Desktop/remminamac-backup.json
|
cp "$PROFILE_DIR/profiles.json" ~/Desktop/remminamac-backup.json
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ rm -rf "$APP"
|
|||||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
cp "$BIN" "$APP/Contents/MacOS/RemminaMac"
|
cp "$BIN" "$APP/Contents/MacOS/RemminaMac"
|
||||||
chmod +x "$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)"
|
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
|
# Info.plist mit Version versehen
|
||||||
|
|||||||
Reference in New Issue
Block a user