Tray-Menü listet alle Projekte mit Icon: Klick startet („starten") oder holt das laufende Terminal nach vorn („zeigen"); Session-Watcher baut das Menü bei Status-/Listenänderung neu

This commit is contained in:
marcus.hinz
2026-07-05 13:08:01 +02:00
parent 69fa8b338c
commit e21db6845c
3 changed files with 132 additions and 19 deletions
+1 -1
View File
@@ -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"] }
+115 -18
View File
@@ -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<String> {
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(&current).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<Option<String>, 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<Option<tauri::image::Image<'static>>, 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<TerminalConfig, String> {
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<tauri::menu::Menu<tauri::Wry>, Box<dyn std::error::Error>> {
use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem};
let paths = Paths::real();
let menu = Menu::new(app)?;
menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?;
menu.append(&PredefinedMenuItem::separator(app)?)?;
let mut projects = list_projects_in(&paths)?;
projects.sort_by(|a, b| a.name.cmp(&b.name));
for p in projects {
let 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<tauri::Wry> {
use tauri::Manager;
@@ -1486,8 +1580,9 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
#[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<tauri::Wry> {
.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<tauri::Wry> {
w.set_focus().unwrap();
}
"quit" => app.exit(0),
_ => {}
id => {
if let Some(project) = id.strip_prefix("project:") {
start_or_focus(project);
}
}
})
.build(app)?;
+16
View File
@@ -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.