Merge pull request #391 from huy97/feature/close-confirm-tray-dialog

feat: confirm minimize-to-tray or quit when closing the window
This commit is contained in:
andy
2026-05-28 17:54:09 -07:00
committed by GitHub
13 changed files with 243 additions and 2 deletions
+1
View File
@@ -6606,6 +6606,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
+1 -1
View File
@@ -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"
+113 -1
View File
@@ -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<Vec<String>> = 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<String> = 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,
+2
View File
@@ -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 (
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
<CloseConfirmDialog />
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
searchQuery={searchQuery}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RippleButton } from "./ui/ripple";
export function CloseConfirmDialog() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("closeConfirm.title")}</DialogTitle>
<DialogDescription>{t("closeConfirm.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
void handleMinimize();
}}
>
{t("closeConfirm.minimize")}
</RippleButton>
<RippleButton
variant="destructive"
onClick={() => {
void handleQuit();
}}
>
{t("closeConfirm.quit")}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -1912,5 +1912,11 @@
"goIntegrations": "統合へ移動",
"goAccount": "アカウントへ移動",
"goSettings": "設定へ移動"
},
"closeConfirm": {
"title": "Donut Browser を閉じますか?",
"description": "アプリをシステムトレイに格納しますか、それとも終了しますか?",
"minimize": "トレイに格納",
"quit": "終了"
}
}
+6
View File
@@ -1912,5 +1912,11 @@
"goIntegrations": "통합으로 이동",
"goAccount": "계정으로 이동",
"goSettings": "설정으로 이동"
},
"closeConfirm": {
"title": "Donut Browser를 닫으시겠습니까?",
"description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?",
"minimize": "트레이로 최소화",
"quit": "종료"
}
}
+6
View File
@@ -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"
}
}
+6
View File
@@ -1912,5 +1912,11 @@
"goIntegrations": "Перейти к Интеграциям",
"goAccount": "Перейти к Аккаунту",
"goSettings": "Перейти к Настройкам"
},
"closeConfirm": {
"title": "Закрыть Donut Browser?",
"description": "Свернуть приложение в системный трей или выйти?",
"minimize": "Свернуть в трей",
"quit": "Выйти"
}
}
+6
View File
@@ -1912,5 +1912,11 @@
"goIntegrations": "转到集成",
"goAccount": "转到账户",
"goSettings": "转到设置"
},
"closeConfirm": {
"title": "关闭 Donut Browser",
"description": "您想将应用最小化到系统托盘还是退出?",
"minimize": "最小化到托盘",
"quit": "退出"
}
}