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.
This commit is contained in:
Huy Le Tien
2026-05-29 00:06:44 +07:00
parent 56c547d7e0
commit fdecf445ec
14 changed files with 244 additions and 3 deletions
+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": "退出"
}
}