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
+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>
);
}