<system-reminder>[Truncated: PARTIAL view — showing lines 1-1284 of 3148 total (52077 tokens, cap 25000). Call Read with offset=1285 limit=1284 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	mod terminal;
2
3	use serde::{Deserialize, Serialize};
4	use sha2::{Digest, Sha256};
5	use std::collections::{HashMap, HashSet};
6	use std::fs;
7	use std::io::{BufRead, BufReader};
8	use std::os::unix::fs::PermissionsExt;
9	use std::path::PathBuf;
10	use std::process::Command;
11	use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13	/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
14	/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
15	/// die App speichert keine Tokens.
16	const APIKEY_FILE: &str = "apikey";
17	const POOL_FILE: &str = "pool.json";
18	const PROJECT_FILE: &str = "ai-control.json";
19	const PROJECTS_FILE: &str = "projects.json";
20
21	/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
22	pub(crate) struct Paths {
23	  home: PathBuf,
24	}
25
26	impl Paths {
27	  pub(crate) fn real() -> Self {
28	    Paths {
29	      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
30	    }
31	  }
32
33	  /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
34	  /// Projekte ohne gewählten Zielordner.
35	  fn projects_dir(&self) -> PathBuf {
36	    self.home.join("claude-projects")
37	  }
38
39	  fn config_dir(&self) -> PathBuf {
40	    self.home.join(".config").join("ai-control")
41	  }
42
43	  fn projects_file(&self) -> PathBuf {
44	    self.config_dir().join(PROJECTS_FILE)
45	  }
46
47	  fn pools_dir(&self) -> PathBuf {
48	    self.config_dir().join("pools")
49	  }
50
51	  /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
52	  /// unabhängig von Pool-Zuordnung und Quell-Repos.
53	  fn icons_dir(&self) -> PathBuf {
54	    self.config_dir().join("icons")
55	  }
56
57	  fn pool_dir(&self, pool: &str) -> PathBuf {
58	    self.pools_dir().join(pool)
59	  }
60	}
61
62	// ---------- Projekt-Registry ----------
63
64	/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
65	/// Home-Bereich bleiben damit maschinenübergreifend stabil.
66	fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
67	  match p.strip_prefix(&paths.home) {
68	    Ok(rest) => format!("~/{}", rest.display()),
69	    Err(_) => p.display().to_string(),
70	  }
71	}
72
73	/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
74	fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
75	  let file = paths.projects_file();
76	  if !file.is_file() {
77	    return Ok(std::collections::BTreeMap::new());
78	  }
79	  let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
80	  let map: std::collections::BTreeMap<String, String> =
81	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
82	  Ok(
83	    map
84	      .into_iter()
85	      .map(|(name, p)| {
86	        let dir = expand_home(paths, &p);
87	        (name, dir)
88	      })
89	      .collect(),
90	  )
91	}
92
93	fn save_registry(
94	  paths: &Paths,
95	  reg: &std::collections::BTreeMap<String, PathBuf>,
96	) -> Result<(), String> {
97	  let contracted: std::collections::BTreeMap<&String, String> =
98	    reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
99	  let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
100	  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
101	  let file = paths.projects_file();
102	  fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
103	}
104
105	/// Ordner eines registrierten Projekts.
106	pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
107	  load_registry(paths)?
108	    .remove(name)
109	    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
110	}
111
112	/// Nimmt ein Projekt in die Registry auf.
113	fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
114	  let mut reg = load_registry(paths)?;
115	  if reg.contains_key(name) {
116	    return Err(format!("Projekt existiert bereits: {name}"));
117	  }
118	  reg.insert(name.to_string(), dir.to_path_buf());
119	  save_registry(paths, &reg)
120	}
121
122	/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
123	fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
124	  let mut reg = load_registry(paths)?;
125	  if reg.remove(name).is_none() {
126	    return Err(format!("Projekt nicht registriert: {name}"));
127	  }
128	  save_registry(paths, &reg)
129	}
130
131	fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
132	  Ok(project_dir(paths, project)?.join(PROJECT_FILE))
133	}
134
135	#[derive(Serialize)]
136	struct Project {
137	  name: String,
138	  path: String,
139	  pool: Option<String>,
140	  running: bool,
141	  terminal: TerminalConfig,
142	}
143
144	#[derive(Serialize, Deserialize)]
145	struct Pool {
146	  name: String,
147	  #[serde(rename = "credentialType")]
148	  credential_type: String,
149	}
150
151	#[derive(Serialize, Deserialize, Default)]
152	struct ProjectConfig {
153	  #[serde(default, skip_serializing_if = "Option::is_none")]
154	  pool: Option<String>,
155	  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
156	  terminal: TerminalConfig,
157	}
158
159	#[derive(Serialize, Deserialize, Default, Clone)]
160	pub struct TerminalConfig {
161	  #[serde(default, skip_serializing_if = "Option::is_none")]
162	  pub theme: Option<String>,
163	  #[serde(default, skip_serializing_if = "Option::is_none")]
164	  pub icon: Option<String>,
165	  #[serde(default, skip_serializing_if = "Option::is_none")]
166	  pub title: Option<String>,
167	}
168
169	impl TerminalConfig {
170	  fn is_empty(&self) -> bool {
171	    self.theme.is_none() && self.icon.is_none() && self.title.is_none()
172	  }
173	}
174
175	fn is_running(project: &str) -> bool {
176	  !terminal_pids(project).is_empty()
177	}
178
179	/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
180	/// Lauf-Erkennung wie die Projektliste (`is_running`).
181	fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
182	  Ok(projects_using_pool(paths, pool)?
183	    .into_iter()
184	    .filter(|p| is_running(p))
185	    .collect())
186	}
187
188	/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
189	fn terminal_pids(project: &str) -> Vec<u32> {
190	  let out = Command::new("pgrep")
191	    .args(["-f", "--", &format!("--terminal {project}$")])
192	    .output();
193	  match out {
194	    Ok(o) => String::from_utf8_lossy(&o.stdout)
195	      .lines()
196	      .filter_map(|l| l.trim().parse().ok())
197	      .collect(),
198	    Err(_) => Vec::new(),
199	  }
200	}
201
202	/// Tick-Intervall des Session-Watchers.
203	const WATCH_INTERVAL: Duration = Duration::from_secs(5);
204
205	/// App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen).
206	const APP_SETTINGS_FILE: &str = "settings.json";
207
208	/// Opt-in: synct der Watcher bei Session-Ende? Default aus.
209	fn sync_on_session_end(paths: &Paths) -> bool {
210	  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
211	    .ok()
212	    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
213	    .and_then(|v| v["syncOnSessionEnd"].as_bool())
214	    .unwrap_or(false)
215	}
216
217	/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
218	pub(crate) fn claude_command(paths: &Paths) -> String {
219	  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
220	    .ok()
221	    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
222	    .and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
223	    .unwrap_or_else(|| "claude".into())
224	}
225
226	/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
227	/// CLAUDE_CONFIG_DIR mitgegeben.
228	pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
229	  let paths = Paths::real();
230	  Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
231	}
232
233	/// Setzt das Opt-in; erhält übrige App-settings.
234	fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
235	  let path = paths.config_dir().join(APP_SETTINGS_FILE);
236	  let mut v: serde_json::Value = fs::read_to_string(&path)
237	    .ok()
238	    .and_then(|s| serde_json::from_str(&s).ok())
239	    .unwrap_or_else(|| serde_json::json!({}));
240	  v["syncOnSessionEnd"] = serde_json::json!(enabled);
241	  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
242	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
243	  fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
244	}
245
246	/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
247	fn git_root(dir: &std::path::Path) -> Option<PathBuf> {
248	  dir
249	    .ancestors()
250	    .find(|a| a.join(".git").exists())
251	    .map(|a| a.to_path_buf())
252	}
253
254	/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
255	/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
256	fn project_state(paths: &Paths) -> Vec<(String, bool)> {
257	  let mut state: Vec<(String, bool)> = list_projects_in(paths)
258	    .map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
259	    .unwrap_or_default();
260	  state.sort();
261	  state
262	}
263
264	/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
265	/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
266	/// jeder andere Fehler bricht die Kette mit Meldung ab.
267	fn sync_session_context(repo: &std::path::Path, project: &str) {
268	  let run = |args: &[&str]| {
269	    Command::new("git")
270	      .arg("-C")
271	      .arg(repo)
272	      .args(args)
273	      .output()
274	      .map_err(|e| format!("git {}: {e}", args.join(" ")))
275	  };
276	  let msg = format!("session-end sync: {project}");
277	  let steps: [&[&str]; 4] = [
278	    &["add", "-A"],
279	    &["commit", "-m", &msg],
280	    &["pull", "--rebase"],
281	    &["push"],
282	  ];
283	  for (i, args) in steps.iter().enumerate() {
284	    match run(args) {
285	      Ok(o) if o.status.success() => {}
286	      // commit ohne Änderungen: nichts zu syncen, kein Fehler.
287	      Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
288	        return;
289	      }
290	      Ok(o) => {
291	        eprintln!(
292	          "session-end sync {project}: git {} — {}",
293	          args.join(" "),
294	          String::from_utf8_lossy(&o.stderr).trim()
295	        );
296	        return;
297	      }
298	      Err(e) => {
299	        eprintln!("session-end sync {project}: {e}");
300	        return;
301	      }
302	    }
303	  }
304	}
305
306	/// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines
307	/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
308	/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
309	/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
310	/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.
311	fn spawn_session_watcher(app: tauri::AppHandle) {
312	  std::thread::spawn(move || {
313	    let paths = Paths::real();
314	    let mut state = project_state(&paths);
315	    loop {
316	      std::thread::sleep(WATCH_INTERVAL);
317	      let current = project_state(&paths);
318	      if current == state {
319	        continue;
320	      }
321	      let ended: Vec<&String> = state
322	        .iter()
323	        .filter(|(name, running)| {
324	          *running && !current.iter().any(|(n, r)| n == name && *r)
325	        })
326	        .map(|(name, _)| name)
327	        .collect();
328	      // Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
329	      // der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
330	      if !ended.is_empty() && sync_on_session_end(&paths) {
331	        for project in ended {
332	          if let Ok(dir) = project_dir(&paths, project) {
333	            if let Some(repo) = git_root(&dir) {
334	              sync_session_context(&repo, project);
335	            }
336	          }
337	        }
338	      }
339	      // Menü-Objekte gehören auf den Main-Thread.
340	      let handle = app.clone();
341	      app
342	        .run_on_main_thread(move || {
343	          // Nur wenn ein Tray existiert (macOS). Unter Linux pollt das Popup selbst.
344	          if let Some(tray) = handle.tray_by_id("main") {
345	            if let Ok(menu) = tray_menu(&handle) {
346	              let _ = tray.set_menu(Some(menu));
347	            }
348	          }
349	        })
350	        .expect("Main-Thread nicht erreichbar");
351	      state = current;
352	    }
353	  });
354	}
355
356	fn check_name(name: &str) -> Result<(), String> {
357	  if name.trim().is_empty() || name.contains('/') || name.contains("..") {
358	    return Err(format!("ungültiger Name: {name}"));
359	  }
360	  Ok(())
361	}
362
363	fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
364	  let cfg_path = paths.pool_dir(pool).join(POOL_FILE);
365	  let raw = fs::read_to_string(&cfg_path)
366	    .map_err(|e| format!("{}: {e}", cfg_path.display()))?;
367	  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
368	}
369
370	// ---------- Projekte ----------
371
372	fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
373	  let mut projects = Vec::new();
374	  for (name, dir) in load_registry(paths)? {
375	    let cfg = read_project_config_in(paths, &name)?;
376	    projects.push(Project {
377	      running: is_running(&name),
378	      path: contract_home(paths, &dir),
379	      pool: cfg.pool,
380	      terminal: cfg.terminal,
381	      name,
382	    });
383	  }
384	  Ok(projects)
385	}
386
387	// ---------- Feature: Todoliste ----------
388
389	/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
390	/// als additionalContext in jede Session injiziert.
391	const TODO_FILE: &str = "OFFENE-PUNKTE.md";
392	const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
393
394	fn todo_hook_command(dir: &std::path::Path) -> String {
395	  format!(
396	    "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
397	    dir.join(TODO_FILE).display()
398	  )
399	}
400
401	fn settings_path(dir: &std::path::Path) -> PathBuf {
402	  dir.join(".claude").join("settings.json")
403	}
404
405	fn hook_is_todo(group: &serde_json::Value) -> bool {
406	  group["hooks"]
407	    .as_array()
408	    .map(|hs| {
409	      hs.iter().any(|h| {
410	        h["command"]
411	          .as_str()
412	          .map_or(false, |c| c.contains(TODO_FILE))
413	      })
414	    })
415	    .unwrap_or(false)
416	}
417
418	fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
419	  check_name(name)?;
420	  let sp = settings_path(&project_dir(paths, name)?);
421	  if !sp.is_file() {
422	    return Ok(false);
423	  }
424	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
425	  let v: serde_json::Value =
426	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
427	  Ok(
428	    v["hooks"]["SessionStart"]
429	      .as_array()
430	      .map(|a| a.iter().any(hook_is_todo))
431	      .unwrap_or(false),
432	  )
433	}
434
435	fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
436	  check_name(name)?;
437	  let dir = project_dir(paths, name)?;
438	  let sp = settings_path(&dir);
439	  if !sp.is_file() {
440	    return Err(format!("settings.json fehlt: {}", sp.display()));
441	  }
442	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
443	  let mut v: serde_json::Value =
444	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
445
446	  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
447	  let hooks = root
448	    .entry("hooks")
449	    .or_insert_with(|| serde_json::json!({}))
450	    .as_object_mut()
451	    .ok_or("hooks ist kein Objekt")?;
452	  let session_start = hooks
453	    .entry("SessionStart")
454	    .or_insert_with(|| serde_json::json!([]))
455	    .as_array_mut()
456	    .ok_or("SessionStart ist kein Array")?;
457
458	  session_start.retain(|g| !hook_is_todo(g));
459	  if enabled {
460	    session_start.insert(
461	      0,
462	      serde_json::json!({
463	        "hooks": [
464	          { "type": "command", "command": todo_hook_command(&dir) }
465	        ]
466	      }),
467	    );
468	    let todo_path = dir.join(TODO_FILE);
469	    if !todo_path.is_file() {
470	      fs::write(&todo_path, TODO_SKELETON)
471	        .map_err(|e| format!("{}: {e}", todo_path.display()))?;
472	    }
473	  }
474
475	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
476	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
477	}
478
479	/// "~/x" relativ zum Home auflösen; alles andere unverändert.
480	fn expand_home(paths: &Paths, p: &str) -> PathBuf {
481	  match p.strip_prefix("~/") {
482	    Some(rest) => paths.home.join(rest),
483	    None => PathBuf::from(p),
484	  }
485	}
486
487	/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
488	/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
489	/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
490	/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
491	fn create_project_full_in(
492	  paths: &Paths,
493	  name: &str,
494	  dir: Option<&str>,
495	  pool: Option<&str>,
496	  work_dir: Option<&str>,
497	  create_work_dir: bool,
498	  terminal: TerminalConfig,
499	  todo: bool,
500	) -> Result<(), String> {
501	  check_name(name)?;
502	  let dir = match dir {
503	    Some(d) => expand_home(paths, d),
504	    None => paths.projects_dir().join(name),
505	  };
506	  let mut reg = load_registry(paths)?;
507	  if reg.contains_key(name) {
508	    return Err(format!("Projekt existiert bereits: {name}"));
509	  }
510	  if dir.exists() {
511	    return Err(format!("Ordner existiert bereits: {}", dir.display()));
512	  }
513	  if let Some(pool) = pool {
514	    if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
515	      return Err(format!("Pool existiert nicht: {pool}"));
516	    }
517	  }
518	  // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
519	  // halbes Projekt.
520	  if let Some(wd) = work_dir {
521	    let wd_path = expand_home(paths, wd);
522	    if create_work_dir {
523	      fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
524	    } else if !wd_path.is_dir() {
525	      return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
526	    }
527	  }
528
529	  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
530	  fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
531	  fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;
532
533	  let contracted = contract_home(paths, &dir);
534	  let mut allow = vec![format!("Edit({contracted}/**)")];
535	  let mut additional: Vec<String> = Vec::new();
536	  if let Some(wd) = work_dir {
537	    allow.insert(0, format!("Edit({wd}/**)"));
538	    additional.push(wd.to_string());
539	  }
540	  let settings = serde_json::json!({
541	    "autoMemoryDirectory": format!("{contracted}/memory"),
542	    "permissions": {
543	      "allow": allow,
544	      "additionalDirectories": additional,
545	    },
546	  });
547	  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
548	  fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
549
550	  reg.insert(name.to_string(), dir.clone());
551	  save_registry(paths, &reg)?;
552	  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
553	  if cfg.pool.is_some() || !cfg.terminal.is_empty() {
554	    write_project_config_in(paths, name, &cfg)?;
555	  }
556	  if todo {
557	    set_todo_in(paths, name, true)?;
558	  }
559	  #[cfg(target_os = "linux")]
560	  write_terminal_desktop(paths, name, &cfg.terminal);
561	  Ok(())
562	}
563
564	/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
565	/// auf den alten Root in der settings.json (autoMemoryDirectory,
566	/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner
567	/// muss existieren.
568	fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
569	  check_name(name)?;
570	  let new_dir = expand_home(paths, dir);
571	  if !new_dir.is_dir() {
572	    return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
573	  }
574	  let mut reg = load_registry(paths)?;
575	  let old_dir = reg
576	    .get(name)
577	    .cloned()
578	    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
579	  if old_dir == new_dir {
580	    return Ok(());
581	  }
582	  reg.insert(name.to_string(), new_dir.clone());
583	  save_registry(paths, &reg)?;
584
585	  let sp = settings_path(&new_dir);
586	  if !sp.is_file() {
587	    return Ok(()); // importierte Projekte ohne settings.json
588	  }
589	  let old_c = contract_home(paths, &old_dir);
590	  let new_c = contract_home(paths, &new_dir);
591	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
592	  let mut v: serde_json::Value =
593	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
594	  if let Some(mem) = v["autoMemoryDirectory"].as_str() {
595	    if let Some(rest) = mem.strip_prefix(&old_c) {
596	      v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}"));
597	    }
598	  }
599	  if let Some(allow) = v["permissions"]["allow"].as_array_mut() {
600	    let old_edit = format!("Edit({old_c}/**)");
601	    for e in allow.iter_mut() {
602	      if e.as_str() == Some(&old_edit) {
603	        *e = serde_json::json!(format!("Edit({new_c}/**)"));
604	      }
605	    }
606	  }
607	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
608	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
609	}
610
611	/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
612	fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
613	  let dir = expand_home(paths, path);
614	  if !dir.is_dir() {
615	    return Err(format!("Ordner nicht gefunden: {}", dir.display()));
616	  }
617	  let name = dir
618	    .file_name()
619	    .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
620	    .to_string_lossy()
621	    .into_owned();
622	  check_name(&name)?;
623	  register_project(paths, &name, &dir)?;
624	  #[cfg(target_os = "linux")]
625	  {
626	    let cfg = read_project_config_in(paths, &name)
627	      .map(|c| c.terminal)
628	      .unwrap_or_default();
629	    write_terminal_desktop(paths, &name, &cfg);
630	  }
631	  Ok(())
632	}
633
634	#[cfg(test)]
635	fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
636	  check_name(name)?;
637	  let dir = paths.projects_dir().join(name);
638	  if dir.exists() {
639	    return Err(format!("Projekt existiert bereits: {name}"));
640	  }
641	  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
642	  register_project(paths, name, &dir)
643	}
644
645	/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
646	/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
647	/// Datei an, wenn sie fehlt (importierte Projekte).
648	fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
649	  check_name(name)?;
650	  let wd_path = expand_home(paths, dir);
651	  if !wd_path.is_dir() {
652	    return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
653	  }
654	  let dir = contract_home(paths, &wd_path);
655	  let sp = settings_path(&project_dir(paths, name)?);
656	  let mut v: serde_json::Value = if sp.is_file() {
657	    let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
658	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
659	  } else {
660	    serde_json::json!({})
661	  };
662	  let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
663	  let perms = root
664	    .entry("permissions")
665	    .or_insert_with(|| serde_json::json!({}))
666	    .as_object_mut()
667	    .ok_or("permissions ist kein Objekt")?;
668	  let dirs = perms
669	    .entry("additionalDirectories")
670	    .or_insert_with(|| serde_json::json!([]))
671	    .as_array_mut()
672	    .ok_or("additionalDirectories ist kein Array")?;
673	  if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
674	    return Err(format!("schon eingetragen: {dir}"));
675	  }
676	  dirs.push(serde_json::json!(dir));
677	  let allow = perms
678	    .entry("allow")
679	    .or_insert_with(|| serde_json::json!([]))
680	    .as_array_mut()
681	    .ok_or("allow ist kein Array")?;
682	  allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
683	  let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
684	  fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
685	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
686	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
687	}
688
689	/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
690	/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
691	fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
692	  check_name(name)?;
693	  let sp = settings_path(&project_dir(paths, name)?);
694	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
695	  let mut v: serde_json::Value =
696	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
697	  let perms = v["permissions"]
698	    .as_object_mut()
699	    .ok_or("permissions ist kein Objekt")?;
700	  let dirs = perms["additionalDirectories"]
701	    .as_array_mut()
702	    .ok_or("additionalDirectories ist kein Array")?;
703	  let before = dirs.len();
704	  dirs.retain(|d| d.as_str() != Some(dir));
705	  if dirs.len() == before {
706	    return Err(format!("nicht eingetragen: {dir}"));
707	  }
708	  if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
709	    allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
710	  }
711	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
712	  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
713	}
714
715	/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
716	fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
717	  check_name(name)?;
718	  let sp = settings_path(&project_dir(paths, name)?);
719	  if !sp.is_file() {
720	    return Ok(Vec::new());
721	  }
722	  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
723	  let v: serde_json::Value =
724	    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
725	  Ok(
726	    v["permissions"]["additionalDirectories"]
727	      .as_array()
728	      .map(|a| {
729	        a.iter()
730	          .filter_map(|x| x.as_str().map(String::from))
731	          .collect()
732	      })
733	      .unwrap_or_default(),
734	  )
735	}
736
737	/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
738	/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
739	fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
740	  check_name(name)?;
741	  let dir = project_dir(paths, name)?;
742	  if !dir.is_dir() {
743	    return Err(format!("Projekt nicht gefunden: {name}"));
744	  }
745	  if delete_work_dirs {
746	    for wd in project_work_dirs_in(paths, name)? {
747	      let wd_path = expand_home(paths, &wd);
748	      fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
749	    }
750	  }
751	  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
752	  unregister_project(paths, name)?;
753	  #[cfg(target_os = "linux")]
754	  remove_terminal_desktop(paths, name);
755	  Ok(())
756	}
757
758	fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
759	  let cfg_path = project_config_path(paths, project)?;
760	  if !cfg_path.is_file() {
761	    return Ok(ProjectConfig::default());
762	  }
763	  let raw =
764	    fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
765	  serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
766	}
767
768	fn write_project_config_in(
769	  paths: &Paths,
770	  project: &str,
771	  cfg: &ProjectConfig,
772	) -> Result<(), String> {
773	  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
774	  fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
775	}
776
777	fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
778	  if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
779	    return Err(format!("Pool existiert nicht: {pool}"));
780	  }
781	  let mut cfg = read_project_config_in(paths, project)?;
782	  cfg.pool = Some(pool.to_string());
783	  write_project_config_in(paths, project, &cfg)
784	}
785
786	/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
787	/// Terminal-Einstellungen trägt.
788	fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
789	  let mut cfg = read_project_config_in(paths, project)?;
790	  cfg.pool = None;
791	  if cfg.terminal.is_empty() {
792	    let cfg_path = project_config_path(paths, project)?;
793	    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
794	  }
795	  write_project_config_in(paths, project, &cfg)
796	}
797
798	fn set_terminal_config_in(
799	  paths: &Paths,
800	  project: &str,
801	  mut terminal: TerminalConfig,
802	) -> Result<(), String> {
803	  let mut cfg = read_project_config_in(paths, project)?;
804	  // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
805	  // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
806	  if let Some(icon) = terminal.icon.as_deref() {
807	    if icon.starts_with('/') {
808	      let src = PathBuf::from(icon);
809	      let ext = src
810	        .extension()
811	        .and_then(|e| e.to_str())
812	        .unwrap_or("png")
813	        .to_lowercase();
814	      let name = format!("{project}.{ext}");
815	      let icons = paths.icons_dir();
816	      fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
817	      let dest = icons.join(&name);
818	      if src != dest {
819	        fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
820	      }
821	      terminal.icon = Some(name);
822	    }
823	  }
824	  cfg.terminal = terminal;
825	  write_project_config_in(paths, project, &cfg)?;
826	  #[cfg(target_os = "linux")]
827	  write_terminal_desktop(paths, project, &cfg.terminal);
828	  Ok(())
829	}
830
831	/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
832	/// gemeinsamen Icons-Verzeichnis der App.
833	fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
834	  if icon.starts_with('/') {
835	    PathBuf::from(icon)
836	  } else {
837	    paths.icons_dir().join(icon)
838	  }
839	}
840
841	/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien.
842	/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben.
843	#[cfg(target_os = "linux")]
844	fn applications_dir(paths: &Paths) -> PathBuf {
845	  paths.home.join(".local/share/applications")
846	}
847
848	/// Schreibt/aktualisiert die .desktop eines Projekts: dash-to-dock ordnet dem
849	/// Fenster (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-
850	/// Icon zu. NoDisplay=true hält den App-Starter sauber.
851	#[cfg(target_os = "linux")]
852	pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) {
853	  let dir = applications_dir(paths);
854	  if fs::create_dir_all(&dir).is_err() {
855	    return;
856	  }
857	  let exec = std::env::current_exe()
858	    .ok()
859	    .and_then(|p| p.to_str().map(str::to_string))
860	    .unwrap_or_else(|| "ai-control".into());
861	  let icon_line = cfg
862	    .icon
863	    .as_deref()
864	    .map(|i| resolve_icon_path(paths, i))
865	    .filter(|p| p.exists())
866	    .and_then(|p| p.to_str().map(str::to_string))
867	    .map(|p| format!("Icon={p}\n"))
868	    .unwrap_or_default();
869	  let content = format!(
870	    "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\
871	     {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n"
872	  );
873	  let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content);
874	}
875
876	/// Entfernt die .desktop eines Projekts (beim Löschen).
877	#[cfg(target_os = "linux")]
878	pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) {
879	  let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop")));
880	}
881
882	/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und
883	/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer
884	/// vorhanden und aktuell, bevor überhaupt ein Terminal startet.
885	#[cfg(target_os = "linux")]
886	fn sync_all_desktops(paths: &Paths) {
887	  let Ok(reg) = load_registry(paths) else {
888	    return;
889	  };
890	  for project in reg.keys() {
891	    let cfg = read_project_config_in(paths, project)
892	      .map(|c| c.terminal)
893	      .unwrap_or_default();
894	    write_terminal_desktop(paths, project, &cfg);
895	  }
896	  let dir = applications_dir(paths);
897	  if let Ok(entries) = fs::read_dir(&dir) {
898	    for e in entries.flatten() {
899	      let name = e.file_name();
900	      let Some(name) = name.to_str() else { continue };
901	      if let Some(project) = name
902	        .strip_prefix("aicontrol-")
903	        .and_then(|n| n.strip_suffix(".desktop"))
904	      {
905	        if !reg.contains_key(project) {
906	          let _ = fs::remove_file(e.path());
907	        }
908	      }
909	    }
910	  }
911	}
912
913	/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
914	/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
915	#[tauri::command]
916	fn project_icon(project: String) -> Result<Option<String>, String> {
917	  use base64::{engine::general_purpose::STANDARD, Engine};
918	  let paths = Paths::real();
919	  let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
920	    return Ok(None);
921	  };
922	  let path = resolve_icon_path(&paths, &icon);
923	  let is_icns = path
924	    .extension()
925	    .and_then(|e| e.to_str())
926	    .is_some_and(|e| e.eq_ignore_ascii_case("icns"));
927	  let png = if is_icns {
928	    icns_to_png_bytes(&path, &project)?
929	  } else {
930	    fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?
931	  };
932	  Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
933	}
934
935	/// Menü-Icon fürs Tray: ganz links der Status-Punkt (grün = läuft), daneben
936	/// das Projekt-Icon (sips konvertiert, auch ICNS). Ein RGBA-Bild 56×36 px;
937	/// muda rendert Menü-Icons mit 18 pt Höhe und proportionaler Breite.
938	fn menu_icon(
939	  paths: &Paths,
940	  project: &str,
941	  running: bool,
942	) -> Result<tauri::image::Image<'static>, String> {
943	  const H: usize = 36;
944	  const DOT_W: usize = 20;
945	  const W: usize = DOT_W + H;
946	  let mut canvas = vec![0u8; W * H * 4];
947	  if running {
948	    // macOS-Systemgrün, weicher Rand über Alpha.
949	    let (cx, cy, r) = (10.0f32, 18.0f32, 7.0f32);
950	    for y in 0..H {
951	      for x in 0..DOT_W {
952	        let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
953	        let a = (r - d + 0.5).clamp(0.0, 1.0);
954	        if a > 0.0 {
955	          let i = (y * W + x) * 4;
956	          canvas[i..i + 4].copy_from_slice(&[52, 199, 89, (a * 255.0) as u8]);
957	        }
958	      }
959	    }
960	  }
961	  if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
962	    // Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
963	    // Meldung, Eintrag erscheint ohne Projekt-Icon.
964	    match project_icon_rgba_36(paths, project, &icon) {
965	      Ok(img) => {
966	        let (iw, ih) = (img.width() as usize, img.height() as usize);
967	        let rgba = img.rgba();
968	        for y in 0..ih.min(H) {
969	          for x in 0..iw.min(H) {
970	            let src_i = (y * iw + x) * 4;
971	            let dst_i = (y * W + DOT_W + x) * 4;
972	            canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
973	          }
974	        }
975	      }
976	      Err(e) => eprintln!("Tray-Icon {project}: {e}"),
977	    }
978	  }
979	  Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
980	}
981
982	/// Projekt-Icon als 36×36-RGBA. PNG wird von tauri geladen und skaliert;
983	/// ICNS läuft über sips (nur macOS).
984	fn project_icon_rgba_36(
985	  paths: &Paths,
986	  project: &str,
987	  icon: &str,
988	) -> Result<tauri::image::Image<'static>, String> {
989	  let src = resolve_icon_path(paths, icon);
990	  let is_icns = src
991	    .extension()
992	    .and_then(|e| e.to_str())
993	    .is_some_and(|e| e.eq_ignore_ascii_case("icns"));
994	  let img = if is_icns {
995	    tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())?
996	  } else {
997	    tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))?
998	  };
999	  Ok(scale_rgba(&img, 36))
1000	}
1001
1002	/// Nearest-Neighbor-Skalierung eines RGBA-Bildes auf ein Quadrat der Kantenlänge `size`.
1003	fn scale_rgba(img: &tauri::image::Image, size: usize) -> tauri::image::Image<'static> {
1004	  let (sw, sh) = (img.width() as usize, img.height() as usize);
1005	  let src = img.rgba();
1006	  let mut out = vec![0u8; size * size * 4];
1007	  for y in 0..size {
1008	    for x in 0..size {
1009	      let sx = x * sw / size;
1010	      let sy = y * sh / size;
1011	      let si = (sy * sw + sx) * 4;
1012	      let di = (y * size + x) * 4;
1013	      out[di..di + 4].copy_from_slice(&src[si..si + 4]);
1014	    }
1015	  }
1016	  tauri::image::Image::new_owned(out, size as u32, size as u32)
1017	}
1018
1019	/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS).
1020	fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result<Vec<u8>, String> {
1021	  let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
1022	  let out = Command::new("sips")
1023	    .args(["-s", "format", "png"])
1024	    .arg(src)
1025	    .arg("--out")
1026	    .arg(&tmp)
1027	    .output()
1028	    .map_err(|e| format!("sips: {e}"))?;
1029	  if !out.status.success() {
1030	    return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
1031	  }
1032	  let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
1033	  let _ = fs::remove_file(&tmp);
1034	  Ok(bytes)
1035	}
1036
1037	/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
1038	pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
1039	  Ok(read_project_config_in(&Paths::real(), project)?.terminal)
1040	}
1041
1042	/// Namen aller Projekte, die `pool` zugeordnet haben.
1043	fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
1044	  let mut users = Vec::new();
1045	  for (name, dir) in load_registry(paths)? {
1046	    let cfg_path = dir.join(PROJECT_FILE);
1047	    if !cfg_path.is_file() {
1048	      continue;
1049	    }
1050	    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
1051	    let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
1052	    if cfg.pool.as_deref() == Some(pool) {
1053	      users.push(name);
1054	    }
1055	  }
1056	  Ok(users)
1057	}
1058
1059	// ---------- Pools ----------
1060
1061	/// Ablage der API-Keys: macOS-Keychain / Linux-Secret-Service (keyring-Crate).
1062	/// Die Key-Datei im Pool-Ordner bleibt Fallback, wenn der Store beim Schreiben
1063	/// nicht verfügbar ist.
1064	trait ApikeyStore {
1065	  fn set(&self, pool: &str, key: &str) -> Result<(), String>;
1066	  fn has(&self, pool: &str) -> Result<bool, String>;
1067	  fn delete(&self, pool: &str) -> Result<(), String>;
1068	}
1069
1070	/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die
1071	/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest
1072	/// mit denselben Attributen über secret-tool.
1073	const APIKEY_SERVICE: &str = "ai-control-apikey";
1074
1075	/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch)
1076	/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file.
1077	const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable";
1078
1079	struct KeychainStore;
1080
1081	/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in
1082	/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim
1083	/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das
1084	/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen
1085	/// durchs CLI prompten.
1086	#[cfg(target_os = "macos")]
1087	impl ApikeyStore for KeychainStore {
1088	  fn set(&self, pool: &str, key: &str) -> Result<(), String> {
1089	    let out = Command::new("security")
1090	      .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key])
1091	      .output()
1092	      .map_err(|e| e.to_string())?;
1093	    if out.status.success() {
1094	      Ok(())
1095	    } else {
1096	      Err(String::from_utf8_lossy(&out.stderr).into_owned())
1097	    }
1098	  }
1099
1100	  fn has(&self, pool: &str) -> Result<bool, String> {
1101	    // Ohne -w: nur Attribute, kein Secret — kein ACL-Prompt möglich.
1102	    let out = Command::new("security")
1103	      .args(["find-generic-password", "-s", APIKEY_SERVICE, "-a", pool])
1104	      .output()
1105	      .map_err(|e| e.to_string())?;
1106	    Ok(out.status.success())
1107	  }
1108
1109	  fn delete(&self, pool: &str) -> Result<(), String> {
1110	    // Fehlender Eintrag ist kein Fehler — gelöscht ist gelöscht.
1111	    Command::new("security")
1112	      .args(["delete-generic-password", "-s", APIKEY_SERVICE, "-a", pool])
1113	      .output()
1114	      .map_err(|e| e.to_string())?;
1115	    Ok(())
1116	  }
1117	}
1118
1119	#[cfg(not(target_os = "macos"))]
1120	impl KeychainStore {
1121	  fn entry(pool: &str) -> Result<keyring::Entry, String> {
1122	    keyring::Entry::new(APIKEY_SERVICE, pool).map_err(|e| e.to_string())
1123	  }
1124	}
1125
1126	#[cfg(not(target_os = "macos"))]
1127	impl ApikeyStore for KeychainStore {
1128	  fn set(&self, pool: &str, key: &str) -> Result<(), String> {
1129	    Self::entry(pool)?.set_password(key).map_err(|e| e.to_string())
1130	  }
1131
1132	  fn has(&self, pool: &str) -> Result<bool, String> {
1133	    match Self::entry(pool)?.get_password() {
1134	      Ok(_) => Ok(true),
1135	      // Kein Eintrag oder kein Store verfügbar → die Key-Datei entscheidet.
1136	      Err(
1137	        keyring::Error::NoEntry
1138	        | keyring::Error::PlatformFailure(_)
1139	        | keyring::Error::NoStorageAccess(_),
1140	      ) => Ok(false),
1141	      Err(e) => Err(e.to_string()),
1142	    }
1143	  }
1144
1145	  fn delete(&self, pool: &str) -> Result<(), String> {
1146	    match Self::entry(pool)?.delete_credential() {
1147	      Ok(())
1148	      | Err(
1149	        keyring::Error::NoEntry
1150	        | keyring::Error::PlatformFailure(_)
1151	        | keyring::Error::NoStorageAccess(_),
1152	      ) => Ok(()),
1153	      Err(e) => Err(e.to_string()),
1154	    }
1155	  }
1156	}
1157
1158	/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem
1159	/// Keychain/Keyring, bei fehlendem Eintrag aus der Key-Datei.
1160	fn apikey_helper_command(dir: &std::path::Path, pool_id: &str) -> String {
1161	  let file = dir.join(APIKEY_FILE);
1162	  if cfg!(target_os = "macos") {
1163	    format!(
1164	      "security find-generic-password -w -s {APIKEY_SERVICE} -a {pool_id} 2>/dev/null || cat '{}'",
1165	      file.display()
1166	    )
1167	  } else {
1168	    format!(
1169	      "secret-tool lookup service {APIKEY_SERVICE} username {pool_id} 2>/dev/null || cat '{}'",
1170	      file.display()
1171	    )
1172	  }
1173	}
1174
1175	#[derive(Serialize)]
1176	struct PoolInfo {
1177	  /// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der
1178	  /// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen.
1179	  id: String,
1180	  /// Anzeigename aus pool.json, frei umbenennbar.
1181	  name: String,
1182	  #[serde(rename = "credentialType")]
1183	  credential_type: String,
1184	  projects: Vec<String>,
1185	  /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
1186	  /// Projektliste). Der Löschen-Dialog sperrt darauf.
1187	  running: Vec<String>,
1188	  #[serde(rename = "hasCredentials")]
1189	  has_credentials: bool,
1190	}
1191
1192	fn list_pools_in(paths: &Paths, store: &dyn ApikeyStore) -> Result<Vec<PoolInfo>, String> {
1193	  let mut pools = Vec::new();
1194	  let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
1195	  for entry in entries {
1196	    let entry = entry.map_err(|e| e.to_string())?;
1197	    let cfg_path = entry.path().join(POOL_FILE);
1198	    if !cfg_path.is_file() {
1199	      continue;
1200	    }
1201	    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
1202	    let pool: Pool =
1203	      serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
1204	    let id = entry.file_name().to_string_lossy().into_owned();
1205	    // oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
1206	    // Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
1207	    // apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
1208	    let has_credentials = match pool.credential_type.as_str() {
1209	      "apikey" => {
1210	        store.has(&id)?
1211	          || fs::read_to_string(entry.path().join(APIKEY_FILE))
1212	            .map(|s| !s.trim().is_empty())
1213	            .unwrap_or(false)
1214	      }
1215	      _ => true,
1216	    };
1217	    let projects = projects_using_pool(paths, &id)?;
1218	    let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
1219	    pools.push(PoolInfo {
1220	      id,
1221	      projects,
1222	      running,
1223	      name: pool.name,
1224	      credential_type: pool.credential_type,
1225	      has_credentials,
1226	    });
1227	  }
1228	  pools.sort_by(|a, b| a.name.cmp(&b.name));
1229	  Ok(pools)
1230	}
1231
1232	/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
1233	/// nicht — dann ist die Liste leer.
1234	fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
1235	  let mut out = Vec::new();
1236	  if !paths.pools_dir().is_dir() {
1237	    return Ok(out);
1238	  }
1239	  for entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
1240	    let entry = entry.map_err(|e| e.to_string())?;
1241	    let cfg_path = entry.path().join(POOL_FILE);
1242	    if !cfg_path.is_file() {
1243	      continue;
1244	    }
1245	    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
1246	    let pool: Pool =
1247	      serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
1248	    out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
1249	  }
1250	  Ok(out)
1251	}
1252
1253	/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
1254	/// Ordner für einen neuen Pool: pools/<UUID v4>.
1255	fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
1256	  check_name(name)?;
1257	  if pool_names(paths)?.iter().any(|(_, n)| n == name) {
1258	    return Err(format!("Pool existiert bereits: {name}"));
1259	  }
1260	  Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
1261	}
1262
1263	fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
1264	  fs::create_dir_all(dir).map_err(|e| e.to_string())?;
1265	  let pool = Pool {
1266	    name: name.to_string(),
1267	    credential_type: credential_type.to_string(),
1268	  };
1269	  let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
1270	  fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string())
1271	}
1272
1273	fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
1274	  if let Some(parent) = path.parent() {
1275	    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1276	  }
1277	  fs::write(path, content).map_err(|e| e.to_string())?;
1278	  fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
1279	}
1280
1281	/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
1282	/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
1283	/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
1284	/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst

mod terminal;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
/// die App speichert keine Tokens.
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
const PROJECTS_FILE: &str = "projects.json";

/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
pub(crate) struct Paths {
  home: PathBuf,
}

impl Paths {
  pub(crate) fn real() -> Self {
    Paths {
      home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
    }
  }

  /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
  /// Projekte ohne gewählten Zielordner.
  fn projects_dir(&self) -> PathBuf {
    self.home.join("claude-projects")
  }

  fn config_dir(&self) -> PathBuf {
    self.home.join(".config").join("ai-control")
  }

  fn projects_file(&self) -> PathBuf {
    self.config_dir().join(PROJECTS_FILE)
  }

  fn pools_dir(&self) -> PathBuf {
    self.config_dir().join("pools")
  }

  /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
  /// unabhängig von Pool-Zuordnung und Quell-Repos.
  fn icons_dir(&self) -> PathBuf {
    self.config_dir().join("icons")
  }

  fn pool_dir(&self, pool: &str) -> PathBuf {
    self.pools_dir().join(pool)
  }
}

// ---------- Projekt-Registry ----------

/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
  match p.strip_prefix(&paths.home) {
    Ok(rest) => format!("~/{}", rest.display()),
    Err(_) => p.display().to_string(),
  }
}

/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
  let file = paths.projects_file();
  if !file.is_file() {
    return Ok(std::collections::BTreeMap::new());
  }
  let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
  let map: std::collections::BTreeMap<String, String> =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
  Ok(
    map
      .into_iter()
      .map(|(name, p)| {
        let dir = expand_home(paths, &p);
        (name, dir)
      })
      .collect(),
  )
}

fn save_registry(
  paths: &Paths,
  reg: &std::collections::BTreeMap<String, PathBuf>,
) -> Result<(), String> {
  let contracted: std::collections::BTreeMap<&String, String> =
    reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
  let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
  let file = paths.projects_file();
  fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}

/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
  load_registry(paths)?
    .remove(name)
    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}

/// Nimmt ein Projekt in die Registry auf.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  reg.insert(name.to_string(), dir.to_path_buf());
  save_registry(paths, &reg)
}

/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
  let mut reg = load_registry(paths)?;
  if reg.remove(name).is_none() {
    return Err(format!("Projekt nicht registriert: {name}"));
  }
  save_registry(paths, &reg)
}

fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
  Ok(project_dir(paths, project)?.join(PROJECT_FILE))
}

#[derive(Serialize)]
struct Project {
  name: String,
  path: String,
  pool: Option<String>,
  running: bool,
  terminal: TerminalConfig,
}

#[derive(Serialize, Deserialize)]
struct Pool {
  name: String,
  #[serde(rename = "credentialType")]
  credential_type: String,
}

#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pool: Option<String>,
  #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
  terminal: TerminalConfig,
}

#[derive(Serialize, Deserialize, Default, Clone)]
pub struct TerminalConfig {
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub theme: Option<String>,
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub icon: Option<String>,
  #[serde(default, skip_serializing_if = "Option::is_none")]
  pub title: Option<String>,
}

impl TerminalConfig {
  fn is_empty(&self) -> bool {
    self.theme.is_none() && self.icon.is_none() && self.title.is_none()
  }
}

fn is_running(project: &str) -> bool {
  !terminal_pids(project).is_empty()
}

/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
/// Lauf-Erkennung wie die Projektliste (`is_running`).
fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
  Ok(projects_using_pool(paths, pool)?
    .into_iter()
    .filter(|p| is_running(p))
    .collect())
}

/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
  let out = Command::new("pgrep")
    .args(["-f", "--", &format!("--terminal {project}$")])
    .output();
  match out {
    Ok(o) => String::from_utf8_lossy(&o.stdout)
      .lines()
      .filter_map(|l| l.trim().parse().ok())
      .collect(),
    Err(_) => Vec::new(),
  }
}

/// Tick-Intervall des Session-Watchers.
const WATCH_INTERVAL: Duration = Duration::from_secs(5);

/// App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen).
const APP_SETTINGS_FILE: &str = "settings.json";

/// Opt-in: synct der Watcher bei Session-Ende? Default aus.
fn sync_on_session_end(paths: &Paths) -> bool {
  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
    .ok()
    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
    .and_then(|v| v["syncOnSessionEnd"].as_bool())
    .unwrap_or(false)
}

/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
    .ok()
    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
    .and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
    .unwrap_or_else(|| "claude".into())
}

/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
  let paths = Paths::real();
  Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
}

/// Setzt das Opt-in; erhält übrige App-settings.
fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
  let path = paths.config_dir().join(APP_SETTINGS_FILE);
  let mut v: serde_json::Value = fs::read_to_string(&path)
    .ok()
    .and_then(|s| serde_json::from_str(&s).ok())
    .unwrap_or_else(|| serde_json::json!({}));
  v["syncOnSessionEnd"] = serde_json::json!(enabled);
  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
  fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}

/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
fn git_root(dir: &std::path::Path) -> Option<PathBuf> {
  dir
    .ancestors()
    .find(|a| a.join(".git").exists())
    .map(|a| a.to_path_buf())
}

/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
fn project_state(paths: &Paths) -> Vec<(String, bool)> {
  let mut state: Vec<(String, bool)> = list_projects_in(paths)
    .map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
    .unwrap_or_default();
  state.sort();
  state
}

/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
/// jeder andere Fehler bricht die Kette mit Meldung ab.
fn sync_session_context(repo: &std::path::Path, project: &str) {
  let run = |args: &[&str]| {
    Command::new("git")
      .arg("-C")
      .arg(repo)
      .args(args)
      .output()
      .map_err(|e| format!("git {}: {e}", args.join(" ")))
  };
  let msg = format!("session-end sync: {project}");
  let steps: [&[&str]; 4] = [
    &["add", "-A"],
    &["commit", "-m", &msg],
    &["pull", "--rebase"],
    &["push"],
  ];
  for (i, args) in steps.iter().enumerate() {
    match run(args) {
      Ok(o) if o.status.success() => {}
      // commit ohne Änderungen: nichts zu syncen, kein Fehler.
      Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
        return;
      }
      Ok(o) => {
        eprintln!(
          "session-end sync {project}: git {} — {}",
          args.join(" "),
          String::from_utf8_lossy(&o.stderr).trim()
        );
        return;
      }
      Err(e) => {
        eprintln!("session-end sync {project}: {e}");
        return;
      }
    }
  }
}

/// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.
fn spawn_session_watcher(app: tauri::AppHandle) {
  std::thread::spawn(move || {
    let paths = Paths::real();
    let mut state = project_state(&paths);
    loop {
      std::thread::sleep(WATCH_INTERVAL);
      let current = project_state(&paths);
      if current == state {
        continue;
      }
      let ended: Vec<&String> = state
        .iter()
        .filter(|(name, running)| {
          *running && !current.iter().any(|(n, r)| n == name && *r)
        })
        .map(|(name, _)| name)
        .collect();
      // Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
      // der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
      if !ended.is_empty() && sync_on_session_end(&paths) {
        for project in ended {
          if let Ok(dir) = project_dir(&paths, project) {
            if let Some(repo) = git_root(&dir) {
              sync_session_context(&repo, project);
            }
          }
        }
      }
      // Menü-Objekte gehören auf den Main-Thread.
      let handle = app.clone();
      app
        .run_on_main_thread(move || {
          // Nur wenn ein Tray existiert (macOS). Unter Linux pollt das Popup selbst.
          if let Some(tray) = handle.tray_by_id("main") {
            if let Ok(menu) = tray_menu(&handle) {
              let _ = tray.set_menu(Some(menu));
            }
          }
        })
        .expect("Main-Thread nicht erreichbar");
      state = current;
    }
  });
}

fn check_name(name: &str) -> Result<(), String> {
  if name.trim().is_empty() || name.contains('/') || name.contains("..") {
    return Err(format!("ungültiger Name: {name}"));
  }
  Ok(())
}

fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
  let cfg_path = paths.pool_dir(pool).join(POOL_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()))
}

// ---------- Projekte ----------

fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
  let mut projects = Vec::new();
  for (name, dir) in load_registry(paths)? {
    let cfg = read_project_config_in(paths, &name)?;
    projects.push(Project {
      running: is_running(&name),
      path: contract_home(paths, &dir),
      pool: cfg.pool,
      terminal: cfg.terminal,
      name,
    });
  }
  Ok(projects)
}

// ---------- Feature: Todoliste ----------

/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
/// als additionalContext in jede Session injiziert.
const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";

fn todo_hook_command(dir: &std::path::Path) -> String {
  format!(
    "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
    dir.join(TODO_FILE).display()
  )
}

fn settings_path(dir: &std::path::Path) -> PathBuf {
  dir.join(".claude").join("settings.json")
}

fn hook_is_todo(group: &serde_json::Value) -> bool {
  group["hooks"]
    .as_array()
    .map(|hs| {
      hs.iter().any(|h| {
        h["command"]
          .as_str()
          .map_or(false, |c| c.contains(TODO_FILE))
      })
    })
    .unwrap_or(false)
}

fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
  check_name(name)?;
  let sp = settings_path(&project_dir(paths, name)?);
  if !sp.is_file() {
    return Ok(false);
  }
  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
  let v: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
  Ok(
    v["hooks"]["SessionStart"]
      .as_array()
      .map(|a| a.iter().any(hook_is_todo))
      .unwrap_or(false),
  )
}

fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
  check_name(name)?;
  let dir = project_dir(paths, name)?;
  let sp = settings_path(&dir);
  if !sp.is_file() {
    return Err(format!("settings.json fehlt: {}", sp.display()));
  }
  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 root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
  let hooks = root
    .entry("hooks")
    .or_insert_with(|| serde_json::json!({}))
    .as_object_mut()
    .ok_or("hooks ist kein Objekt")?;
  let session_start = hooks
    .entry("SessionStart")
    .or_insert_with(|| serde_json::json!([]))
    .as_array_mut()
    .ok_or("SessionStart ist kein Array")?;

  session_start.retain(|g| !hook_is_todo(g));
  if enabled {
    session_start.insert(
      0,
      serde_json::json!({
        "hooks": [
          { "type": "command", "command": todo_hook_command(&dir) }
        ]
      }),
    );
    let todo_path = dir.join(TODO_FILE);
    if !todo_path.is_file() {
      fs::write(&todo_path, TODO_SKELETON)
        .map_err(|e| format!("{}: {e}", todo_path.display()))?;
    }
  }

  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}

/// "~/x" relativ zum Home auflösen; alles andere unverändert.
fn expand_home(paths: &Paths, p: &str) -> PathBuf {
  match p.strip_prefix("~/") {
    Some(rest) => paths.home.join(rest),
    None => PathBuf::from(p),
  }
}

/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
fn create_project_full_in(
  paths: &Paths,
  name: &str,
  dir: Option<&str>,
  pool: Option<&str>,
  work_dir: Option<&str>,
  create_work_dir: bool,
  terminal: TerminalConfig,
  todo: bool,
) -> Result<(), String> {
  check_name(name)?;
  let dir = match dir {
    Some(d) => expand_home(paths, d),
    None => paths.projects_dir().join(name),
  };
  let mut reg = load_registry(paths)?;
  if reg.contains_key(name) {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  if dir.exists() {
    return Err(format!("Ordner existiert bereits: {}", dir.display()));
  }
  if let Some(pool) = pool {
    if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
      return Err(format!("Pool existiert nicht: {pool}"));
    }
  }
  // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
  // halbes Projekt.
  if let Some(wd) = work_dir {
    let wd_path = expand_home(paths, wd);
    if create_work_dir {
      fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
    } else if !wd_path.is_dir() {
      return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
    }
  }

  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
  fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;

  let contracted = contract_home(paths, &dir);
  let mut allow = vec![format!("Edit({contracted}/**)")];
  let mut additional: Vec<String> = Vec::new();
  if let Some(wd) = work_dir {
    allow.insert(0, format!("Edit({wd}/**)"));
    additional.push(wd.to_string());
  }
  let settings = serde_json::json!({
    "autoMemoryDirectory": format!("{contracted}/memory"),
    "permissions": {
      "allow": allow,
      "additionalDirectories": additional,
    },
  });
  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
  fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;

  reg.insert(name.to_string(), dir.clone());
  save_registry(paths, &reg)?;
  let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
  if cfg.pool.is_some() || !cfg.terminal.is_empty() {
    write_project_config_in(paths, name, &cfg)?;
  }
  if todo {
    set_todo_in(paths, name, true)?;
  }
  #[cfg(target_os = "linux")]
  write_terminal_desktop(paths, name, &cfg.terminal);
  Ok(())
}

/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
/// auf den alten Root in der settings.json (autoMemoryDirectory,
/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner
/// muss existieren.
fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
  check_name(name)?;
  let new_dir = expand_home(paths, dir);
  if !new_dir.is_dir() {
    return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
  }
  let mut reg = load_registry(paths)?;
  let old_dir = reg
    .get(name)
    .cloned()
    .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
  if old_dir == new_dir {
    return Ok(());
  }
  reg.insert(name.to_string(), new_dir.clone());
  save_registry(paths, &reg)?;

  let sp = settings_path(&new_dir);
  if !sp.is_file() {
    return Ok(()); // importierte Projekte ohne settings.json
  }
  let old_c = contract_home(paths, &old_dir);
  let new_c = contract_home(paths, &new_dir);
  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()))?;
  if let Some(mem) = v["autoMemoryDirectory"].as_str() {
    if let Some(rest) = mem.strip_prefix(&old_c) {
      v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}"));
    }
  }
  if let Some(allow) = v["permissions"]["allow"].as_array_mut() {
    let old_edit = format!("Edit({old_c}/**)");
    for e in allow.iter_mut() {
      if e.as_str() == Some(&old_edit) {
        *e = serde_json::json!(format!("Edit({new_c}/**)"));
      }
    }
  }
  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}

/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
  let dir = expand_home(paths, path);
  if !dir.is_dir() {
    return Err(format!("Ordner nicht gefunden: {}", dir.display()));
  }
  let name = dir
    .file_name()
    .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
    .to_string_lossy()
    .into_owned();
  check_name(&name)?;
  register_project(paths, &name, &dir)?;
  #[cfg(target_os = "linux")]
  {
    let cfg = read_project_config_in(paths, &name)
      .map(|c| c.terminal)
      .unwrap_or_default();
    write_terminal_desktop(paths, &name, &cfg);
  }
  Ok(())
}

#[cfg(test)]
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
  check_name(name)?;
  let dir = paths.projects_dir().join(name);
  if dir.exists() {
    return Err(format!("Projekt existiert bereits: {name}"));
  }
  fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
  register_project(paths, name, &dir)
}

/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
/// Datei an, wenn sie fehlt (importierte Projekte).
fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
  check_name(name)?;
  let wd_path = expand_home(paths, dir);
  if !wd_path.is_dir() {
    return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
  }
  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())?;
  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}

/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
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())?;
  fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}

/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
  check_name(name)?;
  let sp = settings_path(&project_dir(paths, name)?);
  if !sp.is_file() {
    return Ok(Vec::new());
  }
  let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
  let v: serde_json::Value =
    serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
  Ok(
    v["permissions"]["additionalDirectories"]
      .as_array()
      .map(|a| {
        a.iter()
          .filter_map(|x| x.as_str().map(String::from))
          .collect()
      })
      .unwrap_or_default(),
  )
}

/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
  check_name(name)?;
  let dir = project_dir(paths, name)?;
  if !dir.is_dir() {
    return Err(format!("Projekt nicht gefunden: {name}"));
  }
  if delete_work_dirs {
    for wd in project_work_dirs_in(paths, name)? {
      let wd_path = expand_home(paths, &wd);
      fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
    }
  }
  fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
  unregister_project(paths, name)?;
  #[cfg(target_os = "linux")]
  remove_terminal_desktop(paths, name);
  Ok(())
}

fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
  let cfg_path = project_config_path(paths, project)?;
  if !cfg_path.is_file() {
    return Ok(ProjectConfig::default());
  }
  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()))
}

fn write_project_config_in(
  paths: &Paths,
  project: &str,
  cfg: &ProjectConfig,
) -> Result<(), String> {
  let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
  fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
}

fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
  if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
    return Err(format!("Pool existiert nicht: {pool}"));
  }
  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = Some(pool.to_string());
  write_project_config_in(paths, project, &cfg)
}

/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
  let mut cfg = read_project_config_in(paths, project)?;
  cfg.pool = None;
  if cfg.terminal.is_empty() {
    let cfg_path = project_config_path(paths, project)?;
    return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
  }
  write_project_config_in(paths, project, &cfg)
}

fn set_terminal_config_in(
  paths: &Paths,
  project: &str,
  mut terminal: TerminalConfig,
) -> Result<(), String> {
  let mut cfg = read_project_config_in(paths, project)?;
  // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
  // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
  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!("{project}.{ext}");
      let icons = paths.icons_dir();
      fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
      let dest = icons.join(&name);
      if src != dest {
        fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
      }
      terminal.icon = Some(name);
    }
  }
  cfg.terminal = terminal;
  write_project_config_in(paths, project, &cfg)?;
  #[cfg(target_os = "linux")]
  write_terminal_desktop(paths, project, &cfg.terminal);
  Ok(())
}

/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App.
fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
  if icon.starts_with('/') {
    PathBuf::from(icon)
  } else {
    paths.icons_dir().join(icon)
  }
}

/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien.
/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben.
#[cfg(target_os = "linux")]
fn applications_dir(paths: &Paths) -> PathBuf {
  paths.home.join(".local/share/applications")
}

/// Schreibt/aktualisiert die .desktop eines Projekts: dash-to-dock ordnet dem
/// Fenster (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-
/// Icon zu. NoDisplay=true hält den App-Starter sauber.
#[cfg(target_os = "linux")]
pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) {
  let dir = applications_dir(paths);
  if fs::create_dir_all(&dir).is_err() {
    return;
  }
  let exec = std::env::current_exe()
    .ok()
    .and_then(|p| p.to_str().map(str::to_string))
    .unwrap_or_else(|| "ai-control".into());
  let icon_line = cfg
    .icon
    .as_deref()
    .map(|i| resolve_icon_path(paths, i))
    .filter(|p| p.exists())
    .and_then(|p| p.to_str().map(str::to_string))
    .map(|p| format!("Icon={p}\n"))
    .unwrap_or_default();
  let content = format!(
    "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\
     {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n"
  );
  let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content);
}

/// Entfernt die .desktop eines Projekts (beim Löschen).
#[cfg(target_os = "linux")]
pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) {
  let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop")));
}

/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und
/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer
/// vorhanden und aktuell, bevor überhaupt ein Terminal startet.
#[cfg(target_os = "linux")]
fn sync_all_desktops(paths: &Paths) {
  let Ok(reg) = load_registry(paths) else {
    return;
  };
  for project in reg.keys() {
    let cfg = read_project_config_in(paths, project)
      .map(|c| c.terminal)
      .unwrap_or_default();
    write_terminal_desktop(paths, project, &cfg);
  }
  let dir = applications_dir(paths);
  if let Ok(entries) = fs::read_dir(&dir) {
    for e in entries.flatten() {
      let name = e.file_name();
      let Some(name) = name.to_str() else { continue };
      if let Some(project) = name
        .strip_prefix("aicontrol-")
        .and_then(|n| n.strip_suffix(".desktop"))
      {
        if !reg.contains_key(project) {
          let _ = fs::remove_file(e.path());
        }
      }
    }
  }
}

/// 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<Option<String>, 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, &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 {
    icns_to_png_bytes(&path, &project)?
  } else {
    fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?
  };
  Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
}

/// Menü-Icon fürs Tray: ganz links der Status-Punkt (grün = läuft), daneben
/// das Projekt-Icon (sips konvertiert, auch ICNS). Ein RGBA-Bild 56×36 px;
/// muda rendert Menü-Icons mit 18 pt Höhe und proportionaler Breite.
fn menu_icon(
  paths: &Paths,
  project: &str,
  running: bool,
) -> Result<tauri::image::Image<'static>, String> {
  const H: usize = 36;
  const DOT_W: usize = 20;
  const W: usize = DOT_W + H;
  let mut canvas = vec![0u8; W * H * 4];
  if running {
    // macOS-Systemgrün, weicher Rand über Alpha.
    let (cx, cy, r) = (10.0f32, 18.0f32, 7.0f32);
    for y in 0..H {
      for x in 0..DOT_W {
        let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
        let a = (r - d + 0.5).clamp(0.0, 1.0);
        if a > 0.0 {
          let i = (y * W + x) * 4;
          canvas[i..i + 4].copy_from_slice(&[52, 199, 89, (a * 255.0) as u8]);
        }
      }
    }
  }
  if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
    // Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
    // Meldung, Eintrag erscheint ohne Projekt-Icon.
    match project_icon_rgba_36(paths, project, &icon) {
      Ok(img) => {
        let (iw, ih) = (img.width() as usize, img.height() as usize);
        let rgba = img.rgba();
        for y in 0..ih.min(H) {
          for x in 0..iw.min(H) {
            let src_i = (y * iw + x) * 4;
            let dst_i = (y * W + DOT_W + x) * 4;
            canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
          }
        }
      }
      Err(e) => eprintln!("Tray-Icon {project}: {e}"),
    }
  }
  Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}

/// Projekt-Icon als 36×36-RGBA. PNG wird von tauri geladen und skaliert;
/// ICNS läuft über sips (nur macOS).
fn project_icon_rgba_36(
  paths: &Paths,
  project: &str,
  icon: &str,
) -> Result<tauri::image::Image<'static>, String> {
  let src = resolve_icon_path(paths, icon);
  let is_icns = src
    .extension()
    .and_then(|e| e.to_str())
    .is_some_and(|e| e.eq_ignore_ascii_case("icns"));
  let img = if is_icns {
    tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())?
  } else {
    tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))?
  };
  Ok(scale_rgba(&img, 36))
}

/// Nearest-Neighbor-Skalierung eines RGBA-Bildes auf ein Quadrat der Kantenlänge `size`.
fn scale_rgba(img: &tauri::image::Image, size: usize) -> tauri::image::Image<'static> {
  let (sw, sh) = (img.width() as usize, img.height() as usize);
  let src = img.rgba();
  let mut out = vec![0u8; size * size * 4];
  for y in 0..size {
    for x in 0..size {
      let sx = x * sw / size;
      let sy = y * sh / size;
      let si = (sy * sw + sx) * 4;
      let di = (y * size + x) * 4;
      out[di..di + 4].copy_from_slice(&src[si..si + 4]);
    }
  }
  tauri::image::Image::new_owned(out, size as u32, size as u32)
}

/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS).
fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result<Vec<u8>, String> {
  let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
  let out = Command::new("sips")
    .args(["-s", "format", "png"])
    .arg(src)
    .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);
  Ok(bytes)
}

/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
  Ok(read_project_config_in(&Paths::real(), project)?.terminal)
}

/// Namen aller Projekte, die `pool` zugeordnet haben.
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
  let mut users = Vec::new();
  for (name, dir) in load_registry(paths)? {
    let cfg_path = dir.join(PROJECT_FILE);
    if !cfg_path.is_file() {
      continue;
    }
    let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
    let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
    if cfg.pool.as_deref() == Some(pool) {
      users.push(name);
    }
  }
  Ok(users)
}

// ---------- 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<bool, String>;
  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<bool, String> {
    // 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, String> {
    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<bool, String> {
    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,
  projects: Vec<String>,
  /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
  /// Projektliste). Der Löschen-Dialog sperrt darauf.
  running: Vec<String>,
  #[serde(rename = "hasCredentials")]
  has_credentials: bool,
}

fn list_pools_in(paths: &Paths, store: &dyn ApikeyStore) -> Result<Vec<PoolInfo>, String> {
  let mut pools = Vec::new();
  let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
  for entry in entries {
    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()))?;
    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" => {
        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, &id)?;
    let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
    pools.push(PoolInfo {
      id,
      projects,
      running,
      name: pool.name,
      credential_type: pool.credential_type,
      has_credentials,
    });
  }
  pools.sort_by(|a, b| a.name.cmp(&b.name));
  Ok(pools)
}

/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
/// nicht — dann ist die Liste leer.
fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, 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/<UUID v4>.
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
  check_name(name)?;
  if pool_names(paths)?.iter().any(|(_, n)| n == name) {
    return Err(format!("Pool existiert bereits: {name}"));
  }
  Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
}

fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
  fs::create_dir_all(dir).map_err(|e| e.to_string())?;
  let pool = Pool {
    name: name.to_string(),
    credential_type: credential_type.to_string(),
  };
  let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
  fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string())
}

fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
  if let Some(parent) = path.parent() {
    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
  }
  fs::write(path, content).map_err(|e| e.to_string())?;
  fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
}

/// 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.
/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst


die kannst du lesen die liegt innerhalb der erlaubten ordner


Linux-Code prüfen und Mac-Reintegration vorbereiten