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
@@ -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."
}
}
}