RemminaMac 0.1.0: SSH/RDP-Client für macOS (SwiftUI, Remmina-Layout, Profile, SSH-Keys, Docs, Release-Skripte)

This commit is contained in:
x3
2026-09-21 22:43:57 +02:00
commit cd834dce98
21 changed files with 1252 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.build/
dist/
*.zip
*.sha256
*.xcodeproj/
xcuserdata/
DerivedData/
.DS_Store
*.xcuserstate
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+21
View File
@@ -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.
+18
View File
@@ -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"
)
]
)
+118
View File
@@ -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-<UUID>`, `sshkey-<UUID>`.
- 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-<version>-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`.
@@ -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?
}
+15
View File
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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")
]
}
}
+218
View File
@@ -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)
}
}
@@ -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
}
}
}
}
+104
View File
@@ -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()
}
}
@@ -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."
}
}
}
+33
View File
@@ -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.
+77
View File
@@ -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<some View> 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<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
## #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.
+40
View File
@@ -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
+36
View File
@@ -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-<ver>-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="<owner>"
export GITEA_REPO="remmina-mac"
export GITEA_TOKEN="<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"
```
+36
View File
@@ -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_<timestamp>` auf.
- Passphrase → Keychain (`sshkey-<UUID>`).
## 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"
```
+27
View File
@@ -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"
+35
View File
@@ -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"