From 513e1a768f3512e2264f54efc6d51e886668682d Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Wed, 22 Jul 2026 12:58:38 +0200 Subject: [PATCH] =?UTF-8?q?Dopplungen=20konsolidiert:=20settings.json-Perm?= =?UTF-8?q?issions-Helfer,=20pool.json-Lesen=20=C3=BCber=20read=5Fpool,=20?= =?UTF-8?q?gemeinsame=20Helfer=20f=C3=BCr=20Pool-Anlage,=20Projekt-Config,?= =?UTF-8?q?=20Icon-=C3=9Cbernahme=20und=20Running-Guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands.rs | 19 ++-- src-tauri/src/domain/archive.rs | 70 +++++---------- src-tauri/src/domain/mod.rs | 54 ++++++++++++ src-tauri/src/domain/pool.rs | 150 ++++++++++++++------------------ src-tauri/src/domain/project.rs | 146 ++++++++++++------------------- 5 files changed, 208 insertions(+), 231 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ca3d813..8c63faa 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -61,13 +61,19 @@ pub(crate) fn add_project(path: String) -> Result<(), String> { add_project_in(&Paths::real(), &path) } +/// Umbau-Sperre: eine laufende Session des Projekts zuerst beenden. +fn ensure_not_running(project: &str) -> Result<(), String> { + if is_running(project) { + let name = display_name_in(&Paths::real(), project)?; + return Err(format!("{name} läuft noch — erst beenden")); + } + Ok(()) +} + /// Projektordner neu zuordnen; bei laufender Session gesperrt. #[tauri::command] pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> { - if is_running(&project) { - let name = display_name_in(&Paths::real(), &project)?; - return Err(format!("{name} läuft noch — erst beenden")); - } + ensure_not_running(&project)?; set_project_dir_in(&Paths::real(), &project, &dir) } @@ -96,10 +102,7 @@ pub(crate) fn delete_project_scoped( scope: String, delete_work_dirs: bool, ) -> Result<(), String> { - if is_running(&project) { - let name = display_name_in(&Paths::real(), &project)?; - return Err(format!("{name} läuft noch — erst beenden")); - } + ensure_not_running(&project)?; delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs) } diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index 77e3f61..c7101ad 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -60,39 +60,18 @@ pub(crate) fn add_archive_permission( dir: &str, ) -> Result<(), String> { let sp = settings_path(&project_dir(paths, project)?); - let mut v: serde_json::Value = if sp.is_file() { - serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) - .map_err(|e| format!("{}: {e}", sp.display()))? - } else { - serde_json::json!({}) - }; - let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; - let perms = root - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms - .entry("additionalDirectories") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - if !dirs.iter().any(|d| d.as_str() == Some(dir)) { - dirs.push(serde_json::json!(dir)); - } - let edit = format!("Edit({dir}/**)"); - let allow = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("allow ist kein Array")?; - if !allow.iter().any(|p| p.as_str() == Some(&edit)) { - allow.push(serde_json::json!(edit)); - } - let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; - fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - crate::domain::write_atomic(&sp, &(raw + "\n")) + crate::domain::update_settings_permissions(&sp, true, |perms| { + let dirs = crate::domain::perm_array(perms, "additionalDirectories")?; + if !dirs.iter().any(|d| d.as_str() == Some(dir)) { + dirs.push(serde_json::json!(dir)); + } + let edit = format!("Edit({dir}/**)"); + let allow = crate::domain::perm_array(perms, "allow")?; + if !allow.iter().any(|p| p.as_str() == Some(&edit)) { + allow.push(serde_json::json!(edit)); + } + Ok(()) + }) } /// Wechselt das Archiv-Home: neues Home setzen (validieren, anlegen, Rechte, @@ -186,21 +165,16 @@ pub(crate) fn remove_archive_permission( if !sp.is_file() { return Ok(()); } - let mut v: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) - .map_err(|e| format!("{}: {e}", sp.display()))?; - let Some(perms) = v.get_mut("permissions").and_then(|p| p.as_object_mut()) else { - return Ok(()); - }; - if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) { - dirs.retain(|d| d.as_str() != Some(dir)); - } - let edit = format!("Edit({dir}/**)"); - if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { - allow.retain(|p| p.as_str() != Some(&edit)); - } - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - crate::domain::write_atomic(&sp, &(raw + "\n")) + crate::domain::update_settings_permissions(&sp, false, |perms| { + if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) { + dirs.retain(|d| d.as_str() != Some(dir)); + } + let edit = format!("Edit({dir}/**)"); + if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { + allow.retain(|p| p.as_str() != Some(&edit)); + } + Ok(()) + }) } /// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder. diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index a78b586..497c6f5 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -38,6 +38,60 @@ pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(), }) } +/// Read-modify-write auf dem `permissions`-Objekt einer Claude-settings.json +/// (Projekt wie Pool). `create` behandelt eine fehlende Datei als leeres +/// Objekt — für Einträge, die die Datei erst anlegen; ohne `create` scheitert +/// das Lesen laut. Geschrieben wird nur, wenn `f` den Inhalt tatsächlich +/// verändert hat — die Datei gehört claude, unnötige Writes unterbleiben. +pub(crate) fn update_settings_permissions( + sp: &std::path::Path, + create: bool, + f: impl FnOnce(&mut serde_json::Map) -> Result<(), String>, +) -> Result<(), String> { + let mut v: serde_json::Value = if !sp.is_file() && create { + serde_json::json!({}) + } else { + let raw = std::fs::read_to_string(sp).map_err(|e| format!("{}: {e}", sp.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))? + }; + let before = v.clone(); + { + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let perms = root + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + f(perms)?; + } + // Ein hier erst angelegtes, leer gebliebenes permissions-Objekt wieder + // entfernen — sonst zählte ein No-op als Änderung. + if before.get("permissions").is_none() + && v["permissions"].as_object().is_some_and(|o| o.is_empty()) + { + v.as_object_mut().unwrap().remove("permissions"); + } + if v == before { + return Ok(()); + } + let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + write_atomic(sp, &(raw + "\n")) +} + +/// Array-Feld im permissions-Objekt, bei Bedarf angelegt. +pub(crate) fn perm_array<'a>( + perms: &'a mut serde_json::Map, + key: &str, +) -> Result<&'a mut Vec, String> { + perms + .entry(key) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or_else(|| format!("{key} ist kein Array")) +} + /// Namensprüfung für Projekte und Pool-Anzeigenamen. /// /// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien) diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 5b91879..f4aa8ad 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -86,33 +86,38 @@ pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result { serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) } +/// IDs aller Pool-Ordner: Einträge unter pools/ mit pool.json. Beim ersten +/// Pool existiert pools/ noch nicht — dann ist die Liste leer. +fn pool_ids(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| format!("{}: {e}", paths.pools_dir().display()))? + { + let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; + if entry.path().join(POOL_FILE).is_file() { + out.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(out) +} + pub(crate) fn list_pools_in( paths: &Paths, store: &dyn ApikeyStore, ) -> Result, String> { let mut pools = Vec::new(); - if !paths.pools_dir().is_dir() { - return Ok(pools); - } - let entries = - fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - for entry in entries { - let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - 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| format!("{}: {e}", cfg_path.display()))?; - let pool: Pool = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - let id = entry.file_name().to_string_lossy().into_owned(); + for id in pool_ids(paths)? { + let pool = read_pool(paths, &id)?; // 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" => { store.has(&id)? - || fs::read_to_string(entry.path().join(APIKEY_FILE)) + || fs::read_to_string(paths.pool_dir(&id).join(APIKEY_FILE)) .map(|s| !s.trim().is_empty()) .unwrap_or(false) } @@ -142,27 +147,12 @@ pub(crate) fn list_pools_in( Ok(pools) } -/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch -/// nicht — dann ist die Liste leer. +/// (ID, Anzeigename) aller Pools. pub(crate) 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| format!("{}: {e}", paths.pools_dir().display()))? - { - let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - 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| format!("{}: {e}", cfg_path.display()))?; - 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) + pool_ids(paths)? + .into_iter() + .map(|id| read_pool(paths, &id).map(|p| (id, p.name))) + .collect() } /// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den @@ -175,12 +165,37 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result { Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) } -fn write_pool_json(dir: &PathBuf, pool: &Pool) -> Result<(), String> { +fn write_pool_json(dir: &std::path::Path, pool: &Pool) -> Result<(), String> { fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?; crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n")) } +/// Gemeinsamer Abschluss der Pool-Anlage (apikey wie oauth): pool.json +/// schreiben, bei konfiguriertem poolSyncDir die Runtime verlinken; +/// liefert die Pool-ID. +fn finish_pool_create( + paths: &Paths, + dir: &std::path::Path, + name: &str, + credential_type: &str, +) -> Result { + write_pool_json( + dir, + &Pool { + name: name.to_string(), + credential_type: credential_type.into(), + dir: None, + rest: Default::default(), + }, + )?; + let id = dir.file_name().unwrap().to_string_lossy().into_owned(); + if pool_sync_dir(paths).is_some() { + link_pool_runtime_in(paths, &id)?; + } + Ok(id) +} + /// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json /// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als /// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. @@ -244,42 +259,20 @@ const STALE_PANEL_PERMISSIONS: [&str; 3] = /// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt /// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern. +/// Fehler (fehlende/kaputte Datei) bleiben bewusst still: das läuft beim +/// App-Start über alle Pools, auch referenzierte fremde Verzeichnisse. fn ensure_panel_permission(pool_dir: &std::path::Path) { let sp = pool_dir.join("settings.json"); - let Ok(raw) = fs::read_to_string(&sp) else { - return; - }; - let Ok(mut v) = serde_json::from_str::(&raw) else { - return; - }; - let Some(obj) = v.as_object_mut() else { return }; - let Some(perms) = obj - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - else { - return; - }; - let Some(allow) = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - else { - return; - }; - let before = allow.clone(); - allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); - for perm in PANEL_PERMISSIONS { - if !allow.iter().any(|e| e.as_str() == Some(perm)) { - allow.push(serde_json::json!(perm)); + let _ = crate::domain::update_settings_permissions(&sp, false, |perms| { + let allow = crate::domain::perm_array(perms, "allow")?; + allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); + for perm in PANEL_PERMISSIONS { + if !allow.iter().any(|e| e.as_str() == Some(perm)) { + allow.push(serde_json::json!(perm)); + } } - } - if *allow == before { - return; // nichts geändert - } - if let Ok(out) = serde_json::to_string_pretty(&v) { - let _ = crate::domain::write_atomic(&sp, &(out + "\n")); - } + Ok(()) + }); } /// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der @@ -423,11 +416,7 @@ pub(crate) fn create_apikey_pool_in( &dir, serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }), )?; - write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), dir: None, rest: Default::default() })?; - if pool_sync_dir(paths).is_some() { - link_pool_runtime_in(paths, &id)?; - } - Ok(id) + finish_pool_create(paths, &dir, name, "apikey") } /// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md) @@ -437,12 +426,7 @@ pub(crate) fn create_apikey_pool_in( pub(crate) 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, &Pool { name: name.to_string(), credential_type: "oauth".into(), dir: None, rest: Default::default() })?; - let id = dir.file_name().unwrap().to_string_lossy().into_owned(); - if pool_sync_dir(paths).is_some() { - link_pool_runtime_in(paths, &id)?; - } - Ok(id) + finish_pool_create(paths, &dir, name, "oauth") } /// Legt einen Pool an, der auf ein bestehendes Config-Verzeichnis verweist — @@ -524,9 +508,7 @@ pub(crate) fn delete_pool_in( name: &str, ) -> Result<(), String> { let dir = paths.pool_dir(name); - if !dir.join(POOL_FILE).is_file() { - return Err(format!("Pool nicht gefunden: {name}")); - } + // Fehlende pool.json scheitert hier — read_pool nennt den Pfad. if read_pool(paths, name)?.credential_type == "apikey" { store.delete(name)?; } diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index 5ea60f8..c7175f4 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -93,15 +93,18 @@ pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf { dir.join(".claude").join("settings.json") } -pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result { - Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE)) -} - pub(crate) fn read_project_config_in( paths: &Paths, project: &str, ) -> Result { - let cfg_path = project_config_path(paths, project)?; + read_config_at(&project_dir(paths, project)?) +} + +/// config.json unter `/.ai-control` lesen; ohne Datei der Default. +/// Für Fälle, in denen der Ordner vor der Registrierung steht (Import, +/// Migration) — sonst über `read_project_config_in`. +pub(crate) fn read_config_at(dir: &std::path::Path) -> Result { + let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE); if !cfg_path.is_file() { return Ok(ProjectConfig::default()); } @@ -384,13 +387,7 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> { .into_owned(); check_name(&dirname)?; - let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE); - let mut cfg: ProjectConfig = if cfg_path.is_file() { - let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))? - } else { - ProjectConfig::default() - }; + let mut cfg = read_config_at(&dir)?; if let Some(id) = cfg.id.as_deref() { if load_registry(paths)?.contains_key(id) { return Err(format!("Projekt ist schon registriert: {id}")); @@ -425,37 +422,16 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<() } let dir = contract_home(paths, &wd_path); let sp = settings_path(&project_dir(paths, name)?); - let mut v: serde_json::Value = if sp.is_file() { - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))? - } else { - serde_json::json!({}) - }; - let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; - let perms = root - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms - .entry("additionalDirectories") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - if dirs.iter().any(|d| d.as_str() == Some(&dir)) { - return Err(format!("schon eingetragen: {dir}")); - } - dirs.push(serde_json::json!(dir)); - let allow = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("allow ist kein Array")?; - allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); - let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; - fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - crate::domain::write_atomic(&sp, &(raw + "\n")) + crate::domain::update_settings_permissions(&sp, true, |perms| { + let dirs = crate::domain::perm_array(perms, "additionalDirectories")?; + if dirs.iter().any(|d| d.as_str() == Some(&dir)) { + return Err(format!("schon eingetragen: {dir}")); + } + dirs.push(serde_json::json!(dir)); + let allow = crate::domain::perm_array(perms, "allow")?; + allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); + Ok(()) + }) } /// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und @@ -463,25 +439,18 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<() pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { check_name(name)?; let sp = settings_path(&project_dir(paths, name)?); - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let mut v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - let perms = v["permissions"] - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms["additionalDirectories"] - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - let before = dirs.len(); - dirs.retain(|d| d.as_str() != Some(dir)); - if dirs.len() == before { - return Err(format!("nicht eingetragen: {dir}")); - } - if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { - allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); - } - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - crate::domain::write_atomic(&sp, &(raw + "\n")) + crate::domain::update_settings_permissions(&sp, false, |perms| { + let dirs = crate::domain::perm_array(perms, "additionalDirectories")?; + let before = dirs.len(); + dirs.retain(|d| d.as_str() != Some(dir)); + if dirs.len() == before { + return Err(format!("nicht eingetragen: {dir}")); + } + if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { + allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); + } + Ok(()) + }) } /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. @@ -653,20 +622,8 @@ pub(crate) fn set_terminal_config_in( // Dateiname speichern — das Icon synct damit mit dem Projekt. 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 cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR); - fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; - let dest = cfg_dir.join(&name); - if src != dest { - fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; - } - terminal.icon = Some(name); + terminal.icon = Some(adopt_icon(std::path::Path::new(icon), &cfg_dir)?); } } // Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder; @@ -678,6 +635,23 @@ pub(crate) fn set_terminal_config_in( Ok(()) } +/// Icon-Datei als `icon.` (kleingeschrieben, Default png) in den +/// Projekt-Config-Ordner kopieren; liefert den gespeicherten Dateinamen. +fn adopt_icon(src: &std::path::Path, cfg_dir: &std::path::Path) -> Result { + let ext = src + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + let name = format!("icon.{ext}"); + fs::create_dir_all(cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; + let dest = cfg_dir.join(&name); + if src != dest { + fs::copy(src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; + } + Ok(name) +} + /// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im /// Projekt-Config-Ordner (.ai-control) des Projekts. pub(crate) fn resolve_icon_path( @@ -771,13 +745,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> { // war der Anzeigename. let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR); let cfg_path = cfg_dir.join(PROJECT_FILE); - let mut cfg: ProjectConfig = if cfg_path.is_file() { - let raw = - fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))? - } else { - ProjectConfig::default() - }; + let mut cfg = read_config_at(&entry.dir)?; let mut cfg_dirty = cfg.id.is_none() || cfg.name.is_none(); let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone(); cfg.name.get_or_insert_with(|| key.clone()); @@ -789,16 +757,8 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> { if !icon.starts_with('/') { let old = paths.icons_dir().join(&icon); if old.is_file() { - let ext = std::path::Path::new(&icon) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("png") - .to_lowercase(); - let name = format!("icon.{ext}"); - fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; - fs::copy(&old, cfg_dir.join(&name)).map_err(|e| format!("{}: {e}", old.display()))?; + cfg.terminal.icon = Some(adopt_icon(&old, &cfg_dir)?); fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?; - cfg.terminal.icon = Some(name); cfg_dirty = true; } } @@ -826,6 +786,10 @@ mod tests { use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; use crate::domain::todo::TODO_FILE; + fn project_config_path(paths: &Paths, project: &str) -> Result { + Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE)) + } + /// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der /// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges /// Verzeichnis zum CLAUDE_CONFIG_DIR machen.