//! Tauri-Verdrahtung: Haupt-App (Tray, main-/popup-Fenster, Watcher) und //! Terminal-Prozess. Das Popup ist auf allen OS dasselbe HTML-Fenster; nur //! der Klick-Trigger kommt aus platform::init_tray (Anchor-Kontrakt). use crate::domain::paths::Paths; use crate::domain::pool::provision_pools_for_panel; use crate::domain::project::terminal_config; use crate::domain::watcher::spawn_session_watcher; use crate::platform::Anchor; use crate::{commands, terminal}; /// Sperrt das Auto-Hide des Popups bei Fokusverlust für einen Moment nach dem /// Anzeigen (KDE-Erststart-Race). Als Tauri-State geführt. #[cfg(target_os = "linux")] pub(crate) struct PopupBlurGuard(pub(crate) std::sync::Arc); /// Popup an den Klick-Koordinaten platzieren, in den Monitor geklemmt. #[cfg(target_os = "linux")] fn position_popup(w: &tauri::WebviewWindow, x: i32, y: i32) { use tauri::{PhysicalPosition, PhysicalSize}; let size = w.outer_size().unwrap_or_else(|_| PhysicalSize::new(320, 300)); let (mon_pos, mon_size) = w .current_monitor() .ok() .flatten() .map(|m| (*m.position(), *m.size())) .unwrap_or((PhysicalPosition::new(0, 0), PhysicalSize::new(1920, 1080))); // Rechte Kante an den Klick; oben/unten ergibt sich aus dem Klemmen an die // Monitorkante (Panel oben -> unter dem Icon, Panel unten -> darüber). let max_x = mon_pos.x + mon_size.width as i32 - size.width as i32; let max_y = mon_pos.y + mon_size.height as i32 - size.height as i32; let px = (x - size.width as i32).clamp(mon_pos.x, max_x.max(mon_pos.x)); let py = y.clamp(mon_pos.y, max_y.max(mon_pos.y)); let _ = w.set_position(PhysicalPosition::new(px, py)); } /// Popup zeigen — ein Codepfad für alle OS, nur die Platzierung folgt dem /// Anchor aus dem Tray-Trigger. pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) { use tauri::Manager; let win = app.clone(); let _ = app.run_on_main_thread(move || { let Some(w) = win.get_webview_window("popup") else { return; }; match anchor { // Nativer Tray (macOS/Windows): rechte Popup-Kante an der rechten // Icon-Kante; macOS unter dem Menüleisten-Icon, Windows über der Taskbar. Anchor::IconRect { rect, popup_below } => { let scale = w.scale_factor().unwrap_or(1.0); let pos = rect.position.to_physical::(scale); let size = rect.size.to_physical::(scale); let win_w = w.outer_size().map(|s| s.width as f64).unwrap_or(320.0); let x = (pos.x + size.width - win_w).max(0.0); let y = if popup_below { pos.y + size.height } else { let win_h = w.outer_size().map(|s| s.height as f64).unwrap_or(300.0); (pos.y - win_h).max(0.0) }; let _ = w.set_position(tauri::PhysicalPosition::new(x, y)); } // SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren // Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon. Anchor::Cursor => { #[cfg(target_os = "linux")] { if let Some(g) = win.try_state::() { g.0.store(true, std::sync::atomic::Ordering::SeqCst); } match win.cursor_position() { Ok(p) => position_popup(&w, p.x as i32, p.y as i32), Err(_) => { let _ = w.center(); } } // Guard nach kurzer Zeit lösen — danach schließt Fokusverlust wieder // normal. let g2 = win.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(350)); if let Some(g) = g2.try_state::() { g.0.store(false, std::sync::atomic::Ordering::SeqCst); } }); } } // GNOME-Extension/KWin: der Compositor positioniert selbst. Anchor::Managed => {} } let _ = w.show(); let _ = w.set_focus(); }); } /// Popup verstecken (GNOME-Toggle über D-Bus Hide()). pub(crate) fn hide_popup(app: &tauri::AppHandle) { use tauri::Manager; let win = app.clone(); let _ = app.run_on_main_thread(move || { if let Some(w) = win.get_webview_window("popup") { let _ = w.hide(); } }); } /// Haupt-App: reine Tray-App ohne Dock-Eintrag. pub(crate) fn main_builder() -> tauri::Builder { tauri::Builder::default() .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, )) .plugin(tauri_plugin_dialog::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .build(), )?; } // Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag. #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); // Session-Watcher: synct bei Session-Ende (Prozess verschwindet). spawn_session_watcher(); // Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende), // damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht. provision_pools_for_panel(&Paths::real()); // Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen, // damit sie da sind, bevor ein Terminal startet (No-op außerhalb Linux). crate::platform::sync_all_desktops(&Paths::real()); // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess // (gleiches Binary, gleiche Config) kein main-Fenster anlegt. let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) .title("ai-control") .inner_size(800.0, 600.0) // Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab, // unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon. .icon(tauri::image::Image::from_bytes(include_bytes!( "../icons/128x128.png" ))?)? .visible(false); // Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die // Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header). #[cfg(target_os = "macos")] let main_win = main_win .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true); #[cfg(target_os = "linux")] let main_win = main_win.decorations(false); main_win.build()?; // Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Nur // der Weg zum Klick unterscheidet sich (platform::init_tray). 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()?; #[cfg(target_os = "linux")] { use tauri::Manager; app.manage(PopupBlurGuard(std::sync::Arc::new( std::sync::atomic::AtomicBool::new(false), ))); } // Der gesamte Tray-Kontrakt: Icon zeigen, Klick als Anchor melden — // was dann passiert, entscheidet ausschließlich dieser Code. crate::platform::init_tray( app.handle(), crate::platform::TrayCallbacks { show: Box::new(|app, anchor| show_popup(app, anchor)), hide: Box::new(hide_popup), }, )?; Ok(()) }) // Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü. .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); window.hide().unwrap(); } // Popup schließt bei Fokusverlust. if let tauri::WindowEvent::Focused(false) = event { if window.label() == "popup" { // KDE feuert direkt nach show/set_focus ein Focused(false) — ohne // diese kurze Sperre verschwände das Popup sofort wieder (erst der // zweite Klick hielte). GNOME/macOS setzen die Sperre nie. #[cfg(target_os = "linux")] { use tauri::Manager; if let Some(g) = window.try_state::() { if g.0.load(std::sync::atomic::Ordering::SeqCst) { return; } } } let _ = window.hide(); } } }) .invoke_handler(tauri::generate_handler![ commands::list_projects, commands::create_project_full, commands::add_project, commands::remove_project, commands::delete_project, commands::project_work_dirs, commands::set_project_dir, commands::add_work_dir, commands::remove_work_dir, commands::list_pools, commands::create_oauth_pool, commands::create_apikey_pool, commands::rename_pool, commands::delete_pool, commands::assign_pool, commands::unassign_pool, commands::set_terminal_config, commands::project_icon, commands::pool_label, commands::todo_state, commands::set_todo, commands::usage_stats, commands::stop_project, commands::restart_project, commands::start_or_focus_cmd, commands::open_main_window, commands::quit_app, commands::sync_setting, commands::set_sync_setting, commands::terminal_font_size, commands::set_terminal_font_size, commands::link_pool_runtime, commands::oauth_login, commands::keychain_status, commands::set_apikey, terminal::open_terminal ]) } /// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt /// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal. pub(crate) fn terminal_builder(project: String) -> tauri::Builder { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(terminal::Terminals::default()) .setup(move |app| { let cfg = terminal_config(&project)?; terminal::build_window(app.handle(), &project, &cfg)?; Ok(()) }) // Fenster zu → PTY-Session abräumen; danach endet der Prozess. Das // abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel // wieder an (panel-attached), der Prozess läuft weiter. .on_window_event(|window, event| { if let tauri::WindowEvent::Destroyed = event { if window.label().starts_with("panel-") { use tauri::{Emitter, Manager}; let _ = window.app_handle().emit("panel-window-closed", ()); } else { terminal::close(window); } } }) .invoke_handler(tauri::generate_handler![ terminal::term_start, terminal::term_log, terminal::term_write, terminal::term_resize, terminal::panel_read, terminal::panel_set, terminal::open_panel_window, commands::panel_archive_dir_cmd, commands::panel_archive_cmd, commands::reveal_path_cmd, // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. commands::list_projects, commands::project_icon, commands::pool_label, commands::terminal_font_size, commands::set_terminal_font_size, commands::spellcheck_lang ]) }