mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-30 20:09:29 +02:00
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:
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: versiom bump
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.24.3 [skip ci] (#383)
|
||||
|
||||
|
||||
|
||||
Generated
+1
@@ -6606,6 +6606,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,5 +1912,11 @@
|
||||
"goIntegrations": "統合へ移動",
|
||||
"goAccount": "アカウントへ移動",
|
||||
"goSettings": "設定へ移動"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser を閉じますか?",
|
||||
"description": "アプリをシステムトレイに格納しますか、それとも終了しますか?",
|
||||
"minimize": "トレイに格納",
|
||||
"quit": "終了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,5 +1912,11 @@
|
||||
"goIntegrations": "통합으로 이동",
|
||||
"goAccount": "계정으로 이동",
|
||||
"goSettings": "설정으로 이동"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser를 닫으시겠습니까?",
|
||||
"description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?",
|
||||
"minimize": "트레이로 최소화",
|
||||
"quit": "종료"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,5 +1912,11 @@
|
||||
"goIntegrations": "Перейти к Интеграциям",
|
||||
"goAccount": "Перейти к Аккаунту",
|
||||
"goSettings": "Перейти к Настройкам"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Закрыть Donut Browser?",
|
||||
"description": "Свернуть приложение в системный трей или выйти?",
|
||||
"minimize": "Свернуть в трей",
|
||||
"quit": "Выйти"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,5 +1912,11 @@
|
||||
"goIntegrations": "转到集成",
|
||||
"goAccount": "转到账户",
|
||||
"goSettings": "转到设置"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "关闭 Donut Browser?",
|
||||
"description": "您想将应用最小化到系统托盘还是退出?",
|
||||
"minimize": "最小化到托盘",
|
||||
"quit": "退出"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user