From fdecf445ec09349aef23af5e76791035746b3ee7 Mon Sep 17 00:00:00 2001 From: Huy Le Tien Date: Fri, 29 May 2026 00:06:44 +0700 Subject: [PATCH] feat: confirm minimize-to-tray or quit when closing the window Intercept the main window CloseRequested event so the user can choose between minimizing the app to the system tray and quitting, instead of the close button immediately tearing the process down. - Add an on_window_event handler that prevents close, emits close-confirm-requested, and lets the next CloseRequested through once confirm_quit flips a QUIT_CONFIRMED flag. - Add a TrayIconBuilder in the main process with Show / Quit menu items and a left-click handler that restores the window. Tray icon is decoded via the image crate so the donut glyph renders on every platform. - Add hide_to_tray command used by the dialog's Minimize action. - New CloseConfirmDialog React component mounted in app/page.tsx. - Enable Tauri features tray-icon and image-png. - Add closeConfirm strings across all eight locale files. The existing standalone donut-daemon tray binary is left untouched. --- CHANGELOG.md | 2 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 114 +++++++++++++++++++++++- src/app/page.tsx | 2 + src/components/close-confirm-dialog.tsx | 78 ++++++++++++++++ src/i18n/locales/en.json | 6 ++ src/i18n/locales/es.json | 6 ++ src/i18n/locales/fr.json | 6 ++ src/i18n/locales/ja.json | 6 ++ src/i18n/locales/ko.json | 6 ++ src/i18n/locales/pt.json | 6 ++ src/i18n/locales/ru.json | 6 ++ src/i18n/locales/zh.json | 6 ++ 14 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/components/close-confirm-dialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c7184ae..7476d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Maintenance -- chore: versiom bump +- chore: version bump - chore: update flake.nix for v0.24.3 [skip ci] (#383) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f3e7480..7ae1565 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6606,6 +6606,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9cae4e..14cc39f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ resvg = "0.47" [dependencies] serde_json = "1" serde = { version = "1", features = ["derive"] } -tauri = { version = "2", features = ["devtools", "test"] } +tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] } tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-shell = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97314d2..e16dedd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,19 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use std::env; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; -use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_log::{Target, TargetKind}; // Store pending URLs that need to be handled when the window is ready static PENDING_URLS: Mutex> = Mutex::new(Vec::new()); +// Set to true once the user has confirmed they want to quit, so the close +// interceptor lets the next CloseRequested through instead of looping back +// to the confirmation dialog. +static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); + mod api_client; mod api_server; mod app_auto_updater; @@ -1145,6 +1151,30 @@ async fn generate_sample_fingerprint( } } +/// Confirm a quit chosen from the close-confirmation dialog and exit the app. +#[tauri::command] +fn confirm_quit(app_handle: tauri::AppHandle) { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + app_handle.exit(0); +} + +/// Hide the main window so the app keeps running behind its tray icon. +#[tauri::command] +fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app_handle.get_webview_window("main") { + window.hide().map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn show_main_window(app_handle: &tauri::AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = env::args().collect(); @@ -1243,6 +1273,86 @@ pub fn run() { #[allow(unused_variables)] let window = win_builder.build().unwrap(); + // System tray so the user can keep the app running after the close + // dialog's "Minimize" action hides the window. + { + use tauri::menu::{MenuBuilder, MenuItemBuilder}; + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + + let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser") + .build(app) + .map_err(|e| format!("Failed to build tray show item: {e}"))?; + let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit") + .build(app) + .map_err(|e| format!("Failed to build tray quit item: {e}"))?; + let tray_menu = MenuBuilder::new(app) + .item(&show_item) + .separator() + .item(&quit_item) + .build() + .map_err(|e| format!("Failed to build tray menu: {e}"))?; + + // Tray-specific icons. macOS/Linux get a template (black + alpha) + // version so the OS can tint it for light/dark menu bars; Windows + // gets the full-color variant. Decode through the `image` crate so + // we hand Tauri raw RGBA — `Image::from_bytes` can fail silently on + // bitmaps that don't match the size Tauri expects. + #[cfg(target_os = "windows")] + let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png"); + #[cfg(not(target_os = "windows"))] + let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png"); + let tray_rgba = image::load_from_memory(tray_icon_bytes) + .map_err(|e| format!("Failed to decode tray icon: {e}"))? + .into_rgba8(); + let (tray_w, tray_h) = tray_rgba.dimensions(); + let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h); + + let _tray = TrayIconBuilder::with_id("main") + .icon(tray_image) + .icon_as_template(cfg!(not(target_os = "windows"))) + .tooltip("Donut Browser") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app_handle, event| match event.id().as_ref() { + "tray_show" => show_main_window(app_handle), + "tray_quit" => { + QUIT_CONFIRMED.store(true, Ordering::SeqCst); + app_handle.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()); + } + }) + .build(app) + .map_err(|e| format!("Failed to build tray icon: {e}"))?; + } + + // Intercept the window close so the frontend can ask the user whether + // to minimize or quit. The app exits when `confirm_quit` flips + // QUIT_CONFIRMED — until then, every CloseRequested is held back. + { + let app_handle = app.handle().clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if QUIT_CONFIRMED.load(Ordering::SeqCst) { + return; + } + api.prevent_close(); + if let Err(e) = app_handle.emit("close-confirm-requested", ()) { + log::warn!("Failed to emit close-confirm-requested: {e}"); + } + } + }); + } + // Set transparent titlebar for macOS #[cfg(target_os = "macos")] { @@ -1954,6 +2064,8 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + confirm_quit, + hide_to_tray, get_supported_browsers, is_browser_supported_on_platform, download_browser, diff --git a/src/app/page.tsx b/src/app/page.tsx index d27414e..16da79d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { AccountPage } from "@/components/account-page"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CloneProfileDialog } from "@/components/clone-profile-dialog"; +import { CloseConfirmDialog } from "@/components/close-confirm-dialog"; import { CommandPalette } from "@/components/command-palette"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; @@ -1431,6 +1432,7 @@ export default function Home() { return (
+ { + const unlistenPromise = listen("close-confirm-requested", () => { + setIsOpen(true); + }); + return () => { + void unlistenPromise.then((u) => { + u(); + }); + }; + }, []); + + const handleMinimize = async () => { + setIsOpen(false); + try { + await invoke("hide_to_tray"); + } catch (error) { + console.error("Failed to hide to tray:", error); + } + }; + + const handleQuit = async () => { + setIsOpen(false); + try { + await invoke("confirm_quit"); + } catch (error) { + console.error("Failed to quit app:", error); + } + }; + + return ( + + + + {t("closeConfirm.title")} + {t("closeConfirm.description")} + + + { + void handleMinimize(); + }} + > + {t("closeConfirm.minimize")} + + { + void handleQuit(); + }} + > + {t("closeConfirm.quit")} + + + + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d933b5a..78b2962 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1912,5 +1912,11 @@ "goIntegrations": "Go to Integrations", "goAccount": "Go to Account", "goSettings": "Go to Settings" + }, + "closeConfirm": { + "title": "Close Donut Browser?", + "description": "Would you like to send the app to the system tray or quit?", + "minimize": "Minimize to Tray", + "quit": "Quit" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d5d08a2..9191c56 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1912,5 +1912,11 @@ "goIntegrations": "Ir a Integraciones", "goAccount": "Ir a Cuenta", "goSettings": "Ir a Configuración" + }, + "closeConfirm": { + "title": "¿Cerrar Donut Browser?", + "description": "¿Quieres enviar la aplicación a la bandeja del sistema o salir?", + "minimize": "Minimizar a la bandeja", + "quit": "Salir" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3aafbfb..fa77510 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1912,5 +1912,11 @@ "goIntegrations": "Aller à Intégrations", "goAccount": "Aller à Compte", "goSettings": "Aller à Paramètres" + }, + "closeConfirm": { + "title": "Fermer Donut Browser ?", + "description": "Voulez-vous envoyer l'application dans la zone de notification ou quitter ?", + "minimize": "Réduire dans la barre d'état", + "quit": "Quitter" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c7ff08f..f6e7bc1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1912,5 +1912,11 @@ "goIntegrations": "統合へ移動", "goAccount": "アカウントへ移動", "goSettings": "設定へ移動" + }, + "closeConfirm": { + "title": "Donut Browser を閉じますか?", + "description": "アプリをシステムトレイに格納しますか、それとも終了しますか?", + "minimize": "トレイに格納", + "quit": "終了" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index bda1801..58479e2 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1912,5 +1912,11 @@ "goIntegrations": "통합으로 이동", "goAccount": "계정으로 이동", "goSettings": "설정으로 이동" + }, + "closeConfirm": { + "title": "Donut Browser를 닫으시겠습니까?", + "description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?", + "minimize": "트레이로 최소화", + "quit": "종료" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ae38dff..9173e49 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1912,5 +1912,11 @@ "goIntegrations": "Ir para Integrações", "goAccount": "Ir para Conta", "goSettings": "Ir para Configurações" + }, + "closeConfirm": { + "title": "Fechar Donut Browser?", + "description": "Você deseja enviar o aplicativo para a bandeja do sistema ou sair?", + "minimize": "Minimizar para a bandeja", + "quit": "Sair" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 530b61c..38bb80f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1912,5 +1912,11 @@ "goIntegrations": "Перейти к Интеграциям", "goAccount": "Перейти к Аккаунту", "goSettings": "Перейти к Настройкам" + }, + "closeConfirm": { + "title": "Закрыть Donut Browser?", + "description": "Свернуть приложение в системный трей или выйти?", + "minimize": "Свернуть в трей", + "quit": "Выйти" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 881aa9d..8ad2e16 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1912,5 +1912,11 @@ "goIntegrations": "转到集成", "goAccount": "转到账户", "goSettings": "转到设置" + }, + "closeConfirm": { + "title": "关闭 Donut Browser?", + "description": "您想将应用最小化到系统托盘还是退出?", + "minimize": "最小化到托盘", + "quit": "退出" } }