From 71ab1afdaf4fd2f10958095b529b818cfc364800 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 24 Sep 2026 12:51:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(close):=20first-close=20choice=20=E2=80=94?= =?UTF-8?q?=20tray=20or=20quit=20=E2=80=94=20and=20a=20call=20guard=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the window (OS close button or the custom title bar's ×) now goes through one handler: - in a call → always hide to the tray (a close must never drop a call); - saved "tray" → hide; "quit" → exit the app; - "ask" (the default until chosen) → emit `lotus-close-requested` so the web client shows the one-time dialog; if the page isn't listening yet, fall back to the tray. The choice lives in `close-behavior` in the app config dir; get_close_behavior / set_close_behavior / resolve_close_request back the dialog and the Settings select. Verified on Linux under Xvfb + openbox with real WM close requests (wmctrl -c): tray → hidden, still running; quit → exits; unset with no page listening → hidden; ask with the page listening → window stays, page receives the event; quit during a call → hidden, still running. Bump cinny (the dialog + Settings control). Closes #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- cinny | 2 +- src-tauri/src/lib.rs | 118 ++++++++++++++++++++++++++++++++- src-tauri/src/native/chrome.rs | 6 +- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/cinny b/cinny index d5aa18e..359c79a 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit d5aa18e3ab0a83915a007bbed118e8ea0068428c +Subproject commit 359c79a440f61f3c99d7ffa0ec054ee553c61acb diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca40c5a..0aac989 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -618,6 +618,119 @@ fn set_start_minimized(app: tauri::AppHandle, value: bool) -> Result<(), String> std::fs::write(path, if value { "1" } else { "0" }).map_err(|e| e.to_string()) } +/// cinny-desktop #5: what closing the window does. `Ask` until the user has +/// chosen (the web client shows a one-time dialog); then `Tray` or `Quit`. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CloseBehavior { + Ask, + Tray, + Quit, +} + +impl CloseBehavior { + fn as_str(self) -> &'static str { + match self { + CloseBehavior::Ask => "ask", + CloseBehavior::Tray => "tray", + CloseBehavior::Quit => "quit", + } + } + fn parse(value: &str) -> Option { + match value.trim() { + "tray" => Some(CloseBehavior::Tray), + "quit" => Some(CloseBehavior::Quit), + "ask" => Some(CloseBehavior::Ask), + _ => None, + } + } +} + +/// Set once the web client has asked for the close behavior, i.e. it is +/// listening for `lotus-close-requested`. Before that an `Ask` close just goes +/// to the tray (there is nobody to show the dialog). +static CLOSE_DIALOG_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn close_behavior_file(app: &tauri::AppHandle) -> Option { + app.path() + .app_config_dir() + .ok() + .map(|dir| dir.join("close-behavior")) +} + +fn read_close_behavior(app: &tauri::AppHandle) -> CloseBehavior { + close_behavior_file(app) + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|value| CloseBehavior::parse(&value)) + .unwrap_or(CloseBehavior::Ask) +} + +fn write_close_behavior(app: &tauri::AppHandle, behavior: CloseBehavior) -> Result<(), String> { + let path = close_behavior_file(app).ok_or("no app config dir")?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(path, behavior.as_str()).map_err(|e| e.to_string()) +} + +fn in_call(app: &tauri::AppHandle) -> bool { + app.try_state::() + .and_then(|s| s.indicators.lock().ok().map(|i| i.call != TrayCall::None)) + .unwrap_or(false) +} + +fn hide_to_tray(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } +} + +/// Shared by the OS close button (CloseRequested) and the custom title bar's +/// close. A call in progress always goes to the tray so it isn't dropped. +pub(crate) fn handle_close_request(app: &tauri::AppHandle) { + if in_call(app) { + hide_to_tray(app); + return; + } + match read_close_behavior(app) { + CloseBehavior::Tray => hide_to_tray(app), + CloseBehavior::Quit => app.exit(0), + CloseBehavior::Ask => { + if CLOSE_DIALOG_READY.load(std::sync::atomic::Ordering::SeqCst) { + native::emit_to_web(app, "lotus-close-requested", "{}"); + } else { + hide_to_tray(app); + } + } + } +} + +/// The saved close behavior ("ask" | "tray" | "quit"). Calling it also marks +/// the web client as ready to show the first-close dialog. +#[tauri::command] +fn get_close_behavior(app: tauri::AppHandle) -> String { + CLOSE_DIALOG_READY.store(true, std::sync::atomic::Ordering::SeqCst); + read_close_behavior(&app).as_str().to_string() +} + +/// Save the close behavior (from Settings), without closing anything. +#[tauri::command] +fn set_close_behavior(app: tauri::AppHandle, value: String) -> Result<(), String> { + let behavior = CloseBehavior::parse(&value).ok_or("expected ask, tray or quit")?; + write_close_behavior(&app, behavior) +} + +/// The first-close dialog's answer: save it and carry out the close. +#[tauri::command] +fn resolve_close_request(app: tauri::AppHandle, value: String) -> Result<(), String> { + let behavior = CloseBehavior::parse(&value).ok_or("expected tray or quit")?; + write_close_behavior(&app, behavior)?; + match behavior { + CloseBehavior::Quit => app.exit(0), + _ => hide_to_tray(&app), + } + Ok(()) +} + /// Paint a small white-ringed dot of `color` into the bottom-right (or, with /// `left`, bottom-left) corner of an RGBA buffer, in place. Cross-platform /// (operates on raw pixels). @@ -815,6 +928,9 @@ pub fn run() { set_tray_update_ready, set_tray_call_state, set_taskbar_progress, + get_close_behavior, + set_close_behavior, + resolve_close_request, get_start_minimized, set_start_minimized, flash_window, @@ -1050,7 +1166,7 @@ pub fn run() { // Mark the window state as settled so the failsafe below can't // re-show a window the user just closed to the tray. settled_close.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = window_for_close.hide(); + handle_close_request(window_for_close.app_handle()); } }); diff --git a/src-tauri/src/native/chrome.rs b/src-tauri/src/native/chrome.rs index 72990bf..fb129e9 100644 --- a/src-tauri/src/native/chrome.rs +++ b/src-tauri/src/native/chrome.rs @@ -79,7 +79,7 @@ pub fn window_start_drag(app: AppHandle) { /// single explicit quit path. #[tauri::command] pub fn window_close(app: AppHandle) { - if let Some(window) = app.get_webview_window("main") { - let _ = window.hide(); - } + // Same rules as the OS close button (tray / quit / first-close dialog, + // cinny-desktop #5). + crate::handle_close_request(&app); }