feat(close): first-close choice — tray or quit — and a call guard (#5)
Build Lotus Chat Desktop / prepare (push) Successful in 10s
Build Lotus Chat Desktop / build-linux (push) Successful in 28m46s
Build Lotus Chat Desktop / build-arch (push) Successful in 14s
Build Lotus Chat Desktop / build-windows (push) Successful in 38m32s
Build Lotus Chat Desktop / update-manifest (push) Successful in 9s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 12:51:28 -04:00
co-authored by Claude Opus 5.5
parent d6a64695bf
commit 71ab1afdaf
3 changed files with 121 additions and 5 deletions
+1 -1
Submodule cinny updated: d5aa18e3ab...359c79a440
+117 -1
View File
@@ -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<Self> {
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<std::path::PathBuf> {
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::<TrayUnreadState>()
.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());
}
});
+3 -3
View File
@@ -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);
}