diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e705f4..ea88558 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,8 +33,12 @@ impl Paths { self.home.join("claude-projects") } + fn config_dir(&self) -> PathBuf { + self.home.join(".config").join("ai-control") + } + fn pools_dir(&self) -> PathBuf { - self.home.join(".config").join("ai-control").join("pools") + self.config_dir().join("pools") } fn pool_dir(&self, pool: &str) -> PathBuf { @@ -86,12 +90,8 @@ impl TerminalConfig { } } -fn bundle_id(project: &str) -> String { - format!("com.mitchellh.ghostty.{project}") -} - fn is_running(project: &str) -> bool { - ghostty_running(project) || !terminal_pids(project).is_empty() + !terminal_pids(project).is_empty() } /// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe @@ -103,16 +103,6 @@ fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result, .collect()) } -fn ghostty_running(project: &str) -> bool { - let out = Command::new("lsappinfo") - .args(["find", &format!("bundleid={}", bundle_id(project))]) - .output(); - match out { - Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(), - Err(_) => false, - } -} - /// PIDs der eingebauten Terminal-Prozesse (`app --terminal `). fn terminal_pids(project: &str) -> Vec { let out = Command::new("pgrep") @@ -127,6 +117,92 @@ fn terminal_pids(project: &str) -> Vec { } } +/// 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) +} + +/// Setzt das Opt-in; erhält übrige App-settings. +fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> { + let path = paths.config_dir().join(APP_SETTINGS_FILE); + let mut v: serde_json::Value = fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| serde_json::json!({})); + v["syncOnSessionEnd"] = serde_json::json!(enabled); + fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?; + let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; + fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display())) +} + +fn is_git_repo(dir: &PathBuf) -> bool { + dir.join(".git").exists() +} + +/// Namen aller Projekte, die gerade laufen — dieselbe Erkennung wie die +/// Projektliste. +fn running_project_set(paths: &Paths) -> HashSet { + list_projects_in(paths) + .map(|ps| ps.into_iter().filter(|p| p.running).map(|p| p.name).collect()) + .unwrap_or_default() +} + +/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das +/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher). +fn sync_session_context(home: &PathBuf, project: &str) { + let git_sync = home.join("claude-projects/pool/bin/git-sync"); + let repo = home.join("claude-projects"); + match Command::new(&git_sync) + .arg(&repo) + .arg(format!("session-end sync: {project}")) + .output() + { + Ok(o) if o.status.success() => {} + Ok(o) => eprintln!( + "session-end sync {project}: {}", + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"), + } +} + +/// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines +/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen. +/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei +/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr). +fn spawn_session_watcher() { + std::thread::spawn(|| { + let paths = Paths::real(); + let mut running = running_project_set(&paths); + loop { + std::thread::sleep(WATCH_INTERVAL); + let current = running_project_set(&paths); + let ended: Vec<&String> = running.difference(¤t).collect(); + // Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray + // verlangt kein Repo, er nutzt es nur, falls vorhanden. + if !ended.is_empty() + && sync_on_session_end(&paths) + && is_git_repo(&paths.projects_dir()) + { + for project in ended { + sync_session_context(&paths.home, project); + } + } + running = current; + } + }); +} + fn check_name(name: &str) -> Result<(), String> { if name.trim().is_empty() || name.contains('/') || name.contains("..") { return Err(format!("ungültiger Name: {name}")); @@ -971,68 +1047,35 @@ fn oauth_login(pool: String) -> Result<(), String> { Ok(()) } -fn open_project(project: &str) -> Result<(), String> { - // Läuft die UI selbst in einer Ghostty-App, erbt `open` deren - // XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config. - let out = Command::new("open") - .env_remove("XDG_CONFIG_HOME") - .args(["-b", &bundle_id(project)]) - .output() - .map_err(|e| e.to_string())?; - if out.status.success() { - Ok(()) - } else { - Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) - } -} - -#[tauri::command] -fn start_project(project: String) -> Result<(), String> { - open_project(&project) -} - -/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per -/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle -/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit. -#[tauri::command] -fn stop_project(project: String) -> Result<(), String> { - let pids = terminal_pids(&project); - if !pids.is_empty() { - for pid in pids { - 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()); - } - } - return Ok(()); - } - if ghostty_running(&project) { - let out = Command::new("osascript") - .args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))]) +/// 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()); } - return Ok(()); } - Err(format!("{project} läuft nicht")) + Ok(()) } -/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch -/// nachfragen), wartet auf das Prozessende und startet sie neu. +#[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(project: String) -> Result<(), String> { - let out = Command::new("osascript") - .args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } + kill_terminals(&project)?; let deadline = Instant::now() + Duration::from_secs(30); while is_running(&project) { if Instant::now() > deadline { @@ -1040,7 +1083,17 @@ fn restart_project(project: String) -> Result<(), String> { } std::thread::sleep(Duration::from_millis(250)); } - open_project(&project) + terminal::open_terminal(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) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -1098,6 +1151,9 @@ fn main_builder() -> tauri::Builder { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); + // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). + spawn_session_watcher(); + // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) @@ -1153,9 +1209,10 @@ fn main_builder() -> tauri::Builder { todo_state, set_todo, usage_stats, - start_project, stop_project, restart_project, + sync_setting, + set_sync_setting, oauth_login, keychain_status, set_apikey, @@ -1590,6 +1647,16 @@ mod tests { assert!(!p.project_config("proj").exists()); } + #[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(); diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index e74c9c1..d9c1a15 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -93,7 +93,7 @@ pub fn build_window( /// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner. /// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht -/// claude-sync selbst — identisch zum Ghostty-Starter. +/// claude-sync selbst. #[tauri::command] pub fn term_start( window: tauri::WebviewWindow, @@ -181,8 +181,7 @@ pub fn term_resize( } /// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen. -/// Das Droppen des Masters schließt die PTY — claude bekommt HUP wie beim -/// Fenster-X in Ghostty. +/// Das Droppen des Masters schließt die PTY — claude bekommt HUP. pub fn close(window: &tauri::Window) { let terminals = window.state::(); let session = terminals.0.lock().unwrap().remove(window.label());