diff --git a/package.json b/package.json index 8062ef9..515da01 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ai-control", "private": true, - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 85462bf..29fe745 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "ai-control" -version = "0.1.1" +version = "0.2.0" dependencies = [ "base64 0.22.1", "glib", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d52386..2418c95 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ai-control" -version = "0.1.1" +version = "0.2.0" description = "Pool- und Session-Verwaltung für Claude Code" authors = ["marcus.hinz"] license = "MIT" @@ -21,7 +21,7 @@ tauri-build = { version = "2.6.3", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] } +tauri = { version = "2.11.3", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-log = "2" tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39f330e..86d72f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -303,12 +303,12 @@ fn sync_session_context(repo: &std::path::Path, project: &str) { } } -/// 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. +/// 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). -/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf. -fn spawn_session_watcher(app: tauri::AppHandle) { +/// 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); @@ -326,7 +326,7 @@ fn spawn_session_watcher(app: tauri::AppHandle) { .map(|(name, _)| name) .collect(); // Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt — - // der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden. + // 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) { @@ -336,18 +336,6 @@ fn spawn_session_watcher(app: tauri::AppHandle) { } } } - // Menü-Objekte gehören auf den Main-Thread. - let handle = app.clone(); - app - .run_on_main_thread(move || { - // Nur wenn ein Tray existiert (macOS). Unter Linux pollt das Popup selbst. - if let Some(tray) = handle.tray_by_id("main") { - if let Ok(menu) = tray_menu(&handle) { - let _ = tray.set_menu(Some(menu)); - } - } - }) - .expect("Main-Thread nicht erreichbar"); state = current; } }); @@ -932,90 +920,6 @@ fn project_icon(project: String) -> Result, String> { Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png)))) } -/// Menü-Icon fürs Tray: ganz links der Status-Punkt (grün = läuft), daneben -/// das Projekt-Icon (sips konvertiert, auch ICNS). Ein RGBA-Bild 56×36 px; -/// muda rendert Menü-Icons mit 18 pt Höhe und proportionaler Breite. -fn menu_icon( - paths: &Paths, - project: &str, - running: bool, -) -> Result, String> { - const H: usize = 36; - const DOT_W: usize = 20; - const W: usize = DOT_W + H; - let mut canvas = vec![0u8; W * H * 4]; - if running { - // macOS-Systemgrün, weicher Rand über Alpha. - let (cx, cy, r) = (10.0f32, 18.0f32, 7.0f32); - for y in 0..H { - for x in 0..DOT_W { - let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); - let a = (r - d + 0.5).clamp(0.0, 1.0); - if a > 0.0 { - let i = (y * W + x) * 4; - canvas[i..i + 4].copy_from_slice(&[52, 199, 89, (a * 255.0) as u8]); - } - } - } - } - if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon { - // Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon): - // Meldung, Eintrag erscheint ohne Projekt-Icon. - match project_icon_rgba_36(paths, project, &icon) { - Ok(img) => { - let (iw, ih) = (img.width() as usize, img.height() as usize); - let rgba = img.rgba(); - for y in 0..ih.min(H) { - for x in 0..iw.min(H) { - let src_i = (y * iw + x) * 4; - let dst_i = (y * W + DOT_W + x) * 4; - canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]); - } - } - } - Err(e) => eprintln!("Tray-Icon {project}: {e}"), - } - } - Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32)) -} - -/// Projekt-Icon als 36×36-RGBA. PNG wird von tauri geladen und skaliert; -/// ICNS läuft über sips (nur macOS). -fn project_icon_rgba_36( - paths: &Paths, - project: &str, - icon: &str, -) -> Result, String> { - let src = resolve_icon_path(paths, icon); - let is_icns = src - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("icns")); - let img = if is_icns { - tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())? - } else { - tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))? - }; - Ok(scale_rgba(&img, 36)) -} - -/// Nearest-Neighbor-Skalierung eines RGBA-Bildes auf ein Quadrat der Kantenlänge `size`. -fn scale_rgba(img: &tauri::image::Image, size: usize) -> tauri::image::Image<'static> { - let (sw, sh) = (img.width() as usize, img.height() as usize); - let src = img.rgba(); - let mut out = vec![0u8; size * size * 4]; - for y in 0..size { - for x in 0..size { - let sx = x * sw / size; - let sy = y * sh / size; - let si = (sy * sw + sx) * 4; - let di = (y * size + x) * 4; - out[di..di + 4].copy_from_slice(&src[si..si + 4]); - } - } - tauri::image::Image::new_owned(out, size as u32, size as u32) -} - /// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS). fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result, String> { let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png")); @@ -2011,34 +1915,6 @@ pub fn run() { } } -/// Tray-Menü: pro Projekt ein Eintrag — Status-Punkt, Projekt-Icon, Name. -/// Klick startet das Projekt bzw. holt das laufende Terminal 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 icon = menu_icon(&paths, &p.name, p.running)?; - menu.append(&IconMenuItem::with_id( - app, - format!("project:{}", p.name), - &p.name, - true, - Some(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. fn start_or_focus(app: &tauri::AppHandle, project: &str) { @@ -2141,9 +2017,8 @@ fn main_builder() -> tauri::Builder { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); - // Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und - // hält das Tray-Menü aktuell. - spawn_session_watcher(app.handle().clone()); + // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). + spawn_session_watcher(); // Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen, // damit sie da sind, bevor ein Terminal startet. @@ -2171,49 +2046,69 @@ fn main_builder() -> tauri::Builder { let main_win = main_win.decorations(false); main_win.build()?; - // macOS: natives Tray-Menü (rendert Icons pro Eintrag). Unter Linux/GNOME - // übernimmt die Shell-Extension den Panel-Button + Popup — kein Tray hier, - // sonst erschiene das aIC-Icon doppelt. - #[cfg(target_os = "macos")] + // 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"))] { - let menu = tray_menu(app.handle())?; - tauri::tray::TrayIconBuilder::with_id("main") - .icon(tauri::image::Image::from_bytes(include_bytes!( - "../icons/trayTemplate.png" - ))?) - .icon_as_template(true) - .menu(&menu) - .on_menu_event(|app, event| match event.id.as_ref() { - "open" => { - let w = app.get_webview_window("main").unwrap(); - w.show().unwrap(); - w.set_focus().unwrap(); - } - "quit" => app.exit(0), - id => { - if let Some(project) = id.strip_prefix("project:") { - start_or_focus(app, project); + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + #[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: rahmenloses Popup-Fenster + D-Bus-Relay. Die Shell-Extension - // (ai-control-popup@local) ruft Show() und schiebt das Fenster unter ihren - // Panel-Button — GNOME liefert der App selbst keinen Tray-Klick. + // Linux/GNOME: D-Bus-Relay. Die Shell-Extension (ai-control-popup@local) + // ruft Show() und schiebt das Fenster unter ihren Panel-Button — GNOME + // liefert der App selbst keinen Tray-Klick. #[cfg(target_os = "linux")] { - 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()?; let app_dbus = app.handle().clone(); std::thread::spawn(move || { if let Err(e) = serve_popup_dbus(app_dbus) { @@ -2231,7 +2126,6 @@ fn main_builder() -> tauri::Builder { window.hide().unwrap(); } // Popup schließt bei Fokusverlust. - #[cfg(target_os = "linux")] if let tauri::WindowEvent::Focused(false) = event { if window.label() == "popup" { let _ = window.hide(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7c1aece..e32c3c0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ai-control", - "version": "0.1.1", + "version": "0.2.0", "identifier": "com.aicontrol.app", "build": { "frontendDist": "../dist", @@ -10,6 +10,7 @@ "beforeBuildCommand": "npm run build" }, "app": { + "macOSPrivateApi": true, "windows": [], "security": { "csp": null