diff --git a/kwin-script/ai-control-popup/contents/code/main.js b/kwin-script/ai-control-popup/contents/code/main.js new file mode 100644 index 0000000..f80830c --- /dev/null +++ b/kwin-script/ai-control-popup/contents/code/main.js @@ -0,0 +1,37 @@ +// KWin-Script (Plasma 6): das Pendant zur GNOME-Shell-Extension. +// Unter Wayland darf nur der Compositor ein Fenster positionieren — die App +// (Client) kann das nicht. Dieses Script schiebt unser rahmenloses Popup an +// den Mauszeiger, der beim Tray-Klick auf dem Icon sitzt. Sonst nichts. +// +// Kein Drift: Fenster, UI und Verhalten kommen unverändert aus der App; hier +// wird ausschließlich platziert. + +const WIN_TITLE = "ai-control-popup"; + +function place(w) { + if (!w || w.caption !== WIN_TITLE) { + return; + } + const cursor = workspace.cursorPos; + // MaximizeArea = Arbeitsfläche ohne Panels; so sitzt das Popup neben der + // Leiste, nicht darunter. + const area = workspace.clientArea(KWin.MaximizeArea, w); + const width = w.frameGeometry.width; + const height = w.frameGeometry.height; + + // Rechte Fensterkante an den Zeiger; oben/unten aus dem Klemmen an die + // Arbeitsflächenkante (Panel oben -> unter dem Icon, Panel unten -> darüber). + let x = cursor.x - width; + let y = cursor.y; + if (x < area.x) x = area.x; + if (x + width > area.x + area.width) x = area.x + area.width - width; + if (y < area.y) y = area.y; + if (y + height > area.y + area.height) y = area.y + area.height - height; + + w.frameGeometry = Qt.rect(x, y, width, height); +} + +// windowAdded feuert beim (erneuten) Mappen des Popups, windowActivated fängt +// das Wiederanzeigen ab, falls der Compositor das Fenster nur reaktiviert. +workspace.windowAdded.connect(place); +workspace.windowActivated.connect(place); diff --git a/kwin-script/ai-control-popup/metadata.json b/kwin-script/ai-control-popup/metadata.json new file mode 100644 index 0000000..0ff3c74 --- /dev/null +++ b/kwin-script/ai-control-popup/metadata.json @@ -0,0 +1,13 @@ +{ + "KPlugin": { + "Id": "ai-control-popup", + "Name": "ai-control Popup Positioner", + "Description": "Schiebt das rahmenlose ai-control-Popup an den Mauszeiger — das Pendant zur GNOME-Shell-Extension, nur für KWin (Wayland erlaubt Positionieren nur im Compositor).", + "Authors": [{ "Name": "marcus.hinz" }], + "Version": "1.0", + "License": "MIT", + "EnabledByDefault": false + }, + "X-Plasma-API": "javascript", + "X-Plasma-MainScript": "code/main.js" +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dbc23a7..81f255e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "base64 0.22.1", "glib", "keyring", + "ksni", "log", "objc2", "objc2-app-kit", @@ -94,6 +95,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -266,6 +276,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "auto-launch" version = "0.5.0" @@ -623,6 +644,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "combine" version = "4.6.7" @@ -803,7 +839,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.118", ] @@ -829,6 +865,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c" +dependencies = [ + "clap", + "dbus", + "xml-rs", +] + [[package]] name = "dbus-secret-service" version = "4.1.0" @@ -839,6 +886,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1660,6 +1716,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2090,6 +2155,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ksni" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c7d627125aac4ea80802d51d9f8c3e4ceb948792414b0e345903ff78337b3a7" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-tree", + "thiserror 1.0.69", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2779,7 +2856,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", "rustix", "windows-sys 0.61.2", @@ -3714,6 +3791,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -4180,6 +4263,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4604,6 +4696,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "url" version = "2.5.8" @@ -4665,6 +4763,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.1" @@ -5455,6 +5559,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c42f64..8222b35 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ keyring = { version = "3", features = ["apple-native", "sync-secret-service", "w zbus = "5" glib = "0.18" rfd = { version = "0.15", default-features = false, features = ["gtk3"] } +ksni = "0.1" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs new file mode 100644 index 0000000..ea3580b --- /dev/null +++ b/src-tauri/src/app.rs @@ -0,0 +1,301 @@ +//! Tauri-Verdrahtung: Haupt-App (Tray, main-/popup-Fenster, Watcher) und +//! Terminal-Prozess. Das Popup ist auf allen OS dasselbe HTML-Fenster; nur +//! der Klick-Trigger kommt aus platform::init_tray (Anchor-Kontrakt). + +use crate::domain::paths::Paths; +use crate::domain::pool::provision_pools_for_panel; +use crate::domain::project::terminal_config; +use crate::domain::watcher::spawn_session_watcher; +use crate::platform::Anchor; +use crate::{commands, terminal}; + +/// Sperrt das Auto-Hide des Popups bei Fokusverlust für einen Moment nach dem +/// Anzeigen (KDE-Erststart-Race). Als Tauri-State geführt. +#[cfg(target_os = "linux")] +pub(crate) struct PopupBlurGuard(pub(crate) std::sync::Arc); + +/// Popup an den Klick-Koordinaten platzieren, in den Monitor geklemmt. +#[cfg(target_os = "linux")] +fn position_popup(w: &tauri::WebviewWindow, x: i32, y: i32) { + use tauri::{PhysicalPosition, PhysicalSize}; + let size = w.outer_size().unwrap_or_else(|_| PhysicalSize::new(320, 300)); + let (mon_pos, mon_size) = w + .current_monitor() + .ok() + .flatten() + .map(|m| (*m.position(), *m.size())) + .unwrap_or((PhysicalPosition::new(0, 0), PhysicalSize::new(1920, 1080))); + // Rechte Kante an den Klick; oben/unten ergibt sich aus dem Klemmen an die + // Monitorkante (Panel oben -> unter dem Icon, Panel unten -> darüber). + let max_x = mon_pos.x + mon_size.width as i32 - size.width as i32; + let max_y = mon_pos.y + mon_size.height as i32 - size.height as i32; + let px = (x - size.width as i32).clamp(mon_pos.x, max_x.max(mon_pos.x)); + let py = y.clamp(mon_pos.y, max_y.max(mon_pos.y)); + let _ = w.set_position(PhysicalPosition::new(px, py)); +} + +/// Popup zeigen — ein Codepfad für alle OS, nur die Platzierung folgt dem +/// Anchor aus dem Tray-Trigger. +pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) { + use tauri::Manager; + let win = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(w) = win.get_webview_window("popup") else { + return; + }; + match anchor { + // Nativer Tray (macOS/Windows): rechte Popup-Kante an der rechten + // Icon-Kante; macOS unter dem Menüleisten-Icon, Windows über der Taskbar. + Anchor::IconRect { rect, popup_below } => { + let scale = w.scale_factor().unwrap_or(1.0); + let pos = rect.position.to_physical::(scale); + let size = rect.size.to_physical::(scale); + let win_w = w.outer_size().map(|s| s.width as f64).unwrap_or(320.0); + let x = (pos.x + size.width - win_w).max(0.0); + let y = if popup_below { + pos.y + size.height + } else { + let win_h = w.outer_size().map(|s| s.height as f64).unwrap_or(300.0); + (pos.y - win_h).max(0.0) + }; + let _ = w.set_position(tauri::PhysicalPosition::new(x, y)); + } + // SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren + // Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon. + Anchor::Cursor => { + #[cfg(target_os = "linux")] + { + if let Some(g) = win.try_state::() { + g.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + match win.cursor_position() { + Ok(p) => position_popup(&w, p.x as i32, p.y as i32), + Err(_) => { + let _ = w.center(); + } + } + // Guard nach kurzer Zeit lösen — danach schließt Fokusverlust wieder + // normal. + let g2 = win.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(350)); + if let Some(g) = g2.try_state::() { + g.0.store(false, std::sync::atomic::Ordering::SeqCst); + } + }); + } + } + // GNOME-Extension/KWin: der Compositor positioniert selbst. + Anchor::Managed => {} + } + let _ = w.show(); + let _ = w.set_focus(); + }); +} + +/// Popup verstecken (GNOME-Toggle über D-Bus Hide()). +pub(crate) fn hide_popup(app: &tauri::AppHandle) { + use tauri::Manager; + let win = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(w) = win.get_webview_window("popup") { + let _ = w.hide(); + } + }); +} + +/// Haupt-App: reine Tray-App ohne Dock-Eintrag. +pub(crate) fn main_builder() -> tauri::Builder { + tauri::Builder::default() + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + + // Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag. + #[cfg(target_os = "macos")] + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + + // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). + spawn_session_watcher(); + + // Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende), + // damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht. + provision_pools_for_panel(&Paths::real()); + + // Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen, + // damit sie da sind, bevor ein Terminal startet (No-op außerhalb Linux). + crate::platform::sync_all_desktops(&Paths::real()); + + // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess + // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. + let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) + .title("ai-control") + .inner_size(800.0, 600.0) + // Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab, + // unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon. + .icon(tauri::image::Image::from_bytes(include_bytes!( + "../icons/128x128.png" + ))?)? + .visible(false); + // Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die + // Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header). + #[cfg(target_os = "macos")] + let main_win = main_win + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + #[cfg(target_os = "linux")] + let main_win = main_win.decorations(false); + main_win.build()?; + + // Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Nur + // der Weg zum Klick unterscheidet sich (platform::init_tray). + tauri::WebviewWindowBuilder::new(app, "popup", tauri::WebviewUrl::App("popup.html".into())) + .title("ai-control-popup") + .inner_size(320.0, 300.0) + .decorations(false) + .visible(false) + .transparent(true) + .skip_taskbar(true) + .always_on_top(true) + .resizable(false) + .build()?; + + #[cfg(target_os = "linux")] + { + use tauri::Manager; + app.manage(PopupBlurGuard(std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ))); + } + + // Der gesamte Tray-Kontrakt: Icon zeigen, Klick als Anchor melden — + // was dann passiert, entscheidet ausschließlich dieser Code. + crate::platform::init_tray( + app.handle(), + crate::platform::TrayCallbacks { + show: Box::new(|app, anchor| show_popup(app, anchor)), + hide: Box::new(hide_popup), + }, + )?; + + Ok(()) + }) + // Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü. + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + window.hide().unwrap(); + } + // Popup schließt bei Fokusverlust. + if let tauri::WindowEvent::Focused(false) = event { + if window.label() == "popup" { + // KDE feuert direkt nach show/set_focus ein Focused(false) — ohne + // diese kurze Sperre verschwände das Popup sofort wieder (erst der + // zweite Klick hielte). GNOME/macOS setzen die Sperre nie. + #[cfg(target_os = "linux")] + { + use tauri::Manager; + if let Some(g) = window.try_state::() { + if g.0.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + } + } + let _ = window.hide(); + } + } + }) + .invoke_handler(tauri::generate_handler![ + commands::list_projects, + commands::create_project_full, + commands::add_project, + commands::remove_project, + commands::delete_project, + commands::project_work_dirs, + commands::set_project_dir, + commands::add_work_dir, + commands::remove_work_dir, + commands::list_pools, + commands::create_oauth_pool, + commands::create_apikey_pool, + commands::rename_pool, + commands::delete_pool, + commands::assign_pool, + commands::unassign_pool, + commands::set_terminal_config, + commands::project_icon, + commands::pool_label, + commands::todo_state, + commands::set_todo, + commands::usage_stats, + commands::stop_project, + commands::restart_project, + commands::start_or_focus_cmd, + commands::open_main_window, + commands::quit_app, + commands::sync_setting, + commands::set_sync_setting, + commands::terminal_font_size, + commands::set_terminal_font_size, + commands::link_pool_runtime, + commands::oauth_login, + commands::keychain_status, + commands::set_apikey, + terminal::open_terminal + ]) +} + +/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt +/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. +pub(crate) fn terminal_builder(project: String) -> tauri::Builder { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .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. Das + // abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel + // wieder an (panel-attached), der Prozess läuft weiter. + .on_window_event(|window, event| { + if let tauri::WindowEvent::Destroyed = event { + if window.label().starts_with("panel-") { + use tauri::{Emitter, Manager}; + let _ = window.app_handle().emit("panel-window-closed", ()); + } else { + terminal::close(window); + } + } + }) + .invoke_handler(tauri::generate_handler![ + terminal::term_start, + terminal::term_log, + terminal::term_write, + terminal::term_resize, + terminal::panel_read, + terminal::panel_set, + terminal::open_panel_window, + commands::panel_archive_dir_cmd, + commands::panel_archive_cmd, + commands::reveal_path_cmd, + // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. + commands::list_projects, + commands::project_icon, + commands::pool_label, + commands::terminal_font_size, + commands::set_terminal_font_size, + commands::spellcheck_lang + ]) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..d4b1cd7 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,354 @@ +//! Tauri-Commands der Verwaltungs-UI (Haupt-App, Popup, Terminal-Header) — +//! dünne Delegation an domain/ und platform/. + +use std::time::{Duration, Instant}; + +use crate::domain::archive::{archive_panel_content, project_archive_dir}; +use crate::domain::credentials::keychain_service; +use crate::domain::paths::Paths; +use crate::domain::pool::{ + create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool, + link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo, +}; +use crate::domain::project::{ + add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in, + is_running, kill_terminals, list_projects_in, project_work_dirs_in, read_project_config_in, + remove_work_dir_in, resolve_icon_path, running_projects_using_pool, set_project_dir_in, + set_terminal_config_in, unassign_pool_in, Project, TerminalConfig, +}; +use crate::domain::registry::unregister_project; +use crate::domain::settings; +use crate::domain::todo::{set_todo_in, todo_state_in}; +use crate::domain::usage::{usage_stats_in, UsageRow}; +use crate::platform::KeychainStore; +use crate::terminal; + +// ---------- Projekte ---------- + +#[tauri::command] +pub(crate) fn list_projects() -> Result, String> { + list_projects_in(&Paths::real()) +} + +#[tauri::command] +pub(crate) fn create_project_full( + name: String, + dir: Option, + pool: Option, + work_dir: Option, + 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] +pub(crate) fn add_project(path: String) -> Result<(), String> { + add_project_in(&Paths::real(), &path) +} + +/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet. +#[tauri::command] +pub(crate) 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] +pub(crate) 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] +pub(crate) fn add_work_dir(project: String, dir: String) -> Result<(), String> { + add_work_dir_in(&Paths::real(), &project, &dir) +} + +#[tauri::command] +pub(crate) fn remove_work_dir(project: String, dir: String) -> Result<(), String> { + remove_work_dir_in(&Paths::real(), &project, &dir) +} + +#[tauri::command] +pub(crate) 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] +pub(crate) fn project_work_dirs(project: String) -> Result, String> { + project_work_dirs_in(&Paths::real(), &project) +} + +#[tauri::command] +pub(crate) fn todo_state(project: String) -> Result { + todo_state_in(&Paths::real(), &project) +} + +#[tauri::command] +pub(crate) fn set_todo(project: String, enabled: bool) -> Result<(), String> { + set_todo_in(&Paths::real(), &project, enabled) +} + +#[tauri::command] +pub(crate) fn set_terminal_config( + project: String, + theme: Option, + icon: Option, + title: Option, +) -> Result<(), String> { + set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title }) +} + +/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG). +#[tauri::command] +pub(crate) fn project_icon(project: String) -> Result, String> { + use base64::{engine::general_purpose::STANDARD, Engine}; + let paths = Paths::real(); + let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else { + return Ok(None); + }; + let path = resolve_icon_path(&paths, &icon); + let mime = match path.extension().and_then(|e| e.to_str()) { + Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml", + _ => "image/png", + }; + let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes)))) +} + +// ---------- Pools ---------- + +#[tauri::command] +pub(crate) fn list_pools() -> Result, String> { + list_pools_in(&Paths::real(), &KeychainStore) +} + +#[tauri::command] +pub(crate) fn create_oauth_pool(name: String) -> Result { + create_oauth_pool_in(&Paths::real(), &name) +} + +#[tauri::command] +pub(crate) fn create_apikey_pool( + name: String, + key: String, + allow_file: bool, +) -> Result { + create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file) +} + +#[tauri::command] +pub(crate) fn rename_pool(pool: String, name: String) -> Result<(), String> { + rename_pool_in(&Paths::real(), &pool, &name) +} + +#[tauri::command] +pub(crate) 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] +pub(crate) fn assign_pool(project: String, pool: String) -> Result<(), String> { + assign_pool_in(&Paths::real(), &project, &pool) +} + +#[tauri::command] +pub(crate) fn unassign_pool(project: String) -> Result<(), String> { + unassign_pool_in(&Paths::real(), &project) +} + +/// Anzeigename eines Pools (pool.json) — die ID ist der Ordnername. +#[tauri::command] +pub(crate) fn pool_label(pool: String) -> Result { + Ok(read_pool(&Paths::real(), &pool)?.name) +} + +#[tauri::command] +pub(crate) fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<(), String> { + set_apikey_in(&Paths::real(), &KeychainStore, &pool, &key, allow_file) +} + +#[tauri::command] +pub(crate) fn keychain_status(pool: String) -> Result { + crate::platform::oauth_keychain_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] +pub(crate) 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 crate::platform::oauth_keychain_exists(&service)? { + crate::platform::oauth_keychain_delete(&service)?; + } + Ok(()) +} + +/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool — +/// sonst würde das Transkript der laufenden Session ersetzt. +#[tauri::command] +pub(crate) 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) +} + +// ---------- Sessions ---------- + +#[tauri::command] +pub(crate) fn stop_project(project: String) -> Result<(), String> { + if crate::platform::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] +pub(crate) 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) +} + +/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn, +/// sonst startet es. +fn start_or_focus(app: &tauri::AppHandle, project: &str) { + match crate::platform::terminal_pids(project).first() { + Some(pid) => crate::platform::focus_terminal(*pid), + None => { + if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) { + eprintln!("{project} starten: {e}"); + } + } + } +} + +/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren. +#[tauri::command] +pub(crate) fn start_or_focus_cmd(app: tauri::AppHandle, project: String) { + start_or_focus(&app, &project); +} + +// ---------- Popup-Fußzeile ---------- + +/// Popup-Fußzeile „Öffnen": das Hauptfenster zeigen. +#[tauri::command] +pub(crate) fn open_main_window(app: tauri::AppHandle) { + use tauri::Manager; + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } +} + +/// Popup-Fußzeile „Beenden": App verlassen. +#[tauri::command] +pub(crate) fn quit_app(app: tauri::AppHandle) { + app.exit(0); +} + +// ---------- App-Settings ---------- + +#[tauri::command] +pub(crate) fn sync_setting() -> Result { + Ok(settings::sync_on_session_end(&Paths::real())) +} + +#[tauri::command] +pub(crate) fn set_sync_setting(enabled: bool) -> Result<(), String> { + settings::set_sync_on_session_end_in(&Paths::real(), enabled) +} + +#[tauri::command] +pub(crate) fn terminal_font_size() -> u32 { + settings::terminal_font_size(&Paths::real()) +} + +#[tauri::command] +pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> { + settings::set_terminal_font_size_in(&Paths::real(), size) +} + +#[tauri::command] +pub(crate) fn spellcheck_lang() -> String { + settings::spellcheck_lang(&Paths::real()) +} + +// ---------- Verbrauch ---------- + +#[tauri::command] +pub(crate) fn usage_stats(days: u32) -> Result, String> { + usage_stats_in(&Paths::real(), days) +} + +// ---------- Panel-Archiv ---------- + +/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet, +/// ob der Ordner-Dialog nötig ist). +#[tauri::command] +pub(crate) fn panel_archive_dir_cmd(project: String) -> Option { + project_archive_dir(&project).map(|p| p.display().to_string()) +} + +/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt +/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein. +#[tauri::command] +pub(crate) fn panel_archive_cmd(project: String, dir: Option) -> Result { + archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string()) +} + +/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen"). +#[tauri::command] +pub(crate) fn reveal_path_cmd(path: String) { + crate::platform::reveal_path(std::path::Path::new(&path)); +} diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs new file mode 100644 index 0000000..6c75669 --- /dev/null +++ b/src-tauri/src/domain/archive.rs @@ -0,0 +1,209 @@ +//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im +//! konfigurierten Archiv-Ordner des Projekts persistieren. + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::domain::paths::{contract_home, expand_home, panel_file, Paths}; +use crate::domain::project::{ + read_project_config_in, settings_path, write_project_config_in, +}; +use crate::domain::registry::project_dir; + +/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir), +/// Home-expandiert; None, wenn nicht gesetzt. +pub(crate) fn project_archive_dir(project: &str) -> Option { + let paths = Paths::real(); + let dir = read_project_config_in(&paths, project).ok()?.archive_dir?; + Some(expand_home(&paths, &dir)) +} + +/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in +/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit +/// claude das Archiv später lesen/scannen darf. +pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> { + let paths = Paths::real(); + let expanded = expand_home(&paths, dir); + if !expanded.is_absolute() { + return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display())); + } + // Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde + // claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab. + if paths.home.starts_with(&expanded) { + return Err(format!( + "Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.", + expanded.display() + )); + } + fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; + let contracted = contract_home(&paths, &expanded); + let mut cfg = read_project_config_in(&paths, project)?; + cfg.archive_dir = Some(contracted.clone()); + write_project_config_in(&paths, project, &cfg)?; + add_archive_permission(&paths, project, &contracted) +} + +/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein. +fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { + let sp = settings_path(&project_dir(paths, project)?); + let mut v: serde_json::Value = if sp.is_file() { + serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) + .map_err(|e| format!("{}: {e}", sp.display()))? + } else { + serde_json::json!({}) + }; + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let perms = root + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms + .entry("additionalDirectories") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + if !dirs.iter().any(|d| d.as_str() == Some(dir)) { + dirs.push(serde_json::json!(dir)); + } + let edit = format!("Edit({dir}/**)"); + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("allow ist kein Array")?; + if !allow.iter().any(|p| p.as_str() == Some(&edit)) { + allow.push(serde_json::json!(edit)); + } + let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; + fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit +/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich +/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad. +pub(crate) fn archive_panel_content( + project: &str, + dir_override: Option<&str>, +) -> Result { + if let Some(d) = dir_override { + set_project_archive_dir(project, d)?; + } + let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?; + let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); + if text.trim().is_empty() { + return Err("Panel ist leer — nichts zu archivieren".into()); + } + fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs(); + let (stamp, iso) = utc_stamp(secs); + let title = first_line(&text); + let path = dir.join(format!("{stamp}-{}.md", slugify(&title))); + let doc = format!( + "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n", + title.replace('"', "'"), + text.trim_end(), + ); + fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(path) +} + +/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile. +fn first_line(text: &str) -> String { + let mut fallback: Option<&str> = None; + for line in text.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + let h = t.trim_start_matches('#').trim(); + if t.starts_with('#') && !h.is_empty() { + return h.to_string(); + } + fallback.get_or_insert(t); + } + fallback.unwrap_or("entwurf").to_string() +} + +/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen). +fn slugify(s: &str) -> String { + let mut out = String::new(); + for c in s.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let out: String = out.trim_matches('-').chars().take(60).collect(); + if out.is_empty() { + "entwurf".into() + } else { + out + } +} + +/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`). +fn utc_stamp(secs: u64) -> (String, String) { + let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60); + let (y, m, d) = civil_from_days((secs / 86400) as i64); + ( + format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"), + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"), + ) +} + +/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utc_stamp_bekannte_zeit() { + // 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC. + let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15; + let (stamp, iso) = utc_stamp(secs); + assert_eq!(stamp, "2026-07-11_1114"); + assert_eq!(iso, "2026-07-11T11:14:15Z"); + } + + #[test] + fn civil_from_days_referenz() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_645), (2026, 7, 11)); + } + + #[test] + fn slugify_grenzen() { + assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen"); + assert_eq!(slugify(" "), "entwurf"); + assert_eq!(slugify("###"), "entwurf"); + } + + #[test] + fn first_line_ueberschrift_oder_erste_zeile() { + assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel"); + assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading"); + assert_eq!(first_line(" \n"), "entwurf"); + } +} diff --git a/src-tauri/src/domain/credentials.rs b/src-tauri/src/domain/credentials.rs new file mode 100644 index 0000000..8675967 --- /dev/null +++ b/src-tauri/src/domain/credentials.rs @@ -0,0 +1,65 @@ +//! Credential-Ablage der Pools. Der ApikeyStore abstrahiert den nativen +//! Secret-Store (macOS-Keychain / Secret Service / Credential Manager) — +//! die Implementierungen liegen in platform/. + +use sha2::{Digest, Sha256}; + +use crate::domain::paths::Paths; + +/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die +/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest +/// mit denselben Attributen über secret-tool. +pub(crate) const APIKEY_SERVICE: &str = "ai-control-apikey"; + +/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch) +/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file. +pub(crate) const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable"; + +/// Ablage der API-Keys im nativen Secret-Store. Die Key-Datei im Pool-Ordner +/// bleibt Fallback, wenn der Store beim Schreiben nicht verfügbar ist. +pub(crate) trait ApikeyStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String>; + fn has(&self, pool: &str) -> Result; + fn delete(&self, pool: &str) -> Result<(), String>; +} + +/// Keychain-Service-Name des OAuth-Pools: claude legt pro CLAUDE_CONFIG_DIR +/// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 +/// über den Pool-Pfad. +pub(crate) 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]) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// Referenzwert vom echten System: privateDefault → 096c4ef9 + /// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag). + #[test] + fn keychain_service_suffix() { + let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; + assert_eq!( + keychain_service(&p, "privateDefault"), + "Claude Code-credentials-096c4ef9" + ); + } + + #[test] + fn apikey_helper_kette_referenz() { + let dir = PathBuf::from("/pools/abc"); + let cmd = crate::platform::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(target_os = "linux")] + assert!(cmd.starts_with( + "secret-tool lookup service ai-control-apikey username abc 2>/dev/null" + )); + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs new file mode 100644 index 0000000..a5962da --- /dev/null +++ b/src-tauri/src/domain/mod.rs @@ -0,0 +1,25 @@ +//! OS-freier Kern, nach Domänen geschnitten. Alles hier ist über `Paths` +//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe +//! laufen ausschließlich über crate::platform. + +pub(crate) mod archive; +pub(crate) mod credentials; +pub(crate) mod paths; +pub(crate) mod pool; +pub(crate) mod project; +pub(crate) mod registry; +pub(crate) mod settings; +pub(crate) mod todo; +pub(crate) mod usage; +pub(crate) mod watcher; + +#[cfg(test)] +pub(crate) mod testutil; + +/// Namensprüfung für Projekte und Pool-Anzeigenamen (keine Pfad-Bestandteile). +pub(crate) fn check_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() || name.contains('/') || name.contains("..") { + return Err(format!("ungültiger Name: {name}")); + } + Ok(()) +} diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs new file mode 100644 index 0000000..ae46d18 --- /dev/null +++ b/src-tauri/src/domain/paths.rs @@ -0,0 +1,77 @@ +//! Wurzelpfade der App und Home-Kontraktion/-Expansion. + +use std::path::PathBuf; + +/// Dateiname der Projekt-Registry unter ~/.config/ai-control. +const PROJECTS_FILE: &str = "projects.json"; + +/// Wurzelpfade; in Tests mit temporärem home instanziierbar. +pub(crate) struct Paths { + pub(crate) home: PathBuf, +} + +impl Paths { + pub(crate) fn real() -> Self { + Paths { home: crate::platform::home_dir() } + } + + /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer + /// Projekte ohne gewählten Zielordner. + pub(crate) fn projects_dir(&self) -> PathBuf { + self.home.join("claude-projects") + } + + pub(crate) fn config_dir(&self) -> PathBuf { + self.home.join(".config").join("ai-control") + } + + pub(crate) fn projects_file(&self) -> PathBuf { + self.config_dir().join(PROJECTS_FILE) + } + + pub(crate) fn pools_dir(&self) -> PathBuf { + self.config_dir().join("pools") + } + + /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config, + /// unabhängig von Pool-Zuordnung und Quell-Repos. + pub(crate) fn icons_dir(&self) -> PathBuf { + self.config_dir().join("icons") + } + + pub(crate) fn pool_dir(&self, pool: &str) -> PathBuf { + self.pools_dir().join(pool) + } + + /// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein, + /// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel. + fn panels_dir(&self) -> PathBuf { + self.config_dir().join("panels") + } +} + +/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als +/// AI_CONTROL_PANEL in der PTY-Umgebung. +pub(crate) fn panel_file(project: &str) -> PathBuf { + Paths::real().panels_dir().join(format!("{project}.md")) +} + +/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. +pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf { + if p == "~" { + return paths.home.clone(); + } + match p.strip_prefix("~/") { + Some(rest) => paths.home.join(rest), + None => PathBuf::from(p), + } +} + +/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im +/// Home-Bereich bleiben damit maschinenübergreifend stabil. +pub(crate) fn contract_home(paths: &Paths, p: &std::path::Path) -> String { + match p.strip_prefix(&paths.home) { + Ok(rest) => format!("~/{}", rest.display()), + Err(_) => p.display().to_string(), + } +} diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs new file mode 100644 index 0000000..5dd625c --- /dev/null +++ b/src-tauri/src/domain/pool.rs @@ -0,0 +1,824 @@ +//! Pools: benannte Credential-Sets unter ~/.config/ai-control/pools/. +//! Jeder Pool-Ordner ist ein vollständiges CLAUDE_CONFIG_DIR (settings.json, +//! CLAUDE.md, Panel-Skill, MCP-Registrierung); oauth-Credentials verwaltet +//! claude selbst (Keychain), die App speichert keine Tokens. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::domain::check_name; +use crate::domain::credentials::{ApikeyStore, KEYCHAIN_UNAVAILABLE}; +use crate::domain::paths::Paths; +use crate::domain::project::{is_running, projects_using_pool, unassign_pool_in}; +use crate::domain::settings::pool_sync_dir; + +/// Feste Dateinamen im Pool-Ordner. +pub(crate) const APIKEY_FILE: &str = "apikey"; +pub(crate) const POOL_FILE: &str = "pool.json"; + +#[derive(Serialize, Deserialize)] +pub(crate) struct Pool { + pub(crate) name: String, + #[serde(rename = "credentialType")] + pub(crate) credential_type: String, +} + +#[derive(Serialize)] +pub(crate) struct PoolInfo { + /// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der + /// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen. + pub(crate) id: String, + /// Anzeigename aus pool.json, frei umbenennbar. + pub(crate) name: String, + #[serde(rename = "credentialType")] + pub(crate) credential_type: String, + pub(crate) projects: Vec, + /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die + /// Projektliste). Der Löschen-Dialog sperrt darauf. + pub(crate) running: Vec, + #[serde(rename = "hasCredentials")] + pub(crate) has_credentials: bool, +} + +pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result { + let cfg_path = paths.pool_dir(pool).join(POOL_FILE); + let raw = fs::read_to_string(&cfg_path) + .map_err(|e| format!("{}: {e}", cfg_path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) +} + +pub(crate) fn list_pools_in( + paths: &Paths, + store: &dyn ApikeyStore, +) -> Result, String> { + let mut pools = Vec::new(); + if !paths.pools_dir().is_dir() { + return Ok(pools); + } + let entries = + fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; + let cfg_path = entry.path().join(POOL_FILE); + if !cfg_path.is_file() { + continue; + } + let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + let pool: Pool = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + let id = entry.file_name().to_string_lossy().into_owned(); + // oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen + // Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true. + // apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei. + let has_credentials = match pool.credential_type.as_str() { + "apikey" => { + store.has(&id)? + || fs::read_to_string(entry.path().join(APIKEY_FILE)) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + } + _ => true, + }; + let projects = projects_using_pool(paths, &id)?; + let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); + pools.push(PoolInfo { + id, + projects, + running, + name: pool.name, + credential_type: pool.credential_type, + has_credentials, + }); + } + pools.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(pools) +} + +/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch +/// nicht — dann ist die Liste leer. +pub(crate) fn pool_names(paths: &Paths) -> Result, String> { + let mut out = Vec::new(); + if !paths.pools_dir().is_dir() { + return Ok(out); + } + for entry in + fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))? + { + let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; + let cfg_path = entry.path().join(POOL_FILE); + if !cfg_path.is_file() { + continue; + } + let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + let pool: Pool = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + out.push((entry.file_name().to_string_lossy().into_owned(), pool.name)); + } + Ok(out) +} + +/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den +/// Ordner für einen neuen Pool: pools/. +fn check_new_pool(paths: &Paths, name: &str) -> Result { + check_name(name)?; + if pool_names(paths)?.iter().any(|(_, n)| n == name) { + return Err(format!("Pool existiert bereits: {name}")); + } + Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) +} + +fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let pool = Pool { + name: name.to_string(), + credential_type: credential_type.to_string(), + }; + let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?; + fs::write(dir.join(POOL_FILE), raw + "\n") + .map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display())) +} + +/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json +/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als +/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. +/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst +/// erscheint oben die Vorschlagstabelle. +pub(crate) fn init_pool_config( + dir: &PathBuf, + extra: serde_json::Value, +) -> Result<(), String> { + let mut settings = serde_json::json!({ + "promptSuggestionEnabled": false, + "awaySummaryEnabled": false, + "permissions": { "allow": [PANEL_WRITE_PERMISSION] }, + }); + 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| format!("{}: {e}", dir.display()))?; + 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| format!("{}: {e}", dir.join("settings.json").display()))?; + let claude_md = dir.join("CLAUDE.md"); + if !claude_md.is_file() { + fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?; + } + install_panel_skill(dir); + register_mcp_server(dir); + Ok(()) +} + +/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im +/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet. +const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md"); + +/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt +/// („text panel"). Bestimmt den Tool-Namespace `mcp____`. +const PANEL_MCP_SERVER: &str = "text-panel"; + +/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage +/// läuft. Namensschema: `mcp____`. +const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel"; + +/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine +/// evtl. ältere, tee-basierte Fassung). +fn install_panel_skill(pool_dir: &std::path::Path) { + let skill_dir = pool_dir.join("skills").join("panel"); + if fs::create_dir_all(&skill_dir).is_ok() { + let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL); + } +} + +/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings +/// entfernt werden. +const STALE_PANEL_PERMISSIONS: [&str; 3] = + ["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"]; + +/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt +/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern. +fn ensure_panel_permission(pool_dir: &std::path::Path) { + let sp = pool_dir.join("settings.json"); + let Ok(raw) = fs::read_to_string(&sp) else { + return; + }; + let Ok(mut v) = serde_json::from_str::(&raw) else { + return; + }; + let Some(obj) = v.as_object_mut() else { return }; + let Some(perms) = obj + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + else { + return; + }; + let Some(allow) = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + else { + return; + }; + let before = allow.len(); + allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); + let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION)); + if !has { + allow.push(serde_json::json!(PANEL_WRITE_PERMISSION)); + } + if has && allow.len() == before { + return; // nichts geändert + } + if let Ok(out) = serde_json::to_string_pretty(&v) { + let _ = fs::write(&sp, out + "\n"); + } +} + +/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der +/// `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle +/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt. +fn register_mcp_server(pool_dir: &std::path::Path) { + let Ok(exe) = std::env::current_exe() else { + return; + }; + // Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt + // den Entwurf in den Chat statt ins Panel. + let desired = serde_json::json!({ + "type": "stdio", + "command": exe.to_string_lossy(), + "args": ["--mcp-panel"], + "alwaysLoad": true, + }); + let cfg = pool_dir.join(".claude.json"); + let mut v = fs::read_to_string(&cfg) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_else(|| serde_json::json!({})); + let Some(obj) = v.as_object_mut() else { return }; + let Some(servers) = obj + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + else { + return; + }; + // Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist + // claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht. + if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) { + return; + } + servers.remove("aicontrol"); // alter Key vor Umbenennung + servers.insert(PANEL_MCP_SERVER.into(), desired); + if let Ok(out) = serde_json::to_string_pretty(&v) { + let _ = fs::write(&cfg, out + "\n"); + } +} + +/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool +/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung. +fn provision_pool(pool_dir: &std::path::Path) { + install_panel_skill(pool_dir); + register_mcp_server(pool_dir); + ensure_panel_permission(pool_dir); +} + +/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start). +pub(crate) fn provision_pools_for_panel(paths: &Paths) { + if let Ok(pools) = pool_names(paths) { + for (id, _) in pools { + provision_pool(&paths.pool_dir(&id)); + } + } +} + +/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos, +/// Prompt-Historie. (name, ist_ordner) +pub(crate) const SYNCED_RUNTIME: [(&str, bool); 3] = + [("projects", true), ("todos", true), ("history.jsonl", false)]; + +/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir. +pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result { + 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). +pub(crate) 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| format!("{}: {e}", target.display()))?; + } else { + fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?; + if !target.exists() { + fs::write(&target, "").map_err(|e| format!("{}: {e}", target.display()))?; + } + } + let link = src.join(name); + if link.is_dir() && !link.is_symlink() { + fs::remove_dir_all(&link).map_err(|e| format!("{}: {e}", link.display()))?; + } else if link.is_symlink() || link.exists() { + fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?; + } + crate::platform::symlink(&target, &link)?; + } + 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. +pub(crate) fn create_apikey_pool_in( + paths: &Paths, + store: &dyn ApikeyStore, + name: &str, + key: &str, + allow_file: bool, +) -> Result { + let dir = check_new_pool(paths, name)?; + let key = key.trim(); + if key.is_empty() { + return Err("leerer API-Key".into()); + } + let 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()); + } + crate::platform::write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?; + } + init_pool_config( + &dir, + serde_json::json!({ "apiKeyHelper": crate::platform::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. +pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result { + let dir = check_new_pool(paths, name)?; + init_pool_config(&dir, serde_json::json!({}))?; + write_pool_json(&dir, 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. +pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> { + check_name(name)?; + let current = read_pool(paths, pool)?; + if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) { + return Err(format!("Pool existiert bereits: {name}")); + } + write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type) +} + +/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den +/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung +/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende +/// Sessions setzt der delete_pool-Command davor. +pub(crate) 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| format!("{}: {e}", dir.display())) +} + +/// 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. +pub(crate) 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| format!("{}: {e}", key_path.display()))?; + } + } else { + if !allow_file { + return Err(KEYCHAIN_UNAVAILABLE.into()); + } + crate::platform::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!(crate::platform::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| format!("{}: {e}", settings_path.display())) +} + +pub(crate) 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::project::{ + assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig, + }; + use crate::domain::settings::APP_SETTINGS_FILE; + use crate::domain::testutil::{ + create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore, + }; + + #[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(), + crate::platform::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(), + crate::platform::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 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 = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false)); + assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false)); + assert!(dir.join("CLAUDE.md").is_file()); + } + + #[test] + fn pool_init_erhaelt_bestehende_claude_md() { + let p = tmp_paths(); + let dir = p.pool_dir("privat"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap(); + // Anlegen darf die vorhandene CLAUDE.md nicht überschreiben. + // (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen) + init_pool_config(&dir, serde_json::json!({})).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("CLAUDE.md")).unwrap(), + "meine Vorgaben\n" + ); + } + + #[test] + fn pool_doppelt_anlegen_scheitert() { + let p = tmp_paths(); + let store = map_store(); + make_apikey_pool(&p, &store, "kunde", "sk-1"); + let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err(); + assert!(err.contains("existiert bereits")); + } + + // -- Pool umbenennen -- + + #[test] + fn pool_umbenennen() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &id).unwrap(); + + rename_pool_in(&p, &id, "kunde-neu").unwrap(); + + // Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben. + let pool = read_pool(&p, &id).unwrap(); + assert_eq!(pool.name, "kunde-neu"); + assert_eq!(pool.credential_type, "apikey"); + assert!(p.pool_dir(&id).is_dir()); + let cfg = read_project_config_in(&p, "proj").unwrap(); + assert_eq!(cfg.pool.as_deref(), Some(id.as_str())); + } + + #[test] + fn pool_umbenennen_auf_vergebenen_namen_scheitert() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + make_oauth_pool(&p, "privat"); + let err = rename_pool_in(&p, &id, "privat").unwrap_err(); + assert!(err.contains("existiert bereits")); + // Umbenennen auf den eigenen Namen ist erlaubt (No-op). + rename_pool_in(&p, &id, "kunde").unwrap(); + } + + #[test] + fn pool_leerer_key_scheitert() { + let p = tmp_paths(); + assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err()); + } + + #[test] + fn pool_name_mit_slash_scheitert() { + let p = tmp_paths(); + assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err()); + } + + // -- Pool ändern -- + + #[test] + fn apikey_aendern() { + let p = tmp_paths(); + let store = map_store(); + let id = make_apikey_pool(&p, &store, "kunde", "sk-alt"); + set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); + assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); + assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists()); + } + + /// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage): + /// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden. + #[test] + fn apikey_aendern_migriert_datei_in_store() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); + let dir = p.pool_dir(&id); + // Bestand simulieren: Helper wie vor der Keychain-Ablage. + let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) }); + fs::write(dir.join("settings.json"), old.to_string()).unwrap(); + + let store = map_store(); + set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); + + assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); + assert!(!dir.join(APIKEY_FILE).exists()); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["apiKeyHelper"].as_str().unwrap(), + crate::platform::apikey_helper_command(&dir, &id) + ); + } + + #[test] + fn apikey_aendern_ohne_store_schreibt_datei() { + let p = tmp_paths(); + let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); + set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap(); + let key_path = p.pool_dir(&id).join(APIKEY_FILE); + assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n"); + assert_eq!(mode(&key_path), 0o600); + } + + #[test] + fn apikey_aendern_auf_oauth_pool_scheitert() { + let p = tmp_paths(); + let id = make_oauth_pool(&p, "privat"); + assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err()); + } + + #[test] + fn ensure_oauth_pool_auf_apikey_scheitert() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + assert!(ensure_oauth_pool(&p, &kunde).is_err()); + let privat = make_oauth_pool(&p, "privat"); + assert!(ensure_oauth_pool(&p, &privat).is_ok()); + } + + // -- Pool löschen -- + + #[test] + fn pool_loeschen() { + let p = tmp_paths(); + let store = map_store(); + let id = make_apikey_pool(&p, &store, "kunde", "sk-1"); + delete_pool_in(&p, &store, &id).unwrap(); + assert!(!p.pool_dir(&id).exists()); + // Der Keychain-Eintrag geht mit. + assert!(!store.has(&id).unwrap()); + } + + #[test] + fn pool_key_status() { + let p = tmp_paths(); + // Datei-Pool (ohne Store angelegt): Datei entscheidet. + let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1"); + make_oauth_pool(&p, "privat"); + + let pools = list_pools_in(&p, &FailStore).unwrap(); + assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + // oauth: Keychain wird im Listing bewusst nicht geprüft → immer true. + assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials); + + fs::write(p.pool_dir(&kunde).join(APIKEY_FILE), "\n").unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); + assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + + fs::remove_file(p.pool_dir(&kunde).join(APIKEY_FILE)).unwrap(); + let pools = list_pools_in(&p, &FailStore).unwrap(); + assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + } + + /// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei. + #[test] + fn pool_key_status_aus_store() { + let p = tmp_paths(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); + assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists()); + let pools = list_pools_in(&p, &store).unwrap(); + assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + store.delete(&kunde).unwrap(); + let pools = list_pools_in(&p, &store).unwrap(); + assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); + } + + #[test] + fn pool_loeschen_loest_zuordnungen() { + let p = tmp_paths(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); + delete_pool_in(&p, &store, &kunde).unwrap(); + assert!(!p.pool_dir(&kunde).exists()); + assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists()); + } + + #[test] + fn pool_anlegen_verlinkt_synced_runtime() { + let p = tmp_paths(); + fs::create_dir_all(p.config_dir()).unwrap(); + fs::write( + p.config_dir().join(APP_SETTINGS_FILE), + "{ \"poolSyncDir\": \"~/claude-projects/pool\" }\n", + ) + .unwrap(); + let id = make_oauth_pool(&p, "privat"); + let pooldir = p.pool_dir(&id); + for (name, is_dir) in SYNCED_RUNTIME { + let link = pooldir.join(name); + assert!(link.is_symlink(), "{name} sollte Symlink sein"); + assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).unwrap().join(name)); + let target = pool_data_dir(&p, &id).unwrap().join(name); + assert_eq!(target.is_dir(), is_dir); + assert!(target.exists()); + } + // Zieldaten liegen unter dem konfigurierten poolSyncDir// + assert!(p.home.join("claude-projects/pool").join(&id).is_dir()); + } + + /// Ohne poolSyncDir bleibt alles lokal: keine Symlinks, Verlinken scheitert laut. + #[test] + fn pool_anlegen_ohne_sync_dir_bleibt_lokal() { + let p = tmp_paths(); + let id = make_oauth_pool(&p, "privat"); + for (name, _) in SYNCED_RUNTIME { + assert!(!p.pool_dir(&id).join(name).is_symlink(), "{name} darf kein Symlink sein"); + } + let err = link_pool_runtime_in(&p, &id).unwrap_err(); + assert!(err.contains("poolSyncDir")); + } + + #[test] + fn pool_loeschen_erhaelt_terminal_config() { + let p = tmp_paths(); + let store = map_store(); + let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); + set_terminal_config_in( + &p, + "proj", + TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, + ) + .unwrap(); + delete_pool_in(&p, &store, &kunde).unwrap(); + let cfg = read_project_config_in(&p, "proj").unwrap(); + assert_eq!(cfg.pool, None); + assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); + } + + #[test] + fn pool_loeschen_unbekannt_scheitert() { + let p = tmp_paths(); + assert!(delete_pool_in(&p, &map_store(), "gibtsnicht").is_err()); + } +} diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs new file mode 100644 index 0000000..39ad1f4 --- /dev/null +++ b/src-tauri/src/domain/project.rs @@ -0,0 +1,762 @@ +//! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung, +//! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json +//! und die ai-control.json (Pool + Terminal-Config). + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::domain::check_name; +use crate::domain::paths::{contract_home, expand_home, Paths}; +use crate::domain::pool::POOL_FILE; +use crate::domain::registry::{load_registry, project_dir, register_project, save_registry}; + +/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen). +pub(crate) const PROJECT_FILE: &str = "ai-control.json"; + +#[derive(Serialize)] +pub(crate) struct Project { + pub(crate) name: String, + pub(crate) path: String, + pub(crate) pool: Option, + pub(crate) running: bool, + pub(crate) terminal: TerminalConfig, +} + +#[derive(Serialize, Deserialize, Default)] +pub(crate) struct ProjectConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pool: Option, + #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] + pub(crate) terminal: TerminalConfig, + /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert). + #[serde( + default, + rename = "archiveDir", + skip_serializing_if = "Option::is_none" + )] + pub(crate) archive_dir: Option, +} + +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct TerminalConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +impl TerminalConfig { + pub(crate) fn is_empty(&self) -> bool { + self.theme.is_none() && self.icon.is_none() && self.title.is_none() + } +} + +pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf { + dir.join(".claude").join("settings.json") +} + +pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result { + Ok(project_dir(paths, project)?.join(PROJECT_FILE)) +} + +pub(crate) fn read_project_config_in( + paths: &Paths, + project: &str, +) -> Result { + let cfg_path = project_config_path(paths, project)?; + if !cfg_path.is_file() { + return Ok(ProjectConfig::default()); + } + let raw = + fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) +} + +pub(crate) fn write_project_config_in( + paths: &Paths, + project: &str, + cfg: &ProjectConfig, +) -> Result<(), String> { + let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; + let path = project_config_path(paths, project)?; + fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) +} + +pub(crate) fn is_running(project: &str) -> bool { + !crate::platform::terminal_pids(project).is_empty() +} + +/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte PID. +pub(crate) fn kill_terminals(project: &str) -> Result<(), String> { + for pid in crate::platform::terminal_pids(project) { + crate::platform::kill_terminal(pid)?; + } + Ok(()) +} + +/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe +/// Lauf-Erkennung wie die Projektliste (`is_running`). +pub(crate) fn running_projects_using_pool( + paths: &Paths, + pool: &str, +) -> Result, String> { + Ok(projects_using_pool(paths, pool)? + .into_iter() + .filter(|p| is_running(p)) + .collect()) +} + +/// Namen aller Projekte, die `pool` zugeordnet haben. +pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { + let mut users = Vec::new(); + for (name, dir) in load_registry(paths)? { + let cfg_path = dir.join(PROJECT_FILE); + if !cfg_path.is_file() { + continue; + } + let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; + let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; + if cfg.pool.as_deref() == Some(pool) { + users.push(name); + } + } + Ok(users) +} + +pub(crate) fn list_projects_in(paths: &Paths) -> Result, String> { + let mut projects = Vec::new(); + for (name, dir) in load_registry(paths)? { + let cfg = read_project_config_in(paths, &name)?; + projects.push(Project { + running: is_running(&name), + path: contract_home(paths, &dir), + pool: cfg.pool, + terminal: cfg.terminal, + name, + }); + } + Ok(projects) +} + +/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als +/// CLAUDE_CONFIG_DIR mitgegeben. +pub(crate) fn project_pool_dir(project: &str) -> Result, String> { + let paths = Paths::real(); + Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p))) +} + +/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. +pub(crate) fn terminal_config(project: &str) -> Result { + Ok(read_project_config_in(&Paths::real(), project)?.terminal) +} + +/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore +/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, +/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag. +/// Ohne Zielordner landet das Projekt unter ~/claude-projects/. +#[allow(clippy::too_many_arguments)] +pub(crate) fn create_project_full_in( + paths: &Paths, + name: &str, + dir: Option<&str>, + pool: Option<&str>, + work_dir: Option<&str>, + create_work_dir: bool, + terminal: TerminalConfig, + todo: bool, +) -> Result<(), String> { + check_name(name)?; + let dir = match dir { + Some(d) => expand_home(paths, d), + None => paths.projects_dir().join(name), + }; + let mut reg = load_registry(paths)?; + if reg.contains_key(name) { + return Err(format!("Projekt existiert bereits: {name}")); + } + if dir.exists() { + return Err(format!("Ordner existiert bereits: {}", dir.display())); + } + if let Some(pool) = pool { + if !paths.pool_dir(pool).join(POOL_FILE).is_file() { + return Err(format!("Pool existiert nicht: {pool}")); + } + } + // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein + // halbes Projekt. + if let Some(wd) = work_dir { + let wd_path = expand_home(paths, wd); + if create_work_dir { + fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; + } else if !wd_path.is_dir() { + return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); + } + } + + fs::create_dir_all(dir.join(".claude")) + .map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; + fs::create_dir_all(dir.join("memory")) + .map_err(|e| format!("{}: {e}", dir.join("memory").display()))?; + fs::write(dir.join(".gitignore"), ".ai-control-running\n") + .map_err(|e| format!("{}: {e}", dir.join(".gitignore").display()))?; + + let contracted = contract_home(paths, &dir); + let mut allow = vec![format!("Edit({contracted}/**)")]; + let mut additional: Vec = Vec::new(); + if let Some(wd) = work_dir { + allow.insert(0, format!("Edit({wd}/**)")); + additional.push(wd.to_string()); + } + let settings = serde_json::json!({ + "autoMemoryDirectory": format!("{contracted}/memory"), + "permissions": { + "allow": allow, + "additionalDirectories": additional, + }, + }); + let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; + fs::write(dir.join(".claude").join("settings.json"), raw + "\n") + .map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?; + + reg.insert(name.to_string(), dir.clone()); + save_registry(paths, ®)?; + let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None }; + if cfg.pool.is_some() || !cfg.terminal.is_empty() { + write_project_config_in(paths, name, &cfg)?; + } + if todo { + crate::domain::todo::set_todo_in(paths, name, true)?; + } + crate::platform::write_terminal_desktop(paths, name, &cfg.terminal); + Ok(()) +} + +/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise +/// auf den alten Root in der settings.json (autoMemoryDirectory, +/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner +/// muss existieren. +pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { + check_name(name)?; + let new_dir = expand_home(paths, dir); + if !new_dir.is_dir() { + return Err(format!("Ordner nicht gefunden: {}", new_dir.display())); + } + let mut reg = load_registry(paths)?; + let old_dir = reg + .get(name) + .cloned() + .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?; + if old_dir == new_dir { + return Ok(()); + } + reg.insert(name.to_string(), new_dir.clone()); + save_registry(paths, ®)?; + + let sp = settings_path(&new_dir); + if !sp.is_file() { + return Ok(()); // importierte Projekte ohne settings.json + } + let old_c = contract_home(paths, &old_dir); + let new_c = contract_home(paths, &new_dir); + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let mut v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + if let Some(mem) = v["autoMemoryDirectory"].as_str() { + if let Some(rest) = mem.strip_prefix(&old_c) { + v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}")); + } + } + if let Some(allow) = v["permissions"]["allow"].as_array_mut() { + let old_edit = format!("Edit({old_c}/**)"); + for e in allow.iter_mut() { + if e.as_str() == Some(&old_edit) { + *e = serde_json::json!(format!("Edit({new_c}/**)")); + } + } + } + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername. +pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> { + let dir = expand_home(paths, path); + if !dir.is_dir() { + return Err(format!("Ordner nicht gefunden: {}", dir.display())); + } + let name = dir + .file_name() + .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))? + .to_string_lossy() + .into_owned(); + check_name(&name)?; + register_project(paths, &name, &dir)?; + let cfg = read_project_config_in(paths, &name) + .map(|c| c.terminal) + .unwrap_or_default(); + crate::platform::write_terminal_desktop(paths, &name, &cfg); + Ok(()) +} + +/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im +/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die +/// Datei an, wenn sie fehlt (importierte Projekte). +pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { + check_name(name)?; + let wd_path = expand_home(paths, dir); + if !wd_path.is_dir() { + return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); + } + let dir = contract_home(paths, &wd_path); + let sp = settings_path(&project_dir(paths, name)?); + let mut v: serde_json::Value = if sp.is_file() { + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))? + } else { + serde_json::json!({}) + }; + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let perms = root + .entry("permissions") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms + .entry("additionalDirectories") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + if dirs.iter().any(|d| d.as_str() == Some(&dir)) { + return Err(format!("schon eingetragen: {dir}")); + } + dirs.push(serde_json::json!(dir)); + let allow = perms + .entry("allow") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("allow ist kein Array")?; + allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); + let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; + fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und +/// zugehörige Edit-Permission. Der Ordner selbst bleibt. +pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { + check_name(name)?; + let sp = settings_path(&project_dir(paths, name)?); + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let mut v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + let perms = v["permissions"] + .as_object_mut() + .ok_or("permissions ist kein Objekt")?; + let dirs = perms["additionalDirectories"] + .as_array_mut() + .ok_or("additionalDirectories ist kein Array")?; + let before = dirs.len(); + dirs.retain(|d| d.as_str() != Some(dir)); + if dirs.len() == before { + return Err(format!("nicht eingetragen: {dir}")); + } + if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { + allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); + } + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. +pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result, String> { + check_name(name)?; + let sp = settings_path(&project_dir(paths, name)?); + if !sp.is_file() { + return Ok(Vec::new()); + } + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + Ok( + v["permissions"]["additionalDirectories"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + ) +} + +/// Löscht den Projektordner und den Registry-Eintrag; optional auch die +/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen. +pub(crate) fn delete_project_in( + paths: &Paths, + name: &str, + delete_work_dirs: bool, +) -> Result<(), String> { + check_name(name)?; + let dir = project_dir(paths, name)?; + if !dir.is_dir() { + return Err(format!("Projekt nicht gefunden: {name}")); + } + if delete_work_dirs { + for wd in project_work_dirs_in(paths, name)? { + let wd_path = expand_home(paths, &wd); + fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; + } + } + fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + crate::domain::registry::unregister_project(paths, name)?; + crate::platform::remove_terminal_desktop(paths, name); + Ok(()) +} + +pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> { + if !paths.pool_dir(pool).join(POOL_FILE).is_file() { + return Err(format!("Pool existiert nicht: {pool}")); + } + let mut cfg = read_project_config_in(paths, project)?; + cfg.pool = Some(pool.to_string()); + write_project_config_in(paths, project, &cfg) +} + +/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch +/// Terminal-Einstellungen trägt. +pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { + let mut cfg = read_project_config_in(paths, project)?; + cfg.pool = None; + if cfg.terminal.is_empty() && cfg.archive_dir.is_none() { + let cfg_path = project_config_path(paths, project)?; + return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); + } + write_project_config_in(paths, project, &cfg) +} + +pub(crate) fn set_terminal_config_in( + paths: &Paths, + project: &str, + mut terminal: TerminalConfig, +) -> Result<(), String> { + let mut cfg = read_project_config_in(paths, project)?; + // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als + // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo. + if let Some(icon) = terminal.icon.as_deref() { + if icon.starts_with('/') { + let src = PathBuf::from(icon); + let ext = src + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png") + .to_lowercase(); + let name = format!("{project}.{ext}"); + let icons = paths.icons_dir(); + fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?; + let dest = icons.join(&name); + if src != dest { + fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; + } + terminal.icon = Some(name); + } + } + cfg.terminal = terminal; + write_project_config_in(paths, project, &cfg)?; + crate::platform::write_terminal_desktop(paths, project, &cfg.terminal); + Ok(()) +} + +/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im +/// gemeinsamen Icons-Verzeichnis der App. +pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf { + if icon.starts_with('/') { + PathBuf::from(icon) + } else { + paths.icons_dir().join(icon) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; + use crate::domain::todo::TODO_FILE; + + #[test] + fn projekt_wizard_scaffold() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project_full_in( + &p, + "neu", + None, + Some(&kunde), + Some("~/projects/neu"), + true, + TerminalConfig { + theme: Some("dracula".into()), + icon: None, + title: Some("Neu".into()), + }, + true, + ) + .unwrap(); + + let dir = p.projects_dir().join("neu"); + assert!(dir.join("memory").is_dir()); + assert_eq!( + fs::read_to_string(dir.join(".gitignore")).unwrap(), + ".ai-control-running\n" + ); + assert!(p.home.join("projects").join("neu").is_dir()); + + let cfg = read_project_config_in(&p, "neu").unwrap(); + assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); + assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); + assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); + + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory"); + assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)"); + assert_eq!( + settings["permissions"]["allow"][1], + "Edit(~/claude-projects/neu/**)" + ); + assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); + // todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da + assert!(dir.join(TODO_FILE).is_file()); + let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(groups.len(), 1); + assert!(groups[0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(TODO_FILE)); + } + + #[test] + fn projekt_wizard_minimal_ohne_pool_und_workdir() { + let p = tmp_paths(); + create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap(); + let dir = p.projects_dir().join("neu"); + assert!(dir.join(".claude").join("settings.json").is_file()); + // ohne Pool und Terminal-Config entsteht keine ai-control.json + assert!(!project_config_path(&p, "neu").unwrap().is_file()); + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)"); + assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([])); + } + + #[test] + fn projekt_wizard_doppelt_scheitert() { + let p = tmp_paths(); + create_project(&p, "neu").unwrap(); + let err = + create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false) + .unwrap_err(); + assert!(err.contains("existiert bereits")); + } + + #[test] + fn projekt_wizard_fehlendes_workdir_scheitert() { + let p = tmp_paths(); + let err = create_project_full_in( + &p, + "neu", + None, + None, + Some("~/projects/gibtsnicht"), + false, + TerminalConfig::default(), + false, + ) + .unwrap_err(); + assert!(err.contains("Arbeitsverzeichnis fehlt")); + // kein halbes Projekt zurückgeblieben + assert!(!p.projects_dir().join("neu").exists()); + } + + // -- Projekt zuordnen / rausnehmen / wechseln -- + + #[test] + fn projekt_zuordnen() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); + + let cfg: ProjectConfig = + serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap()) + .unwrap(); + assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); + + let pools = + crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap(); + assert_eq!(pools[0].projects, vec!["proj"]); + } + + #[test] + fn projekt_zuordnen_pool_fehlt_scheitert() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err()); + } + + #[test] + fn projekt_rausnehmen() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); + unassign_pool_in(&p, "proj").unwrap(); + assert!(!project_config_path(&p, "proj").unwrap().exists()); + } + + #[test] + fn projekt_rausnehmen_ohne_zuordnung_scheitert() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + assert!(unassign_pool_in(&p, "proj").is_err()); + } + + /// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen, + /// der Credential-Typ ist ihm egal. + #[test] + fn pool_wechseln_typ_ist_projekt_egal() { + let p = tmp_paths(); + let privat = make_oauth_pool(&p, "privat"); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + + assign_pool_in(&p, "proj", &privat).unwrap(); + assign_pool_in(&p, "proj", &kunde).unwrap(); + + let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(); + let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(cfg, serde_json::json!({ "pool": kunde })); + } + + // -- Projekt anlegen / löschen -- + + #[test] + fn projekt_anlegen() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + assert!(p.projects_dir().join("proj").join(".claude").is_dir()); + + let projects = list_projects_in(&p).unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "proj"); + assert_eq!(projects[0].pool, None); + } + + #[test] + fn projekt_doppelt_anlegen_scheitert() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + assert!(create_project(&p, "proj").is_err()); + } + + #[test] + fn projekt_loeschen() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + delete_project_in(&p, "proj", false).unwrap(); + assert!(!p.projects_dir().join("proj").exists()); + } + + #[test] + fn projekt_loeschen_laesst_arbeitsordner() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + .unwrap(); + delete_project_in(&p, "proj", false).unwrap(); + assert!(!p.projects_dir().join("proj").exists()); + assert!(p.home.join("projects").join("proj").is_dir()); + } + + #[test] + fn projekt_loeschen_mit_arbeitsordner() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + .unwrap(); + delete_project_in(&p, "proj", true).unwrap(); + assert!(!p.projects_dir().join("proj").exists()); + assert!(!p.home.join("projects").join("proj").exists()); + } + + #[test] + fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + .unwrap(); + fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); + assert!(delete_project_in(&p, "proj", true).is_err()); + } + + #[test] + fn projekt_arbeitsordner_auslesen() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) + .unwrap(); + assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); + // Projekt ohne settings.json → leer + create_project(&p, "alt").unwrap(); + assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); + } + + #[test] + fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + .unwrap(); + fs::create_dir_all(p.home.join("projects/extra")).unwrap(); + let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); + add_work_dir_in(&p, "proj", &picked).unwrap(); + // gespeichert wird ~-kontrahiert, in beiden permissions-Feldern + assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]); + let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); + assert!(raw.contains("Edit(~/projects/extra/**)")); + assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt + + remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap(); + assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty()); + let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); + assert!(!raw.contains("~/projects/extra")); + // Ordner selbst bleibt + assert!(p.home.join("projects/extra").is_dir()); + } + + #[test] + fn projektordner_verlegen() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + .unwrap(); + // Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu. + let new_dir = p.home.join("elsewhere").join("proj"); + fs::create_dir_all(p.home.join("elsewhere")).unwrap(); + fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap(); + set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap(); + + assert_eq!(project_dir(&p, "proj").unwrap(), new_dir); + let raw = fs::read_to_string(settings_path(&new_dir)).unwrap(); + assert!(raw.contains("~/elsewhere/proj/memory")); + assert!(raw.contains("Edit(~/elsewhere/proj/**)")); + assert!(!raw.contains("claude-projects/proj")); + } + + #[test] + fn projekt_loeschen_unbekannt_scheitert() { + let p = tmp_paths(); + assert!(delete_project_in(&p, "gibtsnicht", false).is_err()); + } +} diff --git a/src-tauri/src/domain/registry.rs b/src-tauri/src/domain/registry.rs new file mode 100644 index 0000000..d8bfc75 --- /dev/null +++ b/src-tauri/src/domain/registry.rs @@ -0,0 +1,70 @@ +//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use crate::domain::paths::{contract_home, expand_home, Paths}; + +/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte. +pub(crate) fn load_registry(paths: &Paths) -> Result, String> { + let file = paths.projects_file(); + if !file.is_file() { + return Ok(BTreeMap::new()); + } + let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + let map: BTreeMap = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?; + Ok( + map + .into_iter() + .map(|(name, p)| { + let dir = expand_home(paths, &p); + (name, dir) + }) + .collect(), + ) +} + +pub(crate) fn save_registry( + paths: &Paths, + reg: &BTreeMap, +) -> Result<(), String> { + let contracted: BTreeMap<&String, String> = + reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect(); + let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?; + fs::create_dir_all(paths.config_dir()) + .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; + let file = paths.projects_file(); + fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display())) +} + +/// Ordner eines registrierten Projekts. +pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result { + load_registry(paths)? + .remove(name) + .ok_or_else(|| format!("Projekt nicht registriert: {name}")) +} + +/// Nimmt ein Projekt in die Registry auf. +pub(crate) fn register_project( + paths: &Paths, + name: &str, + dir: &std::path::Path, +) -> Result<(), String> { + let mut reg = load_registry(paths)?; + if reg.contains_key(name) { + return Err(format!("Projekt existiert bereits: {name}")); + } + reg.insert(name.to_string(), dir.to_path_buf()); + save_registry(paths, ®) +} + +/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt. +pub(crate) fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> { + let mut reg = load_registry(paths)?; + if reg.remove(name).is_none() { + return Err(format!("Projekt nicht registriert: {name}")); + } + save_registry(paths, ®) +} diff --git a/src-tauri/src/domain/settings.rs b/src-tauri/src/domain/settings.rs new file mode 100644 index 0000000..c287969 --- /dev/null +++ b/src-tauri/src/domain/settings.rs @@ -0,0 +1,93 @@ +//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen): +//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang. + +use std::fs; +use std::path::PathBuf; + +use crate::domain::paths::{expand_home, Paths}; + +pub(crate) const APP_SETTINGS_FILE: &str = "settings.json"; + +fn read_app_settings(paths: &Paths) -> Option { + let raw = fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// Opt-in: synct der Watcher bei Session-Ende? Default aus. +pub(crate) fn sync_on_session_end(paths: &Paths) -> bool { + read_app_settings(paths) + .and_then(|v| v["syncOnSessionEnd"].as_bool()) + .unwrap_or(false) +} + +/// Setzt das Opt-in; erhält übrige App-settings. +pub(crate) fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> { + write_app_setting(paths, "syncOnSessionEnd", serde_json::json!(enabled)) +} + +/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand). +pub(crate) fn claude_command(paths: &Paths) -> String { + read_app_settings(paths) + .and_then(|v| v["claudeCommand"].as_str().map(str::to_string)) + .unwrap_or_else(|| "claude".into()) +} + +/// Sync-Ziel für Pool-Laufzeitdaten (settings.json: poolSyncDir). +/// Ungesetzt = Feature aus, alle Daten bleiben lokal im Pool-Ordner. +pub(crate) fn pool_sync_dir(paths: &Paths) -> Option { + read_app_settings(paths) + .and_then(|v| v["poolSyncDir"].as_str().map(|d| expand_home(paths, d))) +} + +/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle +/// Terminals. Default 13. +pub(crate) fn terminal_font_size(paths: &Paths) -> u32 { + read_app_settings(paths) + .and_then(|v| v["terminalFontSize"].as_u64()) + .map(|n| n as u32) + .unwrap_or(13) +} + +/// Setzt die Schriftgröße; erhält übrige App-settings. +pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(), String> { + write_app_setting(paths, "terminalFontSize", serde_json::json!(size)) +} + +/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang), +/// Default „de". Pro Text im Panel überschreibbar. +pub(crate) fn spellcheck_lang(paths: &Paths) -> String { + read_app_settings(paths) + .and_then(|v| v["spellcheckLang"].as_str().map(str::to_string)) + .unwrap_or_else(|| "de".to_string()) +} + +/// Einen Schlüssel setzen; übrige App-settings bleiben erhalten. +fn write_app_setting( + paths: &Paths, + key: &str, + value: serde_json::Value, +) -> Result<(), String> { + let path = paths.config_dir().join(APP_SETTINGS_FILE); + let mut v = read_app_settings(paths).unwrap_or_else(|| serde_json::json!({})); + v[key] = value; + fs::create_dir_all(paths.config_dir()) + .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; + 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())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::testutil::tmp_paths; + + #[test] + fn sync_optin_default_aus_und_umschaltbar() { + let p = tmp_paths(); + assert!(!sync_on_session_end(&p)); // default: kein Sync ohne Zustimmung + set_sync_on_session_end_in(&p, true).unwrap(); + assert!(sync_on_session_end(&p)); + set_sync_on_session_end_in(&p, false).unwrap(); + assert!(!sync_on_session_end(&p)); + } +} diff --git a/src-tauri/src/domain/testutil.rs b/src-tauri/src/domain/testutil.rs new file mode 100644 index 0000000..61a7be6 --- /dev/null +++ b/src-tauri/src/domain/testutil.rs @@ -0,0 +1,93 @@ +//! Gemeinsame Test-Helfer der Domänen-Module: Pseudo-Home, In-Memory-Store, +//! Kurzformen für Pool-/Projekt-Anlage. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::domain::credentials::ApikeyStore; +use crate::domain::paths::Paths; +use crate::domain::pool::{create_apikey_pool_in, create_oauth_pool_in}; +use crate::domain::registry::register_project; + +static N: AtomicU32 = AtomicU32::new(0); + +/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem +/// echten System. +pub(crate) 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. +pub(crate) struct MapStore(pub(crate) std::sync::Mutex>); + +pub(crate) fn map_store() -> MapStore { + MapStore(std::sync::Mutex::new(HashMap::new())) +} + +impl ApikeyStore for MapStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + self.0.lock().unwrap().insert(pool.into(), key.into()); + Ok(()) + } + fn has(&self, pool: &str) -> Result { + Ok(self.0.lock().unwrap().contains_key(pool)) + } + fn delete(&self, pool: &str) -> Result<(), String> { + self.0.lock().unwrap().remove(pool); + Ok(()) + } +} + +/// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts. +pub(crate) struct FailStore; + +impl ApikeyStore for FailStore { + fn set(&self, _pool: &str, _key: &str) -> Result<(), String> { + Err("kein Keychain".into()) + } + fn has(&self, _pool: &str) -> Result { + Ok(false) + } + fn delete(&self, _pool: &str) -> Result<(), String> { + Ok(()) + } +} + +/// Liefert die Pool-ID (UUID-Ordnername). +pub(crate) fn make_oauth_pool(p: &Paths, name: &str) -> String { + create_oauth_pool_in(p, name).unwrap() +} + +pub(crate) fn make_apikey_pool( + p: &Paths, + store: &dyn ApikeyStore, + name: &str, + key: &str, +) -> String { + create_apikey_pool_in(p, store, name, key, true).unwrap() +} + +/// Minimal-Projekt: Ordner mit .claude/ + Registry-Eintrag. +pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> { + crate::domain::check_name(name)?; + let dir = paths.projects_dir().join(name); + if dir.exists() { + return Err(format!("Projekt existiert bereits: {name}")); + } + fs::create_dir_all(dir.join(".claude")) + .map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; + register_project(paths, name, &dir) +} + +pub(crate) fn mode(path: &PathBuf) -> u32 { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path).unwrap().permissions().mode() & 0o777 +} diff --git a/src-tauri/src/domain/todo.rs b/src-tauri/src/domain/todo.rs new file mode 100644 index 0000000..c0c9513 --- /dev/null +++ b/src-tauri/src/domain/todo.rs @@ -0,0 +1,137 @@ +//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per +//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert. + +use std::fs; + +use crate::domain::check_name; +use crate::domain::paths::Paths; +use crate::domain::project::settings_path; +use crate::domain::registry::project_dir; + +pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md"; +const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n"; + +fn todo_hook_command(dir: &std::path::Path) -> String { + format!( + "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}", + dir.join(TODO_FILE).display() + ) +} + +fn hook_is_todo(group: &serde_json::Value) -> bool { + group["hooks"] + .as_array() + .map(|hs| { + hs.iter().any(|h| { + h["command"] + .as_str() + .map_or(false, |c| c.contains(TODO_FILE)) + }) + }) + .unwrap_or(false) +} + +pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result { + check_name(name)?; + let sp = settings_path(&project_dir(paths, name)?); + if !sp.is_file() { + return Ok(false); + } + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + Ok( + v["hooks"]["SessionStart"] + .as_array() + .map(|a| a.iter().any(hook_is_todo)) + .unwrap_or(false), + ) +} + +pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { + check_name(name)?; + let dir = project_dir(paths, name)?; + let sp = settings_path(&dir); + if !sp.is_file() { + return Err(format!("settings.json fehlt: {}", sp.display())); + } + let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; + let mut v: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; + + let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; + let hooks = root + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("hooks ist kein Objekt")?; + let session_start = hooks + .entry("SessionStart") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or("SessionStart ist kein Array")?; + + session_start.retain(|g| !hook_is_todo(g)); + if enabled { + session_start.insert( + 0, + serde_json::json!({ + "hooks": [ + { "type": "command", "command": todo_hook_command(&dir) } + ] + }), + ); + let todo_path = dir.join(TODO_FILE); + if !todo_path.is_file() { + fs::write(&todo_path, TODO_SKELETON) + .map_err(|e| format!("{}: {e}", todo_path.display()))?; + } + } + + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::project::{create_project_full_in, TerminalConfig}; + use crate::domain::testutil::{create_project, tmp_paths}; + + #[test] + fn todo_zuschalten_und_abschalten() { + let p = tmp_paths(); + create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) + .unwrap(); + assert!(!todo_state_in(&p, "proj").unwrap()); + + set_todo_in(&p, "proj", true).unwrap(); + assert!(todo_state_in(&p, "proj").unwrap()); + let todo_path = p.projects_dir().join("proj").join(TODO_FILE); + assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); + + // doppelt aktivieren erzeugt keinen zweiten Hook + set_todo_in(&p, "proj", true).unwrap(); + let settings: serde_json::Value = serde_json::from_str( + &fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(), + ) + .unwrap(); + let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1); + + // Abschalten: Hook weg, Datei (mit Inhalt) bleibt + fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); + set_todo_in(&p, "proj", false).unwrap(); + assert!(!todo_state_in(&p, "proj").unwrap()); + assert!(todo_path.is_file()); + assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); + } + + #[test] + fn todo_ohne_settings_scheitert() { + let p = tmp_paths(); + create_project(&p, "alt").unwrap(); + assert!(set_todo_in(&p, "alt", true).is_err()); + assert!(!todo_state_in(&p, "alt").unwrap()); + } +} diff --git a/src-tauri/src/domain/usage.rs b/src-tauri/src/domain/usage.rs new file mode 100644 index 0000000..5364259 --- /dev/null +++ b/src-tauri/src/domain/usage.rs @@ -0,0 +1,282 @@ +//! Verbrauch: aggregiert message.usage aus den Transcript-JSONLs aller Pools +//! zu Pool × Projekt, mit Kostenschätzung nach Modell-Raten. + +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::domain::paths::Paths; +use crate::domain::pool::{read_pool, POOL_FILE}; +use crate::domain::registry::load_registry; + +#[derive(Serialize, Default)] +pub(crate) 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)] +pub(crate) 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 { + if ts.len() < 19 { + return None; + } + let num = |r: std::ops::Range| ts.get(r)?.parse::().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, + 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 == "" { + 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: +/// /projects//*.jsonl → Pool × Projekt. +/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays). +pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result, 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(); + + if !paths.pools_dir().is_dir() { + return Ok(Vec::new()); + } + for pool_entry in + fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))? + { + let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; + 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| format!("{}: {e}", projects_root.display()))? + { + let proj_entry = proj_entry.map_err(|e| format!("{}: {e}", projects_root.display()))?; + 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| format!("{}: {e}", proj_entry.path().display()))? + { + let file = file.map_err(|e| format!("{}: {e}", proj_entry.path().display()))?; + 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 = 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::testutil::{create_project, make_apikey_pool, map_store, tmp_paths}; + + fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String { + serde_json::json!({ + "type": "assistant", + "timestamp": ts, + "requestId": req, + "message": { + "id": id, + "model": model, + "usage": { + "input_tokens": input, + "output_tokens": output, + "cache_creation_input_tokens": 1_000_000, + "cache_read_input_tokens": 2_000_000 + } + } + }) + .to_string() + } + + #[test] + fn verbrauch_aggregation_dedup_und_zeitfilter() { + let p = tmp_paths(); + let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + create_project(&p, "proj").unwrap(); + + let proj_path = p.projects_dir().join("proj"); + let encoded = encode_project_path(&proj_path.to_string_lossy()); + let dir = p.pool_dir(&kunde).join("projects").join(&encoded); + fs::create_dir_all(&dir).unwrap(); + + let lines = [ + // Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60) + usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), + // Retry-Duplikat: gleiche message.id + requestId → zählt nicht + usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), + // außerhalb des Zeitfensters → zählt nicht + usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5), + // kein usage-Objekt → ignoriert + r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(), + // kaputte Zeile (laufende Session) → ignoriert + r#"{"type":"assist"#.to_string(), + ]; + fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap(); + + // Cutoff = jetzt − 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig + let rows = usage_stats_in(&p, 365).unwrap(); + assert_eq!(rows.len(), 1); + let r = &rows[0]; + assert_eq!(r.pool, "kunde"); + assert_eq!(r.project, "proj"); + assert_eq!(r.totals.input_tokens, 1_000_000); + assert_eq!(r.totals.output_tokens, 1_000_000); + assert_eq!(r.totals.cache_creation_tokens, 1_000_000); + assert_eq!(r.totals.cache_read_tokens, 2_000_000); + assert!((r.totals.cost_usd - 22.35).abs() < 1e-9); + } + + #[test] + fn verbrauch_leer_ohne_transcripts() { + let p = tmp_paths(); + make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); + assert!(usage_stats_in(&p, 30).unwrap().is_empty()); + } + + #[test] + fn parse_ts_referenz() { + // 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000 + assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000)); + assert_eq!(parse_ts("kein datum"), None); + } +} diff --git a/src-tauri/src/domain/watcher.rs b/src-tauri/src/domain/watcher.rs new file mode 100644 index 0000000..cd2469b --- /dev/null +++ b/src-tauri/src/domain/watcher.rs @@ -0,0 +1,109 @@ +//! Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft" +//! auf „läuft nicht mehr", ist die Session beendet → Kontext syncen (Opt-in). + +use std::process::Command; +use std::time::Duration; + +use crate::domain::paths::Paths; +use crate::domain::project::list_projects_in; +use crate::domain::registry::project_dir; +use crate::domain::settings::sync_on_session_end; + +/// Tick-Intervall des Session-Watchers. +const WATCH_INTERVAL: Duration = Duration::from_secs(5); + +/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts. +fn git_root(dir: &std::path::Path) -> Option { + dir + .ancestors() + .find(|a| a.join(".git").exists()) + .map(|a| a.to_path_buf()) +} + +/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die +/// Projektliste; Grundlage für Session-Watcher und Tray-Menü. +fn project_state(paths: &Paths) -> Vec<(String, bool)> { + let mut state: Vec<(String, bool)> = list_projects_in(paths) + .map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect()) + .unwrap_or_default(); + state.sort(); + state +} + +/// Committet/pusht das Repo des Projekts nach einem Session-Ende: +/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still; +/// jeder andere Fehler bricht die Kette mit Meldung ab. +fn sync_session_context(repo: &std::path::Path, project: &str) { + let run = |args: &[&str]| { + Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .map_err(|e| format!("git {}: {e}", args.join(" "))) + }; + let msg = format!("session-end sync: {project}"); + let steps: [&[&str]; 4] = [ + &["add", "-A"], + &["commit", "-m", &msg], + &["pull", "--rebase"], + &["push"], + ]; + for (i, args) in steps.iter().enumerate() { + match run(args) { + Ok(o) if o.status.success() => {} + // commit ohne Änderungen: nichts zu syncen, kein Fehler. + Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => { + return; + } + Ok(o) => { + eprintln!( + "session-end sync {project}: git {} — {}", + args.join(" "), + String::from_utf8_lossy(&o.stderr).trim() + ); + return; + } + Err(e) => { + eprintln!("session-end sync {project}: {e}"); + return; + } + } + } +} + +/// 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. +pub(crate) 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; + } + }); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ca0b51..3498572 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,2202 +1,15 @@ +//! Einstieg: Argument-Dispatch auf die drei Prozessrollen eines Binaries — +//! Haupt-App (Tray), Terminal-Prozess (`--terminal `) und +//! MCP-stdio-Server (`--mcp-panel`). Fachlogik liegt in domain/, OS-Aufrufe +//! in platform/, Tauri-Verdrahtung in app.rs/commands.rs. + +mod app; +mod commands; +mod domain; mod mcp; +mod platform; mod terminal; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::os::unix::fs::PermissionsExt; -use std::path::PathBuf; -use std::process::Command; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das -/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain); -/// die App speichert keine Tokens. -const APIKEY_FILE: &str = "apikey"; -const POOL_FILE: &str = "pool.json"; -const PROJECT_FILE: &str = "ai-control.json"; -const PROJECTS_FILE: &str = "projects.json"; - -/// Wurzelpfade; in Tests mit temporärem home instanziierbar. -pub(crate) struct Paths { - home: PathBuf, -} - -impl Paths { - pub(crate) fn real() -> Self { - Paths { - home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")), - } - } - - /// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer - /// Projekte ohne gewählten Zielordner. - fn projects_dir(&self) -> PathBuf { - self.home.join("claude-projects") - } - - fn config_dir(&self) -> PathBuf { - self.home.join(".config").join("ai-control") - } - - fn projects_file(&self) -> PathBuf { - self.config_dir().join(PROJECTS_FILE) - } - - fn pools_dir(&self) -> PathBuf { - self.config_dir().join("pools") - } - - /// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config, - /// unabhängig von Pool-Zuordnung und Quell-Repos. - fn icons_dir(&self) -> PathBuf { - self.config_dir().join("icons") - } - - fn pool_dir(&self, pool: &str) -> PathBuf { - self.pools_dir().join(pool) - } - - /// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein, - /// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel. - fn panels_dir(&self) -> PathBuf { - self.config_dir().join("panels") - } -} - -/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als -/// AI_CONTROL_PANEL in der PTY-Umgebung. -pub(crate) fn panel_file(project: &str) -> PathBuf { - Paths::real().panels_dir().join(format!("{project}.md")) -} - -// ---------- Archiv (Panel-Entwürfe persistieren) ---------- - -/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir), -/// Home-expandiert; None, wenn nicht gesetzt. -pub(crate) fn project_archive_dir(project: &str) -> Option { - let paths = Paths::real(); - let dir = read_project_config_in(&paths, project).ok()?.archive_dir?; - Some(expand_home(&paths, &dir)) -} - -/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in -/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit -/// claude das Archiv später lesen/scannen darf. -pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> { - let paths = Paths::real(); - let expanded = expand_home(&paths, dir); - if !expanded.is_absolute() { - return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display())); - } - // Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde - // claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab. - if paths.home.starts_with(&expanded) { - return Err(format!( - "Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.", - expanded.display() - )); - } - fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; - let contracted = contract_home(&paths, &expanded); - let mut cfg = read_project_config_in(&paths, project)?; - cfg.archive_dir = Some(contracted.clone()); - write_project_config_in(&paths, project, &cfg)?; - add_archive_permission(&paths, project, &contracted) -} - -/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein. -fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> { - let sp = settings_path(&project_dir(paths, project)?); - let mut v: serde_json::Value = if sp.is_file() { - serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?) - .map_err(|e| format!("{}: {e}", sp.display()))? - } else { - serde_json::json!({}) - }; - let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; - let perms = root - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms - .entry("additionalDirectories") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - if !dirs.iter().any(|d| d.as_str() == Some(dir)) { - dirs.push(serde_json::json!(dir)); - } - let edit = format!("Edit({dir}/**)"); - let allow = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("allow ist kein Array")?; - if !allow.iter().any(|p| p.as_str() == Some(&edit)) { - allow.push(serde_json::json!(edit)); - } - let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; - fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) -} - -/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit -/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich -/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad. -pub(crate) fn archive_panel_content( - project: &str, - dir_override: Option<&str>, -) -> Result { - if let Some(d) = dir_override { - set_project_archive_dir(project, d)?; - } - let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?; - let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); - if text.trim().is_empty() { - return Err("Panel ist leer — nichts zu archivieren".into()); - } - fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_secs(); - let (stamp, iso) = utc_stamp(secs); - let title = first_line(&text); - let path = dir.join(format!("{stamp}-{}.md", slugify(&title))); - let doc = format!( - "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n", - title.replace('"', "'"), - text.trim_end(), - ); - fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; - Ok(path) -} - -/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile. -fn first_line(text: &str) -> String { - let mut fallback: Option<&str> = None; - for line in text.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - let h = t.trim_start_matches('#').trim(); - if t.starts_with('#') && !h.is_empty() { - return h.to_string(); - } - fallback.get_or_insert(t); - } - fallback.unwrap_or("entwurf").to_string() -} - -/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen). -fn slugify(s: &str) -> String { - let mut out = String::new(); - for c in s.chars() { - if c.is_ascii_alphanumeric() { - out.push(c.to_ascii_lowercase()); - } else if !out.ends_with('-') { - out.push('-'); - } - } - let out: String = out.trim_matches('-').chars().take(60).collect(); - if out.is_empty() { - "entwurf".into() - } else { - out - } -} - -/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`). -fn utc_stamp(secs: u64) -> (String, String) { - let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60); - let (y, m, d) = civil_from_days((secs / 86400) as i64); - ( - format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"), - format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"), - ) -} - -/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus). -fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719468; - let era = (if z >= 0 { z } else { z - 146096 }) / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - (if m <= 2 { y + 1 } else { y }, m, d) -} - -// ---------- Projekt-Registry ---------- - -/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im -/// Home-Bereich bleiben damit maschinenübergreifend stabil. -fn contract_home(paths: &Paths, p: &std::path::Path) -> String { - match p.strip_prefix(&paths.home) { - Ok(rest) => format!("~/{}", rest.display()), - Err(_) => p.display().to_string(), - } -} - -/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte. -fn load_registry(paths: &Paths) -> Result, String> { - let file = paths.projects_file(); - if !file.is_file() { - return Ok(std::collections::BTreeMap::new()); - } - let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; - let map: std::collections::BTreeMap = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?; - Ok( - map - .into_iter() - .map(|(name, p)| { - let dir = expand_home(paths, &p); - (name, dir) - }) - .collect(), - ) -} - -fn save_registry( - paths: &Paths, - reg: &std::collections::BTreeMap, -) -> Result<(), String> { - let contracted: std::collections::BTreeMap<&String, String> = - reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect(); - let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?; - fs::create_dir_all(paths.config_dir()) - .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; - let file = paths.projects_file(); - fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display())) -} - -/// Ordner eines registrierten Projekts. -pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result { - load_registry(paths)? - .remove(name) - .ok_or_else(|| format!("Projekt nicht registriert: {name}")) -} - -/// Nimmt ein Projekt in die Registry auf. -fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> { - let mut reg = load_registry(paths)?; - if reg.contains_key(name) { - return Err(format!("Projekt existiert bereits: {name}")); - } - reg.insert(name.to_string(), dir.to_path_buf()); - save_registry(paths, ®) -} - -/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt. -fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> { - let mut reg = load_registry(paths)?; - if reg.remove(name).is_none() { - return Err(format!("Projekt nicht registriert: {name}")); - } - save_registry(paths, ®) -} - -fn project_config_path(paths: &Paths, project: &str) -> Result { - Ok(project_dir(paths, project)?.join(PROJECT_FILE)) -} - -#[derive(Serialize)] -struct Project { - name: String, - path: String, - pool: Option, - running: bool, - terminal: TerminalConfig, -} - -#[derive(Serialize, Deserialize)] -struct Pool { - name: String, - #[serde(rename = "credentialType")] - credential_type: String, -} - -#[derive(Serialize, Deserialize, Default)] -struct ProjectConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pool: Option, - #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] - terminal: TerminalConfig, - /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert). - #[serde( - default, - rename = "archiveDir", - skip_serializing_if = "Option::is_none" - )] - archive_dir: Option, -} - -#[derive(Serialize, Deserialize, Default, Clone)] -pub struct TerminalConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub theme: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub icon: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, -} - -impl TerminalConfig { - fn is_empty(&self) -> bool { - self.theme.is_none() && self.icon.is_none() && self.title.is_none() - } -} - -fn is_running(project: &str) -> bool { - !terminal_pids(project).is_empty() -} - -/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe -/// Lauf-Erkennung wie die Projektliste (`is_running`). -fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { - Ok(projects_using_pool(paths, pool)? - .into_iter() - .filter(|p| is_running(p)) - .collect()) -} - -/// PIDs der eingebauten Terminal-Prozesse (`app --terminal `). -fn terminal_pids(project: &str) -> Vec { - let out = Command::new("pgrep") - .args(["-f", "--", &format!("--terminal {project}$")]) - .output(); - match out { - Ok(o) => String::from_utf8_lossy(&o.stdout) - .lines() - .filter_map(|l| l.trim().parse().ok()) - .collect(), - Err(_) => Vec::new(), - } -} - -/// Tick-Intervall des Session-Watchers. -const WATCH_INTERVAL: Duration = Duration::from_secs(5); - -/// App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen). -const APP_SETTINGS_FILE: &str = "settings.json"; - -/// Opt-in: synct der Watcher bei Session-Ende? Default aus. -fn sync_on_session_end(paths: &Paths) -> bool { - fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v["syncOnSessionEnd"].as_bool()) - .unwrap_or(false) -} - -/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand). -pub(crate) fn claude_command(paths: &Paths) -> String { - fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v["claudeCommand"].as_str().map(str::to_string)) - .unwrap_or_else(|| "claude".into()) -} - -/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als -/// CLAUDE_CONFIG_DIR mitgegeben. -pub(crate) fn project_pool_dir(project: &str) -> Result, String> { - let paths = Paths::real(); - Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p))) -} - -/// Setzt das Opt-in; erhält übrige App-settings. -fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> { - let path = paths.config_dir().join(APP_SETTINGS_FILE); - let mut v: serde_json::Value = fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| serde_json::json!({})); - v["syncOnSessionEnd"] = serde_json::json!(enabled); - fs::create_dir_all(paths.config_dir()) - .map_err(|e| format!("{}: {e}", paths.config_dir().display()))?; - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) -} - -/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts. -fn git_root(dir: &std::path::Path) -> Option { - dir - .ancestors() - .find(|a| a.join(".git").exists()) - .map(|a| a.to_path_buf()) -} - -/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die -/// Projektliste; Grundlage für Session-Watcher und Tray-Menü. -fn project_state(paths: &Paths) -> Vec<(String, bool)> { - let mut state: Vec<(String, bool)> = list_projects_in(paths) - .map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect()) - .unwrap_or_default(); - state.sort(); - state -} - -/// Committet/pusht das Repo des Projekts nach einem Session-Ende: -/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still; -/// jeder andere Fehler bricht die Kette mit Meldung ab. -fn sync_session_context(repo: &std::path::Path, project: &str) { - let run = |args: &[&str]| { - Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" "))) - }; - let msg = format!("session-end sync: {project}"); - let steps: [&[&str]; 4] = [ - &["add", "-A"], - &["commit", "-m", &msg], - &["pull", "--rebase"], - &["push"], - ]; - for (i, args) in steps.iter().enumerate() { - match run(args) { - Ok(o) if o.status.success() => {} - // commit ohne Änderungen: nichts zu syncen, kein Fehler. - Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => { - return; - } - Ok(o) => { - eprintln!( - "session-end sync {project}: git {} — {}", - args.join(" "), - String::from_utf8_lossy(&o.stderr).trim() - ); - return; - } - Err(e) => { - eprintln!("session-end sync {project}: {e}"); - return; - } - } - } -} - -/// Session-Watcher: 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; - } - }); -} - -fn check_name(name: &str) -> Result<(), String> { - if name.trim().is_empty() || name.contains('/') || name.contains("..") { - return Err(format!("ungültiger Name: {name}")); - } - Ok(()) -} - -fn read_pool(paths: &Paths, pool: &str) -> Result { - let cfg_path = paths.pool_dir(pool).join(POOL_FILE); - let raw = fs::read_to_string(&cfg_path) - .map_err(|e| format!("{}: {e}", cfg_path.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) -} - -// ---------- Projekte ---------- - -fn list_projects_in(paths: &Paths) -> Result, String> { - let mut projects = Vec::new(); - for (name, dir) in load_registry(paths)? { - let cfg = read_project_config_in(paths, &name)?; - projects.push(Project { - running: is_running(&name), - path: contract_home(paths, &dir), - pool: cfg.pool, - terminal: cfg.terminal, - name, - }); - } - Ok(projects) -} - -// ---------- Feature: Todoliste ---------- - -/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq) -/// als additionalContext in jede Session injiziert. -const TODO_FILE: &str = "OFFENE-PUNKTE.md"; -const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n"; - -fn todo_hook_command(dir: &std::path::Path) -> String { - format!( - "jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}", - dir.join(TODO_FILE).display() - ) -} - -fn settings_path(dir: &std::path::Path) -> PathBuf { - dir.join(".claude").join("settings.json") -} - -fn hook_is_todo(group: &serde_json::Value) -> bool { - group["hooks"] - .as_array() - .map(|hs| { - hs.iter().any(|h| { - h["command"] - .as_str() - .map_or(false, |c| c.contains(TODO_FILE)) - }) - }) - .unwrap_or(false) -} - -fn todo_state_in(paths: &Paths, name: &str) -> Result { - check_name(name)?; - let sp = settings_path(&project_dir(paths, name)?); - if !sp.is_file() { - return Ok(false); - } - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - Ok( - v["hooks"]["SessionStart"] - .as_array() - .map(|a| a.iter().any(hook_is_todo)) - .unwrap_or(false), - ) -} - -fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { - check_name(name)?; - let dir = project_dir(paths, name)?; - let sp = settings_path(&dir); - if !sp.is_file() { - return Err(format!("settings.json fehlt: {}", sp.display())); - } - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let mut v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - - let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; - let hooks = root - .entry("hooks") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .ok_or("hooks ist kein Objekt")?; - let session_start = hooks - .entry("SessionStart") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("SessionStart ist kein Array")?; - - session_start.retain(|g| !hook_is_todo(g)); - if enabled { - session_start.insert( - 0, - serde_json::json!({ - "hooks": [ - { "type": "command", "command": todo_hook_command(&dir) } - ] - }), - ); - let todo_path = dir.join(TODO_FILE); - if !todo_path.is_file() { - fs::write(&todo_path, TODO_SKELETON) - .map_err(|e| format!("{}: {e}", todo_path.display()))?; - } - } - - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) -} - -/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. -fn expand_home(paths: &Paths, p: &str) -> PathBuf { - if p == "~" { - return paths.home.clone(); - } - match p.strip_prefix("~/") { - Some(rest) => paths.home.join(rest), - None => PathBuf::from(p), - } -} - -/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore -/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, -/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag. -/// Ohne Zielordner landet das Projekt unter ~/claude-projects/. -fn create_project_full_in( - paths: &Paths, - name: &str, - dir: Option<&str>, - pool: Option<&str>, - work_dir: Option<&str>, - create_work_dir: bool, - terminal: TerminalConfig, - todo: bool, -) -> Result<(), String> { - check_name(name)?; - let dir = match dir { - Some(d) => expand_home(paths, d), - None => paths.projects_dir().join(name), - }; - let mut reg = load_registry(paths)?; - if reg.contains_key(name) { - return Err(format!("Projekt existiert bereits: {name}")); - } - if dir.exists() { - return Err(format!("Ordner existiert bereits: {}", dir.display())); - } - if let Some(pool) = pool { - if !paths.pool_dir(pool).join(POOL_FILE).is_file() { - return Err(format!("Pool existiert nicht: {pool}")); - } - } - // Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein - // halbes Projekt. - if let Some(wd) = work_dir { - let wd_path = expand_home(paths, wd); - if create_work_dir { - fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; - } else if !wd_path.is_dir() { - return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); - } - } - - fs::create_dir_all(dir.join(".claude")) - .map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; - fs::create_dir_all(dir.join("memory")) - .map_err(|e| format!("{}: {e}", dir.join("memory").display()))?; - fs::write(dir.join(".gitignore"), ".ai-control-running\n") - .map_err(|e| format!("{}: {e}", dir.join(".gitignore").display()))?; - - let contracted = contract_home(paths, &dir); - let mut allow = vec![format!("Edit({contracted}/**)")]; - let mut additional: Vec = Vec::new(); - if let Some(wd) = work_dir { - allow.insert(0, format!("Edit({wd}/**)")); - additional.push(wd.to_string()); - } - let settings = serde_json::json!({ - "autoMemoryDirectory": format!("{contracted}/memory"), - "permissions": { - "allow": allow, - "additionalDirectories": additional, - }, - }); - let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; - fs::write(dir.join(".claude").join("settings.json"), raw + "\n") - .map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?; - - reg.insert(name.to_string(), dir.clone()); - save_registry(paths, ®)?; - let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None }; - if cfg.pool.is_some() || !cfg.terminal.is_empty() { - write_project_config_in(paths, name, &cfg)?; - } - if todo { - set_todo_in(paths, name, true)?; - } - #[cfg(target_os = "linux")] - write_terminal_desktop(paths, name, &cfg.terminal); - Ok(()) -} - -/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise -/// auf den alten Root in der settings.json (autoMemoryDirectory, -/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner -/// muss existieren. -fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { - check_name(name)?; - let new_dir = expand_home(paths, dir); - if !new_dir.is_dir() { - return Err(format!("Ordner nicht gefunden: {}", new_dir.display())); - } - let mut reg = load_registry(paths)?; - let old_dir = reg - .get(name) - .cloned() - .ok_or_else(|| format!("Projekt nicht registriert: {name}"))?; - if old_dir == new_dir { - return Ok(()); - } - reg.insert(name.to_string(), new_dir.clone()); - save_registry(paths, ®)?; - - let sp = settings_path(&new_dir); - if !sp.is_file() { - return Ok(()); // importierte Projekte ohne settings.json - } - let old_c = contract_home(paths, &old_dir); - let new_c = contract_home(paths, &new_dir); - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let mut v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - if let Some(mem) = v["autoMemoryDirectory"].as_str() { - if let Some(rest) = mem.strip_prefix(&old_c) { - v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}")); - } - } - if let Some(allow) = v["permissions"]["allow"].as_array_mut() { - let old_edit = format!("Edit({old_c}/**)"); - for e in allow.iter_mut() { - if e.as_str() == Some(&old_edit) { - *e = serde_json::json!(format!("Edit({new_c}/**)")); - } - } - } - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) -} - -/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername. -fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> { - let dir = expand_home(paths, path); - if !dir.is_dir() { - return Err(format!("Ordner nicht gefunden: {}", dir.display())); - } - let name = dir - .file_name() - .ok_or_else(|| format!("kein Ordnername: {}", dir.display()))? - .to_string_lossy() - .into_owned(); - check_name(&name)?; - register_project(paths, &name, &dir)?; - #[cfg(target_os = "linux")] - { - let cfg = read_project_config_in(paths, &name) - .map(|c| c.terminal) - .unwrap_or_default(); - write_terminal_desktop(paths, &name, &cfg); - } - Ok(()) -} - -#[cfg(test)] -fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { - check_name(name)?; - let dir = paths.projects_dir().join(name); - if dir.exists() { - return Err(format!("Projekt existiert bereits: {name}")); - } - fs::create_dir_all(dir.join(".claude")) - .map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?; - register_project(paths, name, &dir) -} - -/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im -/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die -/// Datei an, wenn sie fehlt (importierte Projekte). -fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { - check_name(name)?; - let wd_path = expand_home(paths, dir); - if !wd_path.is_dir() { - return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display())); - } - let dir = contract_home(paths, &wd_path); - let sp = settings_path(&project_dir(paths, name)?); - let mut v: serde_json::Value = if sp.is_file() { - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))? - } else { - serde_json::json!({}) - }; - let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?; - let perms = root - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms - .entry("additionalDirectories") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - if dirs.iter().any(|d| d.as_str() == Some(&dir)) { - return Err(format!("schon eingetragen: {dir}")); - } - dirs.push(serde_json::json!(dir)); - let allow = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .ok_or("allow ist kein Array")?; - allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); - let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; - fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) -} - -/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und -/// zugehörige Edit-Permission. Der Ordner selbst bleibt. -fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { - check_name(name)?; - let sp = settings_path(&project_dir(paths, name)?); - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let mut v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - let perms = v["permissions"] - .as_object_mut() - .ok_or("permissions ist kein Objekt")?; - let dirs = perms["additionalDirectories"] - .as_array_mut() - .ok_or("additionalDirectories ist kein Array")?; - let before = dirs.len(); - dirs.retain(|d| d.as_str() != Some(dir)); - if dirs.len() == before { - return Err(format!("nicht eingetragen: {dir}")); - } - if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { - allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); - } - let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; - fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) -} - -/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. -fn project_work_dirs_in(paths: &Paths, name: &str) -> Result, String> { - check_name(name)?; - let sp = settings_path(&project_dir(paths, name)?); - if !sp.is_file() { - return Ok(Vec::new()); - } - let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; - let v: serde_json::Value = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?; - Ok( - v["permissions"]["additionalDirectories"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(), - ) -} - -/// Löscht den Projektordner und den Registry-Eintrag; optional auch die -/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen. -fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> { - check_name(name)?; - let dir = project_dir(paths, name)?; - if !dir.is_dir() { - return Err(format!("Projekt nicht gefunden: {name}")); - } - if delete_work_dirs { - for wd in project_work_dirs_in(paths, name)? { - let wd_path = expand_home(paths, &wd); - fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; - } - } - fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; - unregister_project(paths, name)?; - #[cfg(target_os = "linux")] - remove_terminal_desktop(paths, name); - Ok(()) -} - -fn read_project_config_in(paths: &Paths, project: &str) -> Result { - let cfg_path = project_config_path(paths, project)?; - if !cfg_path.is_file() { - return Ok(ProjectConfig::default()); - } - let raw = - fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) -} - -fn write_project_config_in( - paths: &Paths, - project: &str, - cfg: &ProjectConfig, -) -> Result<(), String> { - let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; - let path = project_config_path(paths, project)?; - fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) -} - -fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> { - if !paths.pool_dir(pool).join(POOL_FILE).is_file() { - return Err(format!("Pool existiert nicht: {pool}")); - } - let mut cfg = read_project_config_in(paths, project)?; - cfg.pool = Some(pool.to_string()); - write_project_config_in(paths, project, &cfg) -} - -/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch -/// Terminal-Einstellungen trägt. -fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { - let mut cfg = read_project_config_in(paths, project)?; - cfg.pool = None; - if cfg.terminal.is_empty() && cfg.archive_dir.is_none() { - let cfg_path = project_config_path(paths, project)?; - return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); - } - write_project_config_in(paths, project, &cfg) -} - -fn set_terminal_config_in( - paths: &Paths, - project: &str, - mut terminal: TerminalConfig, -) -> Result<(), String> { - let mut cfg = read_project_config_in(paths, project)?; - // Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als - // Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo. - if let Some(icon) = terminal.icon.as_deref() { - if icon.starts_with('/') { - let src = PathBuf::from(icon); - let ext = src - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("png") - .to_lowercase(); - let name = format!("{project}.{ext}"); - let icons = paths.icons_dir(); - fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?; - let dest = icons.join(&name); - if src != dest { - fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?; - } - terminal.icon = Some(name); - } - } - cfg.terminal = terminal; - write_project_config_in(paths, project, &cfg)?; - #[cfg(target_os = "linux")] - write_terminal_desktop(paths, project, &cfg.terminal); - Ok(()) -} - -/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im -/// gemeinsamen Icons-Verzeichnis der App. -fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf { - if icon.starts_with('/') { - PathBuf::from(icon) - } else { - paths.icons_dir().join(icon) - } -} - -/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien. -/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben. -#[cfg(target_os = "linux")] -fn applications_dir(paths: &Paths) -> PathBuf { - paths.home.join(".local/share/applications") -} - -/// Schreibt/aktualisiert die .desktop eines Projekts: dash-to-dock ordnet dem -/// Fenster (app_id `aicontrol-`, via set_prgname) darüber das Projekt- -/// Icon zu. NoDisplay=true hält den App-Starter sauber. -#[cfg(target_os = "linux")] -pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) { - let dir = applications_dir(paths); - if fs::create_dir_all(&dir).is_err() { - return; - } - let exec = std::env::current_exe() - .ok() - .and_then(|p| p.to_str().map(str::to_string)) - .unwrap_or_else(|| "ai-control".into()); - let icon_line = cfg - .icon - .as_deref() - .map(|i| resolve_icon_path(paths, i)) - .filter(|p| p.exists()) - .and_then(|p| p.to_str().map(str::to_string)) - .map(|p| format!("Icon={p}\n")) - .unwrap_or_default(); - let content = format!( - "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\ - {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n" - ); - let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content); -} - -/// Entfernt die .desktop eines Projekts (beim Löschen). -#[cfg(target_os = "linux")] -pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) { - let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop"))); -} - -/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und -/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer -/// vorhanden und aktuell, bevor überhaupt ein Terminal startet. -#[cfg(target_os = "linux")] -fn sync_all_desktops(paths: &Paths) { - let Ok(reg) = load_registry(paths) else { - return; - }; - for project in reg.keys() { - let cfg = read_project_config_in(paths, project) - .map(|c| c.terminal) - .unwrap_or_default(); - write_terminal_desktop(paths, project, &cfg); - } - let dir = applications_dir(paths); - if let Ok(entries) = fs::read_dir(&dir) { - for e in entries.flatten() { - let name = e.file_name(); - let Some(name) = name.to_str() else { continue }; - if let Some(project) = name - .strip_prefix("aicontrol-") - .and_then(|n| n.strip_suffix(".desktop")) - { - if !reg.contains_key(project) { - let _ = fs::remove_file(e.path()); - } - } - } - } -} - -/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG). -#[tauri::command] -fn project_icon(project: String) -> Result, String> { - use base64::{engine::general_purpose::STANDARD, Engine}; - let paths = Paths::real(); - let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else { - return Ok(None); - }; - let path = resolve_icon_path(&paths, &icon); - let mime = match path.extension().and_then(|e| e.to_str()) { - Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml", - _ => "image/png", - }; - let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; - Ok(Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes)))) -} - -/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. -pub(crate) fn terminal_config(project: &str) -> Result { - Ok(read_project_config_in(&Paths::real(), project)?.terminal) -} - -/// Namen aller Projekte, die `pool` zugeordnet haben. -fn projects_using_pool(paths: &Paths, pool: &str) -> Result, String> { - let mut users = Vec::new(); - for (name, dir) in load_registry(paths)? { - let cfg_path = dir.join(PROJECT_FILE); - if !cfg_path.is_file() { - continue; - } - let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?; - if cfg.pool.as_deref() == Some(pool) { - users.push(name); - } - } - Ok(users) -} - -// ---------- Pools ---------- - -/// Ablage der API-Keys: macOS-Keychain / Linux-Secret-Service (keyring-Crate). -/// Die Key-Datei im Pool-Ordner bleibt Fallback, wenn der Store beim Schreiben -/// nicht verfügbar ist. -trait ApikeyStore { - fn set(&self, pool: &str, key: &str) -> Result<(), String>; - fn has(&self, pool: &str) -> Result; - fn delete(&self, pool: &str) -> Result<(), String>; -} - -/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die -/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest -/// mit denselben Attributen über secret-tool. -const APIKEY_SERVICE: &str = "ai-control-apikey"; - -/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch) -/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file. -const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable"; - -struct KeychainStore; - -/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in -/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim -/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das -/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen -/// durchs CLI prompten. -#[cfg(target_os = "macos")] -impl ApikeyStore for KeychainStore { - fn set(&self, pool: &str, key: &str) -> Result<(), String> { - let out = Command::new("security") - .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key]) - .output() - .map_err(|e| e.to_string())?; - if out.status.success() { - Ok(()) - } else { - Err(String::from_utf8_lossy(&out.stderr).into_owned()) - } - } - - fn has(&self, pool: &str) -> Result { - // Ohne -w: nur Attribute, kein Secret — kein ACL-Prompt möglich. - let out = Command::new("security") - .args(["find-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) - .output() - .map_err(|e| e.to_string())?; - Ok(out.status.success()) - } - - fn delete(&self, pool: &str) -> Result<(), String> { - // Fehlender Eintrag ist kein Fehler — gelöscht ist gelöscht. - Command::new("security") - .args(["delete-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) - .output() - .map_err(|e| e.to_string())?; - Ok(()) - } -} - -#[cfg(not(target_os = "macos"))] -impl KeychainStore { - fn entry(pool: &str) -> Result { - keyring::Entry::new(APIKEY_SERVICE, pool).map_err(|e| e.to_string()) - } -} - -#[cfg(not(target_os = "macos"))] -impl ApikeyStore for KeychainStore { - fn set(&self, pool: &str, key: &str) -> Result<(), String> { - Self::entry(pool)?.set_password(key).map_err(|e| e.to_string()) - } - - fn has(&self, pool: &str) -> Result { - match Self::entry(pool)?.get_password() { - Ok(_) => Ok(true), - // Kein Eintrag oder kein Store verfügbar → die Key-Datei entscheidet. - Err( - keyring::Error::NoEntry - | keyring::Error::PlatformFailure(_) - | keyring::Error::NoStorageAccess(_), - ) => Ok(false), - Err(e) => Err(e.to_string()), - } - } - - fn delete(&self, pool: &str) -> Result<(), String> { - match Self::entry(pool)?.delete_credential() { - Ok(()) - | Err( - keyring::Error::NoEntry - | keyring::Error::PlatformFailure(_) - | keyring::Error::NoStorageAccess(_), - ) => Ok(()), - Err(e) => Err(e.to_string()), - } - } -} - -/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem -/// Keychain/Keyring, bei fehlendem Eintrag aus der Key-Datei. -fn apikey_helper_command(dir: &std::path::Path, pool_id: &str) -> String { - let file = dir.join(APIKEY_FILE); - if cfg!(target_os = "macos") { - format!( - "security find-generic-password -w -s {APIKEY_SERVICE} -a {pool_id} 2>/dev/null || cat '{}'", - file.display() - ) - } else { - format!( - "secret-tool lookup service {APIKEY_SERVICE} username {pool_id} 2>/dev/null || cat '{}'", - file.display() - ) - } -} - -#[derive(Serialize)] -struct PoolInfo { - /// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der - /// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen. - id: String, - /// Anzeigename aus pool.json, frei umbenennbar. - name: String, - #[serde(rename = "credentialType")] - credential_type: String, - projects: Vec, - /// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die - /// Projektliste). Der Löschen-Dialog sperrt darauf. - running: Vec, - #[serde(rename = "hasCredentials")] - has_credentials: bool, -} - -fn list_pools_in(paths: &Paths, store: &dyn ApikeyStore) -> Result, String> { - let mut pools = Vec::new(); - if !paths.pools_dir().is_dir() { - return Ok(pools); - } - let entries = - fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - for entry in entries { - let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - let cfg_path = entry.path().join(POOL_FILE); - if !cfg_path.is_file() { - continue; - } - let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - let pool: Pool = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - let id = entry.file_name().to_string_lossy().into_owned(); - // oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen - // Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true. - // apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei. - let has_credentials = match pool.credential_type.as_str() { - "apikey" => { - store.has(&id)? - || fs::read_to_string(entry.path().join(APIKEY_FILE)) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) - } - _ => true, - }; - let projects = projects_using_pool(paths, &id)?; - let running = projects.iter().filter(|p| is_running(p)).cloned().collect(); - pools.push(PoolInfo { - id, - projects, - running, - name: pool.name, - credential_type: pool.credential_type, - has_credentials, - }); - } - pools.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(pools) -} - -/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch -/// nicht — dann ist die Liste leer. -fn pool_names(paths: &Paths) -> Result, String> { - let mut out = Vec::new(); - if !paths.pools_dir().is_dir() { - return Ok(out); - } - for entry in - fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))? - { - let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - let cfg_path = entry.path().join(POOL_FILE); - if !cfg_path.is_file() { - continue; - } - let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - let pool: Pool = - serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?; - out.push((entry.file_name().to_string_lossy().into_owned(), pool.name)); - } - Ok(out) -} - -/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den -/// Ordner für einen neuen Pool: pools/. -fn check_new_pool(paths: &Paths, name: &str) -> Result { - check_name(name)?; - if pool_names(paths)?.iter().any(|(_, n)| n == name) { - return Err(format!("Pool existiert bereits: {name}")); - } - Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) -} - -fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> { - fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; - let pool = Pool { - name: name.to_string(), - credential_type: credential_type.to_string(), - }; - let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?; - fs::write(dir.join(POOL_FILE), raw + "\n") - .map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display())) -} - -fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; - } - fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .map_err(|e| format!("{}: {e}", path.display())) -} - -/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json -/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als -/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. -/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst -/// 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, - "permissions": { "allow": [PANEL_WRITE_PERMISSION] }, - }); - 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| format!("{}: {e}", dir.display()))?; - 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| format!("{}: {e}", dir.join("settings.json").display()))?; - let claude_md = dir.join("CLAUDE.md"); - if !claude_md.is_file() { - fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?; - } - install_panel_skill(dir); - register_mcp_server(dir); - Ok(()) -} - -/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im -/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet. -const PANEL_SKILL: &str = include_str!("../resources/panel-skill.md"); - -/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt -/// („text panel"). Bestimmt den Tool-Namespace `mcp____`. -const PANEL_MCP_SERVER: &str = "text-panel"; - -/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage -/// läuft. Namensschema: `mcp____`. -const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel"; - -/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine -/// evtl. ältere, tee-basierte Fassung). -fn install_panel_skill(pool_dir: &std::path::Path) { - let skill_dir = pool_dir.join("skills").join("panel"); - if fs::create_dir_all(&skill_dir).is_ok() { - let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL); - } -} - -/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings -/// entfernt werden. -const STALE_PANEL_PERMISSIONS: [&str; 3] = - ["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"]; - -/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt -/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern. -fn ensure_panel_permission(pool_dir: &std::path::Path) { - let sp = pool_dir.join("settings.json"); - let Ok(raw) = fs::read_to_string(&sp) else { - return; - }; - let Ok(mut v) = serde_json::from_str::(&raw) else { - return; - }; - let Some(obj) = v.as_object_mut() else { return }; - let Some(perms) = obj - .entry("permissions") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - else { - return; - }; - let Some(allow) = perms - .entry("allow") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - else { - return; - }; - let before = allow.len(); - allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); - let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION)); - if !has { - allow.push(serde_json::json!(PANEL_WRITE_PERMISSION)); - } - if has && allow.len() == before { - return; // nichts geändert - } - if let Ok(out) = serde_json::to_string_pretty(&v) { - let _ = fs::write(&sp, out + "\n"); - } -} - -/// Registriert den MCP-Server `aicontrol` (dieses Binary mit `--mcp-panel`) in -/// der `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle -/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt. -fn register_mcp_server(pool_dir: &std::path::Path) { - let Ok(exe) = std::env::current_exe() else { - return; - }; - // Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt - // den Entwurf in den Chat statt ins Panel. - let desired = serde_json::json!({ - "type": "stdio", - "command": exe.to_string_lossy(), - "args": ["--mcp-panel"], - "alwaysLoad": true, - }); - let cfg = pool_dir.join(".claude.json"); - let mut v = fs::read_to_string(&cfg) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .unwrap_or_else(|| serde_json::json!({})); - let Some(obj) = v.as_object_mut() else { return }; - let Some(servers) = obj - .entry("mcpServers") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut() - else { - return; - }; - // Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist - // claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht. - if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) { - return; - } - servers.remove("aicontrol"); // alter Key vor Umbenennung - servers.insert(PANEL_MCP_SERVER.into(), desired); - if let Ok(out) = serde_json::to_string_pretty(&v) { - let _ = fs::write(&cfg, out + "\n"); - } -} - -/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool -/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung. -fn provision_pool(pool_dir: &std::path::Path) { - install_panel_skill(pool_dir); - register_mcp_server(pool_dir); - ensure_panel_permission(pool_dir); -} - -/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start). -fn provision_pools_for_panel(paths: &Paths) { - if let Ok(pools) = pool_names(paths) { - for (id, _) in pools { - provision_pool(&paths.pool_dir(&id)); - } - } -} - -/// 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 { - fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)) - .ok() - .and_then(|s| serde_json::from_str::(&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 { - 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| format!("{}: {e}", target.display()))?; - } else { - fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?; - if !target.exists() { - fs::write(&target, "").map_err(|e| format!("{}: {e}", target.display()))?; - } - } - let link = src.join(name); - if link.is_dir() && !link.is_symlink() { - fs::remove_dir_all(&link).map_err(|e| format!("{}: {e}", link.display()))?; - } else if link.is_symlink() || link.exists() { - fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?; - } - std::os::unix::fs::symlink(&target, &link) - .map_err(|e| format!("{}: {e}", link.display()))?; - } - 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 { - 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 { - 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, ¤t.credential_type) -} - -/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den -/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung -/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende -/// Sessions setzt der delete_pool-Command davor. -fn delete_pool_in(paths: &Paths, store: &dyn ApikeyStore, name: &str) -> Result<(), String> { - let dir = paths.pool_dir(name); - if !dir.join(POOL_FILE).is_file() { - return Err(format!("Pool nicht gefunden: {name}")); - } - if read_pool(paths, name)?.credential_type == "apikey" { - store.delete(name)?; - } - for project in projects_using_pool(paths, name)? { - unassign_pool_in(paths, &project)?; - } - fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display())) -} - -/// 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| format!("{}: {e}", key_path.display()))?; - } - } 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| format!("{}: {e}", settings_path.display())) -} - -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 { - if ts.len() < 19 { - return None; - } - let num = |r: std::ops::Range| ts.get(r)?.parse::().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, - 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 == "" { - 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: -/// /projects//*.jsonl → Pool × Projekt. -/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays). -fn usage_stats_in(paths: &Paths, days: u32) -> Result, 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(); - - if !paths.pools_dir().is_dir() { - return Ok(Vec::new()); - } - for pool_entry in - fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))? - { - let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; - 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| format!("{}: {e}", projects_root.display()))? - { - let proj_entry = proj_entry.map_err(|e| format!("{}: {e}", projects_root.display()))?; - 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| format!("{}: {e}", proj_entry.path().display()))? - { - let file = file.map_err(|e| format!("{}: {e}", proj_entry.path().display()))?; - 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 = 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, String> { - list_projects_in(&Paths::real()) -} - -#[tauri::command] -fn create_project_full( - name: String, - dir: Option, - pool: Option, - work_dir: Option, - 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 { - Ok(read_pool(&Paths::real(), &pool)?.name) -} - -#[tauri::command] -fn todo_state(project: String) -> Result { - 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, String> { - project_work_dirs_in(&Paths::real(), &project) -} - -#[tauri::command] -fn list_pools() -> Result, String> { - list_pools_in(&Paths::real(), &KeychainStore) -} - -#[tauri::command] -fn create_oauth_pool(name: String) -> Result { - create_oauth_pool_in(&Paths::real(), &name) -} - -#[tauri::command] -fn create_apikey_pool(name: String, key: String, allow_file: bool) -> Result { - create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file) -} - -#[tauri::command] -fn 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, String> { - usage_stats_in(&Paths::real(), days) -} - -#[tauri::command] -fn set_terminal_config( - project: String, - theme: Option, - icon: Option, - title: Option, -) -> 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 { - 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 { - 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 { - 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::(&s).ok()) - .and_then(|v| v["terminalFontSize"].as_u64()) - .map(|n| n as u32) - .unwrap_or(13) -} - -/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang), -/// Default „de". Pro Text im Panel überschreibbar. -#[tauri::command] -fn spellcheck_lang() -> String { - fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE)) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v["spellcheckLang"].as_str().map(str::to_string)) - .unwrap_or_else(|| "de".to_string()) -} - -/// 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| format!("{}: {e}", paths.config_dir().display()))?; - 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)] /// Panic-Tracer: erzwingt bei jedem Panic einen vollständigen Backtrace /// (unabhängig von RUST_BACKTRACE), gibt ihn klar abgegrenzt auf stderr aus und /// schreibt ihn zusätzlich in eine kopierbare Datei (Pfad pro Prozess, damit @@ -2217,6 +30,7 @@ fn install_panic_tracer() { })); } +#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { install_panic_tracer(); @@ -2234,7 +48,7 @@ pub fn run() { // ausrollen/aktivieren. Unter GNOME daher auf das deb-/rpm-Paket verweisen und // beenden. KDE/XFCE zeigen das Tray ohne Extension — dort läuft die AppImage. #[cfg(target_os = "linux")] - if std::env::var_os("APPIMAGE").is_some() && is_gnome() { + if std::env::var_os("APPIMAGE").is_some() && platform::is_gnome() { rfd::MessageDialog::new() .set_level(rfd::MessageLevel::Warning) .set_title("ai-control") @@ -2255,19 +69,18 @@ pub fn run() { // 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) + // Eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag + // (muss vor dem GTK-Init stehen; No-op außerhalb Linux). + platform::set_app_id(&format!("aicontrol-{project}")); + let icon = domain::project::terminal_config(&project) .expect("Projekt-Config nicht lesbar") .icon .map(|i| { - resolve_icon_path(&Paths::real(), &i) + domain::project::resolve_icon_path(&domain::paths::Paths::real(), &i) .to_string_lossy() .into_owned() }); - terminal_builder(project) + app::terminal_builder(project) .build(context) .expect("error while building tauri application") // Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt @@ -2275,1275 +88,19 @@ pub fn run() { .run(move |app, event| { if let tauri::RunEvent::Ready = event { if let Some(icon) = icon.as_deref() { - terminal::set_dock_icon(icon); + platform::set_dock_icon(icon); } - terminal::activate_self(app); + platform::activate_self(app); } }); } _ => { - // Linux: feste Wayland-app_id fürs Hauptfenster (vor GTK-Init) — GNOME - // ordnet dem offenen Fenster über ai-control.desktop das App-Icon zu. - #[cfg(target_os = "linux")] - glib::set_prgname(Some("ai-control")); - main_builder() + // Feste Wayland-app_id fürs Hauptfenster (vor GTK-Init) — GNOME ordnet + // dem offenen Fenster über ai-control.desktop das App-Icon zu. + platform::set_app_id("ai-control"); + app::main_builder() .run(context) .expect("error while running tauri application"); } } } - -/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn, -/// sonst startet es. -fn start_or_focus(app: &tauri::AppHandle, project: &str) { - match terminal_pids(project).first() { - Some(pid) => terminal::focus_terminal(*pid), - None => { - if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) { - eprintln!("{project} starten: {e}"); - } - } - } -} - -/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren. -#[tauri::command] -fn start_or_focus_cmd(app: tauri::AppHandle, project: String) { - start_or_focus(&app, &project); -} - -/// Popup-Fußzeile „Öffnen": das Hauptfenster zeigen. -#[tauri::command] -fn open_main_window(app: tauri::AppHandle) { - use tauri::Manager; - if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); - let _ = w.set_focus(); - } -} - -/// Popup-Fußzeile „Beenden": App verlassen. -#[tauri::command] -fn quit_app(app: tauri::AppHandle) { - app.exit(0); -} - -/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet, -/// ob der Ordner-Dialog nötig ist). -#[tauri::command] -fn panel_archive_dir_cmd(project: String) -> Option { - project_archive_dir(&project).map(|p| p.display().to_string()) -} - -/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt -/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein. -#[tauri::command] -fn panel_archive_cmd(project: String, dir: Option) -> Result { - archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string()) -} - -/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen"). -#[tauri::command] -fn reveal_path_cmd(path: String) { - reveal_path(std::path::Path::new(&path)); -} - -#[cfg(target_os = "macos")] -fn reveal_path(path: &std::path::Path) { - let _ = Command::new("open").arg("-R").arg(path).spawn(); -} - -#[cfg(target_os = "linux")] -fn reveal_path(path: &std::path::Path) { - let target = path.parent().unwrap_or(path); - let _ = Command::new("xdg-open").arg(target).spawn(); -} - -#[cfg(target_os = "windows")] -fn reveal_path(path: &std::path::Path) { - let _ = Command::new("explorer") - .arg(format!("/select,{}", path.display())) - .spawn(); -} - -/// D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das Popup-Fenster. -/// Die Extension ruft Show() beim Panel-Klick und positioniert das Fenster selbst. -#[cfg(target_os = "linux")] -struct PopupService { - app: tauri::AppHandle, -} - -#[cfg(target_os = "linux")] -#[zbus::interface(name = "com.aicontrol.Popup1")] -impl PopupService { - fn show(&self) { - use tauri::Manager; - let app = self.app.clone(); - let win = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(w) = win.get_webview_window("popup") { - let _ = w.show(); - let _ = w.set_focus(); - } - }); - } - fn hide(&self) { - use tauri::Manager; - let app = self.app.clone(); - let win = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(w) = win.get_webview_window("popup") { - let _ = w.hide(); - } - }); - } -} - -/// GNOME-Session? (XDG_CURRENT_DESKTOP enthält „GNOME"). Steuert AppImage-Sperre -/// und Extension-Aktivierung. -#[cfg(target_os = "linux")] -fn is_gnome() -> bool { - std::env::var("XDG_CURRENT_DESKTOP") - .map(|d| d.to_uppercase().contains("GNOME")) - .unwrap_or(false) -} - -#[cfg(target_os = "linux")] -fn serve_popup_dbus(app: tauri::AppHandle) -> zbus::Result<()> { - let _conn = zbus::blocking::connection::Builder::session()? - .name("com.aicontrol.Popup")? - .serve_at("/com/aicontrol/Popup", PopupService { app })? - .build()?; - loop { - std::thread::park(); - } -} - -/// Haupt-App: reine Tray-App ohne Dock-Eintrag. -fn main_builder() -> tauri::Builder { - tauri::Builder::default() - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - None, - )) - .plugin(tauri_plugin_dialog::init()) - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - - // Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag. - #[cfg(target_os = "macos")] - app.set_activation_policy(tauri::ActivationPolicy::Accessory); - - // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). - spawn_session_watcher(); - - // Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende), - // damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht. - provision_pools_for_panel(&Paths::real()); - - // Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen, - // damit sie da sind, bevor ein Terminal startet. - #[cfg(target_os = "linux")] - sync_all_desktops(&Paths::real()); - - // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess - // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. - let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) - .title("ai-control") - .inner_size(800.0, 600.0) - // Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab, - // unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon. - .icon(tauri::image::Image::from_bytes(include_bytes!( - "../icons/128x128.png" - ))?)? - .visible(false); - // Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die - // Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header). - #[cfg(target_os = "macos")] - let main_win = main_win - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - #[cfg(target_os = "linux")] - let main_win = main_win.decorations(false); - main_win.build()?; - - // Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Der - // Trigger unterscheidet sich: Linux über die GNOME-Extension/D-Bus, - // macOS/Windows über den nativen Tray-Klick. - tauri::WebviewWindowBuilder::new(app, "popup", tauri::WebviewUrl::App("popup.html".into())) - .title("ai-control-popup") - .inner_size(320.0, 300.0) - .decorations(false) - .visible(false) - .transparent(true) - .skip_taskbar(true) - .always_on_top(true) - .resizable(false) - .build()?; - - // macOS/Windows: natives Tray-Icon; Links-Klick öffnet das Popup, am - // Icon-Rect ausgerichtet (macOS unter dem Menüleisten-Icon, Windows drüber). - #[cfg(not(target_os = "linux"))] - { - use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; - use tauri::Manager; - #[cfg(target_os = "macos")] - let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))?; - #[cfg(not(target_os = "macos"))] - let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?; - let builder = TrayIconBuilder::with_id("main").icon(icon); - #[cfg(target_os = "macos")] - let builder = builder.icon_as_template(true); - builder - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - rect, - .. - } = event - { - if let Some(win) = tray.app_handle().get_webview_window("popup") { - let scale = win.scale_factor().unwrap_or(1.0); - let pos = rect.position.to_physical::(scale); - let size = rect.size.to_physical::(scale); - let win_w = win.outer_size().map(|s| s.width as f64).unwrap_or(320.0); - // Rechte Popup-Kante an der rechten Icon-Kante ausrichten. - let x = (pos.x + size.width - win_w).max(0.0); - #[cfg(target_os = "macos")] - let y = pos.y + size.height; - #[cfg(not(target_os = "macos"))] - let y = { - let win_h = win.outer_size().map(|s| s.height as f64).unwrap_or(300.0); - (pos.y - win_h).max(0.0) - }; - let _ = win.set_position(tauri::PhysicalPosition::new(x, y)); - let _ = win.show(); - let _ = win.set_focus(); - } - } - }) - .build(app)?; - } - - // Linux: GNOME kann kein SNI-Tray, daher dort der Extension/D-Bus-Relay; - // alle anderen Desktops (KDE, Cinnamon, LXQt, …) bekommen ein natives - // Tray-Icon. libappindicator liefert keine Klick-Events, nur ein Menü — - // deshalb Menü statt Klick-Popup wie auf macOS/Windows. - #[cfg(target_os = "linux")] - { - use tauri::Manager; - if is_gnome() { - // Die Extension liegt systemweit (aus dem Paket), aktiviert wird sie - // pro Benutzer in dconf — das erledigt die im Benutzerkontext - // laufende App. Show() der Extension schiebt das Popup unter den - // Panel-Button. - let _ = Command::new("gnome-extensions") - .args(["enable", "ai-control-popup@local"]) - .status(); - let app_dbus = app.handle().clone(); - std::thread::spawn(move || { - if let Err(e) = serve_popup_dbus(app_dbus) { - eprintln!("D-Bus-Popup-Dienst: {e}"); - } - }); - } else { - use tauri::menu::MenuItemBuilder; - use tauri::menu::MenuBuilder; - use tauri::tray::TrayIconBuilder; - let open = MenuItemBuilder::with_id("open", "ai-control öffnen").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?; - let menu = MenuBuilder::new(app).items(&[&open, &quit]).build()?; - let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayLinux.png"))?; - TrayIconBuilder::with_id("main") - .icon(icon) - .menu(&menu) - .on_menu_event(|app, event| match event.id().as_ref() { - "open" => { - if let Some(w) = app.get_webview_window("popup") { - let _ = w.center(); - let _ = w.show(); - let _ = w.set_focus(); - } - } - "quit" => app.exit(0), - _ => {} - }) - .build(app)?; - } - } - - Ok(()) - }) - // Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü. - .on_window_event(|window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - window.hide().unwrap(); - } - // Popup schließt bei Fokusverlust. - if let tauri::WindowEvent::Focused(false) = event { - if window.label() == "popup" { - let _ = window.hide(); - } - } - }) - .invoke_handler(tauri::generate_handler![ - list_projects, - create_project_full, - add_project, - remove_project, - delete_project, - project_work_dirs, - set_project_dir, - add_work_dir, - remove_work_dir, - list_pools, - create_oauth_pool, - create_apikey_pool, - rename_pool, - delete_pool, - assign_pool, - unassign_pool, - set_terminal_config, - project_icon, - pool_label, - todo_state, - set_todo, - usage_stats, - stop_project, - restart_project, - start_or_focus_cmd, - open_main_window, - quit_app, - sync_setting, - set_sync_setting, - terminal_font_size, - set_terminal_font_size, - link_pool_runtime, - oauth_login, - keychain_status, - set_apikey, - terminal::open_terminal - ]) -} - -/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt -/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. -fn terminal_builder(project: String) -> tauri::Builder { - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .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. Das - // abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel - // wieder an (panel-attached), der Prozess läuft weiter. - .on_window_event(|window, event| { - if let tauri::WindowEvent::Destroyed = event { - if window.label().starts_with("panel-") { - use tauri::{Emitter, Manager}; - let _ = window.app_handle().emit("panel-window-closed", ()); - } else { - terminal::close(window); - } - } - }) - .invoke_handler(tauri::generate_handler![ - terminal::term_start, - terminal::term_log, - terminal::term_write, - terminal::term_resize, - terminal::panel_read, - terminal::panel_set, - terminal::open_panel_window, - panel_archive_dir_cmd, - panel_archive_cmd, - reveal_path_cmd, - // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. - list_projects, - project_icon, - pool_label, - terminal_font_size, - set_terminal_font_size, - spellcheck_lang - ]) -} - -// ---------- 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>); - - fn map_store() -> MapStore { - MapStore(std::sync::Mutex::new(HashMap::new())) - } - - impl ApikeyStore for MapStore { - fn set(&self, pool: &str, key: &str) -> Result<(), String> { - self.0.lock().unwrap().insert(pool.into(), key.into()); - Ok(()) - } - fn has(&self, pool: &str) -> Result { - Ok(self.0.lock().unwrap().contains_key(pool)) - } - fn delete(&self, pool: &str) -> Result<(), String> { - self.0.lock().unwrap().remove(pool); - Ok(()) - } - } - - /// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts. - struct FailStore; - - impl ApikeyStore for FailStore { - fn set(&self, _pool: &str, _key: &str) -> Result<(), String> { - Err("kein Keychain".into()) - } - fn has(&self, _pool: &str) -> Result { - Ok(false) - } - fn delete(&self, _pool: &str) -> Result<(), String> { - Ok(()) - } - } - - /// Liefert die Pool-ID (UUID-Ordnername). - fn make_oauth_pool(p: &Paths, name: &str) -> String { - create_oauth_pool_in(p, name).unwrap() - } - - fn make_apikey_pool(p: &Paths, store: &dyn ApikeyStore, name: &str, key: &str) -> String { - create_apikey_pool_in(p, store, name, key, true).unwrap() - } - - fn mode(path: &PathBuf) -> u32 { - fs::metadata(path).unwrap().permissions().mode() & 0o777 - } - - // -- Archiv-Helfer -- - - #[test] - fn utc_stamp_bekannte_zeit() { - // 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC. - let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15; - let (stamp, iso) = utc_stamp(secs); - assert_eq!(stamp, "2026-07-11_1114"); - assert_eq!(iso, "2026-07-11T11:14:15Z"); - } - - #[test] - fn civil_from_days_referenz() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(civil_from_days(20_645), (2026, 7, 11)); - } - - #[test] - fn slugify_grenzen() { - assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen"); - assert_eq!(slugify(" "), "entwurf"); - assert_eq!(slugify("###"), "entwurf"); - } - - #[test] - fn first_line_ueberschrift_oder_erste_zeile() { - assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel"); - assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading"); - assert_eq!(first_line(" \n"), "entwurf"); - } - - // -- 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 = - serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) - .unwrap(); - assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false)); - assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false)); - assert!(dir.join("CLAUDE.md").is_file()); - } - - #[test] - fn pool_init_erhaelt_bestehende_claude_md() { - let p = tmp_paths(); - let dir = p.pool_dir("privat"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap(); - // Anlegen darf die vorhandene CLAUDE.md nicht überschreiben. - // (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen) - init_pool_config(&dir, serde_json::json!({})).unwrap(); - assert_eq!( - fs::read_to_string(dir.join("CLAUDE.md")).unwrap(), - "meine Vorgaben\n" - ); - } - - #[test] - fn pool_doppelt_anlegen_scheitert() { - let p = tmp_paths(); - let store = map_store(); - make_apikey_pool(&p, &store, "kunde", "sk-1"); - let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err(); - assert!(err.contains("existiert bereits")); - } - - // -- Pool umbenennen -- - - #[test] - fn pool_umbenennen() { - let p = tmp_paths(); - let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", &id).unwrap(); - - rename_pool_in(&p, &id, "kunde-neu").unwrap(); - - // Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben. - let pool = read_pool(&p, &id).unwrap(); - assert_eq!(pool.name, "kunde-neu"); - assert_eq!(pool.credential_type, "apikey"); - assert!(p.pool_dir(&id).is_dir()); - let cfg = read_project_config_in(&p, "proj").unwrap(); - assert_eq!(cfg.pool.as_deref(), Some(id.as_str())); - } - - #[test] - fn pool_umbenennen_auf_vergebenen_namen_scheitert() { - let p = tmp_paths(); - let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - make_oauth_pool(&p, "privat"); - let err = rename_pool_in(&p, &id, "privat").unwrap_err(); - assert!(err.contains("existiert bereits")); - // Umbenennen auf den eigenen Namen ist erlaubt (No-op). - rename_pool_in(&p, &id, "kunde").unwrap(); - } - - #[test] - fn pool_leerer_key_scheitert() { - let p = tmp_paths(); - assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err()); - } - - #[test] - fn pool_name_mit_slash_scheitert() { - let p = tmp_paths(); - assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err()); - } - - // -- Pool ändern -- - - #[test] - fn apikey_aendern() { - let p = tmp_paths(); - let store = map_store(); - let id = make_apikey_pool(&p, &store, "kunde", "sk-alt"); - set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); - assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); - assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists()); - } - - /// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage): - /// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden. - #[test] - fn apikey_aendern_migriert_datei_in_store() { - let p = tmp_paths(); - let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); - let dir = p.pool_dir(&id); - // Bestand simulieren: Helper wie vor der Keychain-Ablage. - let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) }); - fs::write(dir.join("settings.json"), old.to_string()).unwrap(); - - let store = map_store(); - set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap(); - - assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu"); - assert!(!dir.join(APIKEY_FILE).exists()); - let settings: serde_json::Value = - serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap()) - .unwrap(); - assert_eq!( - settings["apiKeyHelper"].as_str().unwrap(), - apikey_helper_command(&dir, &id) - ); - } - - #[test] - fn apikey_aendern_ohne_store_schreibt_datei() { - let p = tmp_paths(); - let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt"); - set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap(); - let key_path = p.pool_dir(&id).join(APIKEY_FILE); - assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n"); - assert_eq!(mode(&key_path), 0o600); - } - - #[test] - fn apikey_aendern_auf_oauth_pool_scheitert() { - let p = tmp_paths(); - let id = make_oauth_pool(&p, "privat"); - assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err()); - } - - #[test] - fn ensure_oauth_pool_auf_apikey_scheitert() { - let p = tmp_paths(); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - assert!(ensure_oauth_pool(&p, &kunde).is_err()); - let privat = make_oauth_pool(&p, "privat"); - assert!(ensure_oauth_pool(&p, &privat).is_ok()); - } - - // -- Pool löschen -- - - #[test] - fn pool_loeschen() { - let p = tmp_paths(); - let store = map_store(); - let id = make_apikey_pool(&p, &store, "kunde", "sk-1"); - delete_pool_in(&p, &store, &id).unwrap(); - assert!(!p.pool_dir(&id).exists()); - // Der Keychain-Eintrag geht mit. - assert!(!store.has(&id).unwrap()); - } - - // -- Projekt-Wizard -- - - #[test] - fn projekt_wizard_scaffold() { - let p = tmp_paths(); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_full_in( - &p, - "neu", - None, - Some(&kunde), - Some("~/projects/neu"), - true, - TerminalConfig { - theme: Some("dracula".into()), - icon: None, - title: Some("Neu".into()), - }, - true, - ) - .unwrap(); - - let dir = p.projects_dir().join("neu"); - assert!(dir.join("memory").is_dir()); - assert_eq!( - fs::read_to_string(dir.join(".gitignore")).unwrap(), - ".ai-control-running\n" - ); - assert!(p.home.join("projects").join("neu").is_dir()); - - let cfg = read_project_config_in(&p, "neu").unwrap(); - assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); - assert_eq!(cfg.terminal.title.as_deref(), Some("Neu")); - assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); - - let settings: serde_json::Value = serde_json::from_str( - &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), - ) - .unwrap(); - assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory"); - assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)"); - assert_eq!( - settings["permissions"]["allow"][1], - "Edit(~/claude-projects/neu/**)" - ); - assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); - // todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da - assert!(dir.join(TODO_FILE).is_file()); - let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); - assert_eq!(groups.len(), 1); - assert!(groups[0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains(TODO_FILE)); - } - - #[test] - fn todo_zuschalten_und_abschalten() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) - .unwrap(); - assert!(!todo_state_in(&p, "proj").unwrap()); - - set_todo_in(&p, "proj", true).unwrap(); - assert!(todo_state_in(&p, "proj").unwrap()); - let todo_path = p.projects_dir().join("proj").join(TODO_FILE); - assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON); - - // doppelt aktivieren erzeugt keinen zweiten Hook - set_todo_in(&p, "proj", true).unwrap(); - let settings: serde_json::Value = serde_json::from_str( - &fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(), - ) - .unwrap(); - let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); - assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1); - - // Abschalten: Hook weg, Datei (mit Inhalt) bleibt - fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap(); - set_todo_in(&p, "proj", false).unwrap(); - assert!(!todo_state_in(&p, "proj").unwrap()); - assert!(todo_path.is_file()); - assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig")); - } - - #[test] - fn todo_ohne_settings_scheitert() { - let p = tmp_paths(); - create_project_in(&p, "alt").unwrap(); - assert!(set_todo_in(&p, "alt", true).is_err()); - assert!(!todo_state_in(&p, "alt").unwrap()); - } - - #[test] - fn projekt_wizard_minimal_ohne_pool_und_workdir() { - let p = tmp_paths(); - create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap(); - let dir = p.projects_dir().join("neu"); - assert!(dir.join(".claude").join("settings.json").is_file()); - // ohne Pool und Terminal-Config entsteht keine ai-control.json - assert!(!project_config_path(&p, "neu").unwrap().is_file()); - let settings: serde_json::Value = serde_json::from_str( - &fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(), - ) - .unwrap(); - assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)"); - assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([])); - } - - #[test] - fn projekt_wizard_doppelt_scheitert() { - let p = tmp_paths(); - create_project_in(&p, "neu").unwrap(); - let err = - create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false) - .unwrap_err(); - assert!(err.contains("existiert bereits")); - } - - #[test] - fn projekt_wizard_fehlendes_workdir_scheitert() { - let p = tmp_paths(); - let err = create_project_full_in( - &p, - "neu", - None, - None, - Some("~/projects/gibtsnicht"), - false, - TerminalConfig::default(), - false, - ) - .unwrap_err(); - assert!(err.contains("Arbeitsverzeichnis fehlt")); - // kein halbes Projekt zurückgeblieben - assert!(!p.projects_dir().join("neu").exists()); - } - - // -- Verbrauch -- - - fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String { - serde_json::json!({ - "type": "assistant", - "timestamp": ts, - "requestId": req, - "message": { - "id": id, - "model": model, - "usage": { - "input_tokens": input, - "output_tokens": output, - "cache_creation_input_tokens": 1_000_000, - "cache_read_input_tokens": 2_000_000 - } - } - }) - .to_string() - } - - #[test] - fn verbrauch_aggregation_dedup_und_zeitfilter() { - let p = tmp_paths(); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - - let proj_path = p.projects_dir().join("proj"); - let encoded = encode_project_path(&proj_path.to_string_lossy()); - let dir = p.pool_dir(&kunde).join("projects").join(&encoded); - fs::create_dir_all(&dir).unwrap(); - - let lines = [ - // Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60) - usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), - // Retry-Duplikat: gleiche message.id + requestId → zählt nicht - usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000), - // außerhalb des Zeitfensters → zählt nicht - usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5), - // kein usage-Objekt → ignoriert - r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(), - // kaputte Zeile (laufende Session) → ignoriert - r#"{"type":"assist"#.to_string(), - ]; - fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap(); - - // Cutoff = jetzt − 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig - let rows = usage_stats_in(&p, 365).unwrap(); - assert_eq!(rows.len(), 1); - let r = &rows[0]; - assert_eq!(r.pool, "kunde"); - assert_eq!(r.project, "proj"); - assert_eq!(r.totals.input_tokens, 1_000_000); - assert_eq!(r.totals.output_tokens, 1_000_000); - assert_eq!(r.totals.cache_creation_tokens, 1_000_000); - assert_eq!(r.totals.cache_read_tokens, 2_000_000); - assert!((r.totals.cost_usd - 22.35).abs() < 1e-9); - } - - #[test] - fn verbrauch_leer_ohne_transcripts() { - let p = tmp_paths(); - make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - assert!(usage_stats_in(&p, 30).unwrap().is_empty()); - } - - #[test] - fn parse_ts_referenz() { - // 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000 - assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000)); - assert_eq!(parse_ts("kein datum"), None); - } - - /// Referenzwert vom echten System: privateDefault → 096c4ef9 - /// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag). - #[test] - fn keychain_service_suffix() { - let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; - assert_eq!( - keychain_service(&p, "privateDefault"), - "Claude Code-credentials-096c4ef9" - ); - } - - #[test] - fn pool_key_status() { - let p = tmp_paths(); - // Datei-Pool (ohne Store angelegt): Datei entscheidet. - let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1"); - make_oauth_pool(&p, "privat"); - - let pools = list_pools_in(&p, &FailStore).unwrap(); - assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - // oauth: Keychain wird im Listing bewusst nicht geprüft → immer true. - assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials); - - fs::write(p.pool_dir(&kunde).join(APIKEY_FILE), "\n").unwrap(); - let pools = list_pools_in(&p, &FailStore).unwrap(); - assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - - fs::remove_file(p.pool_dir(&kunde).join(APIKEY_FILE)).unwrap(); - let pools = list_pools_in(&p, &FailStore).unwrap(); - assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - } - - /// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei. - #[test] - fn pool_key_status_aus_store() { - let p = tmp_paths(); - let store = map_store(); - let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); - assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists()); - let pools = list_pools_in(&p, &store).unwrap(); - assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - store.delete(&kunde).unwrap(); - let pools = list_pools_in(&p, &store).unwrap(); - assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials); - } - - #[test] - fn pool_loeschen_loest_zuordnungen() { - let p = tmp_paths(); - let store = map_store(); - let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", &kunde).unwrap(); - delete_pool_in(&p, &store, &kunde).unwrap(); - assert!(!p.pool_dir(&kunde).exists()); - assert!(!project_config_path(&p, "proj").unwrap().exists()); - } - - #[test] - fn pool_anlegen_verlinkt_synced_runtime() { - let p = tmp_paths(); - fs::create_dir_all(p.config_dir()).unwrap(); - fs::write( - p.config_dir().join(APP_SETTINGS_FILE), - "{ \"poolSyncDir\": \"~/claude-projects/pool\" }\n", - ) - .unwrap(); - let id = make_oauth_pool(&p, "privat"); - let pooldir = p.pool_dir(&id); - for (name, is_dir) in SYNCED_RUNTIME { - let link = pooldir.join(name); - assert!(link.is_symlink(), "{name} sollte Symlink sein"); - assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).unwrap().join(name)); - let target = pool_data_dir(&p, &id).unwrap().join(name); - assert_eq!(target.is_dir(), is_dir); - assert!(target.exists()); - } - // Zieldaten liegen unter dem konfigurierten poolSyncDir// - assert!(p.home.join("claude-projects/pool").join(&id).is_dir()); - } - - /// Ohne poolSyncDir bleibt alles lokal: keine Symlinks, Verlinken scheitert laut. - #[test] - fn pool_anlegen_ohne_sync_dir_bleibt_lokal() { - let p = tmp_paths(); - let id = make_oauth_pool(&p, "privat"); - for (name, _) in SYNCED_RUNTIME { - assert!(!p.pool_dir(&id).join(name).is_symlink(), "{name} darf kein Symlink sein"); - } - let err = link_pool_runtime_in(&p, &id).unwrap_err(); - assert!(err.contains("poolSyncDir")); - } - - #[test] - fn sync_optin_default_aus_und_umschaltbar() { - let p = tmp_paths(); - assert!(!sync_on_session_end(&p)); // default: kein Sync ohne Zustimmung - set_sync_on_session_end_in(&p, true).unwrap(); - assert!(sync_on_session_end(&p)); - set_sync_on_session_end_in(&p, false).unwrap(); - assert!(!sync_on_session_end(&p)); - } - - #[test] - fn pool_loeschen_erhaelt_terminal_config() { - let p = tmp_paths(); - let store = map_store(); - let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", &kunde).unwrap(); - set_terminal_config_in( - &p, - "proj", - TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, - ) - .unwrap(); - delete_pool_in(&p, &store, &kunde).unwrap(); - let cfg = read_project_config_in(&p, "proj").unwrap(); - assert_eq!(cfg.pool, None); - assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula")); - } - - #[test] - fn pool_loeschen_unbekannt_scheitert() { - let p = tmp_paths(); - assert!(delete_pool_in(&p, &map_store(), "gibtsnicht").is_err()); - } - - // -- Projekt zuordnen / rausnehmen / wechseln -- - - #[test] - fn projekt_zuordnen() { - let p = tmp_paths(); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", &kunde).unwrap(); - - let cfg: ProjectConfig = - serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap()) - .unwrap(); - assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str())); - - let pools = list_pools_in(&p, &FailStore).unwrap(); - assert_eq!(pools[0].projects, vec!["proj"]); - } - - #[test] - fn projekt_zuordnen_pool_fehlt_scheitert() { - let p = tmp_paths(); - create_project_in(&p, "proj").unwrap(); - assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err()); - } - - #[test] - fn projekt_rausnehmen() { - let p = tmp_paths(); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - assign_pool_in(&p, "proj", &kunde).unwrap(); - unassign_pool_in(&p, "proj").unwrap(); - assert!(!project_config_path(&p, "proj").unwrap().exists()); - } - - #[test] - fn projekt_rausnehmen_ohne_zuordnung_scheitert() { - let p = tmp_paths(); - create_project_in(&p, "proj").unwrap(); - assert!(unassign_pool_in(&p, "proj").is_err()); - } - - /// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen, - /// der Credential-Typ ist ihm egal. - #[test] - fn pool_wechseln_typ_ist_projekt_egal() { - let p = tmp_paths(); - let privat = make_oauth_pool(&p, "privat"); - let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1"); - create_project_in(&p, "proj").unwrap(); - - assign_pool_in(&p, "proj", &privat).unwrap(); - assign_pool_in(&p, "proj", &kunde).unwrap(); - - let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(); - let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap(); - assert_eq!(cfg, serde_json::json!({ "pool": kunde })); - } - - // -- Projekt anlegen / löschen -- - - #[test] - fn projekt_anlegen() { - let p = tmp_paths(); - create_project_in(&p, "proj").unwrap(); - assert!(p.projects_dir().join("proj").join(".claude").is_dir()); - - let projects = list_projects_in(&p).unwrap(); - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].name, "proj"); - assert_eq!(projects[0].pool, None); - } - - #[test] - fn projekt_doppelt_anlegen_scheitert() { - let p = tmp_paths(); - create_project_in(&p, "proj").unwrap(); - assert!(create_project_in(&p, "proj").is_err()); - } - - #[test] - fn projekt_loeschen() { - let p = tmp_paths(); - create_project_in(&p, "proj").unwrap(); - delete_project_in(&p, "proj", false).unwrap(); - assert!(!p.projects_dir().join("proj").exists()); - } - - #[test] - fn projekt_loeschen_laesst_arbeitsordner() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) - .unwrap(); - delete_project_in(&p, "proj", false).unwrap(); - assert!(!p.projects_dir().join("proj").exists()); - assert!(p.home.join("projects").join("proj").is_dir()); - } - - #[test] - fn projekt_loeschen_mit_arbeitsordner() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) - .unwrap(); - delete_project_in(&p, "proj", true).unwrap(); - assert!(!p.projects_dir().join("proj").exists()); - assert!(!p.home.join("projects").join("proj").exists()); - } - - #[test] - fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) - .unwrap(); - fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); - assert!(delete_project_in(&p, "proj", true).is_err()); - } - - #[test] - fn projekt_arbeitsordner_auslesen() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) - .unwrap(); - assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); - // Projekt ohne settings.json → leer - create_project_in(&p, "alt").unwrap(); - assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty()); - } - - #[test] - fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) - .unwrap(); - fs::create_dir_all(p.home.join("projects/extra")).unwrap(); - let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); - add_work_dir_in(&p, "proj", &picked).unwrap(); - // gespeichert wird ~-kontrahiert, in beiden permissions-Feldern - assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]); - let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); - assert!(raw.contains("Edit(~/projects/extra/**)")); - assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt - - remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap(); - assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty()); - let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(); - assert!(!raw.contains("~/projects/extra")); - // Ordner selbst bleibt - assert!(p.home.join("projects/extra").is_dir()); - } - - #[test] - fn projektordner_verlegen() { - let p = tmp_paths(); - create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) - .unwrap(); - // Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu. - let new_dir = p.home.join("elsewhere").join("proj"); - fs::create_dir_all(p.home.join("elsewhere")).unwrap(); - fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap(); - set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap(); - - assert_eq!(project_dir(&p, "proj").unwrap(), new_dir); - let raw = fs::read_to_string(settings_path(&new_dir)).unwrap(); - assert!(raw.contains("~/elsewhere/proj/memory")); - assert!(raw.contains("Edit(~/elsewhere/proj/**)")); - assert!(!raw.contains("claude-projects/proj")); - } - - #[test] - fn projekt_loeschen_unbekannt_scheitert() { - let p = tmp_paths(); - assert!(delete_project_in(&p, "gibtsnicht", false).is_err()); - } -} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index dc24a3e..52fe9ef 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -138,7 +138,7 @@ fn call_write(req: &Value) -> Value { fn call_archive(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); let dir = req["params"]["arguments"]["dir"].as_str(); - match crate::archive_panel_content(&project, dir) { + match crate::domain::archive::archive_panel_content(&project, dir) { Ok(path) => json!({ "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }], }), diff --git a/src-tauri/src/platform/keyring_store.rs b/src-tauri/src/platform/keyring_store.rs new file mode 100644 index 0000000..42442e6 --- /dev/null +++ b/src-tauri/src/platform/keyring_store.rs @@ -0,0 +1,44 @@ +//! API-Key-Ablage über die keyring-Crate — Linux: Secret Service, +//! Windows: Credential Manager. macOS hat eine eigene Implementierung über +//! das security-CLI (ACL-Gründe, siehe macos.rs). + +use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; + +pub(crate) struct KeychainStore; + +impl KeychainStore { + fn entry(pool: &str) -> Result { + keyring::Entry::new(APIKEY_SERVICE, pool).map_err(|e| e.to_string()) + } +} + +impl ApikeyStore for KeychainStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + Self::entry(pool)?.set_password(key).map_err(|e| e.to_string()) + } + + fn has(&self, pool: &str) -> Result { + match Self::entry(pool)?.get_password() { + Ok(_) => Ok(true), + // Kein Eintrag oder kein Store verfügbar → die Key-Datei entscheidet. + Err( + keyring::Error::NoEntry + | keyring::Error::PlatformFailure(_) + | keyring::Error::NoStorageAccess(_), + ) => Ok(false), + Err(e) => Err(e.to_string()), + } + } + + fn delete(&self, pool: &str) -> Result<(), String> { + match Self::entry(pool)?.delete_credential() { + Ok(()) + | Err( + keyring::Error::NoEntry + | keyring::Error::PlatformFailure(_) + | keyring::Error::NoStorageAccess(_), + ) => Ok(()), + Err(e) => Err(e.to_string()), + } + } +} diff --git a/src-tauri/src/platform/linux/desktop.rs b/src-tauri/src/platform/linux/desktop.rs new file mode 100644 index 0000000..341ad4c --- /dev/null +++ b/src-tauri/src/platform/linux/desktop.rs @@ -0,0 +1,76 @@ +//! Pro-Projekt-.desktop-Dateien: dash-to-dock/GNOME ordnen dem Fenster +//! (app_id `aicontrol-`, via set_prgname) darüber das Projekt-Icon zu. + +use std::fs; +use std::path::PathBuf; + +use crate::domain::paths::Paths; +use crate::domain::project::{read_project_config_in, resolve_icon_path, TerminalConfig}; +use crate::domain::registry::load_registry; + +/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien. +/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben. +fn applications_dir(paths: &Paths) -> PathBuf { + paths.home.join(".local/share/applications") +} + +/// Schreibt/aktualisiert die .desktop eines Projekts. NoDisplay=true hält den +/// App-Starter sauber. +pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) { + let dir = applications_dir(paths); + if fs::create_dir_all(&dir).is_err() { + return; + } + let exec = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "ai-control".into()); + let icon_line = cfg + .icon + .as_deref() + .map(|i| resolve_icon_path(paths, i)) + .filter(|p| p.exists()) + .and_then(|p| p.to_str().map(str::to_string)) + .map(|p| format!("Icon={p}\n")) + .unwrap_or_default(); + let content = format!( + "[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\ + {icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n" + ); + let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content); +} + +/// Entfernt die .desktop eines Projekts (beim Löschen). +pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) { + let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop"))); +} + +/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und +/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer +/// vorhanden und aktuell, bevor überhaupt ein Terminal startet. +pub(crate) fn sync_all_desktops(paths: &Paths) { + let Ok(reg) = load_registry(paths) else { + return; + }; + for project in reg.keys() { + let cfg = read_project_config_in(paths, project) + .map(|c| c.terminal) + .unwrap_or_default(); + write_terminal_desktop(paths, project, &cfg); + } + let dir = applications_dir(paths); + if let Ok(entries) = fs::read_dir(&dir) { + for e in entries.flatten() { + let name = e.file_name(); + let Some(name) = name.to_str() else { continue }; + if let Some(project) = name + .strip_prefix("aicontrol-") + .and_then(|n| n.strip_suffix(".desktop")) + { + if !reg.contains_key(project) { + let _ = fs::remove_file(e.path()); + } + } + } + } +} diff --git a/src-tauri/src/platform/linux/gnome.rs b/src-tauri/src/platform/linux/gnome.rs new file mode 100644 index 0000000..353e82e --- /dev/null +++ b/src-tauri/src/platform/linux/gnome.rs @@ -0,0 +1,40 @@ +//! D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das +//! Popup-Fenster. Die Extension ruft Show() beim Panel-Klick und positioniert +//! das Fenster selbst (Anchor::Managed) — unter Wayland darf nur der +//! Compositor fremde Fenster platzieren. + +use crate::platform::{Anchor, TrayCallbacks}; + +struct PopupService { + app: tauri::AppHandle, + cb: TrayCallbacks, +} + +#[zbus::interface(name = "com.aicontrol.Popup1")] +impl PopupService { + fn show(&self) { + (self.cb.show)(&self.app, Anchor::Managed); + } + fn hide(&self) { + (self.cb.hide)(&self.app); + } +} + +/// Hält den D-Bus-Namen com.aicontrol.Popup, solange die App läuft — die +/// Extension zeigt ihren Panel-Button nur, wenn der Name präsent ist. +pub(super) fn spawn_dbus_service(app: tauri::AppHandle, cb: TrayCallbacks) { + std::thread::spawn(move || { + let serve = move || -> zbus::Result<()> { + let _conn = zbus::blocking::connection::Builder::session()? + .name("com.aicontrol.Popup")? + .serve_at("/com/aicontrol/Popup", PopupService { app, cb })? + .build()?; + loop { + std::thread::park(); + } + }; + if let Err(e) = serve() { + eprintln!("D-Bus-Popup-Dienst: {e}"); + } + }); +} diff --git a/src-tauri/src/platform/linux/kwin.rs b/src-tauri/src/platform/linux/kwin.rs new file mode 100644 index 0000000..d1a62ee --- /dev/null +++ b/src-tauri/src/platform/linux/kwin.rs @@ -0,0 +1,28 @@ +//! KWin-Script pro Benutzer aktivieren und KWin neu laden — analog zu +//! `gnome-extensions enable`. Systemweit liegt es aus dem Paket. Nötig, weil +//! unter Wayland nur der Compositor unser Popup positionieren darf. + +use std::process::Command; + +pub(super) fn enable_script() { + let _ = Command::new("kwriteconfig6") + .args([ + "--file", + "kwinrc", + "--group", + "Plugins", + "--key", + "ai-control-popupEnabled", + "true", + ]) + .status(); + let _ = Command::new("dbus-send") + .args([ + "--session", + "--dest=org.kde.KWin", + "--type=method_call", + "/KWin", + "org.kde.KWin.reconfigure", + ]) + .status(); +} diff --git a/src-tauri/src/platform/linux/mod.rs b/src-tauri/src/platform/linux/mod.rs new file mode 100644 index 0000000..be2dc8e --- /dev/null +++ b/src-tauri/src/platform/linux/mod.rs @@ -0,0 +1,111 @@ +//! Linux: Desktop-Erkennung, Secret-Service-Helper, D-Bus-/SNI-Tray. +//! Der Klick-Weg unterscheidet sich je Desktop (GNOME-Extension vs. +//! StatusNotifierItem), das Ergebnis ist überall dasselbe Popup-Fenster. + +mod desktop; +mod gnome; +mod kwin; +mod tray_sni; + +pub(crate) use desktop::{remove_terminal_desktop, sync_all_desktops, write_terminal_desktop}; + +use std::path::Path; +use std::process::Command; + +use crate::domain::credentials::APIKEY_SERVICE; +use crate::domain::pool::APIKEY_FILE; +use crate::platform::TrayCallbacks; + +// ---------- Aktivierung / Fokus / Icons ---------- + +/// Linux kennt keine kooperative Aktivierungsabtretung. +pub(crate) fn yield_activation(_bundle_id: &str) {} + +/// Fenster ohne NSApplication über die Tauri-API fokussieren. +pub(crate) fn activate_self(app: &tauri::AppHandle) { + use tauri::Manager; + if let Some(window) = app.webview_windows().values().next() { + window.set_focus().expect("Terminal-Fenster nicht fokussierbar"); + } +} + +/// Linux kennt kein Dock-Icon pro Prozess — das Icon kommt über die +/// .desktop-Datei (StartupWMClass/app_id, siehe desktop.rs). +pub(crate) fn set_dock_icon(_path: &str) {} + +/// Fremdprozess-Fokus über die PID steht ohne NSRunningApplication nicht zur +/// Verfügung. +pub(crate) fn focus_terminal(_pid: u32) {} + +/// Wayland-app_id des Prozesses (muss vor dem GTK-Init gesetzt sein) — +/// darüber ordnen GNOME/dash-to-dock dem Fenster die .desktop-Datei zu. +pub(crate) fn set_app_id(name: &str) { + glib::set_prgname(Some(name)); +} + +pub(crate) fn reveal_path(path: &Path) { + let target = path.parent().unwrap_or(path); + let _ = Command::new("xdg-open").arg(target).spawn(); +} + +// ---------- Desktop-Erkennung ---------- + +/// GNOME-Session? (XDG_CURRENT_DESKTOP enthält „GNOME"). Steuert +/// AppImage-Sperre und Extension-Aktivierung. +pub(crate) fn is_gnome() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .map(|d| d.to_uppercase().contains("GNOME")) + .unwrap_or(false) +} + +fn is_kde() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .map(|d| d.to_uppercase().contains("KDE")) + .unwrap_or(false) +} + +// ---------- Secrets ---------- + +/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keyring +/// (dieselben service/username-Attribute, die die keyring-Crate anlegt), +/// bei fehlendem Eintrag aus der Key-Datei. +pub(crate) fn apikey_helper_command(dir: &Path, pool_id: &str) -> String { + format!( + "secret-tool lookup service {APIKEY_SERVICE} username {pool_id} 2>/dev/null || cat '{}'", + dir.join(APIKEY_FILE).display() + ) +} + +/// claudes OAuth-Credentials liegen unter Linux nicht in einem per +/// security-CLI prüfbaren Keychain — Status/Reset sind hier nicht verfügbar. +pub(crate) fn oauth_keychain_exists(_service: &str) -> Result { + Err("OAuth-Keychain-Status unter Linux nicht verfügbar".into()) +} + +pub(crate) fn oauth_keychain_delete(_service: &str) -> Result<(), String> { + Err("OAuth-Keychain-Reset unter Linux nicht verfügbar".into()) +} + +// ---------- Tray ---------- + +/// Überall dasselbe Ergebnis — Klick aufs Tray-Icon zeigt das Popup, nie ein +/// Menü. Nur der Weg zum Klick unterscheidet sich: GNOME hat kein Tray, daher +/// die Shell-Extension (D-Bus-Relay); KDE/XFCE/Cinnamon haben ein +/// Standard-Tray (StatusNotifierItem), dessen Activate direkt gemeldet wird. +pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> { + if is_gnome() { + // Die Extension liegt systemweit (aus dem Paket), aktiviert wird sie pro + // Benutzer in dconf — das erledigt die im Benutzerkontext laufende App. + let _ = Command::new("gnome-extensions") + .args(["enable", "ai-control-popup@local"]) + .status(); + gnome::spawn_dbus_service(app.clone(), cb); + } else { + tray_sni::spawn(app.clone(), cb); + // KDE-Wayland: das Popup-Positionieren übernimmt das KWin-Script. + if is_kde() { + kwin::enable_script(); + } + } + Ok(()) +} diff --git a/src-tauri/src/platform/linux/tray_sni.rs b/src-tauri/src/platform/linux/tray_sni.rs new file mode 100644 index 0000000..01df5a4 --- /dev/null +++ b/src-tauri/src/platform/linux/tray_sni.rs @@ -0,0 +1,61 @@ +//! Eigenes StatusNotifierItem (ksni) statt Tauris libappindicator-Tray: +//! libappindicator kann nur ein Menü, wir wollen Linksklick → Popup-Fenster. +//! Activate liefert keine brauchbaren Koordinaten — der Zeiger sitzt beim +//! Klick auf dem Icon, daher Anchor::Cursor. + +use crate::platform::{Anchor, TrayCallbacks}; + +struct AiControlTray { + app: tauri::AppHandle, + icon: Vec, + cb: TrayCallbacks, +} + +impl ksni::Tray for AiControlTray { + fn id(&self) -> String { + "ai-control".into() + } + fn title(&self) -> String { + "ai-control".into() + } + fn icon_pixmap(&self) -> Vec { + self.icon.clone() + } + fn activate(&mut self, _x: i32, _y: i32) { + (self.cb.show)(&self.app, Anchor::Cursor); + } + fn menu(&self) -> Vec> { + use ksni::menu::StandardItem; + vec![StandardItem { + label: "Beenden".into(), + activate: Box::new(|t: &mut AiControlTray| t.app.exit(0)), + ..Default::default() + } + .into()] + } +} + +/// App-Icon (32×32 PNG) als ARGB32 in Network-Byte-Order für das SNI-IconPixmap. +fn tray_icon_argb() -> Vec { + let img = tauri::image::Image::from_bytes(include_bytes!("../../../icons/32x32.png")) + .expect("32x32.png dekodieren"); + let rgba = img.rgba(); + let mut data = Vec::with_capacity(rgba.len()); + for px in rgba.chunks_exact(4) { + data.extend_from_slice(&[px[3], px[0], px[1], px[2]]); + } + vec![ksni::Icon { + width: img.width() as i32, + height: img.height() as i32, + data, + }] +} + +pub(super) fn spawn(app: tauri::AppHandle, cb: TrayCallbacks) { + ksni::TrayService::new(AiControlTray { + app, + icon: tray_icon_argb(), + cb, + }) + .spawn(); +} diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..7488e73 --- /dev/null +++ b/src-tauri/src/platform/macos.rs @@ -0,0 +1,187 @@ +//! macOS: Aktivierung/Fokus über AppKit (objc2), Dock-Icons pro +//! Terminal-Prozess, Keychain über das security-CLI, nativer Tray. + +use std::path::Path; +use std::process::Command; + +use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; +use crate::domain::paths::Paths; +use crate::domain::pool::APIKEY_FILE; +use crate::domain::project::TerminalConfig; +use crate::platform::{Anchor, TrayCallbacks}; + +// ---------- Aktivierung / Fokus / Icons ---------- + +/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser +/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben). Aktivierung ist +/// seit macOS 14 kooperativ — ohne yield öffnet das Terminal-Fenster hinter +/// der aktiven App. +pub(crate) fn yield_activation(bundle_id: &str) { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use objc2_foundation::NSString; + let mtm = MainThreadMarker::new().expect("yield_activation läuft nicht auf dem Main-Thread"); + NSApplication::sharedApplication(mtm) + .yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id)); +} + +/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event); +/// die Gegenseite hat vorher per yield abgetreten. +pub(crate) fn activate_self(app: &tauri::AppHandle) { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use tauri::Manager; + let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread"); + NSApplication::sharedApplication(mtm).activate(); + if let Some(window) = app.webview_windows().values().next() { + window.set_focus().expect("Terminal-Fenster nicht fokussierbar"); + } +} + +/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei. +pub(crate) fn set_dock_icon(path: &str) { + use objc2::{AnyThread, MainThreadMarker}; + use objc2_app_kit::{NSApplication, NSImage}; + use objc2_foundation::NSString; + + let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread"); + // Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal + // ohne eigenes Dock-Icon starten statt den Prozess zu beenden. + let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path)) + else { + eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}"); + return; + }; + // unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig. + unsafe { + NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img)); + } +} + +/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund: +/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit +/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab. +pub(crate) fn focus_terminal(pid: u32) { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication}; + let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32) + else { + eprintln!("Terminal-Prozess {pid} nicht gefunden"); + return; + }; + let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread"); + NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term); + term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows); +} + +/// Wayland-app_id gibt es nur unter Linux. +pub(crate) fn set_app_id(_name: &str) {} + +pub(crate) fn reveal_path(path: &Path) { + let _ = Command::new("open").arg("-R").arg(path).spawn(); +} + +// ---------- Desktop-Integration (nur Linux substantiell) ---------- + +pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {} +pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {} +pub(crate) fn sync_all_desktops(_paths: &Paths) {} + +// ---------- Secrets ---------- + +/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in +/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim +/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das +/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen +/// durchs CLI prompten. +pub(crate) struct KeychainStore; + +impl ApikeyStore for KeychainStore { + fn set(&self, pool: &str, key: &str) -> Result<(), String> { + let out = Command::new("security") + .args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key]) + .output() + .map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).into_owned()) + } + } + + fn has(&self, pool: &str) -> Result { + // Ohne -w: nur Attribute, kein Secret — kein ACL-Prompt möglich. + let out = Command::new("security") + .args(["find-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) + .output() + .map_err(|e| e.to_string())?; + Ok(out.status.success()) + } + + fn delete(&self, pool: &str) -> Result<(), String> { + // Fehlender Eintrag ist kein Fehler — gelöscht ist gelöscht. + Command::new("security") + .args(["delete-generic-password", "-s", APIKEY_SERVICE, "-a", pool]) + .output() + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keychain, +/// bei fehlendem Eintrag aus der Key-Datei. +pub(crate) fn apikey_helper_command(dir: &Path, pool_id: &str) -> String { + format!( + "security find-generic-password -w -s {APIKEY_SERVICE} -a {pool_id} 2>/dev/null || cat '{}'", + dir.join(APIKEY_FILE).display() + ) +} + +/// Existiert claudes suffixierter OAuth-Keychain-Eintrag? +pub(crate) fn oauth_keychain_exists(service: &str) -> Result { + let out = Command::new("security") + .args(["find-generic-password", "-s", service]) + .output() + .map_err(|e| e.to_string())?; + Ok(out.status.success()) +} + +/// Löscht claudes suffixierten OAuth-Keychain-Eintrag (Pool-Reset). +pub(crate) fn oauth_keychain_delete(service: &str) -> Result<(), String> { + let out = Command::new("security") + .args(["delete-generic-password", "-s", service]) + .output() + .map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +// ---------- Tray ---------- + +/// Natives Tray-Icon in der Menüleiste (Template-Icon). Links-Klick meldet +/// das Icon-Rect; das Popup gehört unter das Menüleisten-Icon. +pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + let icon = tauri::image::Image::from_bytes(include_bytes!("../../icons/trayTemplate.png")) + .map_err(|e| e.to_string())?; + TrayIconBuilder::with_id("main") + .icon(icon) + .icon_as_template(true) + .on_tray_icon_event(move |tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } = event + { + (cb.show)(tray.app_handle(), Anchor::IconRect { rect, popup_below: true }); + } + }) + .build(app) + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..939b28f --- /dev/null +++ b/src-tauri/src/platform/mod.rs @@ -0,0 +1,72 @@ +//! OS-Adapter. Dieses Modul definiert die vollständige Liste der +//! plattformabhängigen Operationen — pro Zielsystem existiert genau eine +//! Implementierung mit identischen Signaturen, aufgelöst zur Compile-Zeit +//! (kein dyn Trait). Wer ein neues OS portiert, implementiert diese API: +//! +//! Prozesse terminal_pids, kill_terminal +//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation +//! Fenster/Icons set_dock_icon, set_app_id +//! Dateisystem home_dir, write_secret_file, symlink, reveal_path +//! Shell shell_command +//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command, +//! oauth_keychain_exists, oauth_keychain_delete +//! Desktop write_terminal_desktop, remove_terminal_desktop, +//! sync_all_desktops (nur Linux substantiell, sonst No-ops) +//! Tray init_tray +//! +//! Vertrag Tray/Popup: Das Popup ist auf allen Systemen dasselbe HTML-Fenster +//! (app.rs). Abstrahiert wird NUR der Trigger — init_tray zeigt ein Icon und +//! meldet den Klick mit einem `Anchor`, der sagt, wo das Popup hin soll. +//! Menüs gibt es nicht; einzige Ausnahme ist der SNI-Rechtsklick „Beenden" +//! unter Linux, weil StatusNotifierItem ein Kontextmenü verlangt. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::{ + home_dir, kill_terminal, shell_command, symlink, terminal_pids, write_secret_file, +}; + +#[cfg(not(target_os = "macos"))] +mod keyring_store; +#[cfg(not(target_os = "macos"))] +pub(crate) use keyring_store::KeychainStore; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub(crate) use macos::*; + +#[cfg(target_os = "linux")] +pub(crate) mod linux; +#[cfg(target_os = "linux")] +pub(crate) use linux::*; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub(crate) use windows::*; + +/// Wo das gemeinsame Popup-Fenster hin soll — mehr weiß app.rs über das OS +/// nicht. Je Zielsystem wird nur eine Teilmenge der Varianten konstruiert. +#[allow(dead_code)] +pub(crate) enum Anchor { + /// Nativer Tray liefert das Icon-Rect (macOS, Windows). `popup_below` + /// entscheidet die Plattform: Menüleiste oben → Popup darunter (macOS), + /// Taskbar unten → Popup darüber (Windows). + IconRect { rect: tauri::Rect, popup_below: bool }, + /// SNI/ksni liefert keine Koordinaten — der Zeiger steht beim Klick auf dem + /// Icon (KDE/XFCE/Cinnamon). + Cursor, + /// Positionierung übernimmt der Compositor bzw. die Shell-Extension + /// (GNOME, KDE-Wayland/KWin). + Managed, +} + +/// Klick-Relay des Trays an app.rs: `show` zeigt das Popup am Anchor, +/// `hide` versteckt es (nur der GNOME-Toggle ruft hide). +pub(crate) struct TrayCallbacks { + pub(crate) show: Box, + #[allow(dead_code)] // macOS/Windows togglen nicht — dort versteckt Fokusverlust. + pub(crate) hide: Box, +} diff --git a/src-tauri/src/platform/unix.rs b/src-tauri/src/platform/unix.rs new file mode 100644 index 0000000..c135229 --- /dev/null +++ b/src-tauri/src/platform/unix.rs @@ -0,0 +1,59 @@ +//! Unix-Gemeinsames (macOS + Linux): Prozesse, Shell, Dateirechte, Symlinks. + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) fn home_dir() -> PathBuf { + PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")) +} + +/// Kommando für die PTY: Login-Shell aus $SHELL (-l) baut den PATH aus ihren +/// Profil-Dateien auf — shell-agnostisch. +pub(crate) fn shell_command(cmd: &str) -> portable_pty::CommandBuilder { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let mut c = portable_pty::CommandBuilder::new(&shell); + c.args(["-lc", cmd]); + c +} + +/// PIDs der eingebauten Terminal-Prozesse (`app --terminal `). +pub(crate) fn terminal_pids(project: &str) -> Vec { + let out = Command::new("pgrep") + .args(["-f", "--", &format!("--terminal {project}$")]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|l| l.trim().parse().ok()) + .collect(), + Err(_) => Vec::new(), + } +} + +/// SIGTERM auf die exakte PID. Der Prozesstod schließt den PTY-Master, +/// claude bekommt HUP und endet — wie beim Schließen des Fensters. +pub(crate) fn kill_terminal(pid: u32) -> Result<(), String> { + let out = Command::new("kill") + .arg(pid.to_string()) + .output() + .map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +pub(crate) fn write_secret_file(path: &Path, content: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("{}: {e}", path.display())) +} + +pub(crate) fn symlink(target: &Path, link: &Path) -> Result<(), String> { + std::os::unix::fs::symlink(target, link).map_err(|e| format!("{}: {e}", link.display())) +} diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs new file mode 100644 index 0000000..7cb289c --- /dev/null +++ b/src-tauri/src/platform/windows.rs @@ -0,0 +1,124 @@ +//! Windows: Alpha-Stand. Implementiert ist der native Tray (wie macOS, Popup +//! über der Taskbar), Fenster-Fokus über Tauri, reveal_path und der +//! Credential Manager über die keyring-Crate (keyring_store.rs). Alles +//! andere sind laut scheiternde Stubs — die offenen Punkte stehen an den +//! Funktionen. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::domain::paths::Paths; +use crate::domain::pool::APIKEY_FILE; +use crate::domain::project::TerminalConfig; +use crate::platform::{Anchor, TrayCallbacks}; + +// ---------- Prozesse ---------- + +/// TODO Windows: Prozess-Erkennung (Toolhelp-Snapshot über die Kommandozeile +/// `--terminal `). Bis dahin gilt kein Projekt als laufend. +pub(crate) fn terminal_pids(_project: &str) -> Vec { + Vec::new() +} + +pub(crate) fn kill_terminal(_pid: u32) -> Result<(), String> { + Err("Windows: Terminal-Prozesse beenden ist nicht implementiert".into()) +} + +// ---------- Aktivierung / Fokus / Icons ---------- + +pub(crate) fn yield_activation(_bundle_id: &str) {} + +pub(crate) fn activate_self(app: &tauri::AppHandle) { + use tauri::Manager; + if let Some(window) = app.webview_windows().values().next() { + window.set_focus().expect("Terminal-Fenster nicht fokussierbar"); + } +} + +/// Windows setzt das Taskbar-Icon über das Fenster-Icon (app.rs), nicht pro +/// Prozess. +pub(crate) fn set_dock_icon(_path: &str) {} + +/// TODO Windows: laufendes Terminal-Fenster über die PID nach vorn holen. +pub(crate) fn focus_terminal(_pid: u32) {} + +pub(crate) fn set_app_id(_name: &str) {} + +pub(crate) fn reveal_path(path: &Path) { + let _ = Command::new("explorer") + .arg(format!("/select,{}", path.display())) + .spawn(); +} + +// ---------- Dateisystem / Shell ---------- + +pub(crate) fn home_dir() -> PathBuf { + PathBuf::from(std::env::var("USERPROFILE").expect("USERPROFILE nicht gesetzt")) +} + +/// TODO Windows: PATH-Aufbau/Quoting klären (PowerShell vs. cmd); bis dahin +/// cmd /C mit dem konfigurierten Kommando. +pub(crate) fn shell_command(cmd: &str) -> portable_pty::CommandBuilder { + let mut c = portable_pty::CommandBuilder::new("cmd"); + c.args(["/C", cmd]); + c +} + +/// TODO Windows: Key-Datei mit restriktiver ACL (Pendant zu 0600). +pub(crate) fn write_secret_file(_path: &Path, _content: &str) -> Result<(), String> { + Err("Windows: geschützte Key-Datei ist nicht implementiert — Credential Manager verwenden".into()) +} + +/// TODO Windows: Symlinks brauchen Developer-Mode oder Privileg; Junctions +/// wären die Alternative für Ordner. +pub(crate) fn symlink(_target: &Path, _link: &Path) -> Result<(), String> { + Err("Windows: Pool-Runtime-Symlinks sind nicht implementiert".into()) +} + +// ---------- Desktop-Integration (nur Linux substantiell) ---------- + +pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {} +pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {} +pub(crate) fn sync_all_desktops(_paths: &Paths) {} + +// ---------- Secrets ---------- + +/// TODO Windows: Helper-Kommando, das den Key aus dem Credential Manager +/// liest (claude führt es in seiner Shell aus). Bis dahin nur die Key-Datei. +pub(crate) fn apikey_helper_command(dir: &Path, _pool_id: &str) -> String { + format!("type \"{}\"", dir.join(APIKEY_FILE).display()) +} + +pub(crate) fn oauth_keychain_exists(_service: &str) -> Result { + Err("Windows: OAuth-Keychain-Status ist nicht implementiert".into()) +} + +pub(crate) fn oauth_keychain_delete(_service: &str) -> Result<(), String> { + Err("Windows: OAuth-Keychain-Reset ist nicht implementiert".into()) +} + +// ---------- Tray ---------- + +/// Natives Tray-Icon im System-Tray. Links-Klick meldet das Icon-Rect; die +/// Taskbar liegt unten, das Popup gehört über das Icon. +pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + let icon = tauri::image::Image::from_bytes(include_bytes!("../../icons/32x32.png")) + .map_err(|e| e.to_string())?; + TrayIconBuilder::with_id("main") + .icon(icon) + .on_tray_icon_event(move |tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } = event + { + (cb.show)(tray.app_handle(), Anchor::IconRect { rect, popup_below: false }); + } + }) + .build(app) + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index c6fa676..55f555a 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -1,10 +1,15 @@ use base64::{engine::general_purpose::STANDARD, Engine}; -use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use portable_pty::{native_pty_system, ChildKiller, MasterPty, PtySize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; +use crate::domain::paths::{panel_file, Paths}; +use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig}; +use crate::domain::registry::project_dir; +use crate::domain::settings::claude_command; + /// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label). pub struct Session { writer: Box, @@ -22,13 +27,13 @@ pub struct Terminals(pub Mutex>); /// das Terminal-Fenster hinter der aktiven App. #[tauri::command] pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { - let dir = crate::project_dir(&crate::Paths::real(), &project)?; + let dir = project_dir(&Paths::real(), &project)?; if !dir.is_dir() { return Err(format!("Projektordner fehlt: {}", dir.display())); } let bundle_id = app.config().identifier.clone(); app - .run_on_main_thread(move || yield_activation_to_bundle(&bundle_id)) + .run_on_main_thread(move || crate::platform::yield_activation(&bundle_id)) .map_err(|e| e.to_string())?; let exe = std::env::current_exe().map_err(|e| e.to_string())?; std::process::Command::new(exe) @@ -38,44 +43,6 @@ pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> { Ok(()) } -/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser -/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben). -#[cfg(target_os = "macos")] -fn yield_activation_to_bundle(bundle_id: &str) { - use objc2::MainThreadMarker; - use objc2_app_kit::NSApplication; - use objc2_foundation::NSString; - let mtm = - MainThreadMarker::new().expect("yield_activation_to_bundle läuft nicht auf dem Main-Thread"); - NSApplication::sharedApplication(mtm) - .yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id)); -} - -/// Linux kennt keine kooperative Aktivierungsabtretung. -#[cfg(target_os = "linux")] -fn yield_activation_to_bundle(_bundle_id: &str) {} - -/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event); -/// die Gegenseite hat vorher per yield abgetreten. -#[cfg(target_os = "macos")] -pub fn activate_self(app: &AppHandle) { - use objc2::MainThreadMarker; - use objc2_app_kit::NSApplication; - let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread"); - NSApplication::sharedApplication(mtm).activate(); - if let Some(window) = app.webview_windows().values().next() { - window.set_focus().expect("Terminal-Fenster nicht fokussierbar"); - } -} - -/// Linux: Fenster ohne NSApplication über die Tauri-API fokussieren. -#[cfg(target_os = "linux")] -pub fn activate_self(app: &AppHandle) { - if let Some(window) = app.webview_windows().values().next() { - window.set_focus().expect("Terminal-Fenster nicht fokussierbar"); - } -} - /// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in /// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf. fn theme_background(theme: &str) -> (u8, u8, u8) { @@ -88,60 +55,13 @@ fn theme_background(theme: &str) -> (u8, u8, u8) { } } -/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei. -#[cfg(target_os = "macos")] -pub fn set_dock_icon(path: &str) { - use objc2::{AnyThread, MainThreadMarker}; - use objc2_app_kit::{NSApplication, NSImage}; - use objc2_foundation::NSString; - - let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread"); - // Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal - // ohne eigenes Dock-Icon starten statt den Prozess zu beenden. - let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path)) - else { - eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}"); - return; - }; - // unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig. - unsafe { - NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img)); - } -} - -/// Linux kennt kein Dock-Icon pro Prozess. -#[cfg(target_os = "linux")] -pub fn set_dock_icon(_path: &str) {} - -/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund: -/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit -/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab. -#[cfg(target_os = "macos")] -pub fn focus_terminal(pid: u32) { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication}; - let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32) - else { - eprintln!("Terminal-Prozess {pid} nicht gefunden"); - return; - }; - let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread"); - NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term); - term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows); -} - -/// Linux: Fremdprozess-Fokus über die PID steht ohne NSRunningApplication -/// nicht zur Verfügung. -#[cfg(target_os = "linux")] -pub fn focus_terminal(_pid: u32) {} - /// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst, /// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine /// Ausgaben verloren, bevor der Event-Listener steht. pub fn build_window( app: &AppHandle, project: &str, - cfg: &crate::TerminalConfig, + cfg: &TerminalConfig, ) -> tauri::Result<()> { let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let title = cfg.title.as_deref().unwrap_or(project); @@ -180,8 +100,8 @@ pub fn term_start( rows: u16, cols: u16, ) -> Result<(), String> { - let paths = crate::Paths::real(); - let cwd = crate::project_dir(&paths, &project)?; + let paths = Paths::real(); + let cwd = project_dir(&paths, &project)?; let pty = native_pty_system() .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) @@ -190,20 +110,18 @@ pub fn term_start( // Panel-Kanal: leere Datei anlegen (definierter Startzustand für den // Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill // schreibt seinen Entwurf dorthin. - let panel_path = crate::panel_file(&project); + let panel_path = panel_file(&project); if let Some(parent) = panel_path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&panel_path, ""); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let mut cmd = CommandBuilder::new(&shell); - cmd.args(["-lc", &crate::claude_command(&paths)]); + let mut cmd = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); cmd.env("AI_CONTROL_PANEL", &panel_path); cmd.env("AI_CONTROL_PROJECT", &project); - if let Some(pool_dir) = crate::project_pool_dir(&project)? { + if let Some(pool_dir) = project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); } @@ -293,14 +211,14 @@ fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) { /// Aktueller Panel-Inhalt (Erstbefüllung eines gerade geöffneten Panel-Fensters). #[tauri::command] pub fn panel_read(project: String) -> String { - std::fs::read_to_string(crate::panel_file(&project)).unwrap_or_default() + std::fs::read_to_string(panel_file(&project)).unwrap_or_default() } /// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die /// Änderung als `panel-update` an alle Fenster. #[tauri::command] pub fn panel_set(project: String, text: String) -> Result<(), String> { - std::fs::write(crate::panel_file(&project), text).map_err(|e| e.to_string()) + std::fs::write(panel_file(&project), text).map_err(|e| e.to_string()) } /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach @@ -312,7 +230,7 @@ pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> let _ = w.set_focus(); return Ok(()); } - let cfg = crate::terminal_config(&project)?; + let cfg = terminal_config(&project)?; let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let title = cfg.title.as_deref().unwrap_or(project.as_str()); WebviewWindowBuilder::new( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 63adb06..88b128e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,19 +28,21 @@ ], "linux": { "deb": { - "depends": ["libayatana-appindicator3-1"], "files": { "/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js", "/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json", - "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png" + "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png", + "/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json", + "/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js" } }, "rpm": { - "depends": ["libappindicator-gtk3"], "files": { "/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js", "/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json", - "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png" + "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png", + "/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json", + "/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js" } } },