Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3957bdcf68 | ||
|
|
c657ec2825 | ||
|
|
d0855ab45d | ||
|
|
4209c481dc | ||
|
|
8511e4fdae | ||
|
|
7a792fe680 | ||
|
|
69f9e9952c | ||
|
|
ed6e96b3c1 | ||
|
|
37a70edbab | ||
|
|
ebb838b6b1 | ||
|
|
4fd711474c | ||
|
|
7dc6ff75d2 |
@@ -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,61 +1,69 @@
|
|||||||
# RemminaMac — SSH / RDP Client für macOS
|
# RemminaMac — SSH / RDP Client for macOS
|
||||||
|
|
||||||
Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI.
|

|
||||||
|
|
||||||

|
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
|
||||||
@@ -65,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
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -89,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": "",
|
||||||
@@ -98,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/
|
||||||
@@ -111,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`.
|
||||||
|
|||||||
Binary file not shown.
@@ -10,6 +10,8 @@
|
|||||||
<string>RemminaMac</string>
|
<string>RemminaMac</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>xyz.sysdaemon.remmina-mac</string>
|
<string>xyz.sysdaemon.remmina-mac</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Eingabe-Prüfung + Bereinigung für Profile (reine Funktionen, testbar).
|
||||||
|
/// Fängt die häufigsten „Verbindung falsch"-Ursachen ab: Leerzeichen,
|
||||||
|
/// user@host im Host-Feld, Protokoll-Präfix, leerer Benutzername.
|
||||||
|
enum ProfileValidation {
|
||||||
|
static func cleaned(_ profile: ConnectionProfile) -> ConnectionProfile {
|
||||||
|
var p = profile
|
||||||
|
p.name = p.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.host = p.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.username = p.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.group = p.group.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.resolution = p.resolution.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.extraArgs = p.extraArgs.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
static func warnings(for profile: ConnectionProfile) -> [String] {
|
||||||
|
var w: [String] = []
|
||||||
|
let h = profile.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if h.contains("@") {
|
||||||
|
w.append("Host enthält „@“ — den Benutzernamen ins Feld darunter eintragen, hier nur Hostname/IP.")
|
||||||
|
}
|
||||||
|
if h.contains("://") {
|
||||||
|
w.append("Host enthält „://“ — nur Hostname/IP, kein Protokoll (das steht oben).")
|
||||||
|
}
|
||||||
|
if h.contains(where: { $0.isWhitespace }) {
|
||||||
|
w.append("Host enthält Leerzeichen.")
|
||||||
|
}
|
||||||
|
if profile.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
w.append("Benutzername fehlt.")
|
||||||
|
}
|
||||||
|
if !(1...65535).contains(profile.port) {
|
||||||
|
w.append("Port ungültig (1–65535).")
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,67 @@ enum SSHService {
|
|||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(cmd, forType: .string)
|
NSPasteboard.general.setString(cmd, forType: .string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Passwort / Auto-Login
|
||||||
|
|
||||||
|
/// SSH-Agent-Socket für GUI-Prozesse auflösen (Finder-Apps erben SSH_AUTH_SOCK
|
||||||
|
/// nicht, Terminal schon — ohne diesen Unterschied nutzt Terminal evtl. einen
|
||||||
|
/// Agent-Key, während die App nach dem Passwort fragt und scheitert).
|
||||||
|
static func agentSocket() -> String? {
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||||
|
p.arguments = ["getenv", "SSH_AUTH_SOCK"]
|
||||||
|
let out = Pipe()
|
||||||
|
let err = Pipe()
|
||||||
|
p.standardOutput = out
|
||||||
|
p.standardError = err
|
||||||
|
do {
|
||||||
|
try p.run()
|
||||||
|
p.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let path = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !path.isEmpty,
|
||||||
|
FileManager.default.fileExists(atPath: path) else { return nil }
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gespeichertes Login-Secret: erst Profil-Passwort, dann Key-Passphrase (Keychain, nie JSON).
|
||||||
|
static func loginSecret(for profile: ConnectionProfile, key: StoredSSHKey?) -> String? {
|
||||||
|
if let acct = profile.passwordAccount,
|
||||||
|
let pw = KeychainHelper.load(account: acct), !pw.isEmpty {
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
if let key, let acct = key.passphraseAccount,
|
||||||
|
let pp = KeychainHelper.load(account: acct), !pp.isEmpty {
|
||||||
|
return pp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func findSshpass() -> String? {
|
||||||
|
for p in ["/opt/homebrew/bin/sshpass", "/usr/local/bin/sshpass", "/usr/bin/sshpass"] {
|
||||||
|
if FileManager.default.isExecutableFile(atPath: p) { return p }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// true = beim Verbinden wird ohne Tippen eingeloggt (sshpass + Keychain-Secret).
|
||||||
|
static func willAutoLogin(profile: ConnectionProfile, key: StoredSSHKey?) -> Bool {
|
||||||
|
loginSecret(for: profile, key: key) != nil && findSshpass() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reine Funktion (testbar): mit Secret+sshpass -> sshpass -e … + SSHPASS-Env,
|
||||||
|
/// sonst direkter Aufruf. Passwort steht NIE in der Befehlszeile (nur im Env).
|
||||||
|
static func sessionConfig(executable: String, args: [String], password: String?, sshpassPath: String?)
|
||||||
|
-> (executable: String, arguments: [String], extraEnv: [String: String], autoLogin: Bool) {
|
||||||
|
if let password, !password.isEmpty, let sshpass = sshpassPath, !sshpass.isEmpty {
|
||||||
|
return (sshpass, ["-e", executable] + args, ["SSHPASS": password], true)
|
||||||
|
}
|
||||||
|
return (executable, args, [:], false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RDPService {
|
enum RDPService {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Host-Key-Prüfung OHNE interaktiven Yes/No-Prompt (der kommt ohne echtes
|
||||||
|
/// Terminal/Ctty nicht an — ssh bricht dann mit „Host key verification failed." ab).
|
||||||
|
/// Ablauf: ssh-keyscan → Fingerprint → Popup (akzeptieren/ablehnen) → known_hosts.
|
||||||
|
/// Nutzt bewusst ~/.ssh/known_hosts gemeinsam mit Terminal.app.
|
||||||
|
enum HostKeyStatus: Equatable {
|
||||||
|
case known
|
||||||
|
case unknown(HostKeyInfo)
|
||||||
|
case changed(HostKeyInfo)
|
||||||
|
case unreachable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HostKeyInfo: Equatable {
|
||||||
|
var host: String
|
||||||
|
var port: Int
|
||||||
|
var keyType: String
|
||||||
|
var fingerprintSHA256: String
|
||||||
|
var fingerprintMD5: String
|
||||||
|
var allKeyLines: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HostKeyService {
|
||||||
|
static var knownHostsPath = NSHomeDirectory() + "/.ssh/known_hosts"
|
||||||
|
static var scanTimeoutSeconds = "5"
|
||||||
|
|
||||||
|
static func canonicalHost(_ host: String, port: Int) -> String {
|
||||||
|
port == 22 ? host : "[\(host)]:\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func run(_ exe: String, _ args: [String]) -> (Int32, String) {
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: exe)
|
||||||
|
p.arguments = args
|
||||||
|
let out = Pipe()
|
||||||
|
p.standardOutput = out
|
||||||
|
p.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try p.run()
|
||||||
|
} catch {
|
||||||
|
return (-1, "")
|
||||||
|
}
|
||||||
|
p.waitUntilExit()
|
||||||
|
let data = out.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (p.terminationStatus, String(data: data, encoding: .utf8) ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rohe Key-Zeilen vom Server (ohne #-Kommentare).
|
||||||
|
static func keyscan(host: String, port: Int) -> [String] {
|
||||||
|
let (code, out) = run("/usr/bin/ssh-keyscan", ["-T", scanTimeoutSeconds, "-p", "\(port)", host])
|
||||||
|
guard code == 0 else { return [] }
|
||||||
|
return out.split(separator: "\n").map(String.init).filter { line in
|
||||||
|
let t = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
return !t.isEmpty && !t.hasPrefix("#") && t.contains(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func knownHostsContent() -> String {
|
||||||
|
(try? String(contentsOfFile: knownHostsPath, encoding: .utf8)) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(host: String, port: Int) -> HostKeyStatus {
|
||||||
|
let h = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !h.isEmpty else { return .unreachable("Kein Host.") }
|
||||||
|
let lines = keyscan(host: h, port: port)
|
||||||
|
guard !lines.isEmpty else {
|
||||||
|
return .unreachable("Host-Key nicht abfragbar (ssh-keyscan ohne Antwort).")
|
||||||
|
}
|
||||||
|
guard let info = info(host: h, port: port, lines: lines) else {
|
||||||
|
return .unreachable("Fingerprint nicht berechenbar.")
|
||||||
|
}
|
||||||
|
let (fcode, fout) = run("/usr/bin/ssh-keygen", ["-F", canonicalHost(h, port: port), "-f", knownHostsPath])
|
||||||
|
let hasEntry = fcode == 0 && fout.contains("found:")
|
||||||
|
if hasEntry {
|
||||||
|
let knownKeys = lines.compactMap { $0.split(separator: " ").last.map(String.init) }
|
||||||
|
if knownKeys.contains(where: { knownHostsContent().contains($0) }) {
|
||||||
|
return .known
|
||||||
|
}
|
||||||
|
return .changed(info)
|
||||||
|
}
|
||||||
|
return .unknown(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevorzugter Key (ed25519 > ecdsa > Rest) + Fingerprints via ssh-keygen.
|
||||||
|
static func info(host: String, port: Int, lines: [String]) -> HostKeyInfo? {
|
||||||
|
func rank(_ l: String) -> Int {
|
||||||
|
if l.contains("ssh-ed25519") { return 0 }
|
||||||
|
if l.contains("ecdsa-") { return 1 }
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
guard let line = lines.sorted(by: { rank($0) < rank($1) }).first else { return nil }
|
||||||
|
let parts = line.split(separator: " ")
|
||||||
|
guard parts.count >= 3 else { return nil }
|
||||||
|
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("rmm-fp-\(UUID().uuidString)")
|
||||||
|
do {
|
||||||
|
try (line + "\n").write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||||
|
let (_, sha) = run("/usr/bin/ssh-keygen", ["-l", "-f", tmp.path])
|
||||||
|
let (_, md5) = run("/usr/bin/ssh-keygen", ["-l", "-E", "md5", "-f", tmp.path])
|
||||||
|
return HostKeyInfo(host: host, port: port, keyType: String(parts[1]),
|
||||||
|
fingerprintSHA256: parseFP(sha), fingerprintMD5: parseFP(md5),
|
||||||
|
allKeyLines: lines)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus `256 SHA256:XYZ host (ED25519)` wird `SHA256:XYZ`.
|
||||||
|
static func parseFP(_ out: String) -> String {
|
||||||
|
let parts = out.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ")
|
||||||
|
guard parts.count >= 2 else { return out.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
return String(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fehlende Key-Zeilen an known_hosts anhängen (0600, ~/.ssh 0700).
|
||||||
|
static func accept(_ info: HostKeyInfo) -> Bool {
|
||||||
|
let dir = (knownHostsPath as NSString).deletingLastPathComponent
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true,
|
||||||
|
attributes: [.posixPermissions: 0o700])
|
||||||
|
let existing = knownHostsContent()
|
||||||
|
let missing = info.allKeyLines.filter { line in
|
||||||
|
let key = line.split(separator: " ").last.map(String.init) ?? line
|
||||||
|
return !existing.contains(key)
|
||||||
|
}
|
||||||
|
guard !missing.isEmpty else { return true }
|
||||||
|
if !FileManager.default.fileExists(atPath: knownHostsPath) {
|
||||||
|
FileManager.default.createFile(atPath: knownHostsPath, contents: nil,
|
||||||
|
attributes: [.posixPermissions: 0o600])
|
||||||
|
}
|
||||||
|
let fh = try FileHandle(forWritingTo: URL(fileURLWithPath: knownHostsPath))
|
||||||
|
defer { try? fh.close() }
|
||||||
|
try fh.seekToEnd()
|
||||||
|
fh.write(((missing.joined(separator: "\n")) + "\n").data(using: .utf8)!)
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: knownHostsPath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Einträge für Host entfernen (ssh-keygen -R).
|
||||||
|
static func revoke(host: String, port: Int) -> Bool {
|
||||||
|
let (code, _) = run("/usr/bin/ssh-keygen", ["-R", canonicalHost(host, port: port), "-f", knownHostsPath])
|
||||||
|
return code == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,22 +9,36 @@ enum PTYState: Equatable {
|
|||||||
case done(exitCode: Int32?, message: String)
|
case done(exitCode: Int32?, message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY), damit interaktive
|
/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY).
|
||||||
/// Prompts (Passwort, Host-Key) funktionieren. Ausgabe wird via AnsiParser
|
/// - stdin/stdout: PTY-Slave (interaktiv, TTY bleibt erhalten)
|
||||||
/// in gestylte Zeilen für die Dracula-Ansicht übersetzt.
|
/// - stderr: PIPE — macOS verwirft ungelesene PTY-Daten beim Slave-Close,
|
||||||
|
/// dadurch gingen Fehlermeldungen bei Instant-Exit (z. B. Code 255) verloren.
|
||||||
|
/// Pipes liefern gepufferte Daten garantiert aus (Bugfix, siehe docs/ERRORS.md #8).
|
||||||
|
///
|
||||||
|
/// FD-Regeln: masterFD wird NUR auf dem Main-Thread vergeben/geschlossen,
|
||||||
|
/// auf ioQueue nur gelesen (EBADF-tolerant). Kein Close auf ioQueue.
|
||||||
final class PTYSession: ObservableObject {
|
final class PTYSession: ObservableObject {
|
||||||
@Published private(set) var lines: [[StyledText]] = [[]]
|
@Published private(set) var lines: [[StyledText]] = [[]]
|
||||||
@Published private(set) var state: PTYState = .never
|
@Published private(set) var state: PTYState = .never
|
||||||
|
@Published private(set) var autoLogin = false
|
||||||
|
|
||||||
var executable = ""
|
var executable = ""
|
||||||
var arguments: [String] = []
|
var arguments: [String] = []
|
||||||
|
var customEnv: [String: String] = [:]
|
||||||
|
/// Fingerprint der Konfiguration (TerminalManager) — bei Änderung wird neu konfiguriert.
|
||||||
|
var configTag = ""
|
||||||
|
|
||||||
private var masterFD: Int32 = -1
|
private var masterFD: Int32 = -1
|
||||||
private var process: Process?
|
private var process: Process?
|
||||||
private var readSource: DispatchSourceRead?
|
private var readSource: DispatchSourceRead?
|
||||||
|
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 let maxLines = 2000
|
private let maxLines = 2000
|
||||||
|
|
||||||
var isRunning: Bool {
|
var isRunning: Bool {
|
||||||
@@ -41,8 +55,13 @@ final class PTYSession: ObservableObject {
|
|||||||
self.arguments = arguments
|
self.arguments = arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setAutoLogin(_ value: Bool) {
|
||||||
|
autoLogin = value
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
stop()
|
cleanupSync()
|
||||||
|
epoch += 1
|
||||||
finished = false
|
finished = false
|
||||||
parser = AnsiParser()
|
parser = AnsiParser()
|
||||||
state = .running
|
state = .running
|
||||||
@@ -64,35 +83,69 @@ 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 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" }
|
||||||
|
for (k, v) in customEnv { env[k] = v }
|
||||||
p.environment = env
|
p.environment = env
|
||||||
// closeOnDealloc=false: Parent schließt slave explizit nach run().
|
// closeOnDealloc=false: Parent schließt slave explizit nach run().
|
||||||
p.standardInput = FileHandle(fileDescriptor: slave)
|
p.standardInput = FileHandle(fileDescriptor: slave)
|
||||||
p.standardOutput = FileHandle(fileDescriptor: slave)
|
p.standardOutput = FileHandle(fileDescriptor: slave)
|
||||||
p.standardError = FileHandle(fileDescriptor: slave)
|
p.standardError = pipe.fileHandleForWriting
|
||||||
|
let myEpoch = epoch
|
||||||
p.terminationHandler = { [weak self] proc in
|
p.terminationHandler = { [weak self] proc in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.finish(message: "Sitzung beendet (Code \(proc.terminationStatus)).", code: proc.terminationStatus)
|
guard let self, self.epoch == myEpoch else { return }
|
||||||
|
self.ioQueue.async { [weak self] in
|
||||||
|
self?.finalFlush(code: proc.terminationStatus, myEpoch: myEpoch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try p.run()
|
try p.run()
|
||||||
} catch {
|
} catch {
|
||||||
close(slave); close(master); masterFD = -1
|
close(slave); close(master); masterFD = -1
|
||||||
|
errPipe = nil
|
||||||
finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil)
|
finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
process = p
|
process = p
|
||||||
close(slave) // Parent behält nur Master
|
close(slave) // Parent behält nur Master
|
||||||
|
errPipe = pipe
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
||||||
|
let data = h.availableData
|
||||||
|
guard let self else { return }
|
||||||
|
if data.isEmpty {
|
||||||
|
h.readabilityHandler = nil // EOF
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.onData?(data)
|
||||||
|
self.ioQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let events = self.parser.feed(data)
|
||||||
|
DispatchQueue.main.async { self.apply(events) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue)
|
let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue)
|
||||||
source.setEventHandler { [weak self] in self?.drain() }
|
source.setEventHandler { [weak self] in self?.drain() }
|
||||||
source.setCancelHandler { [master] in close(master) }
|
// Kein Close hier (Eigentum: Main-Thread) — nur Cancel.
|
||||||
readSource = source
|
readSource = source
|
||||||
source.resume()
|
source.resume()
|
||||||
}
|
}
|
||||||
@@ -103,11 +156,32 @@ 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()
|
readSource?.cancel() // EOF/EIO — Rest holt finalFlush bzw. ist verworfen
|
||||||
masterFD = -1
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letzte Reste vom PTY-Master einsammeln (non-blocking, EBADF-safe).
|
||||||
|
/// Pipe-Reste kommen garantiert über den readabilityHandler.
|
||||||
|
private func finalFlush(code: Int32, myEpoch: Int) {
|
||||||
|
var events: [TerminalEvent] = []
|
||||||
|
let fd = masterFD
|
||||||
|
if fd >= 0 {
|
||||||
|
var buf = [UInt8](repeating: 0, count: 65536)
|
||||||
|
while true {
|
||||||
|
let n = read(fd, &buf, buf.count)
|
||||||
|
if n <= 0 { break }
|
||||||
|
events += parser.feed(Data(buf[..<n]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.epoch == myEpoch, !self.finished else { return }
|
||||||
|
self.apply(events)
|
||||||
|
self.finish(message: "Sitzung beendet (Code \(code)).", code: code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +223,20 @@ final class PTYSession: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ string: String) {
|
func send(_ string: String) {
|
||||||
let fd = masterFD
|
|
||||||
guard fd >= 0 else { return }
|
|
||||||
guard let data = string.data(using: .utf8) else { return }
|
guard let data = string.data(using: .utf8) else { return }
|
||||||
|
sendBytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw bytes from the terminal emulator (keyboard) to the pty master.
|
||||||
|
func sendBytes(_ data: Data) {
|
||||||
|
let fd = masterFD
|
||||||
|
guard fd >= 0, !data.isEmpty else { return }
|
||||||
ioQueue.async {
|
ioQueue.async {
|
||||||
data.withUnsafeBytes { ptr in
|
data.withUnsafeBytes { ptr in
|
||||||
|
guard let base = ptr.baseAddress else { return }
|
||||||
var written = 0
|
var written = 0
|
||||||
while written < data.count {
|
while written < data.count {
|
||||||
let n = write(fd, ptr.baseAddress!.advanced(by: written), data.count - written)
|
let n = write(fd, base.advanced(by: written), data.count - written)
|
||||||
if n <= 0 { break }
|
if n <= 0 { break }
|
||||||
written += n
|
written += n
|
||||||
}
|
}
|
||||||
@@ -164,40 +244,98 @@ 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 = [[]]
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
/// ctty-run liegt neben diesem Binary (Contents/MacOS wie .build/debug).
|
||||||
process?.terminate()
|
static func cttyHelperURL() -> URL? {
|
||||||
process = nil
|
let sibling = URL(fileURLWithPath: CommandLine.arguments[0])
|
||||||
if !finished {
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("ctty-run")
|
||||||
|
if FileManager.default.isExecutableFile(atPath: sibling.path) {
|
||||||
|
return sibling
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(_ message: String) {
|
||||||
|
lines.append([StyledText(text: message, style: TermStyle(fg: 8))])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorab-Check-Fehlschlag (ohne Spawn): rote Notiz + done.
|
||||||
|
func fail(_ message: String) {
|
||||||
|
cleanupSync()
|
||||||
|
epoch += 1
|
||||||
finished = true
|
finished = true
|
||||||
|
state = .done(exitCode: nil, message: message)
|
||||||
|
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 1))])
|
||||||
|
lines.append([])
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
let wasRunning = isRunning
|
||||||
|
killTree()
|
||||||
|
cleanupSync()
|
||||||
|
if wasRunning {
|
||||||
|
epoch += 1 // späte terminationHandler-Ausläufer verwerfen
|
||||||
|
finished = true
|
||||||
|
state = .done(exitCode: nil, message: "Getrennt.")
|
||||||
|
lines.append([StyledText(text: "— Getrennt. —", style: TermStyle(fg: 8))])
|
||||||
|
lines.append([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beendet Kind + Enkel (ctty-run-Wächter + ssh teilen die Prozessgruppe).
|
||||||
|
private func killTree() {
|
||||||
|
if let pid = process?.processIdentifier, pid > 0 {
|
||||||
|
Darwin.kill(-pid, SIGTERM)
|
||||||
|
}
|
||||||
|
process?.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen.
|
||||||
|
/// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst.
|
||||||
|
private func cleanupSync() {
|
||||||
readSource?.cancel()
|
readSource?.cancel()
|
||||||
readSource = nil
|
readSource = nil
|
||||||
|
if masterFD >= 0 {
|
||||||
|
close(masterFD)
|
||||||
masterFD = -1
|
masterFD = -1
|
||||||
if case .running = state {
|
|
||||||
state = .done(exitCode: nil, message: "Getrennt.")
|
|
||||||
}
|
}
|
||||||
|
if let pipe = errPipe {
|
||||||
|
pipe.fileHandleForReading.readabilityHandler = nil
|
||||||
|
errPipe = nil
|
||||||
}
|
}
|
||||||
|
process = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finish(message: String, code: Int32?) {
|
private func finish(message: String, code: Int32?) {
|
||||||
guard !finished else { return }
|
guard !finished else { return }
|
||||||
finished = true
|
finished = true
|
||||||
readSource?.cancel()
|
cleanupSync()
|
||||||
readSource = nil
|
|
||||||
masterFD = -1
|
|
||||||
process = nil
|
|
||||||
state = .done(exitCode: code, message: message)
|
state = .done(exitCode: code, message: message)
|
||||||
let note = StyledText(text: "— \(message) —", style: TermStyle(fg: 8))
|
lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 8))])
|
||||||
lines.append([note])
|
|
||||||
lines.append([])
|
lines.append([])
|
||||||
}
|
}
|
||||||
|
|
||||||
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,17 +345,58 @@ 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 {
|
||||||
if let existing = sessions[profile.id] { return existing }
|
// Fingerprint aus allem, was die Verbindung bestimmt (inkl. Secret-Wert
|
||||||
let s = PTYSession()
|
// und sshpass-Verfügbarkeit): Ändert der User Host/Passwort/Key oder wird
|
||||||
if profile.proto == .sftp {
|
// sshpass (nach-)installiert, wird neu konfiguriert statt stale weiterzuwählen.
|
||||||
s.configure(executable: "/usr/bin/sftp", arguments: ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"])
|
let secret = SSHService.loginSecret(for: profile, key: key)
|
||||||
} else {
|
let sshpass = SSHService.findSshpass()
|
||||||
s.configure(executable: "/usr/bin/ssh", arguments: SSHService.sshArguments(for: profile, key: key))
|
let tag = Self.configTag(profile: profile, keyPath: key?.privateKeyPath,
|
||||||
|
secret: secret, sshpass: sshpass)
|
||||||
|
if let existing = sessions[profile.id] {
|
||||||
|
// Laufende Session niemals unter den Füßen wegkonfigurieren — erst Trennen.
|
||||||
|
if existing.isRunning || existing.configTag == tag { return existing }
|
||||||
|
existing.stop()
|
||||||
|
configure(existing, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||||
|
existing.configTag = tag
|
||||||
|
return existing
|
||||||
}
|
}
|
||||||
|
let s = PTYSession()
|
||||||
|
configure(s, profile: profile, key: key, secret: secret, sshpass: sshpass)
|
||||||
|
s.configTag = tag
|
||||||
sessions[profile.id] = s
|
sessions[profile.id] = s
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure tag builder (testable): any change here must reconfigure.
|
||||||
|
nonisolated static func configTag(profile: ConnectionProfile, keyPath: String?, secret: String?, sshpass: String?) -> String {
|
||||||
|
[profile.proto.rawValue, profile.host, "\(profile.port)", profile.username,
|
||||||
|
profile.extraArgs, keyPath ?? "", secret ?? "", sshpass ?? ""].joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(_ s: PTYSession, profile: ConnectionProfile, key: StoredSSHKey?, secret: String?, sshpass: String?) {
|
||||||
|
let baseExe: String
|
||||||
|
let baseArgs: [String]
|
||||||
|
if profile.proto == .sftp {
|
||||||
|
baseExe = "/usr/bin/sftp"
|
||||||
|
baseArgs = ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]
|
||||||
|
} else {
|
||||||
|
baseExe = "/usr/bin/ssh"
|
||||||
|
baseArgs = SSHService.sshArguments(for: profile, key: key)
|
||||||
|
}
|
||||||
|
// Auto-Login nur mit Keychain-Secret + sshpass, sonst interaktiver Prompt im Terminal.
|
||||||
|
// SSH-Agent durchreichen (falls vorhanden): gleiche Keys wie in Terminal.app.
|
||||||
|
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
|
||||||
|
password: secret,
|
||||||
|
sshpassPath: sshpass)
|
||||||
|
s.configure(executable: cfg.executable, arguments: cfg.arguments)
|
||||||
|
var env = cfg.extraEnv
|
||||||
|
if let sock = SSHService.agentSocket() {
|
||||||
|
env["SSH_AUTH_SOCK"] = sock
|
||||||
|
}
|
||||||
|
s.customEnv = env
|
||||||
|
s.setAutoLogin(cfg.autoLogin)
|
||||||
|
}
|
||||||
|
|
||||||
func close(_ profile: ConnectionProfile) {
|
func close(_ profile: ConnectionProfile) {
|
||||||
sessions[profile.id]?.stop()
|
sessions[profile.id]?.stop()
|
||||||
sessions.removeValue(forKey: profile.id)
|
sessions.removeValue(forKey: profile.id)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Schneller TCP-Vorab-Check, BEVOR ssh gestartet wird.
|
||||||
|
/// Falscher Port / falscher Host / Firewall → sofort klare Meldung
|
||||||
|
/// statt „Sitzung beendet (Code 255)" ohne Erklärung.
|
||||||
|
enum TCPCheck {
|
||||||
|
private final class Flag {
|
||||||
|
var done = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - completion: nil bei Erfolg, sonst deutsche Fehlermeldung.
|
||||||
|
static func check(host: String, port: Int, timeout: TimeInterval = 4.0, completion: @escaping (String?) -> Void) {
|
||||||
|
let h = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !h.isEmpty else { completion("Kein Host angegeben — Profil bearbeiten."); return }
|
||||||
|
guard (1...65535).contains(port), let p = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
||||||
|
completion("Ungültiger Port \(port)."); return
|
||||||
|
}
|
||||||
|
let conn = NWConnection(host: NWEndpoint.Host(h), port: p, using: .tcp)
|
||||||
|
let lock = NSLock()
|
||||||
|
let flag = Flag()
|
||||||
|
func finish(_ message: String?) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard !flag.done else { return }
|
||||||
|
flag.done = true
|
||||||
|
conn.cancel()
|
||||||
|
completion(message)
|
||||||
|
}
|
||||||
|
conn.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
finish(nil)
|
||||||
|
case .failed(let err):
|
||||||
|
finish(describe(err, host: h, port: port))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.start(queue: .global(qos: .userInitiated))
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
||||||
|
finish("Keine Antwort in \(Int(timeout)) s — Hostname/IP, Netzwerk/VPN oder Firewall prüfen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func describe(_ error: NWError, host: String, port: Int) -> String {
|
||||||
|
switch error {
|
||||||
|
case .posix(let code):
|
||||||
|
switch code.rawValue {
|
||||||
|
case 61:
|
||||||
|
return "Port \(port) auf \(host) verweigert die Verbindung — läuft SSH dort? Stimmt der Port?"
|
||||||
|
case 51, 64, 65:
|
||||||
|
return "Host \(host) nicht erreichbar — Netzwerk/VPN prüfen."
|
||||||
|
case 60:
|
||||||
|
return "Zeitüberschreitung — Host/Port/Firewall prüfen."
|
||||||
|
default:
|
||||||
|
return "Netzwerkfehler (\(code.rawValue)): \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
case .dns:
|
||||||
|
return "Hostname \(host) kann nicht aufgelöst werden — Tippfehler? DNS/VPN prüfen."
|
||||||
|
default:
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
@@ -104,7 +107,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $editingProfile) { profile in
|
.sheet(item: $editingProfile) { profile in
|
||||||
ProfileEditorView(profile: profile) { saved in
|
ProfileEditorView(profile: profile, isNew: !store.profiles.contains(where: { $0.id == profile.id })) { saved in
|
||||||
if store.profiles.contains(where: { $0.id == saved.id }) {
|
if store.profiles.contains(where: { $0.id == saved.id }) {
|
||||||
store.update(saved)
|
store.update(saved)
|
||||||
} else {
|
} else {
|
||||||
@@ -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,13 +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):
|
||||||
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
|
/// cursor addressing, colors, scrollback, Tab completion, arrows, Ctrl+C,
|
||||||
|
/// full-screen apps (vim/htop). Keyboard goes straight to the pty.
|
||||||
|
/// Connects after TCP pre-check + host-key approval (popup).
|
||||||
struct EmbeddedTerminalView: View {
|
struct EmbeddedTerminalView: View {
|
||||||
@ObservedObject var term: PTYSession
|
@ObservedObject var term: PTYSession
|
||||||
|
let profile: ConnectionProfile
|
||||||
var canConnect: Bool
|
var canConnect: Bool
|
||||||
@State private var input = ""
|
@State private var checking = false
|
||||||
@State private var secureInput = false
|
@State private var hostKeyRequest: HostKeyRequest?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -18,8 +21,8 @@ struct EmbeddedTerminalView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if term.isRunning {
|
if term.isRunning {
|
||||||
Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption)
|
Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption)
|
||||||
} else if canConnect {
|
} else if canConnect && !checking {
|
||||||
Button("Verbinden") { term.start() }.buttonStyle(.link).font(.caption)
|
Button("Verbinden") { connect() }.buttonStyle(.link).font(.caption)
|
||||||
}
|
}
|
||||||
Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption)
|
Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption)
|
||||||
}
|
}
|
||||||
@@ -27,131 +30,170 @@ struct EmbeddedTerminalView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Dracula.currentLine.opacity(0.35))
|
.background(Dracula.currentLine.opacity(0.35))
|
||||||
|
|
||||||
// Ausgabe im Dracula-Theme
|
// Fehler-Banner bei Fehlschlag (statt nur „Sitzung beendet")
|
||||||
DraculaTextView(lines: term.lines)
|
if failed {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
// Eingabezeile
|
.foregroundStyle(Dracula.red)
|
||||||
HStack(spacing: 6) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
|
Text("Verbindung fehlgeschlagen\(exitSuffix)")
|
||||||
if secureInput {
|
.font(.callout).bold().foregroundStyle(Dracula.red)
|
||||||
SecureField("Eingabe (versteckt, z. B. Passwort) …", text: $input, onCommit: submit)
|
Text(lastMessage)
|
||||||
.textFieldStyle(.plain)
|
.font(.caption).foregroundStyle(Dracula.foreground)
|
||||||
.font(.system(.body, design: .monospaced))
|
.lineLimit(3)
|
||||||
.foregroundStyle(Dracula.foreground)
|
if lastMessage.localizedCaseInsensitiveContains("permission denied") {
|
||||||
} else {
|
Text("Tipp: Passwort im Auge-Modus eingeben, Tastaturlayout prüfen. Falls Terminal.app ohne Passwort einloggt (Agent-Key: ssh-add -l), Key in der App hinterlegen oder Agent nutzen.")
|
||||||
TextField("Eingabe …", text: $input, onCommit: submit)
|
.font(.caption).foregroundStyle(Dracula.orange)
|
||||||
.textFieldStyle(.plain)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.foregroundStyle(Dracula.foreground)
|
|
||||||
}
|
}
|
||||||
Button(action: { secureInput.toggle() }) {
|
|
||||||
Image(systemName: secureInput ? "eye.slash" : "eye")
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain).foregroundStyle(Dracula.comment)
|
Spacer()
|
||||||
.help("Passwort-Modus (versteckte Eingabe)")
|
Button("Bericht kopieren") { copyReport() }
|
||||||
Button("Senden") { submit() }
|
.buttonStyle(.link).font(.caption)
|
||||||
.disabled(input.isEmpty || !term.isRunning)
|
.help("Kopiert Profil (ohne Passwort), Status und letzte Ausgabe — hierher schicken zur Fehlersuche")
|
||||||
ControlButton(label: "^C", tip: "SIGINT (Strg+C)") { term.send("\u{03}") }
|
Button("Diagnose") { diagnose() }.buttonStyle(.link).font(.caption)
|
||||||
ControlButton(label: "^D", tip: "EOF (Strg+D)") { term.send("\u{04}") }
|
Button("Erneut") { connect() }.buttonStyle(.link).font(.caption)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Dracula.background)
|
.background(Dracula.red.opacity(0.12))
|
||||||
|
.overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full terminal emulator (click to focus, type directly).
|
||||||
|
SwiftTermView(term: term)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.background(Dracula.background)
|
.background(Dracula.background)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if case .never = term.state, canConnect {
|
if case .never = term.state {
|
||||||
term.start() // Auto-Connect beim Öffnen des Tabs (Bugfix: kein externes Terminal mehr nötig)
|
connect() // Auto-Connect beim Öffnen des Tabs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $hostKeyRequest) { req in
|
||||||
|
HostKeySheet(info: req.info, isChange: req.isChange,
|
||||||
|
onAccept: { acceptHostKey(req) },
|
||||||
|
onCancel: {
|
||||||
|
hostKeyRequest = nil
|
||||||
|
checking = false
|
||||||
|
term.fail("Host-Key nicht akzeptiert — Verbindung abgebrochen.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() {
|
||||||
|
guard canConnect, !term.isRunning, !checking else { return }
|
||||||
|
checking = true
|
||||||
|
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let message {
|
||||||
|
checking = false
|
||||||
|
term.fail("Vorab-Check: \(message)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Host-Key vorab per keyscan prüfen (Yes/No-Prompt käme ohne Ctty nicht an).
|
||||||
|
// Läuft im Hintergrund: ssh-keyscan blockiert Sekunden (kein UI-Freeze).
|
||||||
|
let host = profile.host
|
||||||
|
let port = profile.port
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let hk = HostKeyService.status(host: host, port: port)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch hk {
|
||||||
|
case .known, .unreachable:
|
||||||
|
checking = false
|
||||||
|
term.start()
|
||||||
|
case .unknown(let info):
|
||||||
|
hostKeyRequest = HostKeyRequest(info: info, isChange: false)
|
||||||
|
case .changed(let info):
|
||||||
|
hostKeyRequest = HostKeyRequest(info: info, isChange: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func submit() {
|
func acceptHostKey(_ req: HostKeyRequest) {
|
||||||
guard term.isRunning, !input.isEmpty else { return }
|
hostKeyRequest = nil
|
||||||
term.send(input + "\n")
|
if req.isChange {
|
||||||
input = ""
|
_ = HostKeyService.revoke(host: profile.host, port: profile.port)
|
||||||
|
}
|
||||||
|
if HostKeyService.accept(req.info) {
|
||||||
|
checking = false
|
||||||
|
term.note("Host-Key akzeptiert (\(req.info.keyType), \(req.info.fingerprintSHA256.prefix(19))…).")
|
||||||
|
term.start()
|
||||||
|
} else {
|
||||||
|
checking = false
|
||||||
|
term.fail("Host-Key konnte nicht in known_hosts gespeichert werden.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnose() {
|
||||||
|
guard !checking else { return }
|
||||||
|
checking = true
|
||||||
|
term.note("Diagnose: TCP \(profile.host):\(profile.port) …")
|
||||||
|
TCPCheck.check(host: profile.host, port: profile.port) { message in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
checking = false
|
||||||
|
if let message {
|
||||||
|
term.note("Diagnose: \(message)")
|
||||||
|
} else {
|
||||||
|
term.note("Diagnose: Port offen — ssh selbst lehnt ab (User/Key/Passwort/Hostkey prüfen).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fehlerbericht ohne Secrets (Passwort steht weder im Profil noch im Befehl).
|
||||||
|
func copyReport() {
|
||||||
|
var r = "RemminaMac Fehlerbericht\n"
|
||||||
|
r += "Profil: \(profile.name) | \(profile.proto.rawValue) | \(profile.username)@\(profile.host):\(profile.port)\n"
|
||||||
|
r += "Auto-Login: \(term.autoLogin ? "ja" : "nein") | sshpass: \(SSHService.findSshpass() ?? "fehlt")\n"
|
||||||
|
r += "Status: \(statusText)\n"
|
||||||
|
let tail = term.lines.suffix(20).map { $0.map(\.text).joined() }.joined(separator: "\n")
|
||||||
|
r += "--- letzte Ausgabe ---\n\(tail)\n"
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(r, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed: Bool {
|
||||||
|
if case .done(let code, _) = term.state {
|
||||||
|
return (code ?? 1) != 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitSuffix: String {
|
||||||
|
if case .done(let code, _) = term.state, let code {
|
||||||
|
return " (Exit \(code))"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastMessage: String {
|
||||||
|
for row in term.lines.reversed() {
|
||||||
|
let t = row.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !t.isEmpty && !t.hasPrefix("—") { return t }
|
||||||
|
}
|
||||||
|
if case .done(_, let msg) = term.state { return msg }
|
||||||
|
return "Unbekannter Fehler."
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusDot: some View {
|
var statusDot: some View {
|
||||||
Group {
|
Group {
|
||||||
if term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) }
|
if checking || term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) }
|
||||||
else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(Dracula.red) }
|
else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(failed ? Dracula.red : Dracula.comment) }
|
||||||
else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) }
|
else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) }
|
||||||
}.font(.caption2)
|
}.font(.caption2)
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusText: String {
|
var statusText: String {
|
||||||
|
if checking { return "Prüfe \(profile.host):\(profile.port) …" }
|
||||||
switch term.state {
|
switch term.state {
|
||||||
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
||||||
case .running: return "Verbunden (PTY, TERM=xterm-256color)."
|
case .running: return term.autoLogin ? "Verbunden (Auto-Login via Keychain)." : "Verbunden (PTY, TERM=xterm-256color)."
|
||||||
case .done(_, let msg): return msg
|
case .done(_, let msg): return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ControlButton: View {
|
|
||||||
var label: String
|
|
||||||
var tip: String
|
|
||||||
var action: () -> Void
|
|
||||||
var body: some View {
|
|
||||||
Button(label, action: action)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
.foregroundStyle(Dracula.orange)
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Dracula.currentLine.opacity(0.6), in: RoundedRectangle(cornerRadius: 5))
|
|
||||||
.help(tip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read-only NSTextView mit Dracula-Farben, baut NSAttributedString aus StyledText-Zeilen.
|
|
||||||
struct DraculaTextView: NSViewRepresentable {
|
|
||||||
var lines: [[StyledText]]
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSScrollView {
|
|
||||||
let scroll = NSScrollView()
|
|
||||||
scroll.hasVerticalScroller = true
|
|
||||||
scroll.drawsBackground = true
|
|
||||||
scroll.backgroundColor = Dracula.nsBackground
|
|
||||||
let tv = NSTextView()
|
|
||||||
tv.isEditable = false
|
|
||||||
tv.isSelectable = true
|
|
||||||
tv.backgroundColor = Dracula.nsBackground
|
|
||||||
tv.textColor = Dracula.nsColor(for: nil)
|
|
||||||
tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
||||||
tv.textContainerInset = NSSize(width: 8, height: 8)
|
|
||||||
tv.isAutomaticQuoteSubstitutionEnabled = false
|
|
||||||
scroll.documentView = tv
|
|
||||||
return scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
|
||||||
guard let tv = scroll.documentView as? NSTextView else { return }
|
|
||||||
let attr = NSMutableAttributedString()
|
|
||||||
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
||||||
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
|
|
||||||
for (idx, row) in lines.enumerated() {
|
|
||||||
if row.isEmpty {
|
|
||||||
attr.append(NSAttributedString(string: idx < lines.count - 1 ? "\n" : ""))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for seg in row {
|
|
||||||
let attrs: [NSAttributedString.Key: Any] = [
|
|
||||||
.font: seg.style.bold ? boldFont : font,
|
|
||||||
.foregroundColor: Dracula.nsColor(for: seg.style.fg),
|
|
||||||
.backgroundColor: seg.style.bg.map { Dracula.nsColor(for: $0) } ?? Dracula.nsBackground
|
|
||||||
]
|
|
||||||
attr.append(NSAttributedString(string: seg.text, attributes: attrs))
|
|
||||||
}
|
|
||||||
if idx < lines.count - 1 { attr.append(NSAttributedString(string: "\n")) }
|
|
||||||
}
|
|
||||||
// Nur neu setzen wenn geändert (Performance bei viel Output)
|
|
||||||
if tv.textStorage?.string != attr.string {
|
|
||||||
tv.textStorage?.setAttributedString(attr)
|
|
||||||
}
|
|
||||||
tv.scrollToEndOfDocument(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sheet-Request (Identifiable): .sheet(item:) rendert IMMER mit Wert —
|
||||||
|
/// ein `if let` im Sheet kann den Empty-Zweig nehmen und bleibt dann leer.
|
||||||
|
struct HostKeyRequest: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var info: HostKeyInfo
|
||||||
|
var isChange: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Popup bei unbekanntem/geändertem Host-Key: Fingerprint prüfen, dann
|
||||||
|
/// akzeptieren (→ known_hosts + verbinden) oder abbrechen.
|
||||||
|
struct HostKeySheet: View {
|
||||||
|
var info: HostKeyInfo
|
||||||
|
var isChange: Bool
|
||||||
|
var onAccept: () -> Void
|
||||||
|
var onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: isChange ? "exclamationmark.octagon.fill" : "key.fill")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(isChange ? Dracula.red : Dracula.orange)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(isChange ? "Host-Key GEÄNDERT" : "Neuer Host-Key")
|
||||||
|
.font(.headline)
|
||||||
|
Text("\(info.host):\(info.port) · \(info.keyType)")
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isChange {
|
||||||
|
Text("Warnung: Der gespeicherte Schlüssel passt NICHT zum Server. Das kann ein neu aufgesetzter Server sein — oder ein Angriff (MITM). Nur fortfahren, wenn du die Änderung erwartest!")
|
||||||
|
.font(.callout).foregroundStyle(Dracula.red)
|
||||||
|
} else {
|
||||||
|
Text("Erstverbindung: Dieser Server ist noch unbekannt. Fingerprint prüfen (z. B. mit dem Server-Admin oder einer Terminal-Verbindung vergleichen), dann akzeptieren.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox("SHA256-Fingerprint") {
|
||||||
|
Text(info.fingerprintSHA256)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GroupBox("MD5-Fingerprint") {
|
||||||
|
Text(info.fingerprintMD5)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Abbrechen", role: .cancel, action: onCancel)
|
||||||
|
Spacer()
|
||||||
|
Button(isChange ? "Trotzdem ersetzen & verbinden" : "Akzeptieren & verbinden", action: onAccept)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,23 @@ struct ProfileEditorView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var store: ProfileStore
|
@EnvironmentObject var store: ProfileStore
|
||||||
@State var profile: ConnectionProfile
|
@State var profile: ConnectionProfile
|
||||||
|
var isNew: Bool
|
||||||
var onSave: (ConnectionProfile) -> Void
|
var onSave: (ConnectionProfile) -> Void
|
||||||
@State private var password: String = ""
|
@State private var password: String = ""
|
||||||
@State private var savePassword: Bool = false
|
@State private var savePassword: Bool = false
|
||||||
|
@State private var storedPasswordPresent: Bool = false
|
||||||
|
|
||||||
init(profile: ConnectionProfile, onSave: @escaping (ConnectionProfile) -> Void) {
|
init(profile: ConnectionProfile, isNew: Bool, onSave: @escaping (ConnectionProfile) -> Void) {
|
||||||
_profile = State(initialValue: profile)
|
_profile = State(initialValue: profile)
|
||||||
|
self.isNew = isNew
|
||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var liveWarnings: [String] { ProfileValidation.warnings(for: profile) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text(profile.id.uuidString.prefix(8) == ConnectionProfile().id.uuidString.prefix(8) ? "Profil bearbeiten" : "Profil").font(.headline).padding()
|
Text(isNew ? "Neues Profil" : "Profil bearbeiten").font(.headline).padding()
|
||||||
Form {
|
Form {
|
||||||
Section("Allgemein") {
|
Section("Allgemein") {
|
||||||
TextField("Name", text: $profile.name)
|
TextField("Name", text: $profile.name)
|
||||||
@@ -34,18 +39,38 @@ struct ProfileEditorView: View {
|
|||||||
}
|
}
|
||||||
Section("Verbindung") {
|
Section("Verbindung") {
|
||||||
TextField("Host", text: $profile.host)
|
TextField("Host", text: $profile.host)
|
||||||
|
.autocorrectionDisabled()
|
||||||
TextField("Port", value: $profile.port, format: .number)
|
TextField("Port", value: $profile.port, format: .number)
|
||||||
TextField("Benutzername", text: $profile.username)
|
TextField("Benutzername", text: $profile.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
// Passwort direkt unter dem Benutzernamen (Keychain-gespeichert)
|
||||||
|
if storedPasswordPresent && password.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
Text("Passwort gespeichert")
|
||||||
|
Spacer()
|
||||||
|
Button("Löschen", role: .destructive) { deleteStoredPassword() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureField(storedPasswordPresent ? "Neues Passwort …" : "Passwort", text: $password)
|
||||||
|
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
|
||||||
TextField("Auflösung (RDP, z. B. 1920x1080 oder Automatisch)", text: $profile.resolution)
|
TextField("Auflösung (RDP, z. B. 1920x1080 oder Automatisch)", text: $profile.resolution)
|
||||||
TextField("Extra-Argumente", text: $profile.extraArgs)
|
TextField("Extra-Argumente", text: $profile.extraArgs)
|
||||||
|
if !liveWarnings.isEmpty {
|
||||||
|
ForEach(liveWarnings, id: \.self) { w in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange)
|
||||||
|
Text(w).font(.caption).foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
Section("Authentifizierung") {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("SSH-Key (optional)") {
|
||||||
Picker("SSH-Key", selection: $profile.sshKeyID) {
|
Picker("SSH-Key", selection: $profile.sshKeyID) {
|
||||||
Text("Kein Key").tag(UUID?.none)
|
Text("Kein Key (Passwort-Login)").tag(UUID?.none)
|
||||||
ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) }
|
ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) }
|
||||||
}
|
}
|
||||||
SecureField("Passwort (optional, Keychain)", text: $password)
|
Text(sshpassHint).font(.caption).foregroundStyle(.secondary)
|
||||||
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -54,7 +79,7 @@ struct ProfileEditorView: View {
|
|||||||
Button("Abbrechen", role: .cancel) { dismiss() }
|
Button("Abbrechen", role: .cancel) { dismiss() }
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Speichern") {
|
Button("Speichern") {
|
||||||
var toSave = profile
|
var toSave = ProfileValidation.cleaned(profile)
|
||||||
if toSave.port == 0 { toSave.port = toSave.proto.defaultPort }
|
if toSave.port == 0 { toSave.port = toSave.proto.defaultPort }
|
||||||
if toSave.passwordAccount == nil { toSave.passwordAccount = "profile-\(toSave.id.uuidString)" }
|
if toSave.passwordAccount == nil { toSave.passwordAccount = "profile-\(toSave.id.uuidString)" }
|
||||||
if savePassword, !password.isEmpty, let acct = toSave.passwordAccount {
|
if savePassword, !password.isEmpty, let acct = toSave.passwordAccount {
|
||||||
@@ -64,16 +89,35 @@ struct ProfileEditorView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(profile.host.isEmpty || profile.name.isEmpty)
|
.disabled(profile.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
|| profile.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(width: 520, height: 620)
|
.frame(width: 520, height: 680)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
if let acct = profile.passwordAccount,
|
||||||
|
let stored = KeychainHelper.load(account: acct), !stored.isEmpty {
|
||||||
|
storedPasswordPresent = true
|
||||||
|
savePassword = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshpassHint: String {
|
||||||
|
if SSHService.findSshpass() != nil {
|
||||||
|
return "sshpass gefunden → Auto-Login mit Keychain-Passwort aktiv, kein Tippen nötig."
|
||||||
|
} else {
|
||||||
|
return "Ohne sshpass: Passwort wird beim Verbinden im Terminal abgefragt. Für Auto-Login: brew install sshpass."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteStoredPassword() {
|
||||||
if let acct = profile.passwordAccount {
|
if let acct = profile.passwordAccount {
|
||||||
password = KeychainHelper.load(account: acct) ?? ""
|
KeychainHelper.delete(account: acct)
|
||||||
savePassword = !password.isEmpty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
password = ""
|
||||||
|
savePassword = false
|
||||||
|
storedPasswordPresent = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct SessionView: View {
|
|||||||
|
|
||||||
if isSSH {
|
if isSSH {
|
||||||
// In-App-Terminal (Dracula) — kein externes Fenster mehr nötig
|
// In-App-Terminal (Dracula) — kein externes Fenster mehr nötig
|
||||||
EmbeddedTerminalView(term: term, canConnect: canConnect)
|
EmbeddedTerminalView(term: term, profile: profile, canConnect: canConnect)
|
||||||
.frame(minHeight: 380)
|
.frame(minHeight: 380)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
+157
-110
@@ -1,126 +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 dies instantly, no message | stderr shared the PTY: macOS drops unread master data on slave close → bare "code 255" | blocking | ✅ fixed — stderr→pipe, TCP pre-check, error banner |
|
||||||
|
| 9 | 2026-09-21 | `swift build` | `Result<Void, String>`: String is not Error (follow-on DispatchQueue errors) | blocking | ✅ fixed — completion as `(String?) -> Void` |
|
||||||
|
| 10 | 2026-09-21 | icon generator (`RenderIcon.swift`) | 3 Swift errors: non-optional `guard let`, `Int * CGFloat`, wrong preview path | small | ✅ fixed |
|
||||||
|
| 11 | 2026-09-21 | password feature | request: store passwords + keys AND use them (stored but never used) | feature | ✅ done — sshpass auto-login, else interactive |
|
||||||
|
| 12 | 2026-09-21 | "always connection wrong" | user report without details | open | ⬜ partly: editor validation + copyable error report |
|
||||||
|
| 13 | 2026-09-21 | Permission denied with correct password | text substitution in input field mangled special chars | high | ✅ fixed — hardened input field (all substitutions off, 9/9) |
|
||||||
|
| 14 | 2026-09-21 | Terminal works, app doesn't (same password) | SSH agent socket missing for GUI processes → maybe key login in Terminal | high | ✅ app forwards agent socket; user confirmation open |
|
||||||
|
| 15 | 2026-09-21 | crash on type + send | cause unclear (not reproducible headless) | critical | ⬜ hardened (v0.5.3); crash log requested |
|
||||||
|
| 16 | 2026-09-22 | profile edits had no effect | session cache ignored host/password/key corrections | high | ✅ fixed — fingerprint reconfig (9/9) |
|
||||||
|
| 17 | 2026-09-22 | rzsv: pre-check timeout | port 22 dropped server-side (443 ok) — not an app bug | external | ⬜ server side: fail2ban/VPN/firewall |
|
||||||
|
| 18 | 2026-09-22 | host-key yes/no impossible | ssh without controlling terminal: /dev/tty ENXIO → silent abort | critical | ✅ ctty-run helper (double-fork) + fingerprint popup |
|
||||||
|
| 19 | 2026-09-22 | empty popup (see screenshot) | `if let` in sheet takes empty branch with no update path | medium | ✅ `.sheet(item:)` + background keyscan |
|
||||||
|
| 20 | 2026-09-22 | terminal not fully interactive | single-line input, no completion, no vim | feature | ✅ done — SwiftTerm emulator (0.7.0) |
|
||||||
|
| 21 | 2026-09-22 | yes-asked-twice + password ignored | popup-accept race; sshpass installs went stale | medium | ✅ verified no re-ask; sshpass in fingerprint |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #1 — `windowTitle` existiert nicht auf `WindowGroup`
|
## 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.
|
|
||||||
|
|
||||||
## #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
|
## #2 — Slow but clean release build
|
||||||
**Fix (manuell, sobald Credentials stimmen):**
|
**Phase:** `bash scripts/build-release.sh 0.1.0` → `swift build -c release` (~40 s first link).
|
||||||
```bash
|
**Cause:** none — release optimization + link just take a while cold; faster on rebuilds (`.build/` cache).
|
||||||
cd RemminaMac
|
**Status:** ✅ ok.
|
||||||
git remote add origin https://<user>@git.sysdaemon.xyz/<owner>/remmina-mac.git
|
|
||||||
git push -u origin main
|
## #3 — `dist/` almost committed
|
||||||
export GITEA_HOST="https://git.sysdaemon.xyz" GITEA_OWNER="<owner>" GITEA_REPO="remmina-mac" GITEA_TOKEN="<token>" VERSION="0.1.0"
|
**Cause:** `dist/` held ZIP + unpacked binary, `.gitignore` didn't know it.
|
||||||
./scripts/publish-gitea-release.sh
|
**Fix:** ignore `.build/`, `dist/`, `*.zip`, `*.sha256`.
|
||||||
```
|
**Status:** ✅ fixed.
|
||||||
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.
|
## #4 — Gitea upload: 401 despite reachable server
|
||||||
**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.
|
**Phase:** `POST /api/v1/user/repos` + `GET /api/v1/user` on `https://git.sysdaemon.xyz` (v1.27.3 confirmed via `/api/v1/version`).
|
||||||
**Status:** ✅ behoben.
|
**Log:** `GET /user -> 401 {"message":"invalid username, password or token"}` — tried logins `x3` and `x3@sysdaemon.xyz`, both 401 (Basic auth via Python, no shell-quoting issue).
|
||||||
|
**Fix:** with corrected credentials: `GET /api/v1/user → 200 (login x3)`, repo `x3/remmina-mac` created (201), push ok, release `v0.1.0` + assets uploaded.
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|
||||||
|
## #6 — SSH click opens no terminal (user bug report)
|
||||||
|
**Cause:** `SSHService.openInTerminal` used `NSAppleScript(tell application "Terminal" …)` — fails silently without `NSAppleEventsUsageDescription` and with no error handling.
|
||||||
|
**Fix:** `Services/PTYSession.swift` (real PTY via `posix_openpt`), `Views/EmbeddedTerminalView.swift` (in-app Dracula terminal, auto-connect), external fallback via `.command` file + `NSWorkspace` (no automation rights needed).
|
||||||
|
**Verified:** build ok; parser 8/8; PTY smoke (`/bin/echo` → output + exit 0); `ssh -G` through PTY ok.
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|
||||||
|
## #7 — ANSI parser: text got the wrong style
|
||||||
|
**Phase:** `swiftc` harness during #6. Log: `text("rot", fg: nil)` instead of `fg: 1`.
|
||||||
|
**Cause:** chunk flushed only after the SGR switch.
|
||||||
|
**Fix:** `flush()` before `parseCSI`. Harness 8/8 green.
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|
||||||
|
## #8 — Terminal connects then dies instantly, no message (user bug report)
|
||||||
|
**Repro (harness, exact app path `ssh -p 1 nobody@127.0.0.1`):** exit 255, output shows only the session note — ssh's error lost.
|
||||||
|
**Ground truth (Python pty):** even `/bin/echo hello` yields `b''` when read only AFTER child exit; reading promptly yields `b'hello-py\r\n'` → macOS drops unread PTY master data on slave close (then EIO). Our `DispatchSourceRead` loses that race for instant-exit children — and ssh writes everything (incl. "Connection refused") to stderr, which shared the PTY.
|
||||||
|
**Fix:** stderr → `Pipe` (buffered data survives child exit), stdin/stdout stay on PTY; non-blocking `finalFlush` + epoch-guarded lifecycle; TCP pre-check (Network.framework, 4 s) with plain-language messages; red error banner + Diagnose/Retry buttons.
|
||||||
|
**Verified:** refused harness now shows the error (PASS); `cat` roundtrip, `ssh -G`, TCP harness (closed/open/empty) PASS.
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|
||||||
|
## #9 — Build: `Result<Void, String>` invalid
|
||||||
|
**Log:** `error: type 'String' does not conform to protocol 'Error'` (+ follow-on errors).
|
||||||
|
**Fix:** `TCPCheck.check` uses `(String?) -> Void` (nil = ok, else message).
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|
||||||
|
## #10 — Icon generator build errors
|
||||||
|
**Log:** non-optional `guard let` (`??` already unwraps); `Int * CGFloat`; preview landed in `.iconset/` instead of `/tmp/`.
|
||||||
|
**Fix:** assign `??` fallback directly; `CGFloat(...)`; path corrected.
|
||||||
|
**Status:** ✅ fixed. Icon: `Resources/AppIcon.icns` (reproducible via `scripts/generate-icon.sh`), preview `docs/icon-preview.png`.
|
||||||
|
|
||||||
|
## #11 — Store AND use passwords (user request)
|
||||||
|
**State:** passwords/passphrases were stored in Keychain but never used for login (ssh always asked interactively).
|
||||||
|
**Done:** `SSHService.loginSecret()` (profile password → key passphrase), pure/testable `sessionConfig()`: with secret + sshpass → `sshpass -e ssh/sftp …` + `SSHPASS` env (never command line/logs); otherwise direct call + interactive prompt. `PTYSession.customEnv` + auto-login flag; status shows auto-login state. Editor: saved indicator (✓), delete button, sshpass hint.
|
||||||
|
**Verified:** config harness 9/9 (incl. "password never on command line"); real Keychain roundtrip 7/7.
|
||||||
|
**Note:** without `sshpass` (`brew install sshpass`) the interactive prompt remains.
|
||||||
|
**Status:** ✅ done.
|
||||||
|
|
||||||
|
## #12 — "Always connection wrong" (user report, details open)
|
||||||
|
**State:** reported without target host, banner text or credentials — not reproducible remotely.
|
||||||
|
**Checklist:** `user@host` in host field or `ssh://` prefix → live editor warnings now; whitespace trimmed; wrong password / missing key → banner + Diagnose; server blocks (fail2ban, key-only) → check server logs.
|
||||||
|
**Done:** `Models/ProfileValidation.swift` (7/7), password right below username, "Copy report" in banner (no secrets).
|
||||||
|
**Status:** ⬜ waiting for error report.
|
||||||
|
|
||||||
|
## #13 — Permission denied with correct password (user error report)
|
||||||
|
**Report:** `administrator@sysdaemon.xyz:22`, ssh asks 3x → `Permission denied (publickey,password)`. Same password works in Terminal.app.
|
||||||
|
**Cause:** the terminal input was a stock TextField — macOS silently substitutes characters (`"` → “”, `--` → —, autocorrect). A password with special chars arrives mangled; Terminal shows nothing wrong, ssh just denies. Verified: stock `NSTextField` has these switches on, and can't even disable dash substitution (compiler proof).
|
||||||
|
**Fix (`Views/TerminalInputField.swift`, later superseded by SwiftTerm):** NSTextView-based field with all substitutions explicitly off; Secure variant via `NSSecureTextField`; Enter → send, paste normalization.
|
||||||
|
**Verified:** harness 9/9 (all flags off).
|
||||||
|
**Status:** ✅ fixed — retest with v0.5.1+.
|
||||||
|
|
||||||
|
## #14 — Terminal.app logs in, RemminaMac doesn't (same password)
|
||||||
|
**User answers:** v0.5.1 installed, password login, Terminal.app works with the same data.
|
||||||
|
**Analysis:** app side fully verified → difference must be environmental. Main suspect: **SSH agent** — Finder-launched GUI processes don't inherit `SSH_AUTH_SOCK`. If Terminal silently uses an agent key (not the password), the app fails with the password. Verified: `launchctl getenv SSH_AUTH_SOCK` returns the socket.
|
||||||
|
**Fix:** `SSHService.agentSocket()` → `TerminalManager` injects `SSH_AUTH_SOCK` into the ssh env (coexists with SSHPASS).
|
||||||
|
**Verified:** harness 4/4. User confirmation check: `ssh-add -l` + `ssh -o IdentitiesOnly=yes administrator@sysdaemon.xyz`.
|
||||||
|
**Status:** ✅ app side done — waiting for confirmation.
|
||||||
|
|
||||||
|
## #15 — Crash on type + send (user report)
|
||||||
|
**State:** "crashes as soon as I type and send." Not reproducible headless — every layer green alone.
|
||||||
|
**Ruled out by test:** SIGPIPE on write (macOS returns EIO, no signal; send-after-stop survives); view layer (real coordinator + NSTextView: umlauts/quotes/paste/Enter, 200 programmatic sets); PTY roundtrip, refused, Keychain, TCP harnesses.
|
||||||
|
**Hardened (v0.5.3, unproven as THE crash):** detach delegate during programmatic sets (no state mutation mid-render); safe pointer in `send()`.
|
||||||
|
**Still needed if it recurs:** Console.app → Crash Reports → newest `RemminaMac` entry → `Exception Type`, `Termination Reason`, crashed thread.
|
||||||
|
**Status:** ⬜ waiting for feedback / crash log.
|
||||||
|
|
||||||
|
## #16 — Profile edits had no effect ("still doesn't work")
|
||||||
|
**State:** user kept reporting failure after fixes. Code review (`TerminalManager.session`): a once-created session was reused forever — host/password/key corrections never reached it.
|
||||||
|
**Fix:** config fingerprint (proto/host/port/user/extraArgs/key path/secret value) per session; on mismatch with a non-running session: stop + reconfigure (same object, tabs stay). Running sessions are never reconfigured underfoot (disconnect first).
|
||||||
|
**Verified:** harness 9/9.
|
||||||
|
**Status:** ✅ fixed — retest with v0.5.4 (re-save profile once).
|
||||||
|
|
||||||
|
## #17 — rzsv: pre-check timeout (not an app bug, screenshot proof)
|
||||||
|
**Picture:** tab rzsv (`administrator@sysdaemon.xyz:22`), banner "pre-check: no answer in 4 s", ssh correctly NOT started.
|
||||||
|
**Measured (same machine):** `sysdaemon.xyz` → `185.117.250.190`; port 443 ok in 0.02 s, port 22 dropped (timeout, no refused). Server reachable, SSH port dropped from this network. No client can connect from here — the pre-check was right.
|
||||||
|
**Likely causes:** fail2ban ban after the many denied attempts (`fail2ban-client status sshd` → unban); VPN on/off mismatch; firewall allows 22 only from certain networks.
|
||||||
|
**Update from user:** port 22 is blocked on the company network; hotspot works. Confirmed.
|
||||||
|
**Status:** ⬜ server/network side.
|
||||||
|
|
||||||
|
## #18 — Host-key yes/no impossible (user screenshot: Terminal works, app only says "Host key verification failed.")
|
||||||
|
**Analysis:** Terminal shows the `Are you sure...?` prompt (with fingerprint), app aborts without prompt. Proof via `-v`: `read_passphrase: can't open /dev/tty: Device not configured` — ssh prompts need a controlling terminal; our PTY slave wasn't one (GUI app has none, child inherits none). Affects password AND yes/no prompts.
|
||||||
|
**Fix:** `Sources/ctty-run/ctty-run.c` (C helper, SPM target): double-fork (needed because Apple's Process starts children in their own process group → direct setsid = EPERM) → setsid → slave open (= ctty) → exec ssh. stderr stays on the parent pipe (loss-free). `stop()` kills the process group (no orphans, verified). Plus `Views/HostKeySheet.swift` flow: `ssh-keyscan` fingerprint upfront → popup (SHA256/MD5, type) → accept (→ shared `~/.ssh/known_hosts`) / decline; red MITM warning + `ssh-keygen -R` path on changed keys.
|
||||||
|
**Verified (local paramiko server):** bug reproduced (no prompt, exit 255) → after fix: ctty proven via `ps` (ttys001), yes/no visible + answerable, password accepted, shell active. HostKeyService: unknown/accept/known/changed/revoke/unreachable green.
|
||||||
|
**Status:** ✅ fixed (v0.6.0).
|
||||||
|
|
||||||
|
## #19 — Fingerprint popup appears empty (user screenshot)
|
||||||
|
**Picture:** sheet window present but tiny and contentless; status stuck on "checking …".
|
||||||
|
**Cause:** `.sheet(isPresented:)` + `if let info` content: if evaluation takes the empty branch at presentation time, the empty branch has no dependency on the state var → the sheet never updates, stays empty. Classic SwiftUI trap.
|
||||||
|
**Fix:** `.sheet(item: $hostKeyRequest)` with `HostKeyRequest: Identifiable` — content structurally always exists. Plus `HostKeyService.status` (blocking keyscan) moved to a background queue — the UI used to freeze during "checking …".
|
||||||
|
**Status:** ✅ fixed (v0.6.1).
|
||||||
|
|
||||||
|
## #20 — Terminal not fully interactive (user request)
|
||||||
|
**State:** single-line input row, no Tab completion, no vim/full-screen apps.
|
||||||
|
**Done (0.7.0):** SwiftTerm emulator (v1.20.0 SPM dep) as `Views/SwiftTermView.swift`: cursor addressing, colors, scrollback, selection, direct keyboard (completion, arrows, Ctrl+C, Esc), vim/htop. Dracula palette via `installColors` + native fg/bg. PTY layer feeds raw bytes (`onData`); keystrokes go straight to the pty master (`sendBytes`); resizes propagate via `TIOCSWINSZ` (`setWinsize`). Old input row + `TerminalInputField.swift` removed (SwiftTerm handles keys natively, immune to macOS substitutions).
|
||||||
|
**Verified:** headless emulator core 6/6 (text, absolute cursor addressing, alt-screen on/off, clear, 16-color palette); app-path login + shell still green.
|
||||||
|
**Status:** ✅ done.
|
||||||
|
|
||||||
|
## #21 — Asked for yes twice + password ignored (user reports)
|
||||||
|
**Analysis:** (a) empty-popup era (#19) forced ssh's own yes/no prompt — fixed by the popup fix; verified no re-ask after popup accept (harness asserts no "Are you sure" post-accept). (b) `sshpass` installs went stale: the session fingerprint didn't include sshpass presence, so installing sshpass later never switched to auto-login. Fixed by including it in `TerminalManager.configTag` (now `nonisolated` pure function).
|
||||||
|
**Verified:** no-ask + login green; tag changes on sshpass variance.
|
||||||
|
**Status:** ✅ fixed.
|
||||||
|
|||||||
+50
-21
@@ -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
-22
@@ -1,40 +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)
|
./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
|
||||||
# Beispiel:
|
./scripts/generate-icon.sh # re-render AppIcon.icns (Dracula terminal)
|
||||||
./scripts/build-release.sh 0.1.0
|
# Example:
|
||||||
./scripts/build-app.sh 0.1.0
|
./scripts/build-release.sh 0.7.0
|
||||||
./scripts/build-dmg.sh 0.1.0
|
./scripts/build-app.sh 0.7.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"
|
||||||
```
|
```
|
||||||
|
|||||||
+36
-24
@@ -1,34 +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, User ausfüllen.
|
1. `+` → name, group, protocol (SSH/SFTP/RDP/VNC), host, port, username, **password right below** (check Keychain to save it).
|
||||||
2. SSH-Key wählen (vorher unter Schlüssel-Icon importieren).
|
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. Optional Passwort → Haken „in Keychain speichern“.
|
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. 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** — verbindet automatisch, kein externes Fenster nötig. Eingabezeile unten (Auge-Icon = Passwort-Modus), `^C`/`^D`-Buttons, `Leeren`, `Trennen`/`Verbinden`, `Tab schließen`.
|
- 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>`).
|
||||||
|
|
||||||
## Quick-Connect
|
## Host-key verification (popup)
|
||||||
- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.
|
- On first connect the app shows a popup with the SHA256/MD5 fingerprint (fetched via `ssh-keyscan`) — compare it with Terminal output or the server admin's value, then Accept & connect (stored in `~/.ssh/known_hosts`, shared with Terminal.app).
|
||||||
|
- On CHANGED key: red warning (possible MITM!) — only replace if expected (freshly provisioned server). Manual reset: `ssh-keygen -R HOST`.
|
||||||
|
- Tech note: `Sources/ctty-run/ctty-run.c` gives ssh a controlling terminal (without it all interactive prompts fail silently).
|
||||||
|
|
||||||
## Export / Import (manuell, bis GUI-Buttons kommen)
|
## Saving passwords & auto-login
|
||||||
|
- Profile editor: enter password + check Keychain. A saved password is indicated (✓) and can be deleted. `profiles.json` holds only the reference, never the password.
|
||||||
|
- Order at connect time: profile password first, key passphrase second.
|
||||||
|
- **Auto-login without typing:** needs `sshpass` (`brew install sshpass`). ssh logs in with the Keychain secret (password only in the `SSHPASS` process env, never on the command line or in logs). Status shows auto-login state.
|
||||||
|
- **Without sshpass:** password/key passphrase is asked interactively in the terminal.
|
||||||
|
|
||||||
|
## Quick-Connect
|
||||||
|
- With selection: connects directly. Without: opens the editor with a “Quick-Connect” template.
|
||||||
|
|
||||||
|
## Export / Import (manual until GUI buttons land)
|
||||||
```bash
|
```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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Generiert das RemminaMac App-Icon (Dracula-Terminal) als PNG-Set + .iconset.
|
||||||
|
// Aufruf: swift scripts/RenderIcon.swift <output-dir>
|
||||||
|
// Braucht nur macOS-Bordmittel (AppKit). Kein Netzwerk, keine Deps.
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/RemminaMac.iconset"
|
||||||
|
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// Dracula-Palette
|
||||||
|
func c(_ hex: String, _ alpha: CGFloat = 1) -> NSColor {
|
||||||
|
var v: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&v)
|
||||||
|
return NSColor(red: CGFloat((v >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((v >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(v & 0xFF) / 255, alpha: alpha)
|
||||||
|
}
|
||||||
|
let bg1 = c("3A3D4F"), bg2 = c("23242F"), bar = c("44475A")
|
||||||
|
let green = c("50FA7B"), pink = c("FF79C6"), purple = c("BD93F9")
|
||||||
|
let red = c("FF5555"), yellow = c("F1FA8C")
|
||||||
|
|
||||||
|
func drawIcon(size: CGFloat) -> NSImage {
|
||||||
|
let img = NSImage(size: NSSize(width: size, height: size))
|
||||||
|
img.lockFocus()
|
||||||
|
let s = size / 1024.0 // Skalierung relativ zu 1024er-Master
|
||||||
|
NSGraphicsContext.current?.imageInterpolation = .high
|
||||||
|
|
||||||
|
// Hintergrund: abgerundetes Quadrat mit Verlauf
|
||||||
|
let rect = NSRect(x: 0, y: 0, width: size, height: size)
|
||||||
|
let radius = 232 * s
|
||||||
|
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||||
|
let grad = NSGradient(colors: [bg1, bg2])!
|
||||||
|
grad.draw(in: path, angle: 90)
|
||||||
|
// feine Kontur
|
||||||
|
bar.withAlphaComponent(0.9).setStroke()
|
||||||
|
path.lineWidth = max(2, 6 * s)
|
||||||
|
path.stroke()
|
||||||
|
|
||||||
|
// Dezenter Glow hinter der Glyphe
|
||||||
|
let glow = NSShadow()
|
||||||
|
glow.shadowColor = purple.withAlphaComponent(0.55)
|
||||||
|
glow.shadowBlurRadius = 90 * s
|
||||||
|
glow.shadowOffset = .zero
|
||||||
|
|
||||||
|
// Traffic-Lights (Fenster-Look)
|
||||||
|
for (i, col) in [red, yellow, green].enumerated() {
|
||||||
|
let r = 40 * s
|
||||||
|
let cx = CGFloat(150 + i * 108) * s
|
||||||
|
let cy = size - 158.0 * s
|
||||||
|
col.setFill()
|
||||||
|
NSBezierPath(ovalIn: NSRect(x: cx - r, y: cy - r, width: 2 * r, height: 2 * r)).fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt „>" + Block-Cursor (Menlo Bold)
|
||||||
|
let fontSize: CGFloat = 470 * s
|
||||||
|
let font: NSFont = NSFont(name: "Menlo-Bold", size: fontSize)
|
||||||
|
?? NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: green]
|
||||||
|
let glyph = NSAttributedString(string: ">", attributes: attrs)
|
||||||
|
let gw = glyph.size()
|
||||||
|
|
||||||
|
// Cursor-Block rechts vom „>"
|
||||||
|
let gap = 44 * s
|
||||||
|
let cursorW = 128 * s, cursorH = 96 * s
|
||||||
|
let totalW = gw.width + gap + cursorW
|
||||||
|
let startX = (size - totalW) / 2
|
||||||
|
let baseY = size * 0.30
|
||||||
|
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
NSGraphicsContext.current?.cgContext.setShadow(offset: .zero, blur: 90 * s, color: purple.withAlphaComponent(0.55).cgColor)
|
||||||
|
glyph.draw(at: NSPoint(x: startX, y: baseY))
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
|
let cursor = NSBezierPath(roundedRect: NSRect(x: startX + gw.width + gap, y: baseY + 30 * s, width: cursorW, height: cursorH), xRadius: 14 * s, yRadius: 14 * s)
|
||||||
|
pink.setFill()
|
||||||
|
cursor.fill()
|
||||||
|
|
||||||
|
img.unlockFocus()
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ img: NSImage, _ name: String) {
|
||||||
|
guard let tiff = img.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let png = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
fputs("FEHLER bei \(name)\n", stderr); exit(1)
|
||||||
|
}
|
||||||
|
try! png.write(to: URL(fileURLWithPath: "\(outDir)/\(name)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconset: je Größe 1x + 2x
|
||||||
|
for base in [16, 32, 128, 256, 512] {
|
||||||
|
save(drawIcon(size: CGFloat(base)), "icon_\(base)x\(base).png")
|
||||||
|
save(drawIcon(size: CGFloat(base * 2)), "icon_\(base)x\(base)@2x.png")
|
||||||
|
}
|
||||||
|
// Vorschau in groß
|
||||||
|
try? FileManager.default.createDirectory(atPath: "/tmp", withIntermediateDirectories: true)
|
||||||
|
save(drawIcon(size: 512), "RemminaMac-preview-tmp.png")
|
||||||
|
print("iconset fertig: \(outDir)")
|
||||||
@@ -18,6 +18,13 @@ 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)"
|
||||||
|
|
||||||
# Info.plist mit Version versehen
|
# Info.plist mit Version versehen
|
||||||
sed "s|<string>0.1.0</string>|<string>$VERSION</string>|" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
sed "s|<string>0.1.0</string>|<string>$VERSION</string>|" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
||||||
|
|||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generiert AppIcon.icns + Doku-Vorschau aus scripts/RenderIcon.swift (nur Bordmittel).
|
||||||
|
# Usage: ./scripts/generate-icon.sh
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ICONSET="/tmp/RemminaMac.iconset"
|
||||||
|
rm -rf "$ICONSET"
|
||||||
|
swift "$ROOT/scripts/RenderIcon.swift" "$ICONSET"
|
||||||
|
cp "$ICONSET/RemminaMac-preview-tmp.png" "$ROOT/docs/icon-preview.png"
|
||||||
|
iconutil -c icns "$ICONSET" -o "$ROOT/Resources/AppIcon.icns"
|
||||||
|
echo "[icon] Fertig:"
|
||||||
|
ls -lh "$ROOT/Resources/AppIcon.icns" "$ROOT/docs/icon-preview.png"
|
||||||
Reference in New Issue
Block a user