//! 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::credentials::keychain_service; use crate::domain::paths::Paths; use crate::domain::pool::{ create_apikey_pool_in, create_oauth_pool_in, create_reference_pool_in, delete_pool_in, ensure_oauth_pool, link_pool_runtime_in, list_pools_in, offered_default_dir, 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_preview_in, delete_project_scoped_in, display_name_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, DeletePreview, Project, TerminalConfig, }; use crate::domain::settings; 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, ) -> Result { create_project_full_in( &Paths::real(), &name, dir.as_deref(), pool.as_deref(), work_dir.as_deref(), create_work_dir, terminal, ) } /// 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) } /// Umbau-Sperre: eine laufende Session des Projekts zuerst beenden. fn ensure_not_running(project: &str) -> Result<(), String> { if is_running(project) { let name = display_name_in(&Paths::real(), project)?; return Err(format!("{name} läuft noch — erst beenden")); } Ok(()) } /// Projektordner neu zuordnen; bei laufender Session gesperrt. #[tauri::command] pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> { ensure_not_running(&project)?; 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) } /// Artefakt-Vorschau für den Lösch-Dialog. #[tauri::command] pub(crate) fn delete_preview(project: String) -> Result { delete_preview_in(&Paths::real(), &project) } /// Löschen in drei Stufen: "integration" (nur ai-control-Spuren), /// "archive" (zusätzlich Archiv), "full" (zusätzlich Projektordner, /// Arbeitsordner per Flag). #[tauri::command] pub(crate) fn delete_project_scoped( project: String, scope: String, delete_work_dirs: bool, ) -> Result<(), String> { ensure_not_running(&project)?; delete_project_scoped_in(&Paths::real(), &project, &scope, 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 set_terminal_config( project: String, theme: Option, icon: Option, title: Option, ) -> Result<(), String> { set_terminal_config_in( &Paths::real(), &project, TerminalConfig { theme, icon, title, ..Default::default() }, ) } /// 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, &project, &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) } /// Anzeige-Namen der Projekte (für Fehlermeldungen mit Projektliste). fn display_names(paths: &Paths, ids: &[String]) -> Result, String> { ids.iter().map(|id| display_name_in(paths, id)).collect() } #[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: {}", display_names(&paths, &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)?) } /// Claudes Default-Verzeichnis, sofern vorhanden und noch nicht als Pool /// eingebunden — die Pool-Anlage bietet es dann als fertigen Pool an. #[tauri::command] pub(crate) fn default_config_dir() -> Option { offered_default_dir(&Paths::real()) } /// Pool, der auf ein bestehendes Config-Verzeichnis verweist (statt eines neu /// angelegten). Der vorhandene Login dort bleibt gültig. #[tauri::command] pub(crate) fn create_reference_pool(name: String, dir: String) -> Result { create_reference_pool_in(&Paths::real(), &name, &dir) } /// 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)?; // Bei einem verwiesenen Pool hinge daran der Login des fremden // Verzeichnisses — den löscht die App nicht. if let Some(dir) = read_pool(&paths, &pool)?.dir { return Err(format!( "Neuanmeldung geht bei einem verwiesenen Pool nicht ({dir}) — dort meldet claude selbst an" )); } let running = running_projects_using_pool(&paths, &pool)?; if !running.is_empty() { return Err(format!( "Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", display_names(&paths, &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() { let name = display_name_in(&Paths::real(), &project)?; return Err(format!("{name} 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 { let name = display_name_in(&Paths::real(), &project)?; return Err(format!("{name} 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) } // ---------- Verbrauch ---------- #[tauri::command] pub(crate) fn usage_stats(days: u32) -> Result, String> { usage_stats_in(&Paths::real(), days) } /// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module"). #[tauri::command] pub(crate) fn module_registry(project: String) -> Result, String> { crate::domain::modules::module_infos_in(&Paths::real(), &project) } /// Schaltet ein Modul im Projekt an/ab (Settings-Dialog, schreibt sofort). #[tauri::command] pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Result<(), String> { crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled) } /// Aktive Module fürs Frontend (Registry-Enablement der Projekt-Config). #[tauri::command] pub(crate) fn enabled_modules(project: String) -> Result, String> { Ok( crate::domain::modules::active_in(&Paths::real(), &project)? .iter() .map(|m| m.id.to_string()) .collect(), ) } // ---------- Trilium ---------- /// Wizard-Status: Runtime da, Instanz erreichbar, Token gültig. Reine /// Checks, keine Schreiber. #[derive(serde::Serialize)] pub(crate) struct TriliumStatus { version: &'static str, port: u16, data_dir: String, runtime_ready: bool, instance_running: bool, token_ok: bool, } fn trilium_status_for(project: &str) -> TriliumStatus { let paths = Paths::real(); let instance_running = crate::domain::trilium::instance_running(project); TriliumStatus { version: crate::domain::trilium::VERSION, port: crate::domain::trilium::port_for(project), data_dir: crate::domain::paths::contract_home( &paths, &crate::domain::trilium::data_dir(&paths, project), ), runtime_ready: crate::domain::trilium::runtime_ready(&paths), instance_running, token_ok: instance_running && crate::domain::trilium::token_ok(project), } } #[tauri::command] pub(crate) async fn trilium_status(project: String) -> TriliumStatus { trilium_status_for(&project) } /// Richtet die Projekt-Instanz ein: Runtime laden (gepinnt, Checksum), /// Instanz starten. Schreibt KEINE Config — das Modul-Flag setzt erst /// trilium_connect nach erfolgreichem Token-Login. #[tauri::command] pub(crate) async fn trilium_setup(project: String) -> Result { crate::domain::trilium::ensure_instance(&Paths::real(), &project)?; Ok(trilium_status_for(&project)) } /// Richtet die Instanz mit dem Passwort aus dem Wizard vollständig ein: /// Erstlauf legt das Dokument an und setzt das Passwort, dann ETAPI-Token /// holen, im Keyring ablegen und ERST DANN das Modul in der Projekt-Config /// freischalten. Das Passwort wird nicht gespeichert. #[tauri::command] pub(crate) async fn trilium_connect( project: String, password: String, ) -> Result { let paths = Paths::real(); crate::domain::trilium::ensure_instance(&paths, &project)?; if !crate::domain::trilium::initialized(&project)? { crate::domain::trilium::setup_new_document(&project)?; crate::domain::trilium::set_password(&project, &password)?; } let token = crate::domain::trilium::login(&project, &password)?; crate::domain::trilium::store_token(&project, &token)?; if !crate::domain::trilium::token_ok(&project) { crate::domain::trilium::delete_token(&project); return Err("Token angenommen, aber ETAPI-Zugriff scheitert".into()); } crate::domain::modules::set_module_in(&paths, &project, "trilium", true)?; Ok(trilium_status_for(&project)) } /// Öffnet das Trilium-Fenster des Projekts (startet die Instanz bei Bedarf); /// `note_id` springt zur Notiz. Async wie open_panel_window (Fenster-Erzeugung /// aus synchronen Commands klemmt auf dem GTK-Mainloop). #[tauri::command] pub(crate) async fn trilium_open( app: tauri::AppHandle, project: String, note_id: Option, ) -> Result<(), String> { use tauri::Manager; crate::domain::trilium::ensure_instance(&Paths::real(), &project)?; let mut url = crate::domain::trilium::base_url(&project); if let Some(id) = ¬e_id { url.push_str(&format!("/#root/{id}")); } let label = format!("trilium-{project}"); if let Some(win) = app.get_webview_window(&label) { if note_id.is_some() { win .eval(&format!("window.location.href = {}", serde_json::json!(url))) .map_err(|e| e.to_string())?; } win.set_focus().map_err(|e| e.to_string())?; return Ok(()); } let parsed: tauri::Url = url.parse().map_err(|e| format!("{url}: {e}"))?; tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::External(parsed)) .title(format!("Trilium — {}", crate::domain::project::display_name_in(&Paths::real(), &project)?)) .inner_size(1100.0, 780.0) .build() .map_err(|e| e.to_string())?; Ok(()) } /// Wählt das Modul ab und stoppt die Projekt-Instanz. Daten und Token /// bleiben — erneutes Aktivieren verbindet sich wieder. #[tauri::command] pub(crate) async fn trilium_disable(project: String) -> Result<(), String> { crate::domain::modules::set_module_in(&Paths::real(), &project, "trilium", false)?; crate::domain::trilium::stop_instance(&project); Ok(()) }