diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 63f86a168..6e544db7a 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ serde_json = "1" [dependencies] # Tauri core and plugins -tauri = { version = "2.10", features = ["macos-private-api"] } +tauri = { version = "2.10", features = ["macos-private-api", "tray-icon"] } tauri-plugin-deep-link = "2.0.0" tauri-plugin-opener = "2" serde_json = "1" diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 18d230ebc..10f0c01c7 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,7 +3,11 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp mod core_process; -use tauri::{Manager, RunEvent}; +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Manager, RunEvent, +}; #[cfg(any(windows, target_os = "linux"))] use tauri_plugin_deep_link::DeepLinkExt; @@ -18,6 +22,65 @@ fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if let Err(err) = window.show() { + log::error!("[tray] failed to show main window: {err}"); + } + if let Err(err) = window.unminimize() { + log::error!("[tray] failed to unminimize main window: {err}"); + } + if let Err(err) = window.set_focus() { + log::error!("[tray] failed to focus main window: {err}"); + } + } else { + log::error!("[tray] main window not found"); + } +} + +fn setup_tray(app: &AppHandle) -> tauri::Result<()> { + log::info!("[tray] setting up tray icon"); + + let show_item = MenuItem::with_id(app, "tray_show_window", "Open OpenHuman", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "tray_quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let icon = app + .default_window_icon() + .cloned() + .ok_or_else(|| tauri::Error::AssetNotFound("default window icon".to_string()))?; + + TrayIconBuilder::with_id("openhuman-tray") + .icon(icon) + .menu(&menu) + .on_menu_event(|app, event| match event.id().as_ref() { + "tray_show_window" => { + log::info!("[tray] action=show_window source=menu"); + show_main_window(app); + } + "tray_quit" => { + log::info!("[tray] action=quit source=menu"); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + log::info!("[tray] action=show_window source=left_click"); + show_main_window(tray.app_handle()); + } + }) + .build(app)?; + + log::info!("[tray] tray icon ready"); + Ok(()) +} + pub fn run() { let daemon_mode = is_daemon_mode(); @@ -59,30 +122,23 @@ pub fn run() { if daemon_mode { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); + log::info!("[tray] daemon_mode=true window_hidden_on_startup"); } } + if let Err(err) = setup_tray(app.handle()) { + log::error!("[tray] failed to setup tray icon: {err}"); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![core_rpc_url]) - .build({ - let mut context = tauri::generate_context!(); - if daemon_mode { - context.config_mut().app.windows.clear(); - } - context - }) + .build(tauri::generate_context!()) .expect("error while building tauri application") .run(move |app_handle, event| match event { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => { - if !daemon_mode { - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } - } + show_main_window(app_handle); } RunEvent::Exit => { if let Some(core) = app_handle.try_state::() { diff --git a/docs/src-tauri/01-architecture.md b/docs/src-tauri/01-architecture.md index cb82e91f7..3017a5e88 100644 --- a/docs/src-tauri/01-architecture.md +++ b/docs/src-tauri/01-architecture.md @@ -10,7 +10,7 @@ Non-desktop targets fail at compile time (`compile_error!` in `lib.rs`). ``` app/src-tauri/src/ -├── lib.rs # `run()`, tray, plugins, `generate_handler!`, core startup +├── lib.rs # `run()`, tray/menu actions, plugins, `generate_handler!`, core startup ├── main.rs # Binary entry ├── core_process.rs # CoreProcessHandle, spawn/monitor openhuman sidecar ├── core_rpc.rs # HTTP client to core JSON-RPC @@ -37,6 +37,13 @@ React (invoke) `CoreProcessHandle` in `core_process.rs` starts or waits for the sidecar; `commands/core_relay.rs` optionally ensures a **service-managed** core is running before relaying. +## Window and tray behavior + +- The shell creates a tray icon at startup and wires actions to open the main window or quit. +- In daemon mode (`daemon` / `--daemon`), the main window is hidden on launch and can be reopened from tray actions. +- On macOS `RunEvent::Reopen` also restores and focuses the main window. +- Windows and Linux use the same tray actions (`Open OpenHuman`, `Quit`), with desktop-environment-specific tray rendering differences on some Linux setups. + ## Bundled resources `tauri.conf.json` bundles **`../../skills/skills`** and **`../../src/openhuman/agent/prompts`** so skills and prompt markdown ship with the app.