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": "退出" } }