1285	/// erscheint oben die Vorschlagstabelle.
1286	fn init_pool_config(
1287	  dir: &PathBuf,
1288	  extra: serde_json::Value,
1289	) -> Result<(), String> {
1290	  let mut settings = serde_json::json!({
1291	    "promptSuggestionEnabled": false,
1292	    "awaySummaryEnabled": false,
1293	  });
1294	  let base = settings.as_object_mut().unwrap();
1295	  if let Some(obj) = extra.as_object() {
1296	    for (k, v) in obj {
1297	      base.insert(k.clone(), v.clone());
1298	    }
1299	  }
1300	  fs::create_dir_all(dir).map_err(|e| e.to_string())?;
1301	  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
1302	  fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
1303	  let claude_md = dir.join("CLAUDE.md");
1304	  if !claude_md.is_file() {
1305	    fs::write(&claude_md, "").map_err(|e| e.to_string())?;
1306	  }
1307	  Ok(())
1308	}
1309
1310	/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
1311	/// Prompt-Historie. (name, ist_ordner)
1312	const SYNCED_RUNTIME: [(&str, bool); 3] =
1313	  [("projects", true), ("todos", true), ("history.jsonl", false)];
1314
1315	/// Sync-Ziel für Pool-Laufzeitdaten (settings.json: poolSyncDir).
1316	/// Ungesetzt = Feature aus, alle Daten bleiben lokal im Pool-Ordner.
1317	fn pool_sync_dir(paths: &Paths) -> Option<PathBuf> {
1318	  fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
1319	    .ok()
1320	    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
1321	    .and_then(|v| v["poolSyncDir"].as_str().map(|d| expand_home(paths, d)))
1322	}
1323
1324	/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir.
1325	fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
1326	  pool_sync_dir(paths)
1327	    .map(|d| d.join(pool))
1328	    .ok_or_else(|| "poolSyncDir ist nicht konfiguriert".to_string())
1329	}
1330
1331	/// Ersetzt im Pool-Ordner projects/todos/history.jsonl durch Symlinks auf den
1332	/// konfigurierten Sync-Ordner. Die Symlinks sind maschinenlokal, die Daten
1333	/// reisen über den Sync des Zielordners (z. B. git).
1334	/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
1335	fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
1336	  check_name(pool)?;
1337	  let src = paths.pool_dir(pool);
1338	  let data = pool_data_dir(paths, pool)?;
1339	  for (name, is_dir) in SYNCED_RUNTIME {
1340	    let target = data.join(name);
1341	    if is_dir {
1342	      fs::create_dir_all(&target).map_err(|e| e.to_string())?;
1343	    } else {
1344	      fs::create_dir_all(&data).map_err(|e| e.to_string())?;
1345	      if !target.exists() {
1346	        fs::write(&target, "").map_err(|e| e.to_string())?;
1347	      }
1348	    }
1349	    let link = src.join(name);
1350	    if link.is_dir() && !link.is_symlink() {
1351	      fs::remove_dir_all(&link).map_err(|e| e.to_string())?;
1352	    } else if link.is_symlink() || link.exists() {
1353	      fs::remove_file(&link).map_err(|e| e.to_string())?;
1354	    }
1355	    std::os::unix::fs::symlink(&target, &link).map_err(|e| e.to_string())?;
1356	  }
1357	  Ok(())
1358	}
1359
1360	/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit
1361	/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json.
1362	/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht.
1363	/// Liefert die Pool-ID.
1364	fn create_apikey_pool_in(
1365	  paths: &Paths,
1366	  store: &dyn ApikeyStore,
1367	  name: &str,
1368	  key: &str,
1369	  allow_file: bool,
1370	) -> Result<String, String> {
1371	  let dir = check_new_pool(paths, name)?;
1372	  let key = key.trim();
1373	  if key.is_empty() {
1374	    return Err("leerer API-Key".into());
1375	  }
1376	  let id = dir.file_name().unwrap().to_string_lossy().into_owned();
1377	  if store.set(&id, key).is_err() {
1378	    if !allow_file {
1379	      return Err(KEYCHAIN_UNAVAILABLE.into());
1380	    }
1381	    write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?;
1382	  }
1383	  init_pool_config(
1384	    &dir,
1385	    serde_json::json!({ "apiKeyHelper": apikey_helper_command(&dir, &id) }),
1386	  )?;
1387	  write_pool_json(&dir, name, "apikey")?;
1388	  if pool_sync_dir(paths).is_some() {
1389	    link_pool_runtime_in(paths, &id)?;
1390	  }
1391	  Ok(id)
1392	}
1393
1394	/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
1395	/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
1396	/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
1397	/// Liefert die Pool-ID.
1398	fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
1399	  let dir = check_new_pool(paths, name)?;
1400	  init_pool_config(&dir, serde_json::json!({}))?;
1401	  write_pool_json(&dir, name, "oauth")?;
1402	  let id = dir.file_name().unwrap().to_string_lossy().into_owned();
1403	  if pool_sync_dir(paths).is_some() {
1404	    link_pool_runtime_in(paths, &id)?;
1405	  }
1406	  Ok(id)
1407	}
1408
1409	/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
1410	/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
1411	fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
1412	  check_name(name)?;
1413	  let current = read_pool(paths, pool)?;
1414	  if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
1415	    return Err(format!("Pool existiert bereits: {name}"));
1416	  }
1417	  write_pool_json(&paths.pool_dir(pool), name, &current.credential_type)
1418	}
1419
1420	/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
1421	/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
1422	/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
1423	/// Sessions setzt der delete_pool-Command davor.
1424	fn delete_pool_in(paths: &Paths, store: &dyn ApikeyStore, name: &str) -> Result<(), String> {
1425	  let dir = paths.pool_dir(name);
1426	  if !dir.join(POOL_FILE).is_file() {
1427	    return Err(format!("Pool nicht gefunden: {name}"));
1428	  }
1429	  if read_pool(paths, name)?.credential_type == "apikey" {
1430	    store.delete(name)?;
1431	  }
1432	  for project in projects_using_pool(paths, name)? {
1433	    unassign_pool_in(paths, &project)?;
1434	  }
1435	  fs::remove_dir_all(&dir).map_err(|e| e.to_string())
1436	}
1437
1438	/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die
1439	/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern).
1440	/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch
1441	/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben.
1442	fn set_apikey_in(
1443	  paths: &Paths,
1444	  store: &dyn ApikeyStore,
1445	  pool: &str,
1446	  key: &str,
1447	  allow_file: bool,
1448	) -> Result<(), String> {
1449	  let p = read_pool(paths, pool)?;
1450	  if p.credential_type != "apikey" {
1451	    return Err(format!("Pool {pool} ist kein apikey-Pool"));
1452	  }
1453	  let key = key.trim();
1454	  if key.is_empty() {
1455	    return Err("leerer API-Key".into());
1456	  }
1457	  let dir = paths.pool_dir(pool);
1458	  let key_path = dir.join(APIKEY_FILE);
1459	  if store.set(pool, key).is_ok() {
1460	    if key_path.is_file() {
1461	      fs::remove_file(&key_path).map_err(|e| e.to_string())?;
1462	    }
1463	  } else {
1464	    if !allow_file {
1465	      return Err(KEYCHAIN_UNAVAILABLE.into());
1466	    }
1467	    write_secret_file(&key_path, &format!("{key}\n"))?;
1468	  }
1469	  let settings_path = dir.join("settings.json");
1470	  let raw = fs::read_to_string(&settings_path)
1471	    .map_err(|e| format!("{}: {e}", settings_path.display()))?;
1472	  let mut settings: serde_json::Value = serde_json::from_str(&raw)
1473	    .map_err(|e| format!("{}: {e}", settings_path.display()))?;
1474	  settings["apiKeyHelper"] = serde_json::json!(apikey_helper_command(&dir, pool));
1475	  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
1476	  fs::write(&settings_path, raw + "\n").map_err(|e| e.to_string())
1477	}
1478
1479	fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
1480	  let p = read_pool(paths, pool)?;
1481	  if p.credential_type != "oauth" {
1482	    return Err(format!("Pool {pool} ist kein oauth-Pool"));
1483	  }
1484	  Ok(())
1485	}
1486
1487	// ---------- Verbrauch ----------
1488
1489	#[derive(Serialize, Default)]
1490	struct UsageTotals {
1491	  #[serde(rename = "inputTokens")]
1492	  input_tokens: u64,
1493	  #[serde(rename = "outputTokens")]
1494	  output_tokens: u64,
1495	  #[serde(rename = "cacheCreationTokens")]
1496	  cache_creation_tokens: u64,
1497	  #[serde(rename = "cacheReadTokens")]
1498	  cache_read_tokens: u64,
1499	  #[serde(rename = "costUsd")]
1500	  cost_usd: f64,
1501	}
1502
1503	#[derive(Serialize)]
1504	struct UsageRow {
1505	  pool: String,
1506	  project: String,
1507	  #[serde(flatten)]
1508	  totals: UsageTotals,
1509	}
1510
1511	/// USD pro MTok (input, output, cache_write, cache_read).
1512	/// Cache: write 1.25× / read 0.1× des Input-Preises.
1513	fn model_rates(model: &str) -> (f64, f64, f64, f64) {
1514	  let (input, output) = if model.contains("fable") || model.contains("mythos") {
1515	    (10.0, 50.0)
1516	  } else if model.contains("opus") {
1517	    (5.0, 25.0)
1518	  } else if model.contains("haiku") {
1519	    (1.0, 5.0)
1520	  } else {
1521	    // sonnet und Unbekanntes
1522	    (3.0, 15.0)
1523	  };
1524	  (input, output, input * 1.25, input * 0.1)
1525	}
1526
1527	/// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil).
1528	fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
1529	  let y = if m <= 2 { y - 1 } else { y };
1530	  let era = if y >= 0 { y } else { y - 399 } / 400;
1531	  let yoe = y - era * 400;
1532	  let mp = (m + 9) % 12;
1533	  let doy = (153 * mp + 2) / 5 + d - 1;
1534	  let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1535	  era * 146097 + doe - 719468
1536	}
1537
1538	/// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden.
1539	fn parse_ts(ts: &str) -> Option<i64> {
1540	  if ts.len() < 19 {
1541	    return None;
1542	  }
1543	  let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().ok();
1544	  let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
1545	  let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
1546	  Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s)
1547	}
1548
1549	/// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt
1550	/// (alles außer [a-zA-Z0-9] wird '-').
1551	fn encode_project_path(path: &str) -> String {
1552	  path
1553	    .chars()
1554	    .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1555	    .collect()
1556	}
1557
1558	/// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen,
1559	/// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst).
1560	fn add_usage_line(
1561	  line: &str,
1562	  cutoff: i64,
1563	  seen: &mut HashSet<String>,
1564	  totals: &mut UsageTotals,
1565	) {
1566	  let v: serde_json::Value = match serde_json::from_str(line) {
1567	    Ok(v) => v,
1568	    // Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session)
1569	    Err(_) => return,
1570	  };
1571	  let usage = &v["message"]["usage"];
1572	  if !usage.is_object() {
1573	    return;
1574	  }
1575	  match v["timestamp"].as_str().and_then(parse_ts) {
1576	    Some(t) if t >= cutoff => {}
1577	    _ => return,
1578	  }
1579	  if let Some(id) = v["message"]["id"].as_str() {
1580	    let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or(""));
1581	    if !seen.insert(key) {
1582	      return;
1583	    }
1584	  }
1585	  let model = v["message"]["model"].as_str().unwrap_or("");
1586	  if model == "<synthetic>" {
1587	    return;
1588	  }
1589	  let input = usage["input_tokens"].as_u64().unwrap_or(0);
1590	  let output = usage["output_tokens"].as_u64().unwrap_or(0);
1591	  let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
1592	  let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
1593	  let (ri, ro, rw, rr) = model_rates(model);
1594	  totals.input_tokens += input;
1595	  totals.output_tokens += output;
1596	  totals.cache_creation_tokens += cache_write;
1597	  totals.cache_read_tokens += cache_read;
1598	  totals.cost_usd += input as f64 / 1e6 * ri
1599	    + output as f64 / 1e6 * ro
1600	    + cache_write as f64 / 1e6 * rw
1601	    + cache_read as f64 / 1e6 * rr;
1602	}
1603
1604	/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
1605	/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
1606	/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
1607	fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
1608	  let now = SystemTime::now()
1609	    .duration_since(UNIX_EPOCH)
1610	    .map_err(|e| e.to_string())?
1611	    .as_secs() as i64;
1612	  let cutoff = now - days as i64 * 86400;
1613
1614	  // kodierter Projektpfad → Projektname
1615	  let mut names = HashMap::new();
1616	  for (name, dir) in load_registry(paths)? {
1617	    names.insert(encode_project_path(&dir.to_string_lossy()), name);
1618	  }
1619
1620	  let mut seen = HashSet::new();
1621	  let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
1622
1623	  for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
1624	    let pool_entry = pool_entry.map_err(|e| e.to_string())?;
1625	    let id = pool_entry.file_name().to_string_lossy().into_owned();
1626	    // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
1627	    let pool = if pool_entry.path().join(POOL_FILE).is_file() {
1628	      read_pool(paths, &id)?.name
1629	    } else {
1630	      id
1631	    };
1632	    let projects_root = pool_entry.path().join("projects");
1633	    if !projects_root.is_dir() {
1634	      continue;
1635	    }
1636	    for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
1637	      let proj_entry = proj_entry.map_err(|e| e.to_string())?;
1638	      if !proj_entry.path().is_dir() {
1639	        continue;
1640	      }
1641	      let encoded = proj_entry.file_name().to_string_lossy().into_owned();
1642	      // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
1643	      // kodierten Namen ausweisen statt verschlucken.
1644	      let project = names.get(&encoded).cloned().unwrap_or(encoded);
1645	      let totals = rows.entry((pool.clone(), project)).or_default();
1646	      for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? {
1647	        let file = file.map_err(|e| e.to_string())?;
1648	        let path = file.path();
1649	        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1650	          continue;
1651	        }
1652	        let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
1653	        for line in BufReader::new(f).lines() {
1654	          let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
1655	          add_usage_line(&line, cutoff, &mut seen, totals);
1656	        }
1657	      }
1658	    }
1659	  }
1660
1661	  let mut out: Vec<UsageRow> = rows
1662	    .into_iter()
1663	    .map(|((pool, project), totals)| UsageRow { pool, project, totals })
1664	    .collect();
1665	  out.sort_by(|a, b| {
1666	    a.pool
1667	      .cmp(&b.pool)
1668	      .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
1669	  });
1670	  Ok(out)
1671	}
1672
1673	// ---------- Tauri-Commands ----------
1674
1675	#[tauri::command]
1676	fn list_projects() -> Result<Vec<Project>, String> {
1677	  list_projects_in(&Paths::real())
1678	}
1679
1680	#[tauri::command]
1681	fn create_project_full(
1682	  name: String,
1683	  dir: Option<String>,
1684	  pool: Option<String>,
1685	  work_dir: Option<String>,
1686	  create_work_dir: bool,
1687	  terminal: TerminalConfig,
1688	  todo: bool,
1689	) -> Result<(), String> {
1690	  create_project_full_in(
1691	    &Paths::real(),
1692	    &name,
1693	    dir.as_deref(),
1694	    pool.as_deref(),
1695	    work_dir.as_deref(),
1696	    create_work_dir,
1697	    terminal,
1698	    todo,
1699	  )
1700	}
1701
1702	/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
1703	#[tauri::command]
1704	fn add_project(path: String) -> Result<(), String> {
1705	  add_project_in(&Paths::real(), &path)
1706	}
1707
1708	/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
1709	#[tauri::command]
1710	fn remove_project(name: String) -> Result<(), String> {
1711	  if is_running(&name) {
1712	    return Err(format!("{name} läuft noch — erst beenden"));
1713	  }
1714	  unregister_project(&Paths::real(), &name)
1715	}
1716
1717	/// Projektordner neu zuordnen; bei laufender Session gesperrt.
1718	#[tauri::command]
1719	fn set_project_dir(project: String, dir: String) -> Result<(), String> {
1720	  if is_running(&project) {
1721	    return Err(format!("{project} läuft noch — erst beenden"));
1722	  }
1723	  set_project_dir_in(&Paths::real(), &project, &dir)
1724	}
1725
1726	#[tauri::command]
1727	fn add_work_dir(project: String, dir: String) -> Result<(), String> {
1728	  add_work_dir_in(&Paths::real(), &project, &dir)
1729	}
1730
1731	#[tauri::command]
1732	fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
1733	  remove_work_dir_in(&Paths::real(), &project, &dir)
1734	}
1735
1736	/// Anzeigename eines Pools (pool.json) — die ID ist der Ordnername.
1737	#[tauri::command]
1738	fn pool_label(pool: String) -> Result<String, String> {
1739	  Ok(read_pool(&Paths::real(), &pool)?.name)
1740	}
1741
1742	#[tauri::command]
1743	fn todo_state(project: String) -> Result<bool, String> {
1744	  todo_state_in(&Paths::real(), &project)
1745	}
1746
1747	#[tauri::command]
1748	fn set_todo(project: String, enabled: bool) -> Result<(), String> {
1749	  set_todo_in(&Paths::real(), &project, enabled)
1750	}
1751
1752	#[tauri::command]
1753	fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
1754	  if is_running(&name) {
1755	    return Err(format!("{name} läuft noch — erst beenden"));
1756	  }
1757	  delete_project_in(&Paths::real(), &name, delete_work_dirs)
1758	}
1759
1760	#[tauri::command]
1761	fn project_work_dirs(project: String) -> Result<Vec<String>, String> {
1762	  project_work_dirs_in(&Paths::real(), &project)
1763	}
1764
1765	#[tauri::command]
1766	fn list_pools() -> Result<Vec<PoolInfo>, String> {
1767	  list_pools_in(&Paths::real(), &KeychainStore)
1768	}
1769
1770	#[tauri::command]
1771	fn create_oauth_pool(name: String) -> Result<String, String> {
1772	  create_oauth_pool_in(&Paths::real(), &name)
1773	}
1774
1775	#[tauri::command]
1776	fn create_apikey_pool(name: String, key: String, allow_file: bool) -> Result<String, String> {
1777	  create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file)
1778	}
1779
1780	#[tauri::command]
1781	fn rename_pool(pool: String, name: String) -> Result<(), String> {
1782	  rename_pool_in(&Paths::real(), &pool, &name)
1783	}
1784
1785	#[tauri::command]
1786	fn delete_pool(pool: String) -> Result<(), String> {
1787	  let paths = Paths::real();
1788	  let running = running_projects_using_pool(&paths, &pool)?;
1789	  if !running.is_empty() {
1790	    return Err(format!(
1791	      "Pool wird noch benutzt — läuft: {}",
1792	      running.join(", ")
1793	    ));
1794	  }
1795	  delete_pool_in(&paths, &KeychainStore, &pool)
1796	}
1797
1798	#[tauri::command]
1799	fn assign_pool(project: String, pool: String) -> Result<(), String> {
1800	  assign_pool_in(&Paths::real(), &project, &pool)
1801	}
1802
1803	#[tauri::command]
1804	fn unassign_pool(project: String) -> Result<(), String> {
1805	  unassign_pool_in(&Paths::real(), &project)
1806	}
1807
1808	#[tauri::command]
1809	fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
1810	  usage_stats_in(&Paths::real(), days)
1811	}
1812
1813	#[tauri::command]
1814	fn set_terminal_config(
1815	  project: String,
1816	  theme: Option<String>,
1817	  icon: Option<String>,
1818	  title: Option<String>,
1819	) -> Result<(), String> {
1820	  set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
1821	}
1822
1823	#[tauri::command]
1824	fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<(), String> {
1825	  set_apikey_in(&Paths::real(), &KeychainStore, &pool, &key, allow_file)
1826	}
1827
1828	/// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen
1829	/// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über
1830	/// den Pool-Pfad.
1831	fn keychain_service(paths: &Paths, pool: &str) -> String {
1832	  let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
1833	  let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
1834	  format!("Claude Code-credentials-{}", &hex[..8])
1835	}
1836
1837	fn keychain_entry_exists(service: &str) -> Result<bool, String> {
1838	  let out = Command::new("security")
1839	    .args(["find-generic-password", "-s", service])
1840	    .output()
1841	    .map_err(|e| e.to_string())?;
1842	  Ok(out.status.success())
1843	}
1844
1845	#[tauri::command]
1846	fn keychain_status(pool: String) -> Result<bool, String> {
1847	  keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
1848	}
1849
1850	/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
1851	/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
1852	/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
1853	#[tauri::command]
1854	fn oauth_login(pool: String) -> Result<(), String> {
1855	  let paths = Paths::real();
1856	  ensure_oauth_pool(&paths, &pool)?;
1857
1858	  let running = running_projects_using_pool(&paths, &pool)?;
1859	  if !running.is_empty() {
1860	    return Err(format!(
1861	      "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
1862	      running.join(", ")
1863	    ));
1864	  }
1865
1866	  let service = keychain_service(&paths, &pool);
1867	  if keychain_entry_exists(&service)? {
1868	    let out = Command::new("security")
1869	      .args(["delete-generic-password", "-s", &service])
1870	      .output()
1871	      .map_err(|e| e.to_string())?;
1872	    if !out.status.success() {
1873	      return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
1874	    }
1875	  }
1876	  Ok(())
1877	}
1878
1879	/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte
1880	/// PID. Der Prozesstod schließt den PTY-Master, claude bekommt HUP und endet —
1881	/// wie beim Schließen des Fensters.
1882	fn kill_terminals(project: &str) -> Result<(), String> {
1883	  for pid in terminal_pids(project) {
1884	    let out = Command::new("kill")
1885	      .arg(pid.to_string())
1886	      .output()
1887	      .map_err(|e| e.to_string())?;
1888	    if !out.status.success() {
1889	      return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
1890	    }
1891	  }
1892	  Ok(())
1893	}
1894
1895	#[tauri::command]
1896	fn stop_project(project: String) -> Result<(), String> {
1897	  if terminal_pids(&project).is_empty() {
1898	    return Err(format!("{project} läuft nicht"));
1899	  }
1900	  kill_terminals(&project)
1901	}
1902
1903	/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
1904	/// interne Terminal neu.
1905	#[tauri::command]
1906	fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
1907	  kill_terminals(&project)?;
1908	  let deadline = Instant::now() + Duration::from_secs(30);
1909	  while is_running(&project) {
1910	    if Instant::now() > deadline {
1911	      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
1912	    }
1913	    std::thread::sleep(Duration::from_millis(250));
1914	  }
1915	  terminal::open_terminal(app, project)
1916	}
1917
1918	#[tauri::command]
1919	fn sync_setting() -> Result<bool, String> {
1920	  Ok(sync_on_session_end(&Paths::real()))
1921	}
1922
1923	#[tauri::command]
1924	fn set_sync_setting(enabled: bool) -> Result<(), String> {
1925	  set_sync_on_session_end_in(&Paths::real(), enabled)
1926	}
1927
1928	/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
1929	/// Terminals. Default 13.
1930	#[tauri::command]
1931	fn terminal_font_size() -> u32 {
1932	  fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE))
1933	    .ok()
1934	    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
1935	    .and_then(|v| v["terminalFontSize"].as_u64())
1936	    .map(|n| n as u32)
1937	    .unwrap_or(13)
1938	}
1939
1940	/// Setzt die Schriftgröße; erhält übrige App-settings.
1941	#[tauri::command]
1942	fn set_terminal_font_size(size: u32) -> Result<(), String> {
1943	  let paths = Paths::real();
1944	  let path = paths.config_dir().join(APP_SETTINGS_FILE);
1945	  let mut v: serde_json::Value = fs::read_to_string(&path)
1946	    .ok()
1947	    .and_then(|s| serde_json::from_str(&s).ok())
1948	    .unwrap_or_else(|| serde_json::json!({}));
1949	  v["terminalFontSize"] = serde_json::json!(size);
1950	  fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
1951	  let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
1952	  fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
1953	}
1954
1955	/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool —
1956	/// sonst würde das Transkript der laufenden Session ersetzt.
1957	#[tauri::command]
1958	fn link_pool_runtime(pool: String) -> Result<(), String> {
1959	  let paths = Paths::real();
1960	  if !running_projects_using_pool(&paths, &pool)?.is_empty() {
1961	    return Err(format!("{pool} wird benutzt — erst Session beenden"));
1962	  }
1963	  link_pool_runtime_in(&paths, &pool)
1964	}
1965
1966	#[cfg_attr(mobile, tauri::mobile_entry_point)]
1967	pub fn run() {
1968	  // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
1969	  let context = tauri::generate_context!();
1970	  let mut args = std::env::args().skip(1);
1971	  match args.next().as_deref() {
1972	    // `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
1973	    // damit jedes Terminal ein eigenes Dock-Icon bekommt.
1974	    Some("--terminal") => {
1975	      let project = args.next().expect("--terminal braucht einen Projektnamen");
1976	      // Linux: eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag
1977	      // (muss vor dem GTK-Init stehen). Test, ob set_prgname die app_id setzt.
1978	      #[cfg(target_os = "linux")]
1979	      glib::set_prgname(Some(format!("aicontrol-{project}").as_str()));
1980	      let icon = terminal_config(&project)
1981	        .expect("Projekt-Config nicht lesbar")
1982	        .icon
1983	        .map(|i| {
1984	          resolve_icon_path(&Paths::real(), &i)

/// erscheint oben die Vorschlagstabelle.
fn init_pool_config(
  dir: &PathBuf,
  extra: serde_json::Value,
) -> Result<(), String> {
  let mut settings = serde_json::json!({
    "promptSuggestionEnabled": false,
    "awaySummaryEnabled": false,
  });
  let base = settings.as_object_mut().unwrap();
  if let Some(obj) = extra.as_object() {
    for (k, v) in obj {
      base.insert(k.clone(), v.clone());
    }
  }
  fs::create_dir_all(dir).map_err(|e| e.to_string())?;
  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
  fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
  let claude_md = dir.join("CLAUDE.md");
  if !claude_md.is_file() {
    fs::write(&claude_md, "").map_err(|e| e.to_string())?;
  }
  Ok(())
}

/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
/// Prompt-Historie. (name, ist_ordner)
const SYNCED_RUNTIME: [(&str, bool); 3] =
  [("projects", true), ("todos", true), ("history.jsonl", false)];

/// Sync-Ziel für Pool-Laufzeitdaten (settings.json: poolSyncDir).
/// Ungesetzt = Feature aus, alle Daten bleiben lokal im Pool-Ordner.
fn pool_sync_dir(paths: &Paths) -> Option<PathBuf> {
  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["poolSyncDir"].as_str().map(|d| expand_home(paths, d)))
}

/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir.
fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
  pool_sync_dir(paths)
    .map(|d| d.join(pool))
    .ok_or_else(|| "poolSyncDir ist nicht konfiguriert".to_string())
}

/// Ersetzt im Pool-Ordner projects/todos/history.jsonl durch Symlinks auf den
/// konfigurierten Sync-Ordner. Die Symlinks sind maschinenlokal, die Daten
/// reisen über den Sync des Zielordners (z. B. git).
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
  check_name(pool)?;
  let src = paths.pool_dir(pool);
  let data = pool_data_dir(paths, pool)?;
  for (name, is_dir) in SYNCED_RUNTIME {
    let target = data.join(name);
    if is_dir {
      fs::create_dir_all(&target).map_err(|e| e.to_string())?;
    } else {
      fs::create_dir_all(&data).map_err(|e| e.to_string())?;
      if !target.exists() {
        fs::write(&target, "").map_err(|e| e.to_string())?;
      }
    }
    let link = src.join(name);
    if link.is_dir() && !link.is_symlink() {
      fs::remove_dir_all(&link).map_err(|e| e.to_string())?;
    } else if link.is_symlink() || link.exists() {
      fs::remove_file(&link).map_err(|e| e.to_string())?;
    }
    std::os::unix::fs::symlink(&target, &link).map_err(|e| e.to_string())?;
  }
  Ok(())
}

/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit
/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json.
/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht.
/// Liefert die Pool-ID.
fn create_apikey_pool_in(
  paths: &Paths,
  store: &dyn ApikeyStore,
  name: &str,
  key: &str,
  allow_file: bool,
) -> Result<String, String> {
  let dir = check_new_pool(paths, name)?;
  let key = key.trim();
  if key.is_empty() {
    return Err("leerer API-Key".into());
  }
  let id = dir.file_name().unwrap().to_string_lossy().into_owned();
  if store.set(&id, key).is_err() {
    if !allow_file {
      return Err(KEYCHAIN_UNAVAILABLE.into());
    }
    write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?;
  }
  init_pool_config(
    &dir,
    serde_json::json!({ "apiKeyHelper": apikey_helper_command(&dir, &id) }),
  )?;
  write_pool_json(&dir, name, "apikey")?;
  if pool_sync_dir(paths).is_some() {
    link_pool_runtime_in(paths, &id)?;
  }
  Ok(id)
}

/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
/// Liefert die Pool-ID.
fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
  let dir = check_new_pool(paths, name)?;
  init_pool_config(&dir, serde_json::json!({}))?;
  write_pool_json(&dir, name, "oauth")?;
  let id = dir.file_name().unwrap().to_string_lossy().into_owned();
  if pool_sync_dir(paths).is_some() {
    link_pool_runtime_in(paths, &id)?;
  }
  Ok(id)
}

/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
  check_name(name)?;
  let current = read_pool(paths, pool)?;
  if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
    return Err(format!("Pool existiert bereits: {name}"));
  }
  write_pool_json(&paths.pool_dir(pool), name, &current.credential_type)
}

/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
/// Sessions setzt der delete_pool-Command davor.
fn delete_pool_in(paths: &Paths, store: &dyn ApikeyStore, name: &str) -> Result<(), String> {
  let dir = paths.pool_dir(name);
  if !dir.join(POOL_FILE).is_file() {
    return Err(format!("Pool nicht gefunden: {name}"));
  }
  if read_pool(paths, name)?.credential_type == "apikey" {
    store.delete(name)?;
  }
  for project in projects_using_pool(paths, name)? {
    unassign_pool_in(paths, &project)?;
  }
  fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}

/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die
/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern).
/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch
/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben.
fn set_apikey_in(
  paths: &Paths,
  store: &dyn ApikeyStore,
  pool: &str,
  key: &str,
  allow_file: bool,
) -> Result<(), String> {
  let p = read_pool(paths, pool)?;
  if p.credential_type != "apikey" {
    return Err(format!("Pool {pool} ist kein apikey-Pool"));
  }
  let key = key.trim();
  if key.is_empty() {
    return Err("leerer API-Key".into());
  }
  let dir = paths.pool_dir(pool);
  let key_path = dir.join(APIKEY_FILE);
  if store.set(pool, key).is_ok() {
    if key_path.is_file() {
      fs::remove_file(&key_path).map_err(|e| e.to_string())?;
    }
  } else {
    if !allow_file {
      return Err(KEYCHAIN_UNAVAILABLE.into());
    }
    write_secret_file(&key_path, &format!("{key}\n"))?;
  }
  let settings_path = dir.join("settings.json");
  let raw = fs::read_to_string(&settings_path)
    .map_err(|e| format!("{}: {e}", settings_path.display()))?;
  let mut settings: serde_json::Value = serde_json::from_str(&raw)
    .map_err(|e| format!("{}: {e}", settings_path.display()))?;
  settings["apiKeyHelper"] = serde_json::json!(apikey_helper_command(&dir, pool));
  let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
  fs::write(&settings_path, raw + "\n").map_err(|e| e.to_string())
}

fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
  let p = read_pool(paths, pool)?;
  if p.credential_type != "oauth" {
    return Err(format!("Pool {pool} ist kein oauth-Pool"));
  }
  Ok(())
}

// ---------- Verbrauch ----------

#[derive(Serialize, Default)]
struct UsageTotals {
  #[serde(rename = "inputTokens")]
  input_tokens: u64,
  #[serde(rename = "outputTokens")]
  output_tokens: u64,
  #[serde(rename = "cacheCreationTokens")]
  cache_creation_tokens: u64,
  #[serde(rename = "cacheReadTokens")]
  cache_read_tokens: u64,
  #[serde(rename = "costUsd")]
  cost_usd: f64,
}

#[derive(Serialize)]
struct UsageRow {
  pool: String,
  project: String,
  #[serde(flatten)]
  totals: UsageTotals,
}

/// USD pro MTok (input, output, cache_write, cache_read).
/// Cache: write 1.25× / read 0.1× des Input-Preises.
fn model_rates(model: &str) -> (f64, f64, f64, f64) {
  let (input, output) = if model.contains("fable") || model.contains("mythos") {
    (10.0, 50.0)
  } else if model.contains("opus") {
    (5.0, 25.0)
  } else if model.contains("haiku") {
    (1.0, 5.0)
  } else {
    // sonnet und Unbekanntes
    (3.0, 15.0)
  };
  (input, output, input * 1.25, input * 0.1)
}

/// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
  let y = if m <= 2 { y - 1 } else { y };
  let era = if y >= 0 { y } else { y - 399 } / 400;
  let yoe = y - era * 400;
  let mp = (m + 9) % 12;
  let doy = (153 * mp + 2) / 5 + d - 1;
  let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  era * 146097 + doe - 719468
}

/// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden.
fn parse_ts(ts: &str) -> Option<i64> {
  if ts.len() < 19 {
    return None;
  }
  let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().ok();
  let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
  let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
  Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s)
}

/// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt
/// (alles außer [a-zA-Z0-9] wird '-').
fn encode_project_path(path: &str) -> String {
  path
    .chars()
    .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
    .collect()
}

/// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen,
/// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst).
fn add_usage_line(
  line: &str,
  cutoff: i64,
  seen: &mut HashSet<String>,
  totals: &mut UsageTotals,
) {
  let v: serde_json::Value = match serde_json::from_str(line) {
    Ok(v) => v,
    // Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session)
    Err(_) => return,
  };
  let usage = &v["message"]["usage"];
  if !usage.is_object() {
    return;
  }
  match v["timestamp"].as_str().and_then(parse_ts) {
    Some(t) if t >= cutoff => {}
    _ => return,
  }
  if let Some(id) = v["message"]["id"].as_str() {
    let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or(""));
    if !seen.insert(key) {
      return;
    }
  }
  let model = v["message"]["model"].as_str().unwrap_or("");
  if model == "<synthetic>" {
    return;
  }
  let input = usage["input_tokens"].as_u64().unwrap_or(0);
  let output = usage["output_tokens"].as_u64().unwrap_or(0);
  let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
  let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
  let (ri, ro, rw, rr) = model_rates(model);
  totals.input_tokens += input;
  totals.output_tokens += output;
  totals.cache_creation_tokens += cache_write;
  totals.cache_read_tokens += cache_read;
  totals.cost_usd += input as f64 / 1e6 * ri
    + output as f64 / 1e6 * ro
    + cache_write as f64 / 1e6 * rw
    + cache_read as f64 / 1e6 * rr;
}

/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
  let now = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .map_err(|e| e.to_string())?
    .as_secs() as i64;
  let cutoff = now - days as i64 * 86400;

  // kodierter Projektpfad → Projektname
  let mut names = HashMap::new();
  for (name, dir) in load_registry(paths)? {
    names.insert(encode_project_path(&dir.to_string_lossy()), name);
  }

  let mut seen = HashSet::new();
  let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();

  for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
    let pool_entry = pool_entry.map_err(|e| e.to_string())?;
    let id = pool_entry.file_name().to_string_lossy().into_owned();
    // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
    let pool = if pool_entry.path().join(POOL_FILE).is_file() {
      read_pool(paths, &id)?.name
    } else {
      id
    };
    let projects_root = pool_entry.path().join("projects");
    if !projects_root.is_dir() {
      continue;
    }
    for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
      let proj_entry = proj_entry.map_err(|e| e.to_string())?;
      if !proj_entry.path().is_dir() {
        continue;
      }
      let encoded = proj_entry.file_name().to_string_lossy().into_owned();
      // Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
      // kodierten Namen ausweisen statt verschlucken.
      let project = names.get(&encoded).cloned().unwrap_or(encoded);
      let totals = rows.entry((pool.clone(), project)).or_default();
      for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? {
        let file = file.map_err(|e| e.to_string())?;
        let path = file.path();
        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
          continue;
        }
        let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
        for line in BufReader::new(f).lines() {
          let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
          add_usage_line(&line, cutoff, &mut seen, totals);
        }
      }
    }
  }

  let mut out: Vec<UsageRow> = rows
    .into_iter()
    .map(|((pool, project), totals)| UsageRow { pool, project, totals })
    .collect();
  out.sort_by(|a, b| {
    a.pool
      .cmp(&b.pool)
      .then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
  });
  Ok(out)
}

// ---------- Tauri-Commands ----------

#[tauri::command]
fn list_projects() -> Result<Vec<Project>, String> {
  list_projects_in(&Paths::real())
}

#[tauri::command]
fn create_project_full(
  name: String,
  dir: Option<String>,
  pool: Option<String>,
  work_dir: Option<String>,
  create_work_dir: bool,
  terminal: TerminalConfig,
  todo: bool,
) -> Result<(), String> {
  create_project_full_in(
    &Paths::real(),
    &name,
    dir.as_deref(),
    pool.as_deref(),
    work_dir.as_deref(),
    create_work_dir,
    terminal,
    todo,
  )
}

/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
#[tauri::command]
fn add_project(path: String) -> Result<(), String> {
  add_project_in(&Paths::real(), &path)
}

/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
fn remove_project(name: String) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  unregister_project(&Paths::real(), &name)
}

/// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command]
fn set_project_dir(project: String, dir: String) -> Result<(), String> {
  if is_running(&project) {
    return Err(format!("{project} läuft noch — erst beenden"));
  }
  set_project_dir_in(&Paths::real(), &project, &dir)
}

#[tauri::command]
fn add_work_dir(project: String, dir: String) -> Result<(), String> {
  add_work_dir_in(&Paths::real(), &project, &dir)
}

#[tauri::command]
fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
  remove_work_dir_in(&Paths::real(), &project, &dir)
}

/// Anzeigename eines Pools (pool.json) — die ID ist der Ordnername.
#[tauri::command]
fn pool_label(pool: String) -> Result<String, String> {
  Ok(read_pool(&Paths::real(), &pool)?.name)
}

#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
  todo_state_in(&Paths::real(), &project)
}

#[tauri::command]
fn set_todo(project: String, enabled: bool) -> Result<(), String> {
  set_todo_in(&Paths::real(), &project, enabled)
}

#[tauri::command]
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
  if is_running(&name) {
    return Err(format!("{name} läuft noch — erst beenden"));
  }
  delete_project_in(&Paths::real(), &name, delete_work_dirs)
}

#[tauri::command]
fn project_work_dirs(project: String) -> Result<Vec<String>, String> {
  project_work_dirs_in(&Paths::real(), &project)
}

#[tauri::command]
fn list_pools() -> Result<Vec<PoolInfo>, String> {
  list_pools_in(&Paths::real(), &KeychainStore)
}

#[tauri::command]
fn create_oauth_pool(name: String) -> Result<String, String> {
  create_oauth_pool_in(&Paths::real(), &name)
}

#[tauri::command]
fn create_apikey_pool(name: String, key: String, allow_file: bool) -> Result<String, String> {
  create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file)
}

#[tauri::command]
fn rename_pool(pool: String, name: String) -> Result<(), String> {
  rename_pool_in(&Paths::real(), &pool, &name)
}

#[tauri::command]
fn delete_pool(pool: String) -> Result<(), String> {
  let paths = Paths::real();
  let running = running_projects_using_pool(&paths, &pool)?;
  if !running.is_empty() {
    return Err(format!(
      "Pool wird noch benutzt — läuft: {}",
      running.join(", ")
    ));
  }
  delete_pool_in(&paths, &KeychainStore, &pool)
}

#[tauri::command]
fn assign_pool(project: String, pool: String) -> Result<(), String> {
  assign_pool_in(&Paths::real(), &project, &pool)
}

#[tauri::command]
fn unassign_pool(project: String) -> Result<(), String> {
  unassign_pool_in(&Paths::real(), &project)
}

#[tauri::command]
fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
  usage_stats_in(&Paths::real(), days)
}

#[tauri::command]
fn set_terminal_config(
  project: String,
  theme: Option<String>,
  icon: Option<String>,
  title: Option<String>,
) -> Result<(), String> {
  set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
}

#[tauri::command]
fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<(), String> {
  set_apikey_in(&Paths::real(), &KeychainStore, &pool, &key, allow_file)
}

/// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen
/// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über
/// den Pool-Pfad.
fn keychain_service(paths: &Paths, pool: &str) -> String {
  let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
  let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
  format!("Claude Code-credentials-{}", &hex[..8])
}

fn keychain_entry_exists(service: &str) -> Result<bool, String> {
  let out = Command::new("security")
    .args(["find-generic-password", "-s", service])
    .output()
    .map_err(|e| e.to_string())?;
  Ok(out.status.success())
}

#[tauri::command]
fn keychain_status(pool: String) -> Result<bool, String> {
  keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
}

/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
#[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> {
  let paths = Paths::real();
  ensure_oauth_pool(&paths, &pool)?;

  let running = running_projects_using_pool(&paths, &pool)?;
  if !running.is_empty() {
    return Err(format!(
      "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
      running.join(", ")
    ));
  }

  let service = keychain_service(&paths, &pool);
  if keychain_entry_exists(&service)? {
    let out = Command::new("security")
      .args(["delete-generic-password", "-s", &service])
      .output()
      .map_err(|e| e.to_string())?;
    if !out.status.success() {
      return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
    }
  }
  Ok(())
}

/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte
/// PID. Der Prozesstod schließt den PTY-Master, claude bekommt HUP und endet —
/// wie beim Schließen des Fensters.
fn kill_terminals(project: &str) -> Result<(), String> {
  for pid in terminal_pids(project) {
    let out = Command::new("kill")
      .arg(pid.to_string())
      .output()
      .map_err(|e| e.to_string())?;
    if !out.status.success() {
      return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
    }
  }
  Ok(())
}

#[tauri::command]
fn stop_project(project: String) -> Result<(), String> {
  if terminal_pids(&project).is_empty() {
    return Err(format!("{project} läuft nicht"));
  }
  kill_terminals(&project)
}

/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
  kill_terminals(&project)?;
  let deadline = Instant::now() + Duration::from_secs(30);
  while is_running(&project) {
    if Instant::now() > deadline {
      return Err(format!("{project} hat sich nach 30 s nicht beendet"));
    }
    std::thread::sleep(Duration::from_millis(250));
  }
  terminal::open_terminal(app, project)
}

#[tauri::command]
fn sync_setting() -> Result<bool, String> {
  Ok(sync_on_session_end(&Paths::real()))
}

#[tauri::command]
fn set_sync_setting(enabled: bool) -> Result<(), String> {
  set_sync_on_session_end_in(&Paths::real(), enabled)
}

/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
/// Terminals. Default 13.
#[tauri::command]
fn terminal_font_size() -> u32 {
  fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE))
    .ok()
    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
    .and_then(|v| v["terminalFontSize"].as_u64())
    .map(|n| n as u32)
    .unwrap_or(13)
}

/// Setzt die Schriftgröße; erhält übrige App-settings.
#[tauri::command]
fn set_terminal_font_size(size: u32) -> Result<(), String> {
  let paths = Paths::real();
  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["terminalFontSize"] = serde_json::json!(size);
  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()))
}

/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool —
/// sonst würde das Transkript der laufenden Session ersetzt.
#[tauri::command]
fn link_pool_runtime(pool: String) -> Result<(), String> {
  let paths = Paths::real();
  if !running_projects_using_pool(&paths, &pool)?.is_empty() {
    return Err(format!("{pool} wird benutzt — erst Session beenden"));
  }
  link_pool_runtime_in(&paths, &pool)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
  // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
  let context = tauri::generate_context!();
  let mut args = std::env::args().skip(1);
  match args.next().as_deref() {
    // `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
    // damit jedes Terminal ein eigenes Dock-Icon bekommt.
    Some("--terminal") => {
      let project = args.next().expect("--terminal braucht einen Projektnamen");
      // Linux: eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag
      // (muss vor dem GTK-Init stehen). Test, ob set_prgname die app_id setzt.
      #[cfg(target_os = "linux")]
      glib::set_prgname(Some(format!("aicontrol-{project}").as_str()));
      let icon = terminal_config(&project)
        .expect("Projekt-Config nicht lesbar")
        .icon
        .map(|i| {
          resolve_icon_path(&Paths::real(), &i)


die kannst du lesen die liegt innerhalb der erlaubten ordner


Linux-Code prüfen und Mac-Reintegration vorbereiten


2285	    .manage(terminal::Terminals::default())
2286	    .setup(move |app| {
2287	      let cfg = terminal_config(&project)?;
2288	      terminal::build_window(app.handle(), &project, &cfg)?;
2289	      Ok(())
2290	    })
2291	    // Fenster zu → PTY-Session abräumen; danach endet der Prozess.
2292	    .on_window_event(|window, event| {
2293	      if let tauri::WindowEvent::Destroyed = event {
2294	        terminal::close(window);
2295	      }
2296	    })
2297	    .invoke_handler(tauri::generate_handler![
2298	      terminal::term_start,
2299	      terminal::term_log,
2300	      terminal::term_write,
2301	      terminal::term_resize,
2302	      // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
2303	      list_projects,
2304	      project_icon,
2305	      pool_label,
2306	      terminal_font_size,
2307	      set_terminal_font_size
2308	    ])
2309	}
2310
2311	// ---------- Tests ----------
2312
2313	#[cfg(test)]
2314	mod tests {
2315	  use super::*;
2316	  use std::sync::atomic::{AtomicU32, Ordering};
2317
2318	  static N: AtomicU32 = AtomicU32::new(0);
2319
2320	  /// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
2321	  /// echten System.
2322	  fn tmp_paths() -> Paths {
2323	    let dir = std::env::temp_dir().join(format!(
2324	      "ai-control-test-{}-{}",
2325	      std::process::id(),
2326	      N.fetch_add(1, Ordering::SeqCst)
2327	    ));
2328	    fs::create_dir_all(dir.join("claude-projects")).unwrap();
2329	    Paths { home: dir }
2330	  }
2331
2332	  /// In-Memory-ApikeyStore statt echtem Keychain.
2333	  struct MapStore(std::sync::Mutex<HashMap<String, String>>);
2334
2335	  fn map_store() -> MapStore {
2336	    MapStore(std::sync::Mutex::new(HashMap::new()))
2337	  }
2338
2339	  impl ApikeyStore for MapStore {
2340	    fn set(&self, pool: &str, key: &str) -> Result<(), String> {
2341	      self.0.lock().unwrap().insert(pool.into(), key.into());
2342	      Ok(())
2343	    }
2344	    fn has(&self, pool: &str) -> Result<bool, String> {
2345	      Ok(self.0.lock().unwrap().contains_key(pool))
2346	    }
2347	    fn delete(&self, pool: &str) -> Result<(), String> {
2348	      self.0.lock().unwrap().remove(pool);
2349	      Ok(())
2350	    }
2351	  }
2352
2353	  /// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts.
2354	  struct FailStore;
2355
2356	  impl ApikeyStore for FailStore {
2357	    fn set(&self, _pool: &str, _key: &str) -> Result<(), String> {
2358	      Err("kein Keychain".into())
2359	    }
2360	    fn has(&self, _pool: &str) -> Result<bool, String> {
2361	      Ok(false)
2362	    }
2363	    fn delete(&self, _pool: &str) -> Result<(), String> {
2364	      Ok(())
2365	    }
2366	  }
2367
2368	  /// Liefert die Pool-ID (UUID-Ordnername).
2369	  fn make_oauth_pool(p: &Paths, name: &str) -> String {
2370	    create_oauth_pool_in(p, name).unwrap()
2371	  }
2372
2373	  fn make_apikey_pool(p: &Paths, store: &dyn ApikeyStore, name: &str, key: &str) -> String {
2374	    create_apikey_pool_in(p, store, name, key, true).unwrap()
2375	  }
2376
2377	  fn mode(path: &PathBuf) -> u32 {
2378	    fs::metadata(path).unwrap().permissions().mode() & 0o777
2379	  }
2380
2381	  // -- Pool anlegen --
2382
2383	  #[test]
2384	  fn apikey_pool_anlegen() {
2385	    let p = tmp_paths();
2386	    let store = map_store();
2387	    let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123");
2388	    // Ordnername ist eine UUID, der Anzeigename steht nur in pool.json.
2389	    assert!(uuid::Uuid::parse_str(&id).is_ok());
2390	    let dir = p.pool_dir(&id);
2391
2392	    let pool: Pool =
2393	      serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
2394	    assert_eq!(pool.name, "kunde");
2395	    assert_eq!(pool.credential_type, "apikey");
2396
2397	    // Key liegt im Store, keine Datei im Pool-Ordner.
2398	    assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123");
2399	    assert!(!dir.join(APIKEY_FILE).exists());
2400
2401	    let settings: serde_json::Value =
2402	      serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
2403	        .unwrap();
2404	    assert_eq!(
2405	      settings["apiKeyHelper"].as_str().unwrap(),
2406	      apikey_helper_command(&dir, &id)
2407	    );
2408	  }
2409
2410	  #[test]
2411	  fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() {
2412	    let p = tmp_paths();
2413	    let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123");
2414	    let dir = p.pool_dir(&id);
2415
2416	    let key_path = dir.join(APIKEY_FILE);
2417	    assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
2418	    assert_eq!(mode(&key_path), 0o600);
2419
2420	    // Der Helper ist dieselbe Kette — der Store-Teil findet nichts,
2421	    // cat liefert die Datei.
2422	    let settings: serde_json::Value =
2423	      serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
2424	        .unwrap();
2425	    assert_eq!(
2426	      settings["apiKeyHelper"].as_str().unwrap(),
2427	      apikey_helper_command(&dir, &id)
2428	    );
2429	  }
2430
2431	  /// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts.
2432	  #[test]
2433	  fn apikey_anlegen_abbruch_ohne_store() {
2434	    let p = tmp_paths();
2435	    let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err();
2436	    assert_eq!(err, KEYCHAIN_UNAVAILABLE);
2437	    assert!(pool_names(&p).unwrap().is_empty());
2438	    assert!(!p.pools_dir().exists());
2439	  }
2440
2441	  /// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet.
2442	  #[test]
2443	  fn apikey_aendern_abbruch_ohne_store() {
2444	    let p = tmp_paths();
2445	    let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
2446	    let dir = p.pool_dir(&id);
2447	    let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap();
2448
2449	    let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err();
2450	    assert_eq!(err, KEYCHAIN_UNAVAILABLE);
2451	    assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n");
2452	    assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before);
2453	  }
2454
2455	  #[test]
2456	  fn apikey_helper_kette_referenz() {
2457	    let dir = PathBuf::from("/pools/abc");
2458	    let cmd = apikey_helper_command(&dir, "abc");
2459	    assert!(cmd.ends_with("|| cat '/pools/abc/apikey'"));
2460	    #[cfg(target_os = "macos")]
2461	    assert!(cmd.starts_with(
2462	      "security find-generic-password -w -s ai-control-apikey -a abc 2>/dev/null"
2463	    ));
2464	    #[cfg(not(target_os = "macos"))]
2465	    assert!(cmd.starts_with(
2466	      "secret-tool lookup service ai-control-apikey username abc 2>/dev/null"
2467	    ));
2468	  }
2469
2470	  #[test]
2471	  fn oauth_pool_anlegen() {
2472	    let p = tmp_paths();
2473	    let id = make_oauth_pool(&p, "privat");
2474	    let dir = p.pool_dir(&id);
2475
2476	    let pool: Pool =
2477	      serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
2478	    assert_eq!(pool.credential_type, "oauth");
2479
2480	    // Kein Credentials-File — den Login macht claude selbst beim ersten Start.
2481	    assert!(!dir.join(".credentials.json").exists());
2482	    // Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
2483	    // damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
2484	    let settings: serde_json::Value =

    .manage(terminal::Terminals::default())
    .setup(move |app| {
      let cfg = terminal_config(&project)?;
      terminal::build_window(app.handle(), &project, &cfg)?;
      Ok(())
    })
    // Fenster zu → PTY-Session abräumen; danach endet der Prozess.
    .on_window_event(|window, event| {
      if let tauri::WindowEvent::Destroyed = event {
        terminal::close(window);
      }
    })
    .invoke_handler(tauri::generate_handler![
      terminal::term_start,
      terminal::term_log,
      terminal::term_write,
      terminal::term_resize,
      // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
      list_projects,
      project_icon,
      pool_label,
      terminal_font_size,
      set_terminal_font_size
    ])
}

// ---------- Tests ----------

#[cfg(test)]
mod tests {
  use super::*;
  use std::sync::atomic::{AtomicU32, Ordering};

  static N: AtomicU32 = AtomicU32::new(0);

  /// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
  /// echten System.
  fn tmp_paths() -> Paths {
    let dir = std::env::temp_dir().join(format!(
      "ai-control-test-{}-{}",
      std::process::id(),
      N.fetch_add(1, Ordering::SeqCst)
    ));
    fs::create_dir_all(dir.join("claude-projects")).unwrap();
    Paths { home: dir }
  }

  /// In-Memory-ApikeyStore statt echtem Keychain.
  struct MapStore(std::sync::Mutex<HashMap<String, String>>);

  fn map_store() -> MapStore {
    MapStore(std::sync::Mutex::new(HashMap::new()))
  }

  impl ApikeyStore for MapStore {
    fn set(&self, pool: &str, key: &str) -> Result<(), String> {
      self.0.lock().unwrap().insert(pool.into(), key.into());
      Ok(())
    }
    fn has(&self, pool: &str) -> Result<bool, String> {
      Ok(self.0.lock().unwrap().contains_key(pool))
    }
    fn delete(&self, pool: &str) -> Result<(), String> {
      self.0.lock().unwrap().remove(pool);
      Ok(())
    }
  }

  /// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts.
  struct FailStore;

  impl ApikeyStore for FailStore {
    fn set(&self, _pool: &str, _key: &str) -> Result<(), String> {
      Err("kein Keychain".into())
    }
    fn has(&self, _pool: &str) -> Result<bool, String> {
      Ok(false)
    }
    fn delete(&self, _pool: &str) -> Result<(), String> {
      Ok(())
    }
  }

  /// Liefert die Pool-ID (UUID-Ordnername).
  fn make_oauth_pool(p: &Paths, name: &str) -> String {
    create_oauth_pool_in(p, name).unwrap()
  }

  fn make_apikey_pool(p: &Paths, store: &dyn ApikeyStore, name: &str, key: &str) -> String {
    create_apikey_pool_in(p, store, name, key, true).unwrap()
  }

  fn mode(path: &PathBuf) -> u32 {
    fs::metadata(path).unwrap().permissions().mode() & 0o777
  }

  // -- Pool anlegen --

  #[test]
  fn apikey_pool_anlegen() {
    let p = tmp_paths();
    let store = map_store();
    let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123");
    // Ordnername ist eine UUID, der Anzeigename steht nur in pool.json.
    assert!(uuid::Uuid::parse_str(&id).is_ok());
    let dir = p.pool_dir(&id);

    let pool: Pool =
      serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
    assert_eq!(pool.name, "kunde");
    assert_eq!(pool.credential_type, "apikey");

    // Key liegt im Store, keine Datei im Pool-Ordner.
    assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123");
    assert!(!dir.join(APIKEY_FILE).exists());

    let settings: serde_json::Value =
      serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
        .unwrap();
    assert_eq!(
      settings["apiKeyHelper"].as_str().unwrap(),
      apikey_helper_command(&dir, &id)
    );
  }

  #[test]
  fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() {
    let p = tmp_paths();
    let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123");
    let dir = p.pool_dir(&id);

    let key_path = dir.join(APIKEY_FILE);
    assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
    assert_eq!(mode(&key_path), 0o600);

    // Der Helper ist dieselbe Kette — der Store-Teil findet nichts,
    // cat liefert die Datei.
    let settings: serde_json::Value =
      serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
        .unwrap();
    assert_eq!(
      settings["apiKeyHelper"].as_str().unwrap(),
      apikey_helper_command(&dir, &id)
    );
  }

  /// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts.
  #[test]
  fn apikey_anlegen_abbruch_ohne_store() {
    let p = tmp_paths();
    let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err();
    assert_eq!(err, KEYCHAIN_UNAVAILABLE);
    assert!(pool_names(&p).unwrap().is_empty());
    assert!(!p.pools_dir().exists());
  }

  /// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet.
  #[test]
  fn apikey_aendern_abbruch_ohne_store() {
    let p = tmp_paths();
    let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
    let dir = p.pool_dir(&id);
    let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap();

    let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err();
    assert_eq!(err, KEYCHAIN_UNAVAILABLE);
    assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n");
    assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before);
  }

  #[test]
  fn apikey_helper_kette_referenz() {
    let dir = PathBuf::from("/pools/abc");
    let cmd = apikey_helper_command(&dir, "abc");
    assert!(cmd.ends_with("|| cat '/pools/abc/apikey'"));
    #[cfg(target_os = "macos")]
    assert!(cmd.starts_with(
      "security find-generic-password -w -s ai-control-apikey -a abc 2>/dev/null"
    ));
    #[cfg(not(target_os = "macos"))]
    assert!(cmd.starts_with(
      "secret-tool lookup service ai-control-apikey username abc 2>/dev/null"
    ));
  }

  #[test]
  fn oauth_pool_anlegen() {
    let p = tmp_paths();
    let id = make_oauth_pool(&p, "privat");
    let dir = p.pool_dir(&id);

    let pool: Pool =
      serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
    assert_eq!(pool.credential_type, "oauth");

    // Kein Credentials-File — den Login macht claude selbst beim ersten Start.
    assert!(!dir.join(".credentials.json").exists());
    // Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
    // damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
    let settings: serde_json::Value =


Gelesen — lib.rs (3148 Z.), terminal.rs (300 Z.), main.rs, Cargo.toml. Stand zur Mac-Reintegration:

**Plattform-Gating ist symmetrisch und vollständig.** Jede Linux-Primitive hat ihr macOS-Gegenstück:

| Funktion | macOS | Linux |
|---|---|---|
| `yield_activation_to_bundle` | NSApplication yield | no-op |
| `activate_self` | NSApplication.activate | Tauri set_focus |
| `set_dock_icon` | NSImage/setApplicationIconImage | no-op |
| `focus_terminal(pid)` | NSRunningApplication | no-op |
| Fenster-Deko | TitleBarStyle::Overlay | decorations(false) |
| Tray/Popup | natives Tray-Menü | D-Bus-Popup + .desktop |
| Keychain | `security`-CLI | keyring/secret-tool |

**Mac-Pfade intakt** — der Linux-Port hat nichts an macOS entfernt:
- `set_activation_policy(Accessory)`, natives Tray (`#[cfg(macos)]`, lib.rs:2177), ICNS→PNG via `sips`, `security`-CLI-Keychain mit ACL-Trick — alles unverändert vorhanden.
- Linux-Zusätze (`glib::set_prgname`, `.desktop`-Schreiben, `PopupService`/zbus, popup-Fenster) sind durchgehend `#[cfg(target_os = "linux")]`, greifen im Mac-Build nicht.
- `tray_menu`/`icns_to_png_bytes` kompilieren auf beiden, werden auf Linux aber nur über gegatete Aufrufer bzw. `tray_by_id("main")` (existiert dort nicht) erreicht — kein Fehler, höchstens eine dead-code-Warnung.

**Offen:** ob der Mac-Build tatsächlich durchkompiliert, ist per Lesen nicht beweisbar — nur `cargo check` (Toolchain in `~/tools/.cargo`) zeigt das. Das habe ich nicht ausgeführt.


wir können es probieren?
--° compile und mal hochfahrten?


Plan, zwei Schritte getrennt:

1. **Compile** — `npm run tauri dev` baut das Rust-Lib + Frontend, Toolchain aus `~/tools/.cargo`. Reiner Build, nichts läuft. Alternativ nur `cargo build` ohne Start.
2. **Hochfahren** — die App startet als Tray-App + Hauptfenster. Sie öffnet **kein** Terminal von selbst; ein Projekt-Terminal (das Claude startet) entsteht nur per Klick auf eine Projektkachel. Das klicke ich nicht an — das ist der Punkt aus dem Memory, der schon 3× die Session gekillt hat.

Risiko beim Hochfahren: `tauri dev` läuft im Vordergrund und belegt diese Shell, bis ich es abbreche. Ich starte es im Hintergrund, damit die Session frei bleibt.

Go für Schritt 1 (Compile)? Dann Schritt 2 separat.


wir können es probieren? --° compile und mal hochfahrten?


soll ich die installierte app beenden?


Ja. Sonst laufen zwei Instanzen gleichzeitig (zwei Tray-Icons, gleiche Config) und stören sich.


jau rein


Bash

export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH" && cargo build --message-format=short 2>&1 | tail -40

Compile Rust lib for macOS


   Compiling ai-control v0.1.1 (/home/marcuh/projects/ai-control/src-tauri)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 25.00s


Compile grün, keine Warnungen, 25 s. Jetzt Schritt 2 — hochfahren im Hintergrund.


export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH" && cd /home/marcuh/projects/ai-control && npm run tauri dev > "$TMPDIR/aic-dev.log" 2>&1

Launch ai-control dev build in background


Command running in background with ID: bs7jequ2i. Output is being written to: /private/tmp/claude-502/-Users-marcus-hinz-claude-projects-ai-control/83d7fac6-4a02-42d3-b8c9-e7d8284c9c38/tasks/bs7jequ2i.output. You will be notified when it completes. To check interim output, use Read on that file path.


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


export CARGO_HOME="$HOME/tools/.cargo" RUSTUP_HOME="$HOME/tools/.rustup" PATH="$HOME/tools/.cargo/bin:$PATH"; for i in $(seq 1 30); do if grep -qiE 'running|compiled|error|warn|Local:|localhost' "$TMPDIR/aic-dev.log" 2>/dev/null; then break; fi; sleep 2; done; echo "=== LOG ==="; cat "$TMPDIR/aic-dev.log"; echo "=== PROC ==="; pgrep -fl 'ai-control|tauri|vite' | grep -v claude

Wait for dev server startup and show log + processes


=== LOG ===

> ai-control@0.1.1 tauri
> tauri dev

     Running BeforeDevCommand (`npm run dev`)

> ai-control@0.1.1 dev
> vite

8:27:01 AM [vite] (client) Re-optimizing dependencies because lockfile has changed

  VITE v8.1.3  ready in 1307 ms

  ➜  Local:   http://localhost:1420/
     Running DevCommand (`cargo  run --no-default-features --color always --`)
        Info Watching /home/marcuh/projects/ai-control/src-tauri for changes...
8:27:02 AM [vite] (client) [optimizer] bundling dependencies...
   Compiling ai-control v0.1.1 (/home/marcuh/projects/ai-control/src-tauri)
    Building [=======================> ] 404/406: ai-control
    Building [=======================> ] 405/406: ai-control(bin)
    Finished ]8;;https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles\`dev` profile [unoptimized + debuginfo]]8;;\ target(s) in 5.44s
     Running `target/debug/ai-control`
=== PROC ===
32645 npm run tauri dev MANPATH=:/usr/share/man:/usr/local/share/man:/Applications/Ghostty.app/Contents/Resources/ghostty/../man: GHOSTTY_RESOURCES_DIR=/Applications/Ghostty.app/Contents/Resources/ghostty TERM_PROGRAM=ghostty TERM=xterm-ghostty
32661 node /home/marcuh/projects/ai-control/node_modules/.bin/tauri dev
32773 node /home/marcuh/projects/ai-control/node_modules/.bin/vite
32780 target/debug/ai-control


Läuft. Mac-Build kompiliert und gestartet:

- App-Prozess `target/debug/ai-control` (PID 32780), Vite-Dev-Server auf `localhost:1420`.
- Rust-Build 5,4 s, keine Fehler.
- Tray-Icon in der Menüleiste; das Hauptfenster ist per Default versteckt (`visible(false)`) — es erscheint über „Öffnen" im Tray-Menü.

Kein Projekt-Terminal gestartet. Die App bleibt im Hintergrund laufen, bis du Stopp sagst.


das sieht nicht nach dem neuen Dock aus


Was meinst du mit „neues Dock" — das macOS-Dock-Icon, das Tray-Popup oder das umgebaute Hauptfenster (Header fest, Commit 8434255)? Und ist das Fenster, das du ssiehst, der Dev-Build (Titel/Inhalt von `localhost:1420`) oder evtl. noch die installierte App?


Das Try Menu (das was auf icon click angzeigt wird) -° das hatten wir unter linux ziemlich umgebaut
hat mac jetzt ein anderes?


Ja. Die beiden Plattformen laufen auf zwei verschiedenen Tray-Implementierungen:

- **macOS**: natives Tauri-Tray-Menü (`tray_menu`, lib.rs:2177). Pro Projekt ein `IconMenuItem` — Status-Punkt (grün = läuft) + Projekt-Icon + Name, dazu „Öffnen"/„Beenden". Das ist der alte Stand.
- **Linux**: der Umbau lief über ein eigenes HTML-Popup (`popup.html` + `PopupService`/D-Bus, lib.rs:2205). Das ist die neue Optik.

Der Linux-Umbau ist nie auf Mac portiert worden — Mac hängt weiter am nativen Menü. Deshalb sieht es anders aus.


ok
kann man mac auf das Linux menü heben, und diese Lösung dann auch für Windows nutzen?


Ja. Das `popup.html`-Fenster selbst ist plattformneutral — ein Webview-Fenster, das auf allen drei Systemen gleich läuft. Wiederverwendbar wäre die komplette Optik plus die Commands (`start_or_focus_cmd`, `open_main_window`, `quit_app`), die das Popup schon nutzt.

Nicht portabel ist nur der **Auslöse-/Positionier-Mechanismus**:

- **Linux/GNOME**: kein nativer Tray-Klick → Shell-Extension + D-Bus (`PopupService` Show/Hide), die Extension schiebt das Fenster unter ihren Panel-Button. Das bleibt GNOME-spezifisch.
- **macOS/Windows**: beide liefern einen echten Tray-Klick (`TrayIconBuilder` mit `on_tray_icon_event`). Dort ersetzt der Klick-Event das D-Bus-Relay; das Popup wird selbst neben dem Tray-Icon positioniert (macOS oben rechts, Windows unten rechts).

Damit: ein gemeinsames Popup-Fenster für alle drei, natives Tray-Icon als Trigger auf Mac und Windows, D-Bus/Extension nur noch für Linux. Das native macOS-Menü (`tray_menu`, `menu_icon` mit den gerenderten Item-Icons) fällt dabei weg — die Info steckt schon im HTML-Popup.


kannst Du das tun?


Bevor ich das umsetze, lese ich das Popup-Frontend, um zu sehen, was an Positionierung/Schließen schon drinsteckt und was für den nativen Tray-Klick fehlt.


1	<!doctype html>
2
3
4	    <meta charset="UTF-8" />
5	    <title>Projekte</title>
6
13
14
15

16
17
18
19

<!doctype html>

    <meta charset="UTF-8" />
    <title>Projekte</title>


cd /home/marcuh/projects/ai-control && ls src && echo "---" && grep -rln "popup" src 2>/dev/null

List src and find popup frontend files


App.vue
assets
components
i18n.ts
main.ts
popup.ts
style.css
terminal.ts
---


1	import { createApp } from "vue";
2	import Popup from "./components/Popup.vue";
3
4	createApp(Popup).mount("#app");
5

import { createApp } from "vue";
import Popup from "./components/Popup.vue";

createApp(Popup).mount("#app");


1
65
66	<template>
67

68

69

70
71
72
73	        {{ p.name }}
74

75
Keine Projekte

76

77
78
79	      <button class="fbtn" @click="openMain">Öffnen</button>
80	      <button class="fbtn danger" @click="quit">Beenden</button>
81
82

83	</template>
84
85
183

<template>

        {{ p.name }}

Keine Projekte

      <button class="fbtn" @click="openMain">Öffnen</button>
      <button class="fbtn danger" @click="quit">Beenden</button>

</template>


Popup-Frontend (`Popup.vue`) ist komplett plattformneutral: pollt `list_projects` alle 2 s selbst, rendert Punkt+Icon+Name, Fußzeile Öffnen/Beenden, sizet sich selbst. Kein natives Menü nötig. Positioniert wird es nicht selbst — das macht auf Linux die GNOME-Extension.

Plan:

**Rust (`lib.rs`)**
1. `popup`-Fenster auch auf macOS/Windows bauen (gleiche Config wie Linux: dekorationslos, transparent, always-on-top, skip-taskbar, versteckt).
2. macOS/Windows-Tray: `TrayIconBuilder` **ohne** Menü + `on_tray_icon_event` → Links-Klick zeigt das Popup, positioniert am `rect` des Tray-Icons (macOS unter dem Menüleisten-Icon, Windows über dem Tray), `set_focus`.
3. Popup schließt bei Fokusverlust — die bisher Linux-only-Regel auf macOS/Windows ausweiten.
4. `spawn_session_watcher`: den nativen Menü-Neuaufbau (`tray_by_id("main")` + `set_menu`) rauswerfen — das Popup pollt selbst. Session-End-Sync bleibt.
5. Die dann toten Funktionen des nativen Menüs entfernen: `tray_menu`, `menu_icon`, `project_icon_rgba_36`, `scale_rgba`.

**Config**
6. `tauri.conf.json`: `app.macOSPrivateApi: true` setzen — ohne das rendert macOS kein transparentes Fenster (prüfe ich, ggf. ergänzen).

**Frontend**: `Popup.vue` unverändert wiederverwendet.

Zwei Einschränkungen:
- **Windows kann ich hier nicht testen** — Code cfg-gegatet, verifizierbar nur macOS/Linux.
- Positionierung am Tray-`rect` ist der einzige echt neue Teil; Rest ist Umverdrahtung.

Go?


ja


1	{
2	  "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
3	  "productName": "ai-control",
4	  "version": "0.1.1",
5	  "identifier": "com.aicontrol.app",
6	  "build": {
7	    "frontendDist": "../dist",
8	    "devUrl": "http://localhost:1420",
9	    "beforeDevCommand": "npm run dev",
10	    "beforeBuildCommand": "npm run build"
11	  },
12	  "app": {
13	    "windows": [],
14	    "security": {
15	      "csp": null
16	    }
17	  },
18	  "bundle": {
19	    "active": true,
20	    "targets": "all",
21	    "icon": [
22	      "icons/32x32.png",
23	      "icons/128x128.png",
24	      "icons/128x128@2x.png",
25	      "icons/icon.icns",
26	      "icons/icon.ico"
27	    ],
28	    "android": {
29	      "debugApplicationIdSuffix": ".debug"
30	    }
31	  }
32	}
33


Config und Code. Zuerst `macOSPrivateApi` für das transparente Popup:


Edit

  "app": {
    "windows": [],
    "security": {
      "csp": null
    }
  },

  "app": {
    "macOSPrivateApi": true,
    "windows": [],
    "security": {
      "csp": null
    }
  },


The file /home/marcuh/projects/ai-control/src-tauri/tauri.conf.json has been updated successfully. (file state is current in your context — no need to Read it back)

  "app": {
    "windows": [],
    "security": {
      "csp": null
    }
  },

  "app": {
    "macOSPrivateApi": true,
    "windows": [],
    "security": {
      "csp": null
    }
  },


Session-Watcher: nativen Menü-Neuaufbau raus, `app`-Param entfällt:


/// 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;
    }
  });
}

/// Session-Watcher: 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).
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
fn spawn_session_watcher() {
  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 Watcher 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);
            }
          }
        }
      }
      state = current;
    }
  });
}


The file /home/marcuh/projects/ai-control/src-tauri/src/lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// 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;
    }
  });
}

/// Session-Watcher: 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).
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
fn spawn_session_watcher() {
  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 Watcher 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);
            }
          }
        }
      }
      state = current;
    }
  });
}

-/// 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.

+/// Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft"

+/// auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.

-/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.

-fn spawn_session_watcher(app: tauri::AppHandle) {

+/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.

-      // der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.

+      // der Watcher verlangt kein Repo, er nutzt es nur, falls vorhanden.

-      // Menü-Objekte gehören auf den Main-Thread.

-          // 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");


Die toten Funktionen des nativen Menüs entfernen (`menu_icon`, `project_icon_rgba_36`, `scale_rgba`):


/// 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).

/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS).


/// 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).

-/// 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.

-) -> Result<tauri::image::Image<'static>, String> {

-  let mut canvas = vec![0u8; W * H * 4];

-    // macOS-Systemgrün, weicher Rand über Alpha.

-  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) {

-        let (iw, ih) = (img.width() as usize, img.height() as usize);

-            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;

-  let src = resolve_icon_path(paths, icon);

-    .is_some_and(|e| e.eq_ignore_ascii_case("icns"));

-    tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())?

-    tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))?

-/// 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 mut out = vec![0u8; size * size * 4];

-      out[di..di + 4].copy_from_slice(&src[si..si + 4]);

-  tauri::image::Image::new_owned(out, size as u32, size as u32)

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"));


Das native `tray_menu` entfernen:


/// Tray-Menü: pro Projekt ein Eintrag — Status-Punkt, Projekt-Icon, Name.
/// Klick startet das Projekt bzw. holt das laufende Terminal nach vorn.
fn tray_menu(
  app: &tauri::AppHandle,
) -> Result<tauri::menu::Menu<tauri::Wry>, Box<dyn std::error::Error>> {
  use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem};
  let paths = Paths::real();
  let menu = Menu::new(app)?;
  menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?;
  menu.append(&PredefinedMenuItem::separator(app)?)?;
  let mut projects = list_projects_in(&paths)?;
  projects.sort_by(|a, b| a.name.cmp(&b.name));
  for p in projects {
    let icon = menu_icon(&paths, &p.name, p.running)?;
    menu.append(&IconMenuItem::with_id(
      app,
      format!("project:{}", p.name),
      &p.name,
      true,
      Some(icon),
      None::<&str>,
    )?)?;
  }
  menu.append(&PredefinedMenuItem::separator(app)?)?;
  menu.append(&MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?)?;
  Ok(menu)
}

/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.

/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.


/// Tray-Menü: pro Projekt ein Eintrag — Status-Punkt, Projekt-Icon, Name.
/// Klick startet das Projekt bzw. holt das laufende Terminal nach vorn.
fn tray_menu(
  app: &tauri::AppHandle,
) -> Result<tauri::menu::Menu<tauri::Wry>, Box<dyn std::error::Error>> {
  use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem};
  let paths = Paths::real();
  let menu = Menu::new(app)?;
  menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?;
  menu.append(&PredefinedMenuItem::separator(app)?)?;
  let mut projects = list_projects_in(&paths)?;
  projects.sort_by(|a, b| a.name.cmp(&b.name));
  for p in projects {
    let icon = menu_icon(&paths, &p.name, p.running)?;
    menu.append(&IconMenuItem::with_id(
      app,
      format!("project:{}", p.name),
      &p.name,
      true,
      Some(icon),
      None::<&str>,
    )?)?;
  }
  menu.append(&PredefinedMenuItem::separator(app)?)?;
  menu.append(&MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?)?;
  Ok(menu)
}

/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.

-/// Tray-Menü: pro Projekt ein Eintrag — Status-Punkt, Projekt-Icon, Name.

-/// Klick startet das Projekt bzw. holt das laufende Terminal nach vorn.

-) -> Result<tauri::menu::Menu<tauri::Wry>, Box<dyn std::error::Error>> {

-  use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem};

-  menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?;

-  let mut projects = list_projects_in(&paths)?;

-  projects.sort_by(|a, b| a.name.cmp(&b.name));

-    let icon = menu_icon(&paths, &p.name, p.running)?;

-    menu.append(&IconMenuItem::with_id(

-  menu.append(&MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?)?;

fn start_or_focus(app: &tauri::AppHandle, project: &str) {


Setup: Watcher-Aufruf ohne `app`, dann Tray/Popup neu verdrahten:


      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und
      // hält das Tray-Menü aktuell.
      spawn_session_watcher(app.handle().clone());

      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
      spawn_session_watcher();


      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und
      // hält das Tray-Menü aktuell.
      spawn_session_watcher(app.handle().clone());

      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
      spawn_session_watcher();

-      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und

-      spawn_session_watcher(app.handle().clone());

+      // Session-Watcher: synct bei Session-Ende (Prozess verschwindet).

// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,

// damit sie da sind, bevor ein Terminal startet.