From 670281b07c6f58ee988266708400bd83b21dad1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 29 Apr 2026 09:23:12 +0800 Subject: [PATCH] fix: keep desktop app running from tray after close Closing the desktop window should not tear down the local server when users expect the app to keep serving background work. The Tauri layer now owns that lifecycle: close hides the main window, tray actions restore or quit, and true quit paths still stop sidecars. Constraint: Issue #213 asks for Windows close-to-background behavior, and macOS should follow the same desktop lifecycle. Rejected: Frontend-only close handling | native window close events and OS menu actions need one Tauri-owned path. Confidence: high Scope-risk: moderate Directive: Do not remove the explicit quit marker unless all updater, tray, and app-exit paths are rechecked. Tested: cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check Tested: cargo check --manifest-path desktop/src-tauri/Cargo.toml Tested: cargo test --manifest-path desktop/src-tauri/Cargo.toml Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Windows cargo check on this macOS machine; x86_64-pc-windows-msvc cross-check stopped in ring because the host lacks Windows C/MSVC headers. Related: https://github.com/NanmiCoder/cc-haha/issues/213 --- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/lib.rs | 109 +++++++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 25e9326f..33082700 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-process = "2" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d25d8614..c6a33d0f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -15,16 +15,19 @@ use std::{ use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use serde::Serialize; -#[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::Emitter; -use tauri::{AppHandle, Manager, RunEvent, State}; +use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, }; const SERVER_STARTUP_LOG_LIMIT: usize = 80; +const MAIN_WINDOW_LABEL: &str = "main"; +const TRAY_SHOW_ID: &str = "tray_show"; +const TRAY_QUIT_ID: &str = "tray_quit"; #[derive(Default)] struct ServerState(Mutex); @@ -40,6 +43,11 @@ struct ServerStatus { startup_error: Option, } +#[derive(Default)] +struct AppExitState { + is_quitting: Mutex, +} + /// 与 ServerState 平级的 adapter 子进程状态。 /// /// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期 @@ -116,6 +124,7 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { #[tauri::command] fn prepare_for_update_install(app: AppHandle) -> Result<(), String> { + mark_app_quitting(&app); stop_server_sidecar(&app); stop_adapters_sidecar(&app); @@ -130,6 +139,71 @@ fn prepare_for_update_install(app: AppHandle) -> Result<(), String> { Ok(()) } +fn mark_app_quitting(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Ok(mut is_quitting) = state.is_quitting.lock() { + *is_quitting = true; + } + } +} + +fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool { + if label != MAIN_WINDOW_LABEL { + return false; + } + + app.try_state::() + .and_then(|state| state.is_quitting.lock().ok().map(|value| !*value)) + .unwrap_or(true) +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +fn setup_system_tray(app: &mut tauri::App) -> tauri::Result<()> { + let menu = MenuBuilder::new(app) + .text(TRAY_SHOW_ID, "Show Claude Code Haha") + .separator() + .text(TRAY_QUIT_ID, "Quit Claude Code Haha") + .build()?; + + let mut tray = TrayIconBuilder::with_id("main-tray") + .tooltip("Claude Code Haha") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id().as_ref() { + TRAY_SHOW_ID => show_main_window(app), + TRAY_QUIT_ID => { + mark_app_quitting(app); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }); + + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + + tray.build(app)?; + + Ok(()) +} + #[tauri::command] fn terminal_spawn( app: AppHandle, @@ -892,6 +966,7 @@ pub fn run() { .manage(ServerState::default()) .manage(AdapterState::default()) .manage(TerminalState::default()) + .manage(AppExitState::default()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) @@ -967,6 +1042,8 @@ pub fn run() { let app = builder .setup(|app| { + setup_system_tray(app)?; + let state = app.state::(); let mut guard = state .0 @@ -996,10 +1073,34 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application"); - app.run(|app_handle, event| { - if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { + app.run(|app_handle, event| match event { + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if should_hide_to_tray(app_handle, &label) => { + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window(&label) { + let _ = window.hide(); + } + } + #[cfg(target_os = "macos")] + RunEvent::Reopen { + has_visible_windows: false, + .. + } => { + show_main_window(app_handle); + } + RunEvent::ExitRequested { .. } => { + mark_app_quitting(app_handle); stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); } + RunEvent::Exit => { + mark_app_quitting(app_handle); + stop_server_sidecar(app_handle); + stop_adapters_sidecar(app_handle); + } + _ => {} }); }