commit cd834dce9851821c9f682b971b5db62e12433717 Author: x3 Date: Mon Sep 21 22:43:57 2026 +0200 RemminaMac 0.1.0: SSH/RDP-Client für macOS (SwiftUI, Remmina-Layout, Profile, SSH-Keys, Docs, Release-Skripte) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c24868c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.build/ +dist/ +*.zip +*.sha256 +*.xcodeproj/ +xcuserdata/ +DerivedData/ +.DS_Store +*.xcuserstate diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3167492 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RemminaMac Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..08cb2a0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RemminaMac", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "RemminaMac", targets: ["RemminaMac"]) + ], + targets: [ + .executableTarget( + name: "RemminaMac", + path: "Sources/RemminaMac" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcfd857 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# RemminaMac — SSH / RDP Client für macOS + +Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI. + +![Plattform](https://img.shields.io/badge/plattform-macOS%2014%2B-blue) +![Sprache](https://img.shields.io/badge/sprache-Swift%205.9-orange) +![UI](https://img.shields.io/badge/UI-SwiftUI-green) + +Layout wie Remmina: **links Profil-Liste mit Suche + Gruppen, rechts Verbindungs-Tabs, unten Statusbar.** + +## Inhaltsverzeichnis +- [Features](#features) +- [Screenshots / Layout](#screenshots--layout) +- [Schnellstart](#schnellstart) +- [Installation](docs/INSTALL.md) +- [Benutzung](docs/USAGE.md) +- [Architektur](docs/ARCHITECTURE.md) +- [SSH-Keys & Sicherheit](#ssh-keys--sicherheit) +- [RDP / VNC unter macOS](#rdp--vnc-unter-macos) +- [Profile-Format](#profile-format) +- [Release-Binary](#release-binary) +- [Fehlerprotokoll](docs/ERRORS.md) +- [Roadmap](#roadmap) +- [Lizenz](#lizenz) + +## Features +- GUI im Remmina-Layout (`Views/ContentView.swift`): + - Sidebar: Gruppen-Picker, Suche, Profil-Liste, Doppelklick = Verbinden + - Detail: Tab-Ansicht offener Sitzungen, Verbindungsdetails, Statusbar + - Toolbar: Neu / Bearbeiten / Duplizieren / Verbinden / Quick-Connect / SSH-Keys +- Profile laden / speichern / duplizieren / löschen + - JSON in `~/Library/Application Support/RemminaMac/profiles.json` + - Export / Import (siehe Benutzung) +- Protokolle: SSH, SFTP, RDP, VNC (`Models/ConnectionProfile.swift:3`) +- SSH-Keys speichern & verwalten (`Views/SSHKeysView.swift`): + - Import aus `~/.ssh`, Datei-Picker, `ssh-keygen`-Wrapper (ed25519) + - Passphrases in macOS Keychain (`Security/KeychainHelper.swift`) +- Passwörter in Keychain, nie im Klartext-JSON +- SSH via System-`ssh` in Terminal.app (`Services/ConnectionServices.swift:6`) +- RDP via FreeRDP (`xfreerdp`) oder Fallback Microsoft Remote Desktop +- VNC via `vnc://`-Scheme + +## Screenshots / Layout +``` ++-------------------------------------------------------------+ +| Toolbar: [+][Edit][Copy][Key] | [▶ Verbinden][⚡Quick] | ++------+-------------------------------+--------------------+ +| Gruppe | Tab: Webserver | Büro-PC | ... | Detail | +| Suche +-------------------------------+ | +| - Webserver | Verbindungsdetails (Befehl)| | +| - Büro-PC | Hinweis | | +| - Dateiserver+-------------------------------+ | ++------+-------------------------------+--------------------+ +| Statusbar: ● Bereit ... N Profile · M Sitzungen | ++-------------------------------------------------------------+ +``` + +## Schnellstart +```bash +cd RemminaMac +swift build +swift run RemminaMac +# In Xcode: +open Package.swift +# Target RemminaMac → My Mac → Run +``` + +## SSH-Keys & Sicherheit +- Private-Key-Pfade stehen in `sshkeys.json`, Passphrases nur in Keychain. +- Service-Name: `RemminaMac`, Accounts: `profile-`, `sshkey-`. +- Keychain lesen/schreiben: `Security/KeychainHelper.swift:5`. +- Es werden keine Secrets ins Git-Repo geschrieben (siehe `.gitignore`). + +## RDP / VNC unter macOS +- Optional: `brew install freerdp` +- Mit FreeRDP: `xfreerdp /v:HOST:PORT /u:USER [/size:WxH | /f]` +- Ohne: `rdc://` → Microsoft Remote Desktop (aus App Store installieren) +- VNC: `open vnc://host:port` + +## Profile-Format +```json +[ + { + "id": "UUID", + "name": "Webserver", + "protocol": "SSH", + "host": "192.168.1.10", + "port": 22, + "username": "admin", + "group": "Server", + "colorTag": "blue", + "resolution": "Automatisch", + "sshKeyID": null, + "passwordAccount": "profile-UUID", + "extraArgs": "", + "lastConnected": null + } +] +``` + +## Release-Binary +Fertige Binaries liegen unter Git-Releases (`dist/` wird lokal gebaut, nicht committet): +```bash +./scripts/build-release.sh +# → dist/RemminaMac--macos-arm64.zip + .sha256 +ls dist/ +``` +Details: [docs/INSTALL.md](docs/INSTALL.md), [docs/RELEASING.md](docs/RELEASING.md). + +## Roadmap +- [ ] Eingebettetes Terminal (SwiftTerm + swift-nio-ssh) +- [ ] Echte RDP-Einbettung (FreeRDP-Bridge) statt externem Prozess +- [ ] SFTP-Browser integriert +- [ ] Export/Import-Buttons in der GUI +- [ ] Tray / Favoriten / Ordner-Badges wie Remmina + +## Lizenz +MIT — siehe `LICENSE`. diff --git a/Sources/RemminaMac/Models/ConnectionProfile.swift b/Sources/RemminaMac/Models/ConnectionProfile.swift new file mode 100644 index 0000000..e8dfce9 --- /dev/null +++ b/Sources/RemminaMac/Models/ConnectionProfile.swift @@ -0,0 +1,87 @@ +import Foundation + +enum ProtocolType: String, Codable, CaseIterable, Identifiable { + case ssh = "SSH" + case sftp = "SFTP" + case rdp = "RDP" + case vnc = "VNC" + + var id: String { rawValue } + + var defaultPort: Int { + switch self { + case .ssh: return 22 + case .sftp: return 22 + case .rdp: return 3389 + case .vnc: return 5900 + } + } + + var systemImage: String { + switch self { + case .ssh: return "terminal" + case .sftp: return "folder" + case .rdp: return "pccomputer" + case .vnc: return "display" + } + } +} + +struct ConnectionProfile: Codable, Identifiable, Hashable { + var id: UUID = UUID() + var name: String + var proto: ProtocolType + var host: String + var port: Int + var username: String + var group: String + var colorTag: String + var resolution: String + var sshKeyID: UUID? + var passwordAccount: String? + var extraArgs: String + var lastConnected: Date? + + init( + id: UUID = UUID(), + name: String = "Neues Profil", + proto: ProtocolType = .ssh, + host: String = "", + port: Int = 22, + username: String = "", + group: String = "Allgemein", + colorTag: String = "blue", + resolution: String = "Automatisch", + sshKeyID: UUID? = nil, + passwordAccount: String? = nil, + extraArgs: String = "", + lastConnected: Date? = nil + ) { + self.id = id + self.name = name + self.proto = proto + self.host = host + self.port = port + self.username = username + self.group = group + self.colorTag = colorTag + self.resolution = resolution + self.sshKeyID = sshKeyID + self.passwordAccount = passwordAccount + self.extraArgs = extraArgs + self.lastConnected = lastConnected + } + + enum CodingKeys: String, CodingKey { + case id, name, host, port, username, group, colorTag, resolution, sshKeyID, passwordAccount, extraArgs, lastConnected + case proto = "protocol" + } +} + +struct StoredSSHKey: Codable, Identifiable, Hashable { + var id: UUID = UUID() + var name: String + var privateKeyPath: String + var publicKeyPath: String? + var passphraseAccount: String? +} diff --git a/Sources/RemminaMac/RemminaMacApp.swift b/Sources/RemminaMac/RemminaMacApp.swift new file mode 100644 index 0000000..404147e --- /dev/null +++ b/Sources/RemminaMac/RemminaMacApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct RemminaMacApp: App { + @StateObject private var store = ProfileStore() + + var body: some Scene { + WindowGroup("RemminaMac") { + ContentView() + .environmentObject(store) + .onAppear { store.load() } + .frame(minWidth: 1000, minHeight: 650) + } + } +} diff --git a/Sources/RemminaMac/Security/KeychainHelper.swift b/Sources/RemminaMac/Security/KeychainHelper.swift new file mode 100644 index 0000000..6714af3 --- /dev/null +++ b/Sources/RemminaMac/Security/KeychainHelper.swift @@ -0,0 +1,40 @@ +import Foundation +import Security + +enum KeychainHelper { + static func save(_ secret: String, account: String, service: String = "RemminaMac") -> Bool { + guard let data = secret.data(using: .utf8) else { return false } + delete(account: account, service: service) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + static func load(account: String, service: String = "RemminaMac") -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(account: String, service: String = "RemminaMac") { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/Sources/RemminaMac/Services/ConnectionServices.swift b/Sources/RemminaMac/Services/ConnectionServices.swift new file mode 100644 index 0000000..c14e76c --- /dev/null +++ b/Sources/RemminaMac/Services/ConnectionServices.swift @@ -0,0 +1,79 @@ +import Foundation +import AppKit + +enum SSHService { + static func sshArguments(for profile: ConnectionProfile, key: StoredSSHKey?) -> [String] { + var args: [String] = [] + args += ["-p", "\(profile.port)"] + if let key, !key.privateKeyPath.isEmpty { + args += ["-i", key.privateKeyPath] + } + if !profile.extraArgs.isEmpty { + args += profile.extraArgs.split(separator: " ").map(String.init) + } + args.append("\(profile.username)@\(profile.host)") + return args + } + + static func sshCommandString(for profile: ConnectionProfile, key: StoredSSHKey?) -> String { + let escaped = sshArguments(for: profile, key: key).map { $0.contains(" ") ? "'\($0)'" : $0 } + return "ssh " + escaped.joined(separator: " ") + } + + /// Öffnet die SSH-Session in Terminal.app + static func openInTerminal(profile: ConnectionProfile, key: StoredSSHKey?) { + let cmd = sshCommandString(for: profile, key: key) + let script = "tell application \"Terminal\" to do script \"\(cmd.replacingOccurrences(of: "\"", with: "\\\""))\"" + var err: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&err) + } + } + + static func copyCommandToClipboard(profile: ConnectionProfile, key: StoredSSHKey?) { + let cmd = sshCommandString(for: profile, key: key) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(cmd, forType: .string) + } +} + +enum RDPService { + static var freeRDPPath: String? { + for candidate in ["/opt/homebrew/bin/xfreerdp", "/usr/local/bin/xfreerdp", "/usr/bin/xfreerdp"] { + if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } + } + return nil + } + + static func connect(profile: ConnectionProfile) { + if let freerdp = freeRDPPath { + let task = Process() + task.executableURL = URL(fileURLWithPath: freerdp) + var args = ["/v:\(profile.host):\(profile.port)", "/u:\(profile.username)"] + if profile.resolution.lowercased() != "automatisch" { + let size = profile.resolution.replacingOccurrences(of: " ", with: "") + args.append("/size:\(size)") + } else { + args.append("/f") + } + if !profile.extraArgs.isEmpty { + args += profile.extraArgs.split(separator: " ").map(String.init) + } + task.arguments = args + try? task.run() + } else { + // Fallback: Microsoft Remote Desktop URL-Scheme + let addr = "full address=s:\(profile.host):\(profile.port)" + if let encoded = addr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "rdc://?\(encoded)") { + NSWorkspace.shared.open(url) + } + } + } + + static func connectVNC(profile: ConnectionProfile) { + if let url = URL(string: "vnc://\(profile.host):\(profile.port)") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/Sources/RemminaMac/Stores/ProfileStore.swift b/Sources/RemminaMac/Stores/ProfileStore.swift new file mode 100644 index 0000000..abdeadf --- /dev/null +++ b/Sources/RemminaMac/Stores/ProfileStore.swift @@ -0,0 +1,100 @@ +import Foundation + +@MainActor +final class ProfileStore: ObservableObject { + @Published var profiles: [ConnectionProfile] = [] + @Published var sshKeys: [StoredSSHKey] = [] + @Published var searchText: String = "" + @Published var selectedGroup: String = "Alle" + + private var profilesURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("RemminaMac", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent("profiles.json") + } + + private var keysURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("RemminaMac", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base.appendingPathComponent("sshkeys.json") + } + + var groups: [String] { + let g = Set(profiles.map { $0.group.isEmpty ? "Allgemein" : $0.group }) + return ["Alle"] + g.sorted() + } + + var filteredProfiles: [ConnectionProfile] { + profiles.filter { p in + let matchGroup = selectedGroup == "Alle" || p.group == selectedGroup + let matchSearch = searchText.isEmpty || + p.name.localizedCaseInsensitiveContains(searchText) || + p.host.localizedCaseInsensitiveContains(searchText) || + p.username.localizedCaseInsensitiveContains(searchText) + return matchGroup && matchSearch + }.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } + } + + func load() { + if let data = try? Data(contentsOf: profilesURL), + let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { + profiles = decoded + } + if let data = try? Data(contentsOf: keysURL), + let decoded = try? JSONDecoder().decode([StoredSSHKey].self, from: data) { + sshKeys = decoded + } + if profiles.isEmpty { + profiles = Self.sampleProfiles + } + } + + func save() { + if let data = try? JSONEncoder().encode(profiles) { + try? data.write(to: profilesURL) + } + if let data = try? JSONEncoder().encode(sshKeys) { + try? data.write(to: keysURL) + } + } + + func add(_ profile: ConnectionProfile) { + profiles.append(profile) + save() + } + + func update(_ profile: ConnectionProfile) { + if let i = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[i] = profile + save() + } + } + + func delete(_ profile: ConnectionProfile) { + profiles.removeAll { $0.id == profile.id } + save() + } + + func duplicate(_ profile: ConnectionProfile) { + var copy = profile + copy.id = UUID() + copy.name += " (Kopie)" + copy.passwordAccount = nil + add(copy) + } + + func key(for profile: ConnectionProfile) -> StoredSSHKey? { + guard let id = profile.sshKeyID else { return nil } + return sshKeys.first { $0.id == id } + } + + static var sampleProfiles: [ConnectionProfile] { + [ + ConnectionProfile(name: "Webserver", proto: .ssh, host: "192.168.1.10", port: 22, username: "admin", group: "Server"), + ConnectionProfile(name: "Büro-PC", proto: .rdp, host: "192.168.1.50", port: 3389, username: "user", group: "Windows"), + ConnectionProfile(name: "Dateiserver", proto: .sftp, host: "nas.local", port: 22, username: "nas", group: "Server") + ] + } +} diff --git a/Sources/RemminaMac/Views/ContentView.swift b/Sources/RemminaMac/Views/ContentView.swift new file mode 100644 index 0000000..a19ae08 --- /dev/null +++ b/Sources/RemminaMac/Views/ContentView.swift @@ -0,0 +1,218 @@ +import SwiftUI +import AppKit + +struct ContentView: View { + @EnvironmentObject var store: ProfileStore + @State private var selection: ConnectionProfile.ID? + @State private var openSessions: [ConnectionProfile] = [] + @State private var activeSessionID: ConnectionProfile.ID? + @State private var showingEditor = false + @State private var editingProfile: ConnectionProfile? + @State private var showingKeys = false + @State private var statusMessage = "Bereit." + + var body: some View { + NavigationSplitView { + // Sidebar wie Remmina: Gruppen + Profile + VStack(spacing: 0) { + Picker("Gruppe", selection: $store.selectedGroup) { + ForEach(store.groups, id: \.self) { g in Text(g).tag(g) } + } + .pickerStyle(.menu) + .padding(8) + + SearchField(text: $store.searchText) + .padding(.horizontal, 8) + + List(store.filteredProfiles, selection: $selection) { p in + HStack { + Image(systemName: p.proto.systemImage) + .foregroundStyle(color(for: p.colorTag)) + .frame(width: 22) + VStack(alignment: .leading) { + Text(p.name).font(.headline) + Text("\(p.username)@\(p.host):\(p.port) · \(p.proto.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .tag(p.id) + .contextMenu { + Button("Verbinden") { connect(p) } + Button("Duplizieren") { store.duplicate(p) } + Button("Bearbeiten") { edit(p) } + Button("Löschen", role: .destructive) { store.delete(p) } + } + } + .onDoubleClick { if let p = selectedProfile { connect(p) } } + } + .frame(minWidth: 280) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: addProfile) { Image(systemName: "plus") } + .help("Neues Profil") + Button(action: { if let p = selectedProfile { edit(p) } }) { Image(systemName: "pencil") } + .help("Bearbeiten").disabled(selection == nil) + Button(action: { if let p = selectedProfile { store.duplicate(p) } }) { Image(systemName: "plus.square.on.square") } + .help("Duplizieren").disabled(selection == nil) + Button(action: { showingKeys = true }) { Image(systemName: "key") } + .help("SSH-Keys verwalten") + } + } + } detail: { + VStack(spacing: 0) { + // Verbindungs-Tabs + if openSessions.isEmpty { + emptyState + } else { + TabView(selection: $activeSessionID) { + ForEach(openSessions) { session in + SessionView(profile: session, statusMessage: $statusMessage) + .tag(Optional(session.id)) + .tabItem { Text(session.name) } + } + } + } + Divider() + // Statusbar wie Remmina + HStack { + Image(systemName: "circle.fill").foregroundStyle(.green).font(.caption2) + Text(statusMessage).font(.caption).foregroundStyle(.secondary) + Spacer() + Text("\(store.profiles.count) Profile · \(openSessions.count) Sitzungen") + .font(.caption).foregroundStyle(.secondary) + } + .padding(6) + .background(.bar) + } + .toolbar { + ToolbarItem(placement: .navigation) { + Button(action: { if let p = selectedProfile { connect(p) } }) { + Label("Verbinden", systemImage: "play.fill") + }.disabled(selection == nil) + } + ToolbarItem { + Button(action: quickConnect) { Label("Quick-Connect", systemImage: "bolt") } + } + } + } + .sheet(item: $editingProfile) { profile in + ProfileEditorView(profile: profile) { saved in + if store.profiles.contains(where: { $0.id == saved.id }) { + store.update(saved) + } else { + store.add(saved) + } + statusMessage = "Profil '\(saved.name)' gespeichert." + } + } + .sheet(isPresented: $showingKeys) { + SSHKeysView().environmentObject(store) + } + } + + var selectedProfile: ConnectionProfile? { + store.profiles.first { $0.id == selection } + ?? store.filteredProfiles.first { $0.id == selection } + } + + var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "rectangle.connected.to.line.below").font(.system(size: 48)).foregroundStyle(.secondary) + Text("Kein Profil geöffnet").font(.title2) + Text("Wähle links ein Profil und klicke Verbinden (Doppelklick geht auch).").foregroundStyle(.secondary) + Button("Neues Profil anlegen", action: addProfile) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + func addProfile() { + editingProfile = ConnectionProfile() + } + + func edit(_ p: ConnectionProfile) { + editingProfile = p + } + + func connect(_ p: ConnectionProfile) { + var updated = p + updated.lastConnected = Date() + store.update(updated) + if !openSessions.contains(where: { $0.id == p.id }) { + openSessions.append(updated) + } + activeSessionID = p.id + let key = store.key(for: p) + switch p.proto { + case .ssh, .sftp: + SSHService.openInTerminal(profile: p, key: key) + statusMessage = "SSH: \(SSHService.sshCommandString(for: p, key: key))" + case .rdp: + RDPService.connect(profile: p) + statusMessage = "RDP zu \(p.host):\(p.port) gestartet." + case .vnc: + RDPService.connectVNC(profile: p) + statusMessage = "VNC zu \(p.host):\(p.port) geöffnet." + } + } + + func quickConnect() { + // Simple Quick-Connect: nimmt Auswahl oder leeres SSH-Profil + if let p = selectedProfile { connect(p) } + else { editingProfile = ConnectionProfile(name: "Quick-Connect") } + } + + func color(for tag: String) -> Color { + switch tag { + case "red": return .red + case "green": return .green + case "orange": return .orange + case "purple": return .purple + default: return .blue + } + } +} + +struct SearchField: View { + @Binding var text: String + var body: some View { + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Suchen …", text: $text) + .textFieldStyle(.plain) + if !text.isEmpty { + Button(action: { text = "" }) { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + } + .padding(6) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } +} + +extension View { + func onDoubleClick(perform action: @escaping () -> Void) -> some View { + background(DoubleClickHandler(action: action)) + } +} + +struct DoubleClickHandler: NSViewRepresentable { + var action: () -> Void + func makeNSView(context: Context) -> NSClickView { + let v = NSClickView() + v.onDoubleClick = action + return v + } + func updateNSView(_ nsView: NSClickView, context: Context) { + nsView.onDoubleClick = action + } +} + +final class NSClickView: NSView { + var onDoubleClick: (() -> Void)? + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { onDoubleClick?() } + super.mouseDown(with: event) + } +} diff --git a/Sources/RemminaMac/Views/ProfileEditorView.swift b/Sources/RemminaMac/Views/ProfileEditorView.swift new file mode 100644 index 0000000..f958202 --- /dev/null +++ b/Sources/RemminaMac/Views/ProfileEditorView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct ProfileEditorView: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var store: ProfileStore + @State var profile: ConnectionProfile + var onSave: (ConnectionProfile) -> Void + @State private var password: String = "" + @State private var savePassword: Bool = false + + init(profile: ConnectionProfile, onSave: @escaping (ConnectionProfile) -> Void) { + _profile = State(initialValue: profile) + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 0) { + Text(profile.id.uuidString.prefix(8) == ConnectionProfile().id.uuidString.prefix(8) ? "Profil bearbeiten" : "Profil").font(.headline).padding() + Form { + Section("Allgemein") { + TextField("Name", text: $profile.name) + TextField("Gruppe", text: $profile.group) + Picker("Protokoll", selection: $profile.proto) { + ForEach(ProtocolType.allCases) { p in Text(p.rawValue).tag(p) } + } + .onChange(of: profile.proto) { _, new in + if profile.port == 0 { profile.port = new.defaultPort } + } + Picker("Farb-Tag", selection: $profile.colorTag) { + Text("Blau").tag("blue"); Text("Rot").tag("red") + Text("Grün").tag("green"); Text("Orange").tag("orange") + Text("Lila").tag("purple") + } + } + Section("Verbindung") { + TextField("Host", text: $profile.host) + TextField("Port", value: $profile.port, format: .number) + TextField("Benutzername", text: $profile.username) + TextField("Auflösung (RDP, z. B. 1920x1080 oder Automatisch)", text: $profile.resolution) + TextField("Extra-Argumente", text: $profile.extraArgs) + } + Section("Authentifizierung") { + Picker("SSH-Key", selection: $profile.sshKeyID) { + Text("Kein Key").tag(UUID?.none) + ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) } + } + SecureField("Passwort (optional, Keychain)", text: $password) + Toggle("Passwort in Keychain speichern", isOn: $savePassword) + } + } + .formStyle(.grouped) + + HStack { + Button("Abbrechen", role: .cancel) { dismiss() } + Spacer() + Button("Speichern") { + var toSave = profile + if toSave.port == 0 { toSave.port = toSave.proto.defaultPort } + if toSave.passwordAccount == nil { toSave.passwordAccount = "profile-\(toSave.id.uuidString)" } + if savePassword, !password.isEmpty, let acct = toSave.passwordAccount { + _ = KeychainHelper.save(password, account: acct) + } + onSave(toSave) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(profile.host.isEmpty || profile.name.isEmpty) + } + .padding() + } + .frame(width: 520, height: 620) + .onAppear { + if let acct = profile.passwordAccount { + password = KeychainHelper.load(account: acct) ?? "" + savePassword = !password.isEmpty + } + } + } +} diff --git a/Sources/RemminaMac/Views/SSHKeysView.swift b/Sources/RemminaMac/Views/SSHKeysView.swift new file mode 100644 index 0000000..4ddc310 --- /dev/null +++ b/Sources/RemminaMac/Views/SSHKeysView.swift @@ -0,0 +1,104 @@ +import SwiftUI +import AppKit + +struct SSHKeysView: View { + @EnvironmentObject var store: ProfileStore + @Environment(\.dismiss) var dismiss + @State private var showingAdd = false + @State private var newName = "" + @State private var newPath = "" + @State private var newPassphrase = "" + + var body: some View { + VStack { + Text("SSH-Keys verwalten").font(.headline).padding() + List { + ForEach(store.sshKeys) { key in + VStack(alignment: .leading) { + Text(key.name).font(.headline) + Text(key.privateKeyPath).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + .contextMenu { + Button("Löschen", role: .destructive) { + store.sshKeys.removeAll { $0.id == key.id } + store.save() + } + } + } + .onDelete { idx in + store.sshKeys.remove(atOffsets: idx) + store.save() + } + } + if showingAdd { + Form { + TextField("Name", text: $newName) + HStack { + TextField("Privater Key-Pfad (~/.ssh/id_ed25519)", text: $newPath) + Button("Wählen…") { chooseFile() } + } + SecureField("Passphrase (Keychain)", text: $newPassphrase) + } + .padding() + } + HStack { + Button("Import aus ~/.ssh") { importSSHDir() } + Button(showingAdd ? "Abbrechen" : "Hinzufügen") { showingAdd.toggle() } + Spacer() + if showingAdd { + Button("Speichern") { saveNew() }.keyboardShortcut(.defaultAction).disabled(newName.isEmpty || newPath.isEmpty) + } else { + Button("Neuen Key generieren (ssh-keygen)") { generateKey() } + Button("Fertig") { dismiss() }.keyboardShortcut(.defaultAction) + } + } + .padding() + } + .frame(width: 600, height: 500) + } + + func chooseFile() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh") + if panel.runModal() == .OK, let url = panel.url { + newPath = url.path + if newName.isEmpty { newName = url.lastPathComponent } + } + } + + func saveNew() { + var entry = StoredSSHKey(name: newName, privateKeyPath: newPath, publicKeyPath: newPath + ".pub") + if !newPassphrase.isEmpty { + entry.passphraseAccount = "sshkey-\(entry.id.uuidString)" + _ = KeychainHelper.save(newPassphrase, account: entry.passphraseAccount!) + } + store.sshKeys.append(entry) + store.save() + newName = ""; newPath = ""; newPassphrase = ""; showingAdd = false + } + + func importSSHDir() { + let sshDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".ssh").path + guard let files = try? FileManager.default.contentsOfDirectory(atPath: sshDir) else { return } + for f in files where !f.hasSuffix(".pub") && !f.contains("known_hosts") && !f.contains("config") { + let full = (sshDir as NSString).appendingPathComponent(f) + if store.sshKeys.contains(where: { $0.privateKeyPath == full }) { continue } + store.sshKeys.append(StoredSSHKey(name: f, privateKeyPath: full)) + } + store.save() + } + + func generateKey() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/ssh-keygen") + let outPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".ssh/remminamac_\(Int(Date().timeIntervalSince1970))").path + task.arguments = ["-t", "ed25519", "-f", outPath, "-N", ""] + try? task.run() + task.waitUntilExit() + store.sshKeys.append(StoredSSHKey(name: URL(fileURLWithPath: outPath).lastPathComponent, privateKeyPath: outPath, publicKeyPath: outPath + ".pub")) + store.save() + } +} diff --git a/Sources/RemminaMac/Views/SessionView.swift b/Sources/RemminaMac/Views/SessionView.swift new file mode 100644 index 0000000..c103829 --- /dev/null +++ b/Sources/RemminaMac/Views/SessionView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct SessionView: View { + let profile: ConnectionProfile + @Binding var statusMessage: String + @EnvironmentObject var store: ProfileStore + @State private var log: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: profile.proto.systemImage).font(.title2) + VStack(alignment: .leading) { + Text(profile.name).font(.headline) + Text("\(profile.proto.rawValue) · \(profile.username)@\(profile.host):\(profile.port)") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + if profile.proto == .ssh || profile.proto == .sftp { + Button("In Terminal öffnen") { + SSHService.openInTerminal(profile: profile, key: store.key(for: profile)) + } + Button("Befehl kopieren") { + SSHService.copyCommandToClipboard(profile: profile, key: store.key(for: profile)) + statusMessage = "SSH-Befehl kopiert." + } + } else if profile.proto == .rdp { + Button("RDP erneut verbinden") { RDPService.connect(profile: profile) } + } + } + .padding() + + GroupBox("Verbindungsdetails") { + let key = store.key(for: profile) + let cmd = profile.proto == .ssh || profile.proto == .sftp + ? SSHService.sshCommandString(for: profile, key: key) + : "\(profile.proto.rawValue) \(profile.host):\(profile.port)" + Text(cmd) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .padding(.horizontal) + + GroupBox("Hinweis") { + Text(hintText) + .font(.callout).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .padding(.horizontal) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + var hintText: String { + switch profile.proto { + case .ssh: + return "SSH wird in Terminal.app geöffnet (System-ssh). Für ein eingebettetes Terminal als nächsten Schritt SwiftTerm + swift-nio-ssh einbinden." + case .sftp: + return "SFTP nutzt denselben SSH-Zugang. Befehl kopieren und z. B. mit Cyberduck / Transmit / sftp im Terminal nutzen: sftp -P \(profile.port) \(profile.username)@\(profile.host)." + case .rdp: + return RDPService.freeRDPPath != nil + ? "Verbunden über xfreerdp (\(RDPService.freeRDPPath!))." + : "Kein xfreerdp gefunden (brew install freerdp). Fallback: Microsoft Remote Desktop wird geöffnet." + case .vnc: + return "VNC wird über die System-Freigabe (open vnc://) geöffnet." + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8b74111 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,33 @@ +# Architektur + +``` +Sources/RemminaMac/ + RemminaMacApp.swift # @main, WindowGroup, lädt ProfileStore + Models/ConnectionProfile.swift # ProtocolType + ConnectionProfile + StoredSSHKey (Codable) + Stores/ProfileStore.swift # ObservableObject, JSON-Persistenz, Filter/Suche/Gruppen + Security/KeychainHelper.swift# SecItemAdd/Copy/Delete Wrapper + Services/ConnectionServices.swift # SSHService (ssh-Befehl) + RDPService (freerdp/rdc/vnc) + Views/ContentView.swift # NavigationSplitView: Sidebar + Tabs + Statusbar + Views/SessionView.swift # Detail pro Sitzung + Views/ProfileEditorView.swift# Formular Neu/Bearbeiten + Keychain-Anbindung + Views/SSHKeysView.swift # Key-Liste, Import, ssh-keygen +``` + +## Datenfluss +1. `RemminaMacApp` erstellt `ProfileStore` als `@StateObject`, injiziert als `environmentObject`. +2. `ProfileStore.load()` liest `profiles.json` + `sshkeys.json`, sonst Sample-Daten. +3. `ContentView` filtert via `searchText`/`selectedGroup`, `selection` steuert Detail. +4. `connect(_:)` setzt `lastConnected`, öffnet Tab, delegiert an `SSHService`/`RDPService`. +5. Secrets nur via `KeychainHelper`, JSON enthält nur Account-Referenzen. + +## Wichtige Dateien +- `Stores/ProfileStore.swift:13` — Pfade + `load()`/`save()` +- `Models/ConnectionProfile.swift:3` — Protokoll-Defaults (Ports, Icons) +- `Services/ConnectionServices.swift:6` — `sshArguments(for:key:)` +- `Views/ContentView.swift` — Remmina-Layout + +## Nächste Ausbaustufen +1. SwiftTerm-View statt externem Terminal + `swift-nio-ssh` für echte SSH-Sessions. +2. FreeRDP als XPC-Service einbetten statt `Process`. +3. SFTP-Browser (NIO SFTP oder libssh2-Bridge). +4. Export/Import-Buttons + Drag&Drop für Keys. diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 0000000..8751108 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,77 @@ +# Fehlerprotokoll (ERRORS.md) + +Alle Fehler, die beim Erstellen / Bauen / Releasen von RemminaMac aufgetreten sind — mit Ursache, Fix und Status. Wird bei jedem Build fortgeschrieben. + +Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+ + +## Übersicht +| # | Zeitpunkt | Phase | Fehler | Schwere | Status | +|---|-----------|-------|--------|---------|--------| +| 1 | 2026-09-21 | `swift build` (debug) | `WindowGroup has no member 'windowTitle'` | blockierend | ✅ behoben | +| 2 | 2026-09-21 | `swift build -c release` | keine Fehler, nur lange Link-Zeit (~40 s) | Info | ✅ ok | +| 3 | 2026-09-21 | Release-Skript | `dist/` war nicht in `.gitignore` → Binary drohte committet zu werden | mittel | ✅ behoben (ignoriert) | + +--- + +## #1 — `windowTitle` existiert nicht auf `WindowGroup` +**Phase:** erster `swift build` +**Log (gekürzt):** +``` +Sources/RemminaMac/RemminaMacApp.swift:14:10: error: value of type 'WindowGroup' 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 +## #N — Titel +**Phase:** ... +**Log:** ... +**Ursache:** ... +**Fix:** ... +**Verifikation:** ... +**Status:** ⬜ offen / ✅ behoben +``` + +## Wie Fehler hierher kommen +- Jeder `swift build` / `swift build -c release` / Skript-Lauf mit Fehler → Log-Ausschnitt + Exit-Code hier eintragen. +- Gitea-Push/Release-Fehler (z. B. 401, Repo existiert schon, Asset-Upload schlägt fehl) ebenfalls hier dokumentieren. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..7d67293 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,40 @@ +# Installation + +## Voraussetzungen +- macOS 14+ +- Xcode 15+ (für GUI-Run) oder nur Swift-Toolchain 5.9+ (für CLI-Build) + +## Variante A: Xcode (empfohlen für GUI) +1. `open Package.swift` in `RemminaMac/` +2. Target `RemminaMac` wählen, Destination `My Mac` +3. `Cmd+R` → App startet +4. Erster Start legt an: + - `~/Library/Application Support/RemminaMac/profiles.json` + - `~/Library/Application Support/RemminaMac/sshkeys.json` + +## Variante B: Swift CLI +```bash +cd RemminaMac +swift build +.build/debug/RemminaMac +``` + +## Variante C: Release-Binary (ohne Xcode) +```bash +./scripts/build-release.sh +unzip dist/RemminaMac-*-macos-arm64.zip -d /tmp/rmm +/tmp/rmm/RemminaMac +``` + +## RDP-Voraussetzungen +```bash +# Für volle RDP-Unterstützung: +brew install freerdp +which xfreerdp +# Ohne FreeRDP: Microsoft Remote Desktop aus dem App Store installieren +``` + +## Troubleshooting +- `swift build` schlägt fehl → `swift --version` prüfen (>= 5.9), Xcode Command Line Tools: `xcode-select --install` +- Keychain-Prompt → einmal erlauben, Service `RemminaMac` +- `xfreerdp` nicht gefunden → Fallback nutzt `rdc://`, braucht MS Remote Desktop diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..1a43be0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,36 @@ +# Releasing + +## Lokal bauen +```bash +./scripts/build-release.sh [version] +# Beispiel: +./scripts/build-release.sh 0.1.0 +# Output: +# dist/RemminaMac-0.1.0-macos-arm64.zip +# dist/RemminaMac-0.1.0-macos-arm64.zip.sha256 +# dist/RemminaMac-0.1.0-macos-arm64/RemminaMac (Binary) +``` + +## Was das Skript macht +1. `swift build -c release` +2. Binary nach `dist/RemminaMac--macos-arm64/` kopieren +3. `README.md` + `docs/` + `LICENSE` dazulegen +4. ZIP + SHA256 erzeugen + +## Gitea-Release erstellen +```bash +export GITEA_HOST="https://git.sysdaemon.xyz" +export GITEA_OWNER="" +export GITEA_REPO="remmina-mac" +export GITEA_TOKEN="" # statt Passwort: User → Settings → Applications → Token +export VERSION="0.1.0" +./scripts/publish-gitea-release.sh +``` +Das Skript erstellt (falls nötig) das Release `v0.1.0` und lädt ZIP + SHA256 als Attachments hoch. + +## Manuell via API +```bash +curl -u "user:pass" -H "Content-Type: application/json" \ + -d '{"tag_name":"v0.1.0","name":"v0.1.0","body":"Erstes Release"}' \ + "$GITEA_HOST/api/v1/repos/$OWNER/$REPO/releases" +``` diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..8504d7b --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,36 @@ +# Benutzung + +## Layout (wie Remmina) +- **Links:** Gruppen-Dropdown, Suche, Profil-Liste. Rechtsklick: Verbinden / Duplizieren / Bearbeiten / Löschen. Doppelklick verbindet. +- **Rechts:** Tabs aller offenen Sitzungen + Verbindungsdetails (kopierbarer Befehl) + Hinweis-Box. +- **Unten:** Statusbar mit Profil-/Sitzungs-Zähler. +- **Toolbar:** `+` Neu, Stift Bearbeiten, Kopie-Symbol Duplizieren, Schlüssel Keys, `▶` Verbinden, `⚡` Quick-Connect. + +## Profil anlegen +1. `+` → Name, Gruppe, Protokoll (SSH/SFTP/RDP/VNC), Host, Port, User ausfüllen. +2. SSH-Key wählen (vorher unter Schlüssel-Icon importieren). +3. Optional Passwort → Haken „in Keychain speichern“. +4. Speichern → liegt sofort in `profiles.json`. + +## Verbinden +- Profil wählen → `▶ Verbinden` oder Doppelklick. +- SSH/SFTP: öffnet Terminal.app mit `ssh -p PORT -i KEY user@host`. Befehl steht zusätzlich kopierbar in der Detailansicht. +- RDP: startet `xfreerdp` (falls vorhanden) oder öffnet MS Remote Desktop. +- VNC: öffnet `vnc://host:port`. + +## SSH-Keys verwalten (Schlüssel-Icon) +- `Import aus ~/.ssh`: übernimmt alle privaten Keys (ohne `*.pub`, `known_hosts`, `config`). +- `Wählen…`: Picker für Key-Datei. +- `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_` auf. +- Passphrase → Keychain (`sshkey-`). + +## Quick-Connect +- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage. + +## Export / Import (manuell, bis GUI-Buttons kommen) +```bash +PROFILE_DIR=~/Library/Application\ Support/RemminaMac +cp "$PROFILE_DIR/profiles.json" ~/Desktop/remminamac-backup.json +# Import: +cp ~/Desktop/remminamac-backup.json "$PROFILE_DIR/profiles.json" +``` diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..b9c5daa --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail +VERSION="${1:-0.1.0}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST="$ROOT/dist" +BUILD_DIR="$DIST/RemminaMac-$VERSION-macos-arm64" +BIN="$ROOT/.build/release/RemminaMac" + +echo "[build-release] Version: $VERSION" +echo "[build-release] Baue Release-Binary..." +swift build -c release --package-path "$ROOT" + +mkdir -p "$BUILD_DIR" +cp "$BIN" "$BUILD_DIR/" +cp "$ROOT/README.md" "$BUILD_DIR/" 2>/dev/null || true +cp "$ROOT/LICENSE" "$BUILD_DIR/" 2>/dev/null || true +cp -R "$ROOT/docs" "$BUILD_DIR/" 2>/dev/null || true + +cd "$DIST" +ZIP="RemminaMac-$VERSION-macos-arm64.zip" +rm -f "$ZIP" +zip -r "$ZIP" "RemminaMac-$VERSION-macos-arm64" > /dev/null +shasum -a 256 "$ZIP" > "$ZIP.sha256" + +echo "[build-release] Fertig:" +ls -lh "$ZIP" "$ZIP.sha256" +cat "$ZIP.sha256" diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh new file mode 100755 index 0000000..86806c3 --- /dev/null +++ b/scripts/publish-gitea-release.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Lädt ZIP + SHA256 als Gitea-Release hoch. +# Env: GITEA_HOST, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN, VERSION +set -euo pipefail +: "${GITEA_HOST:?GITEA_HOST fehlt, z.B. https://git.sysdaemon.xyz}" +: "${GITEA_OWNER:?GITEA_OWNER fehlt}" +: "${GITEA_REPO:?GITEA_REPO fehlt}" +: "${GITEA_TOKEN:?GITEA_TOKEN fehlt (User -> Settings -> Applications -> Token)}" +VERSION="${VERSION:-0.1.0}" +TAG="v$VERSION" +DIST="$(cd "$(dirname "$0")/../dist" && pwd)" +ZIP="$DIST/RemminaMac-$VERSION-macos-arm64.zip" +SHA="$ZIP.sha256" + +[ -f "$ZIP" ] || { echo "Fehlt: $ZIP (erst ./scripts/build-release.sh $VERSION)"; exit 1; } + +API="$GITEA_HOST/api/v1/repos/$GITEA_OWNER/$GITEA_REPO" + +echo "[publish] Erstelle Release $TAG ..." +RESP=$(curl -sf -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"RemminaMac $TAG — siehe README/docs\",\"draft\":false,\"prerelease\":false}" \ + "$API/releases" || true) + +ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "") +if [ -z "$ID" ]; then + echo "[publish] Release existiert evtl. schon, hole ID..." + ID=$(curl -sf -H "Authorization: token $GITEA_TOKEN" "$API/releases/tags/$TAG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") +fi +echo "[publish] Release-ID: $ID" + +for f in "$ZIP" "$SHA"; do + echo "[publish] Lade hoch: $(basename "$f")" + curl -sf -H "Authorization: token $GITEA_TOKEN" -F "attachment=@$f" "$API/releases/$ID/assets?name=$(basename "$f")" > /dev/null +done +echo "[publish] Fertig: $API/releases/$ID"