From e21db6845cfdb71b6027ca33fe75b1caf00664fc Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Sun, 5 Jul 2026 13:08:01 +0200 Subject: [PATCH] =?UTF-8?q?Tray-Men=C3=BC=20listet=20alle=20Projekte=20mit?= =?UTF-8?q?=20Icon:=20Klick=20startet=20(=E2=80=9Estarten")=20oder=20holt?= =?UTF-8?q?=20das=20laufende=20Terminal=20nach=20vorn=20(=E2=80=9Ezeigen")?= =?UTF-8?q?;=20Session-Watcher=20baut=20das=20Men=C3=BC=20bei=20Status-/Li?= =?UTF-8?q?sten=C3=A4nderung=20neu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 133 ++++++++++++++++++++++++++++++++------ src-tauri/src/terminal.rs | 16 +++++ 3 files changed, 132 insertions(+), 19 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d17a729..883dc8b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,5 +33,5 @@ keyring = { version = "3", features = ["apple-native", "sync-secret-service", "w [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] } +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] } objc2-foundation = { version = "0.3", features = ["NSString"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d58e46..ec5a781 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -149,12 +149,14 @@ 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() +/// (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 claude-projects-Repo nach einem Session-Ende über das @@ -180,14 +182,24 @@ fn sync_session_context(home: &PathBuf, project: &str) { /// 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(|| { +/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf. +fn spawn_session_watcher(app: tauri::AppHandle) { + std::thread::spawn(move || { let paths = Paths::real(); - let mut running = running_project_set(&paths); + let mut state = project_state(&paths); loop { std::thread::sleep(WATCH_INTERVAL); - let current = running_project_set(&paths); - let ended: Vec<&String> = running.difference(¤t).collect(); + 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 ein Git-Repo da ist — der Tray // verlangt kein Repo, er nutzt es nur, falls vorhanden. if !ended.is_empty() @@ -198,7 +210,19 @@ fn spawn_session_watcher() { sync_session_context(&paths.home, project); } } - running = current; + // Menü-Objekte gehören auf den Main-Thread. + let handle = app.clone(); + app + .run_on_main_thread(move || { + let menu = tray_menu(&handle).expect("Tray-Menü nicht aufbaubar"); + handle + .tray_by_id("main") + .expect("Tray-Icon fehlt") + .set_menu(Some(menu)) + .expect("Tray-Menü nicht setzbar"); + }) + .expect("Main-Thread nicht erreichbar"); + state = current; } }); } @@ -574,6 +598,30 @@ fn project_icon(project: String) -> Result, String> { Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png)))) } +/// Projekt-Icon fürs Tray-Menü: sips konvertiert (auch ICNS) und skaliert auf +/// 36 px; muda rendert Menü-Icons auf macOS mit 18 pt. +fn menu_icon(paths: &Paths, project: &str) -> Result>, String> { + let Some(icon) = read_project_config_in(paths, project)?.terminal.icon else { + return Ok(None); + }; + let src = resolve_icon_path(paths, project, &icon); + let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png")); + let out = Command::new("sips") + .args(["-s", "format", "png", "-z", "36", "36"]) + .arg(&src) + .arg("--out") + .arg(&tmp) + .output() + .map_err(|e| format!("sips: {e}"))?; + if !out.status.success() { + return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr))); + } + let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?; + let _ = fs::remove_file(&tmp); + let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?; + Ok(Some(img)) +} + /// 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) @@ -1463,6 +1511,52 @@ pub fn run() { } } +/// Tray-Menü: pro Projekt ein Eintrag mit Icon; das Label nennt die Aktion — +/// „starten" öffnet das Terminal, „zeigen" holt das laufende nach vorn. +fn tray_menu( + app: &tauri::AppHandle, +) -> Result, Box> { + use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem}; + let paths = Paths::real(); + let menu = Menu::new(app)?; + menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?; + menu.append(&PredefinedMenuItem::separator(app)?)?; + let mut projects = list_projects_in(&paths)?; + projects.sort_by(|a, b| a.name.cmp(&b.name)); + for p in projects { + let label = if p.running { + format!("{} zeigen", p.name) + } else { + format!("{} starten", p.name) + }; + let icon = menu_icon(&paths, &p.name)?; + menu.append(&IconMenuItem::with_id( + app, + format!("project:{}", p.name), + label, + true, + icon, + None::<&str>, + )?)?; + } + menu.append(&PredefinedMenuItem::separator(app)?)?; + menu.append(&MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?)?; + Ok(menu) +} + +/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn, +/// sonst startet es. Das Label wechselt, sobald der Watcher den neuen Status sieht. +fn start_or_focus(project: &str) { + match terminal_pids(project).first() { + Some(pid) => terminal::focus_terminal(*pid), + None => { + if let Err(e) = terminal::open_terminal(project.to_string()) { + eprintln!("{project} starten: {e}"); + } + } + } +} + /// Haupt-App: reine Tray-App ohne Dock-Eintrag. fn main_builder() -> tauri::Builder { use tauri::Manager; @@ -1486,8 +1580,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(); + // Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und + // hält das Tray-Menü aktuell. + spawn_session_watcher(app.handle().clone()); // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. @@ -1497,9 +1592,7 @@ fn main_builder() -> tauri::Builder { .visible(false) .build()?; - let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?; - let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; - let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?; + let menu = tray_menu(app.handle())?; tauri::tray::TrayIconBuilder::with_id("main") // include_bytes! statt default_window_icon: cargo trackt die Datei, // neu generierte Icons landen damit sicher im nächsten Build. @@ -1516,7 +1609,11 @@ fn main_builder() -> tauri::Builder { w.set_focus().unwrap(); } "quit" => app.exit(0), - _ => {} + id => { + if let Some(project) = id.strip_prefix("project:") { + start_or_focus(project); + } + } }) .build(app)?; diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index a436b66..4887de4 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -67,6 +67,22 @@ 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. +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); +} + /// 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.