Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd711474c |
@@ -39,6 +39,43 @@ enum SSHService {
|
|||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(cmd, forType: .string)
|
NSPasteboard.general.setString(cmd, forType: .string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Passwort / Auto-Login
|
||||||
|
|
||||||
|
/// Gespeichertes Login-Secret: erst Profil-Passwort, dann Key-Passphrase (Keychain, nie JSON).
|
||||||
|
static func loginSecret(for profile: ConnectionProfile, key: StoredSSHKey?) -> String? {
|
||||||
|
if let acct = profile.passwordAccount,
|
||||||
|
let pw = KeychainHelper.load(account: acct), !pw.isEmpty {
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
if let key, let acct = key.passphraseAccount,
|
||||||
|
let pp = KeychainHelper.load(account: acct), !pp.isEmpty {
|
||||||
|
return pp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func findSshpass() -> String? {
|
||||||
|
for p in ["/opt/homebrew/bin/sshpass", "/usr/local/bin/sshpass", "/usr/bin/sshpass"] {
|
||||||
|
if FileManager.default.isExecutableFile(atPath: p) { return p }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// true = beim Verbinden wird ohne Tippen eingeloggt (sshpass + Keychain-Secret).
|
||||||
|
static func willAutoLogin(profile: ConnectionProfile, key: StoredSSHKey?) -> Bool {
|
||||||
|
loginSecret(for: profile, key: key) != nil && findSshpass() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reine Funktion (testbar): mit Secret+sshpass -> sshpass -e … + SSHPASS-Env,
|
||||||
|
/// sonst direkter Aufruf. Passwort steht NIE in der Befehlszeile (nur im Env).
|
||||||
|
static func sessionConfig(executable: String, args: [String], password: String?, sshpassPath: String?)
|
||||||
|
-> (executable: String, arguments: [String], extraEnv: [String: String], autoLogin: Bool) {
|
||||||
|
if let password, !password.isEmpty, let sshpass = sshpassPath, !sshpass.isEmpty {
|
||||||
|
return (sshpass, ["-e", executable] + args, ["SSHPASS": password], true)
|
||||||
|
}
|
||||||
|
return (executable, args, [:], false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RDPService {
|
enum RDPService {
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ enum PTYState: Equatable {
|
|||||||
final class PTYSession: ObservableObject {
|
final class PTYSession: ObservableObject {
|
||||||
@Published private(set) var lines: [[StyledText]] = [[]]
|
@Published private(set) var lines: [[StyledText]] = [[]]
|
||||||
@Published private(set) var state: PTYState = .never
|
@Published private(set) var state: PTYState = .never
|
||||||
|
@Published private(set) var autoLogin = false
|
||||||
|
|
||||||
var executable = ""
|
var executable = ""
|
||||||
var arguments: [String] = []
|
var arguments: [String] = []
|
||||||
|
var customEnv: [String: String] = [:]
|
||||||
|
|
||||||
private var masterFD: Int32 = -1
|
private var masterFD: Int32 = -1
|
||||||
private var process: Process?
|
private var process: Process?
|
||||||
@@ -48,6 +50,10 @@ final class PTYSession: ObservableObject {
|
|||||||
self.arguments = arguments
|
self.arguments = arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setAutoLogin(_ value: Bool) {
|
||||||
|
autoLogin = value
|
||||||
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
cleanupSync()
|
cleanupSync()
|
||||||
epoch += 1
|
epoch += 1
|
||||||
@@ -79,6 +85,7 @@ final class PTYSession: ObservableObject {
|
|||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["TERM"] = "xterm-256color"
|
env["TERM"] = "xterm-256color"
|
||||||
if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" }
|
if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" }
|
||||||
|
for (k, v) in customEnv { env[k] = v }
|
||||||
p.environment = env
|
p.environment = env
|
||||||
// closeOnDealloc=false: Parent schließt slave explizit nach run().
|
// closeOnDealloc=false: Parent schließt slave explizit nach run().
|
||||||
p.standardInput = FileHandle(fileDescriptor: slave)
|
p.standardInput = FileHandle(fileDescriptor: slave)
|
||||||
@@ -282,11 +289,22 @@ final class TerminalManager: ObservableObject {
|
|||||||
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
|
func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession {
|
||||||
if let existing = sessions[profile.id] { return existing }
|
if let existing = sessions[profile.id] { return existing }
|
||||||
let s = PTYSession()
|
let s = PTYSession()
|
||||||
|
let baseExe: String
|
||||||
|
let baseArgs: [String]
|
||||||
if profile.proto == .sftp {
|
if profile.proto == .sftp {
|
||||||
s.configure(executable: "/usr/bin/sftp", arguments: ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"])
|
baseExe = "/usr/bin/sftp"
|
||||||
|
baseArgs = ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]
|
||||||
} else {
|
} else {
|
||||||
s.configure(executable: "/usr/bin/ssh", arguments: SSHService.sshArguments(for: profile, key: key))
|
baseExe = "/usr/bin/ssh"
|
||||||
|
baseArgs = SSHService.sshArguments(for: profile, key: key)
|
||||||
}
|
}
|
||||||
|
// Auto-Login nur mit Keychain-Secret + sshpass, sonst interaktiver Prompt im Terminal
|
||||||
|
let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs,
|
||||||
|
password: SSHService.loginSecret(for: profile, key: key),
|
||||||
|
sshpassPath: SSHService.findSshpass())
|
||||||
|
s.configure(executable: cfg.executable, arguments: cfg.arguments)
|
||||||
|
s.customEnv = cfg.extraEnv
|
||||||
|
s.setAutoLogin(cfg.autoLogin)
|
||||||
sessions[profile.id] = s
|
sessions[profile.id] = s
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $editingProfile) { profile in
|
.sheet(item: $editingProfile) { profile in
|
||||||
ProfileEditorView(profile: profile) { saved in
|
ProfileEditorView(profile: profile, isNew: !store.profiles.contains(where: { $0.id == profile.id })) { saved in
|
||||||
if store.profiles.contains(where: { $0.id == saved.id }) {
|
if store.profiles.contains(where: { $0.id == saved.id }) {
|
||||||
store.update(saved)
|
store.update(saved)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ struct EmbeddedTerminalView: View {
|
|||||||
if checking { return "Prüfe \(profile.host):\(profile.port) …" }
|
if checking { return "Prüfe \(profile.host):\(profile.port) …" }
|
||||||
switch term.state {
|
switch term.state {
|
||||||
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
||||||
case .running: return "Verbunden (PTY, TERM=xterm-256color)."
|
case .running: return term.autoLogin ? "Verbunden (Auto-Login via Keychain)." : "Verbunden (PTY, TERM=xterm-256color)."
|
||||||
case .done(_, let msg): return msg
|
case .done(_, let msg): return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ struct ProfileEditorView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var store: ProfileStore
|
@EnvironmentObject var store: ProfileStore
|
||||||
@State var profile: ConnectionProfile
|
@State var profile: ConnectionProfile
|
||||||
|
var isNew: Bool
|
||||||
var onSave: (ConnectionProfile) -> Void
|
var onSave: (ConnectionProfile) -> Void
|
||||||
@State private var password: String = ""
|
@State private var password: String = ""
|
||||||
@State private var savePassword: Bool = false
|
@State private var savePassword: Bool = false
|
||||||
|
@State private var storedPasswordPresent: Bool = false
|
||||||
|
|
||||||
init(profile: ConnectionProfile, onSave: @escaping (ConnectionProfile) -> Void) {
|
init(profile: ConnectionProfile, isNew: Bool, onSave: @escaping (ConnectionProfile) -> Void) {
|
||||||
_profile = State(initialValue: profile)
|
_profile = State(initialValue: profile)
|
||||||
|
self.isNew = isNew
|
||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text(profile.id.uuidString.prefix(8) == ConnectionProfile().id.uuidString.prefix(8) ? "Profil bearbeiten" : "Profil").font(.headline).padding()
|
Text(isNew ? "Neues Profil" : "Profil bearbeiten").font(.headline).padding()
|
||||||
Form {
|
Form {
|
||||||
Section("Allgemein") {
|
Section("Allgemein") {
|
||||||
TextField("Name", text: $profile.name)
|
TextField("Name", text: $profile.name)
|
||||||
@@ -44,8 +47,17 @@ struct ProfileEditorView: View {
|
|||||||
Text("Kein Key").tag(UUID?.none)
|
Text("Kein Key").tag(UUID?.none)
|
||||||
ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) }
|
ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) }
|
||||||
}
|
}
|
||||||
SecureField("Passwort (optional, Keychain)", text: $password)
|
if storedPasswordPresent && password.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
Text("Passwort in Keychain gespeichert")
|
||||||
|
Spacer()
|
||||||
|
Button("Löschen", role: .destructive) { deleteStoredPassword() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureField(storedPasswordPresent ? "Neues Passwort …" : "Passwort (optional, Keychain)", text: $password)
|
||||||
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
|
Toggle("Passwort in Keychain speichern", isOn: $savePassword)
|
||||||
|
Text(sshpassHint).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
@@ -68,12 +80,30 @@ struct ProfileEditorView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(width: 520, height: 620)
|
.frame(width: 520, height: 640)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
if let acct = profile.passwordAccount,
|
||||||
|
let stored = KeychainHelper.load(account: acct), !stored.isEmpty {
|
||||||
|
storedPasswordPresent = true
|
||||||
|
savePassword = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshpassHint: String {
|
||||||
|
if SSHService.findSshpass() != nil {
|
||||||
|
return "sshpass gefunden → Auto-Login mit Keychain-Passwort aktiv, kein Tippen nötig."
|
||||||
|
} else {
|
||||||
|
return "Ohne sshpass: Passwort wird beim Verbinden im Terminal abgefragt. Für Auto-Login: brew install sshpass."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteStoredPassword() {
|
||||||
if let acct = profile.passwordAccount {
|
if let acct = profile.passwordAccount {
|
||||||
password = KeychainHelper.load(account: acct) ?? ""
|
KeychainHelper.delete(account: acct)
|
||||||
savePassword = !password.isEmpty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
password = ""
|
||||||
|
savePassword = false
|
||||||
|
storedPasswordPresent = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+
|
|||||||
| 8 | 2026-09-21 | SSH-Session stirbt instant ohne Meldung | stderr hing mit am PTY: macOS verwirft ungelesene Master-Daten bei Slave-Close → „Code 255" ohne Text | blockierend | ✅ behoben — stderr→Pipe, TCP-Vorab-Check, Fehler-Banner |
|
| 8 | 2026-09-21 | SSH-Session stirbt instant ohne Meldung | stderr hing mit am PTY: macOS verwirft ungelesene Master-Daten bei Slave-Close → „Code 255" ohne Text | blockierend | ✅ behoben — stderr→Pipe, TCP-Vorab-Check, Fehler-Banner |
|
||||||
| 9 | 2026-09-21 | `swift build` | `Result<Void, String>`: String conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` |
|
| 9 | 2026-09-21 | `swift build` | `Result<Void, String>`: String conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` |
|
||||||
| 10 | 2026-09-21 | Icon-Generator (`RenderIcon.swift`) | 3 Swift-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben |
|
| 10 | 2026-09-21 | Icon-Generator (`RenderIcon.swift`) | 3 Swift-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben |
|
||||||
|
| 11 | 2026-09-21 | Passwort-Feature | Wunsch: Passwort + Keys speichern UND nutzen (bisher nur gespeichert, nie benutzt) | Feature | ✅ umgesetzt — Auto-Login via sshpass, sonst interaktiv |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,6 +117,16 @@ TEXT: "\n\n— Sitzung beendet (Code 255). —\n" # <- Fehlermeldung fehlt!
|
|||||||
**Fix:** `TCPCheck.check` nutzt `(String?) -> Void` (nil = ok, sonst deutsche Meldung).
|
**Fix:** `TCPCheck.check` nutzt `(String?) -> Void` (nil = ok, sonst deutsche Meldung).
|
||||||
**Status:** ✅ behoben.
|
**Status:** ✅ behoben.
|
||||||
|
|
||||||
|
## #11 — Passwort speichern UND benutzen (User-Wunsch)
|
||||||
|
**Stand:** Passwort/Key-Passphrase wurden in der Keychain gespeichert, aber nie zum Einloggen benutzt (ssh fragte immer interaktiv).
|
||||||
|
**Umsetzung:**
|
||||||
|
- `SSHService.loginSecret()` (Profil-Passwort → Key-Passphrase), `sessionConfig()` (rein, testbar): mit Secret + sshpass → `sshpass -e ssh/sftp …` + `SSHPASS`-Env (nie Befehlszeile/Logs); sonst direkter Aufruf + interaktiver Prompt.
|
||||||
|
- `PTYSession.customEnv` + `autoLogin`-Flag, `TerminalManager` verdrahtet alles; Status zeigt „Auto-Login via Keychain".
|
||||||
|
- Editor: Gespeichert-Anzeige (✓), Löschen-Button, sshpass-Hinweis; Titel-Bug (`UUID`-Vergleich mit neuem Random-Objekt) via `isNew` ersetzt.
|
||||||
|
**Verifikation:** Config-Harness 9/9 (inkl. „Passwort nie in Befehlszeile"); echter Keychain-Roundtrip 7/7 (save/load/delete + Priorität + Fallback).
|
||||||
|
**Hinweis:** Ohne `sshpass` (`brew install sshpass`) bleibt es beim interaktiven Prompt — hier auf dem Build-Mac ebenfalls nicht installiert, getestet wurde der interaktive Pfad + Logik.
|
||||||
|
**Status:** ✅ umgesetzt.
|
||||||
|
|
||||||
## #10 — Icon-Generator Buildfehler
|
## #10 — Icon-Generator Buildfehler
|
||||||
**Log:** `guard let` auf nicht-optionalem Ausdruck (`??` unwrapt schon); `Int * CGFloat`; Preview lag in `.iconset/` statt `/tmp/`.
|
**Log:** `guard let` auf nicht-optionalem Ausdruck (`??` unwrapt schon); `Int * CGFloat`; Preview lag in `.iconset/` statt `/tmp/`.
|
||||||
**Fix:** `??`-Fallback direkt zuweisen; `CGFloat(...)`; Pfad korrigiert.
|
**Fix:** `??`-Fallback direkt zuweisen; `CGFloat(...)`; Pfad korrigiert.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
- `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>` auf.
|
- `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_<timestamp>` auf.
|
||||||
- Passphrase → Keychain (`sshkey-<UUID>`).
|
- Passphrase → Keychain (`sshkey-<UUID>`).
|
||||||
|
|
||||||
|
## Passwort speichern & Auto-Login
|
||||||
|
- Im Profil-Editor: Passwort eingeben + „in Keychain speichern“. Gespeichertes Passwort wird angezeigt (✓) und kann gelöscht werden. In `profiles.json` steht nur die Referenz, nie das Passwort.
|
||||||
|
- Reihenfolge beim Verbinden: Profil-Passwort zuerst, sonst Key-Passphrase.
|
||||||
|
- **Auto-Login ohne Tippen:** braucht `sshpass` (`brew install sshpass`). Dann loggt ssh via Keychain-Secret ein (Passwort nur im Prozess-Env `SSHPASS`, nie in Befehlszeile/Logs). Status zeigt „Auto-Login via Keychain".
|
||||||
|
- **Ohne sshpass:** Passwort/Key-Passphrase wird interaktiv im Terminal abgefragt (Passwort-Modus: Auge-Icon).
|
||||||
|
|
||||||
## Quick-Connect
|
## Quick-Connect
|
||||||
- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.
|
- Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user