RemminaMac 0.1.0: SSH/RDP-Client für macOS (SwiftUI, Remmina-Layout, Profile, SSH-Keys, Docs, Release-Skripte)
This commit is contained in:
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# RemminaMac — SSH / RDP Client für macOS
|
||||||
|
|
||||||
|
Remmina-inspirierter Connection-Manager für macOS, nativ in Swift + SwiftUI.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
Executable
+27
@@ -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"
|
||||||
Executable
+35
@@ -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"
|
||||||
Reference in New Issue
Block a user