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,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user