diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 72cbdd1..6d3d148 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -33,6 +33,7 @@ name = "ai-control" version = "0.1.0" dependencies = [ "base64 0.22.1", + "keyring", "log", "objc2", "objc2-app-kit", @@ -46,6 +47,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-log", + "uuid", ] [[package]] @@ -494,6 +496,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -517,7 +529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -530,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -661,6 +673,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1820,6 +1842,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2965,6 +3002,42 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3394,7 +3467,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5083,6 +5156,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26be606..d17a729 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,8 @@ tauri-plugin-dialog = "2" base64 = "0.22" sha2 = "0.10" portable-pty = "0.9" +uuid = { version = "1", features = ["v4"] } +keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63551b2..9d58e46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -505,13 +505,75 @@ fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { fn set_terminal_config_in( paths: &Paths, project: &str, - terminal: TerminalConfig, + mut terminal: TerminalConfig, ) -> Result<(), String> { + // Absolut gewählte Icons in den Projektordner kopieren und relativ + // speichern — so wird das Icon mit dem Projekt-Repo gesynct. + if let Some(icon) = terminal.icon.as_deref() { + if icon.starts_with('/') { + let src = PathBuf::from(icon); + let ext = src + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + let name = format!("icon.{ext}"); + let dest = paths.projects_dir().join(project).join(&name); + if src != dest { + fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; + } + terminal.icon = Some(name); + } + } let mut cfg = read_project_config_in(paths, project)?; cfg.terminal = terminal; write_project_config_in(paths, project, &cfg) } +/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner. +fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf { + if icon.starts_with('/') { + PathBuf::from(icon) + } else { + paths.projects_dir().join(project).join(icon) + } +} + +/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per +/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert. +#[tauri::command] +fn project_icon(project: String) -> Result, String> { + use base64::{engine::general_purpose::STANDARD, Engine}; + let paths = Paths::real(); + let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else { + return Ok(None); + }; + let path = resolve_icon_path(&paths, &project, &icon); + let is_icns = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("icns")); + let png = if is_icns { + let tmp = std::env::temp_dir().join(format!("ai-control-icon-{project}.png")); + let out = std::process::Command::new("sips") + .args(["-s", "format", "png"]) + .arg(&path) + .arg("--out") + .arg(&tmp) + .output() + .map_err(|e| format!("sips: {e}"))?; + if !out.status.success() { + return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr))); + } + let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?; + let _ = fs::remove_file(&tmp); + bytes + } else { + fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))? + }; + Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png)))) +} + /// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. pub(crate) fn terminal_config(project: &str) -> Result { Ok(read_project_config_in(&Paths::real(), project)?.terminal) @@ -539,8 +601,126 @@ fn projects_using_pool(paths: &Paths, pool: &str) -> Result, String> // ---------- Pools ---------- +/// Ablage der API-Keys: macOS-Keychain / Linux-Secret-Service (keyring-Crate). +/// Die Key-Datei im Pool-Ordner bleibt Fallback, wenn der Store beim Schreiben +/// nicht verfügbar ist. +trait ApikeyStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String>; + fn has(&self, pool: &str) -> Result; + fn delete(&self, pool: &str) -> Result<(), String>; +} + +/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die +/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest +/// mit denselben Attributen über secret-tool. +const APIKEY_SERVICE: &str = "ai-control-apikey"; + +/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch) +/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file. +const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable"; + +struct KeychainStore; + +/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in +/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim +/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das +/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen +/// durchs CLI prompten. +#[cfg(target_os = "macos")] +impl ApikeyStore for KeychainStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + let out = Command::new("security") + .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key]) + .output() + .map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).into_owned()) + } + } + + fn has(&self, pool: &str) -> Result { + // Ohne -w: nur Attribute, kein Secret — kein ACL-Prompt möglich. + let out = Command::new("security") + .args(["find-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) + .output() + .map_err(|e| e.to_string())?; + Ok(out.status.success()) + } + + fn delete(&self, pool: &str) -> Result<(), String> { + // Fehlender Eintrag ist kein Fehler — gelöscht ist gelöscht. + Command::new("security") + .args(["delete-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) + .output() + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +#[cfg(not(target_os = "macos"))] +impl KeychainStore { + fn entry(pool: &str) -> Result { + keyring::Entry::new(APIKEY_SERVICE, pool).map_err(|e| e.to_string()) + } +} + +#[cfg(not(target_os = "macos"))] +impl ApikeyStore for KeychainStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + Self::entry(pool)?.set_password(key).map_err(|e| e.to_string()) + } + + fn has(&self, pool: &str) -> Result { + match Self::entry(pool)?.get_password() { + Ok(_) => Ok(true), + // Kein Eintrag oder kein Store verfügbar → die Key-Datei entscheidet. + Err( + keyring::Error::NoEntry + | keyring::Error::PlatformFailure(_) + | keyring::Error::NoStorageAccess(_), + ) => Ok(false), + Err(e) => Err(e.to_string()), + } + } + + fn delete(&self, pool: &str) -> Result<(), String> { + match Self::entry(pool)?.delete_credential() { + Ok(()) + | Err( + keyring::Error::NoEntry + | keyring::Error::PlatformFailure(_) + | keyring::Error::NoStorageAccess(_), + ) => Ok(()), + Err(e) => Err(e.to_string()), + } + } +} + +/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem +/// Keychain/Keyring, bei fehlendem Eintrag aus der Key-Datei. +fn apikey_helper_command(dir: &std::path::Path, pool_id: &str) -> String { + let file = dir.join(APIKEY_FILE); + if cfg!(target_os = "macos") { + format!( + "security find-generic-password -w -s {APIKEY_SERVICE} -a {pool_id} 2>/dev/null || cat '{}'", + file.display() + ) + } else { + format!( + "secret-tool lookup service {APIKEY_SERVICE} username {pool_id} 2>/dev/null || cat '{}'", + file.display() + ) + } +} + #[derive(Serialize)] struct PoolInfo { + /// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der + /// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen. + id: String, + /// Anzeigename aus pool.json, frei umbenennbar. name: String, #[serde(rename = "credentialType")] credential_type: String, @@ -552,7 +732,7 @@ struct PoolInfo { has_credentials: bool, } -fn list_pools_in(paths: &Paths) -> Result, String> { +fn list_pools_in(paths: &Paths, store: &dyn ApikeyStore) -> Result, String> { let mut pools = Vec::new(); let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?; for entry in entries { @@ -564,17 +744,23 @@ fn list_pools_in(paths: &Paths) -> Result, String> { let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; let pool: Pool = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - // oauth: Credentials liegen im Keychain, dessen Prüfung wäre ein - // security-Aufruf pro Pool im 3-s-Polling — deshalb hier immer true. + let id = entry.file_name().to_string_lossy().into_owned(); + // oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen + // Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true. + // apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei. let has_credentials = match pool.credential_type.as_str() { - "apikey" => fs::read_to_string(entry.path().join(APIKEY_FILE)) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false), + "apikey" => { + store.has(&id)? + || fs::read_to_string(entry.path().join(APIKEY_FILE)) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + } _ => true, }; - let projects = projects_using_pool(paths, &pool.name)?; + let projects = projects_using_pool(paths, &id)?; let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); pools.push(PoolInfo { + id, projects, running, name: pool.name, @@ -586,13 +772,35 @@ fn list_pools_in(paths: &Paths) -> Result, String> { Ok(pools) } +/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch +/// nicht — dann ist die Liste leer. +fn pool_names(paths: &Paths) -> Result, String> { + let mut out = Vec::new(); + if !paths.pools_dir().is_dir() { + return Ok(out); + } + for entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let cfg_path = entry.path().join(POOL_FILE); + if !cfg_path.is_file() { + continue; + } + let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?; + let pool: Pool = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + out.push((entry.file_name().to_string_lossy().into_owned(), pool.name)); + } + Ok(out) +} + +/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den +/// Ordner für einen neuen Pool: pools/. fn check_new_pool(paths: &Paths, name: &str) -> Result { check_name(name)?; - let dir = paths.pool_dir(name); - if dir.exists() { + if pool_names(paths)?.iter().any(|(_, n)| n == name) { return Err(format!("Pool existiert bereits: {name}")); } - Ok(dir) + Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) } fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> { @@ -680,50 +888,91 @@ fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> { Ok(()) } -/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit -/// apiKeyHelper auf diese Datei, CLAUDE.md, pool.json. -fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> { +/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit +/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json. +/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht. +/// Liefert die Pool-ID. +fn create_apikey_pool_in( + paths: &Paths, + store: &dyn ApikeyStore, + name: &str, + key: &str, + allow_file: bool, +) -> Result { let dir = check_new_pool(paths, name)?; let key = key.trim(); if key.is_empty() { return Err("leerer API-Key".into()); } - let key_path = dir.join(APIKEY_FILE); - write_secret_file(&key_path, &format!("{key}\n"))?; + let id = dir.file_name().unwrap().to_string_lossy().into_owned(); + if store.set(&id, key).is_err() { + if !allow_file { + return Err(KEYCHAIN_UNAVAILABLE.into()); + } + write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?; + } init_pool_config( &dir, - serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()) }), + serde_json::json!({ "apiKeyHelper": apikey_helper_command(&dir, &id) }), )?; write_pool_json(&dir, name, "apikey")?; - link_pool_runtime_in(paths, name) + link_pool_runtime_in(paths, &id)?; + Ok(id) } /// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md) /// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools /// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens. -fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> { +/// Liefert die Pool-ID. +fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result { let dir = check_new_pool(paths, name)?; init_pool_config(&dir, serde_json::json!({}))?; write_pool_json(&dir, name, "oauth")?; - link_pool_runtime_in(paths, name) + let id = dir.file_name().unwrap().to_string_lossy().into_owned(); + link_pool_runtime_in(paths, &id)?; + Ok(id) } -/// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte -/// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den -/// Schutz gegen laufende Sessions setzt der delete_pool-Command davor. -fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> { +/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner +/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert. +fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> { + check_name(name)?; + let current = read_pool(paths, pool)?; + if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) { + return Err(format!("Pool existiert bereits: {name}")); + } + write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type) +} + +/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den +/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung +/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende +/// Sessions setzt der delete_pool-Command davor. +fn delete_pool_in(paths: &Paths, store: &dyn ApikeyStore, name: &str) -> Result<(), String> { let dir = paths.pool_dir(name); if !dir.join(POOL_FILE).is_file() { return Err(format!("Pool nicht gefunden: {name}")); } + if read_pool(paths, name)?.credential_type == "apikey" { + store.delete(name)?; + } for project in projects_using_pool(paths, name)? { unassign_pool_in(paths, &project)?; } fs::remove_dir_all(&dir).map_err(|e| e.to_string()) } -/// Schreibt den API-Key eines apikey-Pools neu (0600). -fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> { +/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die +/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern). +/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch +/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben. +fn set_apikey_in( + paths: &Paths, + store: &dyn ApikeyStore, + pool: &str, + key: &str, + allow_file: bool, +) -> Result<(), String> { let p = read_pool(paths, pool)?; if p.credential_type != "apikey" { return Err(format!("Pool {pool} ist kein apikey-Pool")); @@ -732,7 +981,26 @@ fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> { if key.is_empty() { return Err("leerer API-Key".into()); } - write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n")) + let dir = paths.pool_dir(pool); + let key_path = dir.join(APIKEY_FILE); + if store.set(pool, key).is_ok() { + if key_path.is_file() { + fs::remove_file(&key_path).map_err(|e| e.to_string())?; + } + } else { + if !allow_file { + return Err(KEYCHAIN_UNAVAILABLE.into()); + } + write_secret_file(&key_path, &format!("{key}\n"))?; + } + let settings_path = dir.join("settings.json"); + let raw = fs::read_to_string(&settings_path) + .map_err(|e| format!("{}: {e}", settings_path.display()))?; + let mut settings: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| format!("{}: {e}", settings_path.display()))?; + settings["apiKeyHelper"] = serde_json::json!(apikey_helper_command(&dir, pool)); + let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; + fs::write(&settings_path, raw + "\n").map_err(|e| e.to_string()) } fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> { @@ -886,7 +1154,13 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result, String> { for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? { let pool_entry = pool_entry.map_err(|e| e.to_string())?; - let pool = pool_entry.file_name().to_string_lossy().into_owned(); + let id = pool_entry.file_name().to_string_lossy().into_owned(); + // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen. + let pool = if pool_entry.path().join(POOL_FILE).is_file() { + read_pool(paths, &id)?.name + } else { + id + }; let projects_root = pool_entry.path().join("projects"); if !projects_root.is_dir() { continue; @@ -980,30 +1254,35 @@ fn project_work_dirs(project: String) -> Result, String> { #[tauri::command] fn list_pools() -> Result, String> { - list_pools_in(&Paths::real()) + list_pools_in(&Paths::real(), &KeychainStore) } #[tauri::command] -fn create_oauth_pool(name: String) -> Result<(), String> { +fn create_oauth_pool(name: String) -> Result { create_oauth_pool_in(&Paths::real(), &name) } #[tauri::command] -fn create_apikey_pool(name: String, key: String) -> Result<(), String> { - create_apikey_pool_in(&Paths::real(), &name, &key) +fn create_apikey_pool(name: String, key: String, allow_file: bool) -> Result { + create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file) } #[tauri::command] -fn delete_pool(name: String) -> Result<(), String> { +fn rename_pool(pool: String, name: String) -> Result<(), String> { + rename_pool_in(&Paths::real(), &pool, &name) +} + +#[tauri::command] +fn delete_pool(pool: String) -> Result<(), String> { let paths = Paths::real(); - let running = running_projects_using_pool(&paths, &name)?; + let running = running_projects_using_pool(&paths, &pool)?; if !running.is_empty() { return Err(format!( "Pool wird noch benutzt — läuft: {}", running.join(", ") )); } - delete_pool_in(&paths, &name) + delete_pool_in(&paths, &KeychainStore, &pool) } #[tauri::command] @@ -1032,8 +1311,8 @@ fn set_terminal_config( } #[tauri::command] -fn set_apikey(pool: String, key: String) -> Result<(), String> { - set_apikey_in(&Paths::real(), &pool, &key) +fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<(), String> { + set_apikey_in(&Paths::real(), &KeychainStore, &pool, &key, allow_file) } /// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen @@ -1159,7 +1438,12 @@ pub fn run() { let project = args.next().expect("--terminal braucht einen Projektnamen"); let icon = terminal_config(&project) .expect("Projekt-Config nicht lesbar") - .icon; + .icon + .map(|i| { + resolve_icon_path(&Paths::real(), &project, &i) + .to_string_lossy() + .into_owned() + }); terminal_builder(project) .build(context) .expect("error while building tauri application") @@ -1220,7 +1504,7 @@ fn main_builder() -> tauri::Builder { // include_bytes! statt default_window_icon: cargo trackt die Datei, // neu generierte Icons landen damit sicher im nächsten Build. .icon(tauri::image::Image::from_bytes(include_bytes!( - "../icons/128x128.png" + "../icons/trayTemplate.png" ))?) // Template-Icon: macOS färbt es passend zur Menüleiste ein. .icon_as_template(true) @@ -1253,10 +1537,12 @@ fn main_builder() -> tauri::Builder { list_pools, create_oauth_pool, create_apikey_pool, + rename_pool, delete_pool, assign_pool, unassign_pool, set_terminal_config, + project_icon, todo_state, set_todo, usage_stats, @@ -1319,8 +1605,49 @@ mod tests { Paths { home: dir } } - fn make_oauth_pool(p: &Paths, name: &str) { - create_oauth_pool_in(p, name).unwrap(); + /// In-Memory-ApikeyStore statt echtem Keychain. + struct MapStore(std::sync::Mutex>); + + fn map_store() -> MapStore { + MapStore(std::sync::Mutex::new(HashMap::new())) + } + + impl ApikeyStore for MapStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + self.0.lock().unwrap().insert(pool.into(), key.into()); + Ok(()) + } + fn has(&self, pool: &str) -> Result { + Ok(self.0.lock().unwrap().contains_key(pool)) + } + fn delete(&self, pool: &str) -> Result<(), String> { + self.0.lock().unwrap().remove(pool); + Ok(()) + } + } + + /// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts. + struct FailStore; + + impl ApikeyStore for FailStore { + fn set(&self, _pool: &str, _key: &str) -> Result<(), String> { + Err("kein Keychain".into()) + } + fn has(&self, _pool: &str) -> Result { + Ok(false) + } + fn delete(&self, _pool: &str) -> Result<(), String> { + Ok(()) + } + } + + /// Liefert die Pool-ID (UUID-Ordnername). + fn make_oauth_pool(p: &Paths, name: &str) -> String { + create_oauth_pool_in(p, name).unwrap() + } + + fn make_apikey_pool(p: &Paths, store: &dyn ApikeyStore, name: &str, key: &str) -> String { + create_apikey_pool_in(p, store, name, key, true).unwrap() } fn mode(path: &PathBuf) -> u32 { @@ -1332,32 +1659,95 @@ mod tests { #[test] fn apikey_pool_anlegen() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-test-123").unwrap(); - let dir = p.pool_dir("kunde"); + let store = map_store(); + let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123"); + // Ordnername ist eine UUID, der Anzeigename steht nur in pool.json. + assert!(uuid::Uuid::parse_str(&id).is_ok()); + let dir = p.pool_dir(&id); let pool: Pool = serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap(); assert_eq!(pool.name, "kunde"); assert_eq!(pool.credential_type, "apikey"); - let key_path = dir.join(APIKEY_FILE); - assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n"); - assert_eq!(mode(&key_path), 0o600); + // Key liegt im Store, keine Datei im Pool-Ordner. + assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123"); + assert!(!dir.join(APIKEY_FILE).exists()); let settings: serde_json::Value = serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) .unwrap(); assert_eq!( settings["apiKeyHelper"].as_str().unwrap(), - format!("cat '{}'", key_path.display()) + apikey_helper_command(&dir, &id) ); } + #[test] + fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123"); + let dir = p.pool_dir(&id); + + let key_path = dir.join(APIKEY_FILE); + assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n"); + assert_eq!(mode(&key_path), 0o600); + + // Der Helper ist dieselbe Kette — der Store-Teil findet nichts, + // cat liefert die Datei. + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["apiKeyHelper"].as_str().unwrap(), + apikey_helper_command(&dir, &id) + ); + } + + /// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts. + #[test] + fn apikey_anlegen_abbruch_ohne_store() { + let p = tmp_paths(); + let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err(); + assert_eq!(err, KEYCHAIN_UNAVAILABLE); + assert!(pool_names(&p).unwrap().is_empty()); + assert!(!p.pools_dir().exists()); + } + + /// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet. + #[test] + fn apikey_aendern_abbruch_ohne_store() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); + let dir = p.pool_dir(&id); + let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap(); + + let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err(); + assert_eq!(err, KEYCHAIN_UNAVAILABLE); + assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n"); + assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before); + } + + #[test] + fn apikey_helper_kette_referenz() { + let dir = PathBuf::from("/pools/abc"); + let cmd = apikey_helper_command(&dir, "abc"); + assert!(cmd.ends_with("|| cat '/pools/abc/apikey'")); + #[cfg(target_os = "macos")] + assert!(cmd.starts_with( + "security find-generic-password -w -s ai-control-apikey -a abc 2>/dev/null" + )); + #[cfg(not(target_os = "macos"))] + assert!(cmd.starts_with( + "secret-tool lookup service ai-control-apikey username abc 2>/dev/null" + )); + } + #[test] fn oauth_pool_anlegen() { let p = tmp_paths(); - make_oauth_pool(&p, "privat"); - let dir = p.pool_dir("privat"); + let id = make_oauth_pool(&p, "privat"); + let dir = p.pool_dir(&id); let pool: Pool = serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap(); @@ -1393,21 +1783,53 @@ mod tests { #[test] fn pool_doppelt_anlegen_scheitert() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); - let err = create_apikey_pool_in(&p, "kunde", "sk-2").unwrap_err(); + let store = map_store(); + make_apikey_pool(&p, &store, "kunde", "sk-1"); + let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err(); assert!(err.contains("existiert bereits")); } + // -- Pool umbenennen -- + + #[test] + fn pool_umbenennen() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project_in(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &id).unwrap(); + + rename_pool_in(&p, &id, "kunde-neu").unwrap(); + + // Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben. + let pool = read_pool(&p, &id).unwrap(); + assert_eq!(pool.name, "kunde-neu"); + assert_eq!(pool.credential_type, "apikey"); + assert!(p.pool_dir(&id).is_dir()); + let cfg = read_project_config_in(&p, "proj").unwrap(); + assert_eq!(cfg.pool.as_deref(), Some(id.as_str())); + } + + #[test] + fn pool_umbenennen_auf_vergebenen_namen_scheitert() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + make_oauth_pool(&p, "privat"); + let err = rename_pool_in(&p, &id, "privat").unwrap_err(); + assert!(err.contains("existiert bereits")); + // Umbenennen auf den eigenen Namen ist erlaubt (No-op). + rename_pool_in(&p, &id, "kunde").unwrap(); + } + #[test] fn pool_leerer_key_scheitert() { let p = tmp_paths(); - assert!(create_apikey_pool_in(&p, "kunde", " ").is_err()); + assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err()); } #[test] fn pool_name_mit_slash_scheitert() { let p = tmp_paths(); - assert!(create_apikey_pool_in(&p, "a/b", "sk-1").is_err()); + assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err()); } // -- Pool ändern -- @@ -1415,9 +1837,44 @@ mod tests { #[test] fn apikey_aendern() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-alt").unwrap(); - set_apikey_in(&p, "kunde", "sk-neu").unwrap(); - let key_path = p.pool_dir("kunde").join(APIKEY_FILE); + let store = map_store(); + let id = make_apikey_pool(&p, &store, "kunde", "sk-alt"); + set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); + assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); + assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists()); + } + + /// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage): + /// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden. + #[test] + fn apikey_aendern_migriert_datei_in_store() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); + let dir = p.pool_dir(&id); + // Bestand simulieren: Helper wie vor der Keychain-Ablage. + let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) }); + fs::write(dir.join("settings.json"), old.to_string()).unwrap(); + + let store = map_store(); + set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); + + assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); + assert!(!dir.join(APIKEY_FILE).exists()); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["apiKeyHelper"].as_str().unwrap(), + apikey_helper_command(&dir, &id) + ); + } + + #[test] + fn apikey_aendern_ohne_store_schreibt_datei() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); + set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap(); + let key_path = p.pool_dir(&id).join(APIKEY_FILE); assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n"); assert_eq!(mode(&key_path), 0o600); } @@ -1425,17 +1882,17 @@ mod tests { #[test] fn apikey_aendern_auf_oauth_pool_scheitert() { let p = tmp_paths(); - make_oauth_pool(&p, "privat"); - assert!(set_apikey_in(&p, "privat", "sk-1").is_err()); + let id = make_oauth_pool(&p, "privat"); + assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err()); } #[test] fn ensure_oauth_pool_auf_apikey_scheitert() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); - assert!(ensure_oauth_pool(&p, "kunde").is_err()); - make_oauth_pool(&p, "privat"); - assert!(ensure_oauth_pool(&p, "privat").is_ok()); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + assert!(ensure_oauth_pool(&p, &kunde).is_err()); + let privat = make_oauth_pool(&p, "privat"); + assert!(ensure_oauth_pool(&p, &privat).is_ok()); } // -- Pool löschen -- @@ -1443,9 +1900,12 @@ mod tests { #[test] fn pool_loeschen() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); - delete_pool_in(&p, "kunde").unwrap(); - assert!(!p.pool_dir("kunde").exists()); + let store = map_store(); + let id = make_apikey_pool(&p, &store, "kunde", "sk-1"); + delete_pool_in(&p, &store, &id).unwrap(); + assert!(!p.pool_dir(&id).exists()); + // Der Keychain-Eintrag geht mit. + assert!(!store.has(&id).unwrap()); } // -- Projekt-Wizard -- @@ -1453,11 +1913,11 @@ mod tests { #[test] fn projekt_wizard_scaffold() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); create_project_full_in( &p, "neu", - Some("kunde"), + Some(&kunde), Some("~/projects/neu"), true, TerminalConfig { @@ -1478,7 +1938,7 @@ mod tests { assert!(p.home.join("projects").join("neu").is_dir()); let cfg = read_project_config_in(&p, "neu").unwrap(); - assert_eq!(cfg.pool.as_deref(), Some("kunde")); + assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); @@ -1608,12 +2068,12 @@ mod tests { #[test] fn verbrauch_aggregation_dedup_und_zeitfilter() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); let proj_path = p.projects_dir().join("proj"); let encoded = encode_project_path(&proj_path.to_string_lossy()); - let dir = p.pool_dir("kunde").join("projects").join(&encoded); + let dir = p.pool_dir(&kunde).join("projects").join(&encoded); fs::create_dir_all(&dir).unwrap(); let lines = [ @@ -1646,7 +2106,7 @@ mod tests { #[test] fn verbrauch_leer_ohne_transcripts() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); assert!(usage_stats_in(&p, 30).unwrap().is_empty()); } @@ -1671,49 +2131,65 @@ mod tests { #[test] fn pool_key_status() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + // Datei-Pool (ohne Store angelegt): Datei entscheidet. + let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1"); make_oauth_pool(&p, "privat"); - let pools = list_pools_in(&p).unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); // oauth: Keychain wird im Listing bewusst nicht geprüft → immer true. assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials); - fs::write(p.pool_dir("kunde").join(APIKEY_FILE), "\n").unwrap(); - let pools = list_pools_in(&p).unwrap(); + fs::write(p.pool_dir(&kunde).join(APIKEY_FILE), "\n").unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - fs::remove_file(p.pool_dir("kunde").join(APIKEY_FILE)).unwrap(); - let pools = list_pools_in(&p).unwrap(); + fs::remove_file(p.pool_dir(&kunde).join(APIKEY_FILE)).unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); + assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + } + + /// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei. + #[test] + fn pool_key_status_aus_store() { + let p = tmp_paths(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); + assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists()); + let pools = list_pools_in(&p, &store).unwrap(); + assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + store.delete(&kunde).unwrap(); + let pools = list_pools_in(&p, &store).unwrap(); assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); } #[test] fn pool_loeschen_loest_zuordnungen() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", "kunde").unwrap(); - delete_pool_in(&p, "kunde").unwrap(); - assert!(!p.pool_dir("kunde").exists()); + assign_pool_in(&p, "proj", &kunde).unwrap(); + delete_pool_in(&p, &store, &kunde).unwrap(); + assert!(!p.pool_dir(&kunde).exists()); assert!(!p.project_config("proj").exists()); } #[test] fn pool_anlegen_verlinkt_synced_runtime() { let p = tmp_paths(); - create_oauth_pool_in(&p, "privat").unwrap(); - let pooldir = p.pool_dir("privat"); + let id = make_oauth_pool(&p, "privat"); + let pooldir = p.pool_dir(&id); for (name, is_dir) in SYNCED_RUNTIME { let link = pooldir.join(name); assert!(link.is_symlink(), "{name} sollte Symlink sein"); - assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, "privat").join(name)); - let target = pool_data_dir(&p, "privat").join(name); + assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).join(name)); + let target = pool_data_dir(&p, &id).join(name); assert_eq!(target.is_dir(), is_dir); assert!(target.exists()); } - // Zieldaten liegen im claude-projects-Repo unter pool// - assert!(p.projects_dir().join("pool").join("privat").is_dir()); + // Zieldaten liegen im claude-projects-Repo unter pool// + assert!(p.projects_dir().join("pool").join(&id).is_dir()); } #[test] @@ -1729,16 +2205,17 @@ mod tests { #[test] fn pool_loeschen_erhaelt_terminal_config() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", "kunde").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); set_terminal_config_in( &p, "proj", TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, ) .unwrap(); - delete_pool_in(&p, "kunde").unwrap(); + delete_pool_in(&p, &store, &kunde).unwrap(); let cfg = read_project_config_in(&p, "proj").unwrap(); assert_eq!(cfg.pool, None); assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); @@ -1747,7 +2224,7 @@ mod tests { #[test] fn pool_loeschen_unbekannt_scheitert() { let p = tmp_paths(); - assert!(delete_pool_in(&p, "gibtsnicht").is_err()); + assert!(delete_pool_in(&p, &map_store(), "gibtsnicht").is_err()); } // -- Projekt zuordnen / rausnehmen / wechseln -- @@ -1755,16 +2232,16 @@ mod tests { #[test] fn projekt_zuordnen() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", "kunde").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); let cfg: ProjectConfig = serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap()) .unwrap(); - assert_eq!(cfg.pool.as_deref(), Some("kunde")); + assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); - let pools = list_pools_in(&p).unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); assert_eq!(pools[0].projects, vec!["proj"]); } @@ -1778,9 +2255,9 @@ mod tests { #[test] fn projekt_rausnehmen() { let p = tmp_paths(); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", "kunde").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); unassign_pool_in(&p, "proj").unwrap(); assert!(!p.project_config("proj").exists()); } @@ -1797,16 +2274,16 @@ mod tests { #[test] fn pool_wechseln_typ_ist_projekt_egal() { let p = tmp_paths(); - make_oauth_pool(&p, "privat"); - create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); + let privat = make_oauth_pool(&p, "privat"); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", "privat").unwrap(); - assign_pool_in(&p, "proj", "kunde").unwrap(); + assign_pool_in(&p, "proj", &privat).unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); let raw = fs::read_to_string(p.project_config("proj")).unwrap(); let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap(); - assert_eq!(cfg, serde_json::json!({ "pool": "kunde" })); + assert_eq!(cfg, serde_json::json!({ "pool": kunde })); } // -- Projekt anlegen / löschen -- diff --git a/src/components/PoolList.vue b/src/components/PoolList.vue index 18033fe..5e46407 100644 --- a/src/components/PoolList.vue +++ b/src/components/PoolList.vue @@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n"; const { t } = useI18n(); interface Pool { + id: string; name: string; credentialType: string; projects: string[]; @@ -21,6 +22,8 @@ type Mode = "oauth" | "apikey"; const dialog = ref<{ mode: Mode; editing: boolean } | null>(null); const dName = ref(""); const dKey = ref(""); +// Ziel-Pool (ID) beim Key-Ändern; bei Neuanlage leer. +const dPool = ref(""); const dialogTitle = computed(() => { if (!dialog.value) return ""; @@ -32,13 +35,15 @@ const dialogTitle = computed(() => { function openNew(mode: Mode) { dName.value = ""; dKey.value = ""; + dPool.value = ""; error.value = ""; dialog.value = { mode, editing: false }; } -function openEditKey(pool: string) { - dName.value = pool; +function openEditKey(pool: Pool) { + dName.value = pool.name; dKey.value = ""; + dPool.value = pool.id; error.value = ""; dialog.value = { mode: "apikey", editing: true }; } @@ -57,7 +62,11 @@ async function refresh() { } } -async function submit() { +// Zweistufig: erster Versuch ohne Datei-Erlaubnis; meldet das Backend +// keychain-unavailable, fragt ein Dialog nach und wiederholt mit allowFile. +const fileConfirm = ref(false); + +async function submit(allowFile = false) { const d = dialog.value; if (!d) return; busy.value = true; @@ -66,12 +75,44 @@ async function submit() { if (d.mode === "oauth") { await invoke("create_oauth_pool", { name: dName.value }); } else if (d.editing) { - await invoke("set_apikey", { pool: dName.value, key: dKey.value }); + await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile }); } else { - await invoke("create_apikey_pool", { name: dName.value, key: dKey.value }); + await invoke("create_apikey_pool", { name: dName.value, key: dKey.value, allowFile }); } dialog.value = null; await refresh(); + } catch (e) { + if (String(e) === "keychain-unavailable") { + fileConfirm.value = true; + } else { + error.value = String(e); + } + } finally { + busy.value = false; + } +} + +async function confirmFileFallback() { + fileConfirm.value = false; + await submit(true); +} + +const deletePool = ref(null); + +function askDelete(pool: Pool) { + error.value = ""; + deletePool.value = pool; +} + +async function confirmDelete() { + const pool = deletePool.value; + if (!pool) return; + busy.value = true; + error.value = ""; + try { + await invoke("delete_pool", { pool: pool.id }); + deletePool.value = null; + await refresh(); } catch (e) { error.value = String(e); } finally { @@ -79,25 +120,23 @@ async function submit() { } } -const deleteName = ref(null); +const renamePool = ref(null); +const rName = ref(""); -const deleteProjects = computed( - () => pools.value.find((p) => p.name === deleteName.value)?.projects ?? [], -); - -function askDelete(pool: string) { +function openRename(pool: Pool) { error.value = ""; - deleteName.value = pool; + rName.value = pool.name; + renamePool.value = pool; } -async function confirmDelete() { - const name = deleteName.value; - if (!name) return; +async function confirmRename() { + const pool = renamePool.value; + if (!pool) return; busy.value = true; error.value = ""; try { - await invoke("delete_pool", { name }); - deleteName.value = null; + await invoke("rename_pool", { pool: pool.id, name: rName.value }); + renamePool.value = null; await refresh(); } catch (e) { error.value = String(e); @@ -112,17 +151,17 @@ interface Project { running: boolean; } -const reloginPool = ref(null); +const reloginPool = ref(null); const reloginHasEntry = ref(false); const reloginRunning = ref([]); -async function askRelogin(pool: string) { +async function askRelogin(pool: Pool) { error.value = ""; try { - reloginHasEntry.value = await invoke("keychain_status", { pool }); + reloginHasEntry.value = await invoke("keychain_status", { pool: pool.id }); const projects = await invoke("list_projects"); reloginRunning.value = projects - .filter((p) => p.pool === pool && p.running) + .filter((p) => p.pool === pool.id && p.running) .map((p) => p.name); reloginPool.value = pool; } catch (e) { @@ -136,7 +175,7 @@ async function confirmRelogin() { busy.value = true; error.value = ""; try { - await invoke("oauth_login", { pool }); + await invoke("oauth_login", { pool: pool.id }); reloginPool.value = null; await refresh(); } catch (e) { @@ -178,7 +217,7 @@ onMounted(refresh); - + {{ p.name }} @@ -197,13 +236,16 @@ onMounted(refresh); + @@ -211,7 +253,7 @@ onMounted(refresh); @@ -229,12 +271,12 @@ onMounted(refresh);

{{ $t("pools.empty") }}

-
+

{{ dialogTitle }}