diff --git a/README.md b/README.md index 00b9584..eaca16e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # RemminaMac — SSH / RDP Client für macOS +![RemminaMac Icon](docs/icon-preview.png) + Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI. ![Plattform](https://img.shields.io/badge/plattform-macOS%2014%2B-blue) diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..5fce8d6 Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/Info.plist b/Resources/Info.plist index acb6cea..b085876 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -10,6 +10,8 @@ RemminaMac CFBundleIdentifier xyz.sysdaemon.remmina-mac + CFBundleIconFile + AppIcon CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/Sources/RemminaMac/Services/PTYSession.swift b/Sources/RemminaMac/Services/PTYSession.swift index 1fdf636..377aa5a 100644 --- a/Sources/RemminaMac/Services/PTYSession.swift +++ b/Sources/RemminaMac/Services/PTYSession.swift @@ -9,9 +9,14 @@ enum PTYState: Equatable { case done(exitCode: Int32?, message: String) } -/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY), damit interaktive -/// Prompts (Passwort, Host-Key) funktionieren. Ausgabe wird via AnsiParser -/// in gestylte Zeilen für die Dracula-Ansicht übersetzt. +/// SSH/SFTP-Session über ein echtes Pseudo-Terminal (PTY). +/// - stdin/stdout: PTY-Slave (interaktiv, TTY bleibt erhalten) +/// - stderr: PIPE — macOS verwirft ungelesene PTY-Daten beim Slave-Close, +/// dadurch gingen Fehlermeldungen bei Instant-Exit (z. B. Code 255) verloren. +/// Pipes liefern gepufferte Daten garantiert aus (Bugfix, siehe docs/ERRORS.md #8). +/// +/// FD-Regeln: masterFD wird NUR auf dem Main-Thread vergeben/geschlossen, +/// auf ioQueue nur gelesen (EBADF-tolerant). Kein Close auf ioQueue. final class PTYSession: ObservableObject { @Published private(set) var lines: [[StyledText]] = [[]] @Published private(set) var state: PTYState = .never @@ -22,9 +27,11 @@ final class PTYSession: ObservableObject { private var masterFD: Int32 = -1 private var process: Process? private var readSource: DispatchSourceRead? + private var errPipe: Pipe? private let ioQueue = DispatchQueue(label: "RemminaMac.pty") private var parser = AnsiParser() private var finished = false + private var epoch = 0 // nur Main-Thread private let maxLines = 2000 var isRunning: Bool { @@ -42,7 +49,8 @@ final class PTYSession: ObservableObject { } func start() { - stop() + cleanupSync() + epoch += 1 finished = false parser = AnsiParser() state = .running @@ -64,6 +72,7 @@ final class PTYSession: ObservableObject { } masterFD = master + let pipe = Pipe() let p = Process() p.executableURL = URL(fileURLWithPath: executable) p.arguments = arguments @@ -74,25 +83,44 @@ final class PTYSession: ObservableObject { // closeOnDealloc=false: Parent schließt slave explizit nach run(). p.standardInput = 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 - DispatchQueue.main.async { - self?.finish(message: "Sitzung beendet (Code \(proc.terminationStatus)).", code: proc.terminationStatus) + DispatchQueue.main.async { [weak self] in + guard let self, self.epoch == myEpoch else { return } + self.ioQueue.async { [weak self] in + self?.finalFlush(code: proc.terminationStatus, myEpoch: myEpoch) + } } } do { try p.run() } catch { close(slave); close(master); masterFD = -1 + errPipe = nil finish(message: "Start fehlgeschlagen: \(error.localizedDescription)", code: nil) return } process = p close(slave) // Parent behält nur Master + errPipe = pipe + pipe.fileHandleForReading.readabilityHandler = { [weak self] h in + let data = h.availableData + guard let self else { return } + if data.isEmpty { + h.readabilityHandler = nil // EOF + return + } + self.ioQueue.async { [weak self] in + guard let self else { return } + let events = self.parser.feed(data) + DispatchQueue.main.async { self.apply(events) } + } + } let source = DispatchSource.makeReadSource(fileDescriptor: master, queue: ioQueue) source.setEventHandler { [weak self] in self?.drain() } - source.setCancelHandler { [master] in close(master) } + // Kein Close hier (Eigentum: Main-Thread) — nur Cancel. readSource = source source.resume() } @@ -106,8 +134,27 @@ final class PTYSession: ObservableObject { let events = parser.feed(Data(buf[..= 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[..= 0 { + close(masterFD) + masterFD = -1 + } + if let pipe = errPipe { + pipe.fileHandleForReading.readabilityHandler = nil + errPipe = nil + } + process = nil + } + private func finish(message: String, code: Int32?) { guard !finished else { return } finished = true - readSource?.cancel() - readSource = nil - masterFD = -1 - process = nil + cleanupSync() state = .done(exitCode: code, message: message) - let note = StyledText(text: "— \(message) —", style: TermStyle(fg: 8)) - lines.append([note]) + lines.append([StyledText(text: "— \(message) —", style: TermStyle(fg: 8))]) lines.append([]) } deinit { process?.terminate() readSource?.cancel() + if masterFD >= 0 { close(masterFD) } } } diff --git a/Sources/RemminaMac/Services/TCPCheck.swift b/Sources/RemminaMac/Services/TCPCheck.swift new file mode 100644 index 0000000..99904ed --- /dev/null +++ b/Sources/RemminaMac/Services/TCPCheck.swift @@ -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 + } + } +} diff --git a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift index 706b3b9..c06f888 100644 --- a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift +++ b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift @@ -2,12 +2,15 @@ import SwiftUI import AppKit /// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile. +/// Verbindet erst nach TCP-Vorab-Check (falscher Port/Host -> sofort klare Meldung). /// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon. struct EmbeddedTerminalView: View { @ObservedObject var term: PTYSession + let profile: ConnectionProfile var canConnect: Bool @State private var input = "" @State private var secureInput = false + @State private var checking = false var body: some View { VStack(spacing: 0) { @@ -18,8 +21,8 @@ struct EmbeddedTerminalView: View { Spacer() if term.isRunning { Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption) - } else if canConnect { - Button("Verbinden") { term.start() }.buttonStyle(.link).font(.caption) + } else if canConnect && !checking { + Button("Verbinden") { connect() }.buttonStyle(.link).font(.caption) } Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption) } @@ -27,6 +30,27 @@ struct EmbeddedTerminalView: View { .padding(.vertical, 6) .background(Dracula.currentLine.opacity(0.35)) + // Fehler-Banner bei Fehlschlag (statt nur „Sitzung beendet") + if failed { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Dracula.red) + VStack(alignment: .leading, spacing: 2) { + Text("Verbindung fehlgeschlagen\(exitSuffix)") + .font(.callout).bold().foregroundStyle(Dracula.red) + Text(lastMessage) + .font(.caption).foregroundStyle(Dracula.foreground) + .lineLimit(3) + } + Spacer() + Button("Diagnose") { diagnose() }.buttonStyle(.link).font(.caption) + Button("Erneut") { connect() }.buttonStyle(.link).font(.caption) + } + .padding(8) + .background(Dracula.red.opacity(0.12)) + .overlay(Rectangle().fill(Dracula.red.opacity(0.4)).frame(height: 1), alignment: .bottom) + } + // Ausgabe im Dracula-Theme DraculaTextView(lines: term.lines) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -62,8 +86,39 @@ struct EmbeddedTerminalView: View { .cornerRadius(10) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1)) .onAppear { - if case .never = term.state, canConnect { - term.start() // Auto-Connect beim Öffnen des Tabs (Bugfix: kein externes Terminal mehr nötig) + if case .never = term.state { + connect() // Auto-Connect beim Öffnen des Tabs + } + } + } + + func connect() { + guard canConnect, !term.isRunning, !checking else { return } + checking = true + TCPCheck.check(host: profile.host, port: profile.port) { message in + DispatchQueue.main.async { + checking = false + if let message { + term.fail("Vorab-Check: \(message)") + } else { + term.start() + } + } + } + } + + 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).") + } } } } @@ -74,15 +129,39 @@ struct EmbeddedTerminalView: View { input = "" } + var failed: Bool { + if case .done(let code, _) = term.state { + return (code ?? 1) != 0 + } + return false + } + + var exitSuffix: String { + if case .done(let code, _) = term.state, let code { + return " (Exit \(code))" + } + return "" + } + + var lastMessage: String { + for row in term.lines.reversed() { + let t = row.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines) + if !t.isEmpty && !t.hasPrefix("—") { return t } + } + if case .done(_, let msg) = term.state { return msg } + return "Unbekannter Fehler." + } + var statusDot: some View { Group { - if term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) } - else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(Dracula.red) } + if checking || term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) } + else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(failed ? Dracula.red : Dracula.comment) } else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) } }.font(.caption2) } var statusText: String { + if checking { return "Prüfe \(profile.host):\(profile.port) …" } switch term.state { case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt." case .running: return "Verbunden (PTY, TERM=xterm-256color)." diff --git a/Sources/RemminaMac/Views/SessionView.swift b/Sources/RemminaMac/Views/SessionView.swift index 224abb4..329dbe0 100644 --- a/Sources/RemminaMac/Views/SessionView.swift +++ b/Sources/RemminaMac/Views/SessionView.swift @@ -37,7 +37,7 @@ struct SessionView: View { if isSSH { // 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) .padding(.horizontal) } diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 5f2132b..e645249 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -14,6 +14,9 @@ Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+ | 5 | 2026-09-21 | App-Bundle + DMG | keine Fehler: `build-app.sh` (Signatur ok), `build-dmg.sh` (UDZO, 200K), Mount-Verifikation ok (App + Applications/Programme-Links) | Info | ✅ ok | | 6 | 2026-09-21 | SSH verbinden | kein Terminal öffnete sich: `SSHService.openInTerminal` nutzte `NSAppleScript` → scheitert still (keine `NSAppleEventsUsageDescription`, kein Error-Handling) | blockierend | ✅ behoben — In-App-PTY-Terminal (Dracula) + `.command`-Fallback | | 7 | 2026-09-21 | ANSI-Parser (Test) | `feed()` flusht Chunk erst NACH `parseCSI` → Text bekam den neuen statt alten Stil (`rot` mit fg=nil statt 1) | mittel | ✅ behoben — Flush vor Stilwechsel | +| 8 | 2026-09-21 | SSH-Session stirbt instant ohne Meldung | stderr hing mit am PTY: macOS verwirft ungelesene Master-Daten bei Slave-Close → „Code 255" ohne Text | blockierend | ✅ behoben — stderr→Pipe, TCP-Vorab-Check, Fehler-Banner | +| 9 | 2026-09-21 | `swift build` | `Result`: String conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` | +| 10 | 2026-09-21 | Icon-Generator (`RenderIcon.swift`) | 3 Swift-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben | --- @@ -92,6 +95,32 @@ dist/ **Verifikation:** `swift build` ok; Parser-Harness 8/8; PTY-Smoke (`/bin/echo` → Output + Exit 0); `ssh -G example.com` durchs PTY ok. **Status:** ✅ behoben. +## #8 — Terminal verbindet nicht, „direkt gekappt" (User-Bugreport) +**Phase:** Laufzeit, SSH-Profil → Tab öffnet, sofort „Sitzung beendet (Code 255)." — ohne jede Meldung +**Reproduktion (Harness, exakter App-Pfad `ssh -p 1 nobody@127.0.0.1`):** +``` +STATE: done(exitCode: Optional(255), message: "Sitzung beendet (Code 255).") +TEXT: "\n\n— Sitzung beendet (Code 255). —\n" # <- Fehlermeldung fehlt! +``` +**Ground Truth (Python-pty):** Selbst `/bin/echo hello` liefert `b''`, wenn man erst NACH Child-Exit liest; bei sofortigem Lesen kommt `b'hello-py\r\n'`. → macOS verwirft ungelesene PTY-Master-Daten beim Slave-Close (danach EIO). Unser `DispatchSourceRead` verliert dieses Rennen bei Instant-Exit-Kindern — und ssh schreibt ALLES (inkl. „Connection refused") nach stderr, das mit am PTY hing. +**Fix (`Services/PTYSession.swift`, `Services/TCPCheck.swift`, `Views/EmbeddedTerminalView.swift`):** +- stderr → `Pipe` (gepufferte Daten überleben Child-Exit garantiert), stdin/stdout bleiben am PTY (TTY erhalten für interaktive Prompts) +- `finalFlush` (non-blocking Rest-Drain) + Epoch-guarded Lifecycle (kein State-/FD-Durcheinander bei Trennen→Neuverbinden) +- TCP-Vorab-Check (Network.framework, 4 s Timeout) mit deutschen Klartext-Meldungen (refused/DNS/Timeout/Port) +- Rotes Fehler-Banner mit letzter Meldung + Diagnose- und Erneut-Buttons +**Verifikation:** Refused-Harness zeigt jetzt `ssh: connect to host 127.0.0.1 port 1: Connection refused` (PASS); Roundtrip (`cat`) PASS; `ssh -G` PASS; TCP-Harness (closed/open/empty) PASS. +**Status:** ✅ behoben. + +## #9 — Build: `Result` ungültig +**Log:** `error: type 'String' does not conform to protocol 'Error'` (+ Folgefehler an `DispatchQueue.main.async`) +**Fix:** `TCPCheck.check` nutzt `(String?) -> Void` (nil = ok, sonst deutsche Meldung). +**Status:** ✅ behoben. + +## #10 — Icon-Generator Buildfehler +**Log:** `guard let` auf nicht-optionalem Ausdruck (`??` unwrapt schon); `Int * CGFloat`; Preview lag in `.iconset/` statt `/tmp/`. +**Fix:** `??`-Fallback direkt zuweisen; `CGFloat(...)`; Pfad korrigiert. +**Status:** ✅ behoben. Icon: `Resources/AppIcon.icns` (per `scripts/generate-icon.sh` reproduzierbar), Vorschau `docs/icon-preview.png`. + ## #7 — ANSI-Parser: Text bekam falschen Stil **Phase:** `swiftc`-Harness während #6 **Log:** `text("rot", fg: nil)` statt `fg: 1` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4ce2584..653190f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,8 +3,9 @@ ## Lokal bauen ```bash ./scripts/build-release.sh [version] # ZIP mit Binary + Docs -./scripts/build-app.sh [version] # RemminaMac.app (Bundle, ad-hoc-signiert) +./scripts/build-app.sh [version] # RemminaMac.app (Bundle, ad-hoc-signiert, mit Icon) ./scripts/build-dmg.sh [version] # DMG mit App + Programme-Link +./scripts/generate-icon.sh # AppIcon.icns neu rendern (Dracula-Terminal) # Beispiel: ./scripts/build-release.sh 0.1.0 ./scripts/build-app.sh 0.1.0 diff --git a/docs/USAGE.md b/docs/USAGE.md index fd7d39a..f6a80f9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -14,7 +14,7 @@ ## Verbinden - Profil wählen → `▶ Verbinden` oder Doppelklick. -- 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: **In-App-Terminal (Dracula-Theme) direkt im Tab** — erst TCP-Vorab-Check (falscher Port/Host → sofort klare Meldung statt kryptischem Exit-Code), dann Auto-Connect. Eingabezeile unten (Auge-Icon = Passwort-Modus), `^C`/`^D`-Buttons, `Leeren`, `Trennen`/`Verbinden`, `Tab schließen`. Bei Fehlschlag: rotes Banner mit letzter Meldung + Diagnose-Button. - Alternativ: `In Terminal.app öffnen` (öffnet eine `.command`-Datei, braucht keine Automation-Berechtigung) oder `Befehl kopieren`. - RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop. - VNC: öffnet `vnc://host:port`. diff --git a/docs/icon-preview.png b/docs/icon-preview.png new file mode 100644 index 0000000..3ba6f64 Binary files /dev/null and b/docs/icon-preview.png differ diff --git a/scripts/RenderIcon.swift b/scripts/RenderIcon.swift new file mode 100644 index 0000000..592d147 --- /dev/null +++ b/scripts/RenderIcon.swift @@ -0,0 +1,100 @@ +#!/usr/bin/env swift +// Generiert das RemminaMac App-Icon (Dracula-Terminal) als PNG-Set + .iconset. +// Aufruf: swift scripts/RenderIcon.swift +// 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)") diff --git a/scripts/build-app.sh b/scripts/build-app.sh index b2ea9ec..1bbc928 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -18,6 +18,7 @@ rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/RemminaMac" chmod +x "$APP/Contents/MacOS/RemminaMac" +cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/" 2>/dev/null || echo "[build-app] Hinweis: kein AppIcon.icns (./scripts/generate-icon.sh)" # Info.plist mit Version versehen sed "s|0.1.0|$VERSION|" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist" diff --git a/scripts/generate-icon.sh b/scripts/generate-icon.sh new file mode 100755 index 0000000..878adee --- /dev/null +++ b/scripts/generate-icon.sh @@ -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"