From 4209c481dc4d4d6dddd78a3df5b72ab052f88fbc Mon Sep 17 00:00:00 2001 From: x3 Date: Tue, 22 Sep 2026 09:31:08 +0200 Subject: [PATCH] Host-Key-Popup mit Fingerprint + ctty-run-Helper (Ctty fuer ssh-Prompts), Doku --- Package.swift | 8 +- .../RemminaMac/Services/HostKeyService.swift | 148 ++++++++++++++++++ Sources/RemminaMac/Services/PTYSession.swift | 40 ++++- .../Views/EmbeddedTerminalView.swift | 50 +++++- Sources/RemminaMac/Views/HostKeySheet.swift | 57 +++++++ Sources/ctty-run/ctty-run.c | 58 +++++++ docs/ERRORS.md | 9 ++ docs/USAGE.md | 5 + scripts/build-app.sh | 6 + 9 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 Sources/RemminaMac/Services/HostKeyService.swift create mode 100644 Sources/RemminaMac/Views/HostKeySheet.swift create mode 100644 Sources/ctty-run/ctty-run.c diff --git a/Package.swift b/Package.swift index 08cb2a0..65ef8b0 100644 --- a/Package.swift +++ b/Package.swift @@ -7,12 +7,18 @@ let package = Package( .macOS(.v14) ], products: [ - .executable(name: "RemminaMac", targets: ["RemminaMac"]) + .executable(name: "RemminaMac", targets: ["RemminaMac"]), + .executable(name: "ctty-run", targets: ["ctty-run"]) ], targets: [ .executableTarget( name: "RemminaMac", path: "Sources/RemminaMac" + ), + .executableTarget( + name: "ctty-run", + path: "Sources/ctty-run", + sources: ["ctty-run.c"] ) ] ) diff --git a/Sources/RemminaMac/Services/HostKeyService.swift b/Sources/RemminaMac/Services/HostKeyService.swift new file mode 100644 index 0000000..3bb73a2 --- /dev/null +++ b/Sources/RemminaMac/Services/HostKeyService.swift @@ -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 + } +} diff --git a/Sources/RemminaMac/Services/PTYSession.swift b/Sources/RemminaMac/Services/PTYSession.swift index a155f47..1fe22b7 100644 --- a/Sources/RemminaMac/Services/PTYSession.swift +++ b/Sources/RemminaMac/Services/PTYSession.swift @@ -80,10 +80,22 @@ final class PTYSession: ObservableObject { } masterFD = master + // Ctty-Helper (gleiches Verzeichnis wie dieses Binary — .app wie .build): + // Gibt ssh ein echtes Controlling-Terminal, damit Passwort- und + // Yes/No-Prompts funktionieren (/dev/tty statt stiller Abbrüche). + // Ohne Helper: Legacy-Direktstart (Prompts teils stumm). let pipe = Pipe() let p = Process() - p.executableURL = URL(fileURLWithPath: executable) - p.arguments = arguments + if let helper = Self.cttyHelperURL() { + p.executableURL = helper + p.arguments = [slavePath, executable] + arguments + } else { + p.executableURL = URL(fileURLWithPath: executable) + p.arguments = arguments + DispatchQueue.main.async { [weak self] in + self?.note("Hinweis: ohne ctty-run-Helper (eingeschränkte Prompts).") + } + } var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" } @@ -225,6 +237,17 @@ final class PTYSession: ObservableObject { lines = [[]] } + /// ctty-run liegt neben diesem Binary (Contents/MacOS wie .build/debug). + static func cttyHelperURL() -> URL? { + let sibling = URL(fileURLWithPath: CommandLine.arguments[0]) + .deletingLastPathComponent() + .appendingPathComponent("ctty-run") + if FileManager.default.isExecutableFile(atPath: sibling.path) { + return sibling + } + return nil + } + func note(_ message: String) { lines.append([StyledText(text: message, style: TermStyle(fg: 8))]) } @@ -241,7 +264,7 @@ final class PTYSession: ObservableObject { func stop() { let wasRunning = isRunning - process?.terminate() + killTree() cleanupSync() if wasRunning { epoch += 1 // späte terminationHandler-Ausläufer verwerfen @@ -252,6 +275,14 @@ final class PTYSession: ObservableObject { } } + /// Beendet Kind + Enkel (ctty-run-Wächter + ssh teilen die Prozessgruppe). + private func killTree() { + if let pid = process?.processIdentifier, pid > 0 { + Darwin.kill(-pid, SIGTERM) + } + process?.terminate() + } + /// Synchroner Cleanup auf Main: Source canceln, Master schließen, Pipe abklemmen. /// Laufende ioQueue-Reads bekommen EBADF und beenden sich selbst. private func cleanupSync() { @@ -278,6 +309,9 @@ final class PTYSession: ObservableObject { } deinit { + if let pid = process?.processIdentifier, pid > 0 { + Darwin.kill(-pid, SIGTERM) + } process?.terminate() readSource?.cancel() if masterFD >= 0 { close(masterFD) } diff --git a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift index 07a6e6d..d61528a 100644 --- a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift +++ b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift @@ -11,6 +11,9 @@ struct EmbeddedTerminalView: View { @State private var input = "" @State private var secureInput = false @State private var checking = false + @State private var hostKeyInfo: HostKeyInfo? + @State private var hostKeyChange = false + @State private var showingHostKey = false var body: some View { VStack(spacing: 0) { @@ -98,6 +101,18 @@ struct EmbeddedTerminalView: View { connect() // Auto-Connect beim Öffnen des Tabs } } + .sheet(isPresented: $showingHostKey) { + if let info = hostKeyInfo { + HostKeySheet(info: info, isChange: hostKeyChange, + onAccept: acceptHostKey, + onCancel: { + showingHostKey = false + checking = false + hostKeyInfo = nil + term.fail("Host-Key nicht akzeptiert — Verbindung abgebrochen.") + }) + } + } } func connect() { @@ -105,16 +120,47 @@ struct EmbeddedTerminalView: View { checking = true TCPCheck.check(host: profile.host, port: profile.port) { message in DispatchQueue.main.async { - checking = false if let message { + checking = false term.fail("Vorab-Check: \(message)") - } else { + return + } + // Host-Key vorab per keyscan prüfen (Yes/No-Prompt käme ohne Ctty nicht an) + let hk = HostKeyService.status(host: profile.host, port: profile.port) + switch hk { + case .known, .unreachable: + checking = false term.start() + case .unknown(let info): + hostKeyInfo = info + hostKeyChange = false + showingHostKey = true + case .changed(let info): + hostKeyInfo = info + hostKeyChange = true + showingHostKey = true } } } } + func acceptHostKey() { + guard let info = hostKeyInfo else { return } + showingHostKey = false + if hostKeyChange { + _ = HostKeyService.revoke(host: profile.host, port: profile.port) + } + if HostKeyService.accept(info) { + checking = false + term.note("Host-Key akzeptiert (\(info.keyType), \(info.fingerprintSHA256.prefix(19))…).") + term.start() + } else { + checking = false + term.fail("Host-Key konnte nicht in known_hosts gespeichert werden.") + } + hostKeyInfo = nil + } + func diagnose() { guard !checking else { return } checking = true diff --git a/Sources/RemminaMac/Views/HostKeySheet.swift b/Sources/RemminaMac/Views/HostKeySheet.swift new file mode 100644 index 0000000..c39b2e5 --- /dev/null +++ b/Sources/RemminaMac/Views/HostKeySheet.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// 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) + } +} diff --git a/Sources/ctty-run/ctty-run.c b/Sources/ctty-run/ctty-run.c new file mode 100644 index 0000000..bff7065 --- /dev/null +++ b/Sources/ctty-run/ctty-run.c @@ -0,0 +1,58 @@ +// ctty-run: gibt dem Kindprozess ein Controlling-Terminal auf dem PTY-Slave. +// Usage: ctty-run [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 +#include +#include +#include +#include +#include +#include + +int +main(int argc, char **argv) +{ + if (argc < 3) { + dprintf(STDERR_FILENO, "usage: ctty-run [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); +} diff --git a/docs/ERRORS.md b/docs/ERRORS.md index bfb9567..18a5cdb 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -24,6 +24,7 @@ Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+ | 15 | 2026-09-21 | Crash bei Tippen + Senden | Ursache unklar (headless nicht reproduzierbar) | kritisch | ⬜ Gehärtet (v0.5.3); Crash-Log angefragt | | 16 | 2026-09-22 | Profil-Änderungen wirkungslos | Session-Cache ignorierte Host-/Passwort-/Key-Korrekturen | hoch | ✅ behoben — Fingerprint-Reconfig (9/9 Tests) | | 17 | 2026-09-22 | rzsv: Vorab-Check Timeout | Port 22 wird serverseitig gedroppt (443 ok) — kein App-Fehler | extern | ⬜ Server-Seite: fail2ban/VPN/Firewall prüfen | +| 18 | 2026-09-22 | Host-Key-Yes/No unmöglich | ssh ohne Controlling-Terminal: /dev/tty ENXIO → stummer Abbruch | kritisch | ✅ ctty-run-Helper (Double-Fork) + Fingerprint-Popup | --- @@ -232,3 +233,11 @@ Zweite Zeile erzwingt Passwort-Login ohne Agent — scheitert sie auch im Termin 3. Firewall lässt 22 nur aus bestimmten Netzen zu (`ufw status`, `iptables -L`, `ss -tlnp | grep 22` auf dem Server) **Gegenprobe (10 s, Terminal):** `ssh -v -o ConnectTimeout=8 administrator@sysdaemon.xyz` bleibt bei `Connecting...` hängen → bewiesen netzseitig. **Status:** ⬜ Server-/Netz-Seite, warte auf Gegenprobe + VPN-Status. + +## #18 — Host-Key-Yes/No unmöglich (User-Screenshot: Terminal geht, App sagt nur „Host key verification failed.") +**Analyse:** Terminal zeigt den `Are you sure...?`-Prompt (mit Fingerprint), App nur den Abbruch ohne Prompt. Beweis per `-v`: `read_passphrase: can't open /dev/tty: Device not configured` — ssh-Prompts brauchen ein Controlling-Terminal, unser PTY-Slave war keins (GUI-App hat keins, Kind erbt keins). Betrifft Passwort- UND Yes/No-Prompts. +**Fix:** +- `Sources/ctty-run/ctty-run.c` (C-Helper, SPM-Target): Double-Fork (nötig, weil Apples Process Kinder in eigener Prozessgruppe startet → direktes setsid = EPERM) → setsid → Slave-Open (= Ctty) → exec ssh. stderr bleibt auf der Eltern-Pipe (verlustfrei). `stop()` killt die Prozessgruppe (keine Waisen, verifiziert). +- `Views/HostKeySheet.swift` + Flow: Vorab per `ssh-keyscan` Fingerprint holen → Popup (SHA256/MD5, Typ) → Akzeptieren (→ `~/.ssh/known_hosts`, geteilt mit Terminal) / Abbrechen; bei GEÄNDERTEM Key rote MITM-Warnung + `ssh-keygen -R`-Pfad. +**Verifikation (lokaler paramiko-Server):** Bug reproduziert (kein Prompt, Exit 255) → nach Fix: Ctty per `ps` bewiesen (ttys001), Yes/No sichtbar + beantwortbar, Passwort akzeptiert, Shell aktiv. HostKeyService: unknown/accept/known/changed/revoke/unreachable grün. Regression (stop ohne Waisen) grün. +**Status:** ✅ behoben (v0.6.0) — Fingerprint im Popup mit Terminal-Ausgabe vergleichbar. diff --git a/docs/USAGE.md b/docs/USAGE.md index 83802f2..460147b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -26,6 +26,11 @@ - `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_` auf. - Passphrase → Keychain (`sshkey-`). +## Host-Key prüfen & akzeptieren (Popup) +- Bei Erstverbindung zeigt die App ein Popup mit SHA256-/MD5-Fingerprint (per `ssh-keyscan` geholt) — mit Terminal-Ausgabe oder Admin-Angabe vergleichen, dann Akzeptieren & Verbinden (landet in `~/.ssh/known_hosts`, gemeinsam mit Terminal.app). +- Bei GEÄNDERTEM Key: rote Warnung (MITM möglich!) — nur ersetzen, wenn erwartet (Server neu aufgesetzt). Zurücksetzen auch manuell: `ssh-keygen -R HOST`. +- Technik: `Sources/ctty-run/ctty-run.c` gibt ssh ein Controlling-Terminal (sonst scheitern alle interaktiven Prompts still). + ## Passwort speichern & Auto-Login - Im Profil-Editor: Passwort eingeben + „in Keychain speichern“. Gespeichertes Passwort wird angezeigt (✓) und kann gelöscht werden. In `profiles.json` steht nur die Referenz, nie das Passwort. - Reihenfolge beim Verbinden: Profil-Passwort zuerst, sonst Key-Passphrase. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 1bbc928..72a28fe 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -18,6 +18,12 @@ rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/RemminaMac" chmod +x "$APP/Contents/MacOS/RemminaMac" +# Ctty-Helper (gibt ssh ein Controlling-Terminal für Prompts) +if [ -x "$ROOT/.build/release/ctty-run" ]; then + cp "$ROOT/.build/release/ctty-run" "$APP/Contents/MacOS/" +else + echo "[build-app] WARNUNG: ctty-run fehlt (swift build lief ohne C-Target?)" +fi cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/" 2>/dev/null || echo "[build-app] Hinweis: kein AppIcon.icns (./scripts/generate-icon.sh)" # Info.plist mit Version versehen