diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs new file mode 100644 index 00000000..7b070c06 --- /dev/null +++ b/scripts/ui/close-confirm.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDir, '..', '..'); +const htmlPath = path.join(projectRoot, 'ui', 'close-confirm.html'); + +const readHtml = async () => readFile(htmlPath, 'utf8'); + +test('close confirm dialog uses locale copy as the single source of truth for button labels', async () => { + const html = await readHtml(); + + assert.match(html, /trayButton\.textContent = copy\.tray;/); + assert.match(html, /exitButton\.textContent = copy\.exit;/); +}); + +test('close confirm dialog avoids exposing raw invoke errors to users', async () => { + const html = await readHtml(); + + assert.doesNotMatch(html, /invokeError\.message/); + assert.match(html, /error\.textContent = copy\.submitError;/); +}); + +test('close confirm dialog routes Tauri command calls through a local invoke wrapper', async () => { + const html = await readHtml(); + + assert.match(html, /const invokeTauri =/); + assert.doesNotMatch(html, /window\.__TAURI_INTERNALS__\?\.invoke/); + assert.match(html, /await invokeTauri\(/); +}); + +test('close confirm dialog reads close action values from query params instead of hard-coded literals', async () => { + const html = await readHtml(); + + assert.match(html, /const trayAction = params\.get\("trayAction"\);/); + assert.match(html, /const exitAction = params\.get\("exitAction"\);/); + assert.doesNotMatch(html, /submit\("tray"\)/); + assert.doesNotMatch(html, /submit\("exit"\)/); +}); + +test('close confirm dialog only schedules frontend close fallback for tray actions', async () => { + const html = await readHtml(); + + assert.match(html, /if \(action === trayAction\) \{/); + assert.match(html, /recoveryTimer = window\.setTimeout\(/); + assert.match(html, /window\.close\(\);/); +}); + +test('close confirm dialog suppresses invoke teardown errors for exit actions', async () => { + const html = await readHtml(); + + assert.match(html, /catch \(_invokeError\) \{/); + assert.match(html, /if \(action === exitAction\) \{/); + assert.match(html, /return;/); +}); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c1130360..0f2d8f6a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "description": "Default IPC capability for the main window and loopback dashboard origin.", - "windows": ["main"], + "windows": ["main", "close-confirm"], "local": true, "remote": { "urls": ["http://127.0.0.1:*", "http://localhost:*"] diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 1aa8286c..c57f9c22 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -4,8 +4,9 @@ use tauri::{ }; use crate::{ - app_runtime_events, append_desktop_log, append_startup_log, bridge, lifecycle, startup_task, - tray, window, BackendState, DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE, STARTUP_MODE_ENV, + app_runtime_events, append_desktop_log, append_startup_log, bridge, close_behavior, lifecycle, + startup_task, tray, window, BackendState, DEFAULT_SHELL_LOCALE, DESKTOP_LOG_FILE, + STARTUP_MODE_ENV, }; fn configure_plugins(builder: Builder) -> Builder { @@ -22,24 +23,51 @@ fn configure_window_events(builder: Builder) -> Builder builder.on_window_event(|window, event| { let is_quitting = window.app_handle().state::().is_quitting(); let action = match &event { - WindowEvent::CloseRequested { .. } => app_runtime_events::main_window_action( - window.label(), - is_quitting, - false, - true, - false, - ), + WindowEvent::CloseRequested { .. } => { + let packaged_root_dir = crate::runtime_paths::default_packaged_root_dir(); + let saved_close_action = close_behavior::read_cached_close_action( + packaged_root_dir.as_deref(), + append_desktop_log, + ); + + app_runtime_events::main_window_action( + window.label(), + is_quitting, + false, + true, + false, + saved_close_action, + ) + } WindowEvent::Focused(false) => app_runtime_events::main_window_action( window.label(), is_quitting, matches!(window.is_minimized(), Ok(true)), false, true, + None, ), _ => app_runtime_events::MainWindowAction::None, }; match action { + app_runtime_events::MainWindowAction::ShowClosePrompt => { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + } + append_desktop_log( + "main window close requested without saved preference; close prompt pending", + ); + if let Err(error) = window::close_confirm::show_close_confirm_window( + window.app_handle(), + DEFAULT_SHELL_LOCALE, + append_desktop_log, + ) { + append_desktop_log(&format!( + "failed to open close confirm prompt window: {error}" + )); + } + } app_runtime_events::MainWindowAction::PreventCloseAndHide => { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -50,6 +78,12 @@ fn configure_window_events(builder: Builder) -> Builder append_desktop_log, ); } + app_runtime_events::MainWindowAction::ExitApplication => { + lifecycle::events::request_immediate_exit( + window.app_handle(), + lifecycle::events::ImmediateExitTrigger::SavedExitPreference, + ); + } app_runtime_events::MainWindowAction::HideIfMinimized => { window::actions::hide_main_window( window.app_handle(), @@ -170,6 +204,7 @@ pub(crate) fn run() { crate::bridge::commands::desktop_bridge_restart_backend, crate::bridge::commands::desktop_bridge_stop_backend, crate::bridge::commands::desktop_bridge_open_external_url, + crate::bridge::commands::desktop_bridge_submit_close_prompt, crate::bridge::commands::desktop_bridge_check_app_update, crate::bridge::commands::desktop_bridge_install_app_update ]) diff --git a/src-tauri/src/app_runtime_events.rs b/src-tauri/src/app_runtime_events.rs index d932706a..52d94eaf 100644 --- a/src-tauri/src/app_runtime_events.rs +++ b/src-tauri/src/app_runtime_events.rs @@ -1,9 +1,13 @@ use tauri::{webview::PageLoadEvent, RunEvent}; +use crate::close_behavior::CloseAction; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MainWindowAction { None, + ShowClosePrompt, PreventCloseAndHide, + ExitApplication, HideIfMinimized, } @@ -29,6 +33,7 @@ pub(crate) fn main_window_action( minimized_on_focus_lost: bool, is_close_requested: bool, is_focus_lost: bool, + saved_close_action: Option, ) -> MainWindowAction { if window_label != "main" { return MainWindowAction::None; @@ -38,7 +43,11 @@ pub(crate) fn main_window_action( return if is_quitting { MainWindowAction::None } else { - MainWindowAction::PreventCloseAndHide + match saved_close_action { + Some(CloseAction::Tray) => MainWindowAction::PreventCloseAndHide, + Some(CloseAction::Exit) => MainWindowAction::ExitApplication, + None => MainWindowAction::ShowClosePrompt, + } }; } @@ -93,6 +102,7 @@ mod tests { main_window_action, page_load_action, run_event_action, MainWindowAction, PageLoadAction, RunEventAction, }; + use crate::close_behavior::CloseAction; use tauri::{webview::PageLoadEvent, RunEvent}; #[cfg(target_os = "macos")] @@ -101,23 +111,39 @@ mod tests { #[test] fn main_window_action_ignores_non_main_windows() { assert_eq!( - main_window_action("settings", false, false, true, false), + main_window_action("settings", false, false, true, false, None), MainWindowAction::None ); } #[test] - fn main_window_action_hides_on_close_when_not_quitting() { + fn main_window_action_prompts_when_no_saved_close_preference_exists() { + assert_eq!( + main_window_action("main", false, false, true, false, None), + MainWindowAction::ShowClosePrompt + ); + } + + #[test] + fn main_window_action_hides_on_close_when_saved_preference_is_tray() { assert_eq!( - main_window_action("main", false, false, true, false), + main_window_action("main", false, false, true, false, Some(CloseAction::Tray)), MainWindowAction::PreventCloseAndHide ); } + #[test] + fn main_window_action_exits_on_close_when_saved_preference_is_exit() { + assert_eq!( + main_window_action("main", false, false, true, false, Some(CloseAction::Exit)), + MainWindowAction::ExitApplication + ); + } + #[test] fn main_window_action_hides_on_minimized_focus_loss() { assert_eq!( - main_window_action("main", false, true, false, true), + main_window_action("main", false, true, false, true, None), MainWindowAction::HideIfMinimized ); } diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index ddf848f4..edac05ff 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -13,9 +13,10 @@ use crate::bridge::updater_types::{ map_update_channel_ok, map_update_check_error, map_update_install_error, map_update_install_ok, DesktopAppUpdateChannelResult, DesktopAppUpdateCheckResult, DesktopAppUpdateResult, }; +use crate::close_behavior::{self, CloseAction}; use crate::{ append_desktop_log, restart_backend_flow, runtime_paths, shell_locale, tray, update_channel, - BackendBridgeResult, BackendBridgeState, BackendState, DEFAULT_SHELL_LOCALE, + window, BackendBridgeResult, BackendBridgeState, BackendState, DEFAULT_SHELL_LOCALE, }; fn resolve_update_channel(app_handle: &AppHandle) -> update_channel::UpdateChannel { @@ -160,6 +161,33 @@ fn parse_openable_url(raw_url: &str) -> Result { } } +fn parse_close_prompt_action(raw_action: &str) -> Result { + close_behavior::parse_close_action(raw_action).ok_or_else(|| { + format!( + "Invalid close action. Expected '{}' or '{}'.", + close_behavior::CLOSE_ACTION_TRAY, + close_behavior::CLOSE_ACTION_EXIT, + ) + }) +} + +fn finish_tray_close_prompt_cleanup( + cleanup_result: Result<(), String>, + log: Log, +) -> BackendBridgeResult +where + Log: Fn(&str), +{ + if let Err(error) = cleanup_result { + log(&format!("Failed to close confirm prompt window: {error}")); + } + + BackendBridgeResult { + ok: true, + reason: None, + } +} + #[cfg(target_os = "macos")] fn open_url_with_system_browser(url: &str) -> Result<(), String> { Command::new("open") @@ -287,6 +315,78 @@ pub(crate) fn desktop_bridge_open_external_url(url: String) -> BackendBridgeResu } } +#[tauri::command] +pub(crate) fn desktop_bridge_submit_close_prompt( + app_handle: AppHandle, + action: String, + remember: bool, +) -> BackendBridgeResult { + let action = match parse_close_prompt_action(&action) { + Ok(action) => action, + Err(error) => { + return BackendBridgeResult { + ok: false, + reason: Some(error), + }; + } + }; + + if remember { + let packaged_root_dir = runtime_paths::default_packaged_root_dir(); + if let Err(error) = close_behavior::write_cached_close_action( + Some(action), + packaged_root_dir.as_deref(), + append_desktop_log, + ) { + append_desktop_log(&format!( + "failed to persist remembered close action; aborting selected action: {error}" + )); + return BackendBridgeResult { + ok: false, + reason: Some(error), + }; + } + } + + match action { + CloseAction::Tray => { + window::actions::hide_main_window( + &app_handle, + DEFAULT_SHELL_LOCALE, + append_desktop_log, + ); + let cleanup_result = if let Some(prompt_window) = + app_handle.get_webview_window(window::close_confirm::CLOSE_CONFIRM_WINDOW_LABEL) + { + prompt_window.close().map_err(|error| error.to_string()) + } else { + Ok(()) + }; + + finish_tray_close_prompt_cleanup(cleanup_result, append_desktop_log) + } + CloseAction::Exit => { + if let Some(prompt_window) = + app_handle.get_webview_window(window::close_confirm::CLOSE_CONFIRM_WINDOW_LABEL) + { + if let Err(error) = prompt_window.close() { + append_desktop_log(&format!( + "Failed to close confirm prompt window before exit: {error}" + )); + } + } + crate::lifecycle::events::request_immediate_exit( + &app_handle, + crate::lifecycle::events::ImmediateExitTrigger::ClosePromptExitAction, + ); + BackendBridgeResult { + ok: true, + reason: None, + } + } + } +} + #[tauri::command] pub(crate) fn desktop_bridge_set_shell_locale( app_handle: AppHandle, diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs new file mode 100644 index 00000000..7d5c0362 --- /dev/null +++ b/src-tauri/src/close_behavior.rs @@ -0,0 +1,396 @@ +use serde::{de::IgnoredAny, Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value}; +use std::{fs, io::Write, path::Path}; + +pub(crate) const CLOSE_ACTION_TRAY: &str = "tray"; +pub(crate) const CLOSE_ACTION_EXIT: &str = "exit"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CloseAction { + Tray, + Exit, +} + +fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum RawCloseAction { + String(String), + Other(IgnoredAny), + } + + let raw = Option::::deserialize(deserializer)?; + Ok(match raw { + Some(RawCloseAction::String(raw)) => parse_close_action(&raw), + _ => None, + }) +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct DesktopState { + #[serde( + rename = "closeActionOnWindowClose", + default, + deserialize_with = "deserialize_close_action_option", + skip_serializing_if = "Option::is_none" + )] + close_action: Option, + + #[serde(flatten)] + rest: Map, +} + +pub(crate) fn parse_close_action(raw: &str) -> Option { + match raw { + CLOSE_ACTION_TRAY => Some(CloseAction::Tray), + CLOSE_ACTION_EXIT => Some(CloseAction::Exit), + _ => None, + } +} + +fn load_desktop_state(raw: &str, log_subject: &str, log: &F) -> DesktopState +where + F: Fn(&str), +{ + match serde_json::from_str::(raw) { + Ok(state) => state, + Err(error) => { + log(&format!( + "failed to parse {log_subject}: {error}. resetting state semantics" + )); + DesktopState::default() + } + } +} + +pub(crate) fn read_cached_close_action( + packaged_root_dir: Option<&Path>, + log: F, +) -> Option +where + F: Fn(&str), +{ + let state_path = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?; + read_cached_close_action_at_path(&state_path, &log) +} + +fn read_cached_close_action_at_path(state_path: &Path, log: &F) -> Option +where + F: Fn(&str), +{ + let raw = fs::read_to_string(state_path).ok()?; + let state = load_desktop_state(&raw, "desktop close behavior state", log); + state.close_action +} + +fn atomic_write_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { + if let Some(parent_dir) = path.parent() { + fs::create_dir_all(parent_dir).map_err(|error| { + format!( + "Failed to create desktop state directory {}: {}", + parent_dir.display(), + error + ) + })?; + } + + let serialized = serde_json::to_string_pretty(state) + .map_err(|error| format!("Failed to serialize desktop state: {error}"))?; + let tmp_name = format!( + "{}.tmp", + path.file_name() + .map(|value| value.to_string_lossy()) + .unwrap_or_default() + ); + let tmp_path = path.with_file_name(tmp_name); + + let mut file = fs::File::create(&tmp_path).map_err(|error| { + format!( + "Failed to create temporary desktop state file {}: {}", + tmp_path.display(), + error + ) + })?; + file.write_all(serialized.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|error| { + format!( + "Failed to write temporary desktop state file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + format!( + "Failed to atomically replace desktop state file {}: {}", + path.display(), + error + ) + }) +} + +fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { + atomic_write_desktop_state(path, state) +} + +pub(crate) fn write_cached_close_action( + action: Option, + packaged_root_dir: Option<&Path>, + log: F, +) -> Result<(), String> +where + F: Fn(&str), +{ + let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) + else { + let message = "close behavior state path is unavailable; skipping close action persistence"; + log(message); + return Err(message.to_string()); + }; + + write_cached_close_action_at_path(action, &state_path, &log) +} + +fn write_cached_close_action_at_path( + action: Option, + state_path: &Path, + log: &F, +) -> Result<(), String> +where + F: Fn(&str), +{ + let mut state = match fs::read_to_string(state_path) { + Ok(raw) => load_desktop_state( + &raw, + &format!("close behavior state {}", state_path.display()), + log, + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => DesktopState::default(), + Err(error) => { + return Err(format!( + "Failed to read close behavior state {}: {}", + state_path.display(), + error + )); + } + }; + state.close_action = action; + + save_desktop_state(state_path, &state)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + load_desktop_state, parse_close_action, read_cached_close_action_at_path, + write_cached_close_action_at_path, CloseAction, DesktopState, + }; + use serde_json::json; + use std::{fs, path::PathBuf}; + + fn noop_log(_: &str) {} + + fn state_path(temp_dir: &tempfile::TempDir) -> PathBuf { + temp_dir.path().join("data").join("desktop_state.json") + } + + #[test] + fn read_cached_close_action_returns_none_when_state_file_is_missing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + + assert_eq!( + read_cached_close_action_at_path(&state_path(&temp_dir), &noop_log), + None + ); + } + + #[test] + fn parse_close_action_accepts_tray_and_exit_only() { + assert_eq!(parse_close_action("tray"), Some(CloseAction::Tray)); + assert_eq!(parse_close_action("exit"), Some(CloseAction::Exit)); + } + + #[test] + fn parse_close_action_rejects_invalid_values() { + assert_eq!(parse_close_action(""), None); + assert_eq!(parse_close_action(" tray "), None); + assert_eq!(parse_close_action("minimize"), None); + assert_eq!(parse_close_action("TRAY"), None); + } + + #[test] + fn load_desktop_state_deserializes_close_action_and_preserves_other_fields() { + let state = load_desktop_state( + r#"{"closeActionOnWindowClose":"tray","locale":"zh-CN"}"#, + "test desktop state", + &noop_log, + ); + + assert_eq!(state.close_action, Some(CloseAction::Tray)); + assert_eq!(state.rest.get("locale"), Some(&json!("zh-CN"))); + } + + #[test] + fn load_desktop_state_treats_invalid_close_action_as_none_without_dropping_rest() { + let state = load_desktop_state( + r#"{"closeActionOnWindowClose":"bogus","locale":"en-US"}"#, + "test desktop state", + &noop_log, + ); + + assert_eq!(state.close_action, None); + assert_eq!(state.rest.get("locale"), Some(&json!("en-US"))); + } + + #[test] + fn load_desktop_state_treats_non_string_close_action_as_none_without_dropping_rest() { + let state = load_desktop_state( + r#"{"closeActionOnWindowClose":true,"locale":"en-US"}"#, + "test desktop state", + &noop_log, + ); + + assert_eq!(state.close_action, None); + assert_eq!(state.rest.get("locale"), Some(&json!("en-US"))); + } + + #[test] + fn desktop_state_serialization_omits_close_action_when_none() { + let mut state = DesktopState::default(); + state.rest.insert("locale".to_string(), json!("en-US")); + + let serialized = serde_json::to_value(&state).expect("serialize desktop state"); + + assert_eq!(serialized, json!({ "locale": "en-US" })); + } + + #[test] + fn load_desktop_state_reports_parse_failures_through_callback() { + let logs = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let captured_logs = std::rc::Rc::clone(&logs); + + let state = load_desktop_state("[", "test desktop state", &move |message: &str| { + captured_logs.borrow_mut().push(message.to_string()); + }); + + assert_eq!(state.close_action, None); + assert_eq!(logs.borrow().len(), 1); + assert!(logs.borrow()[0].contains("failed to parse test desktop state")); + } + + #[test] + fn write_cached_close_action_preserves_unrelated_state_fields() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let state_path = state_path(&temp_dir); + fs::create_dir_all(state_path.parent().expect("state parent")).expect("create state dir"); + fs::write( + &state_path, + serde_json::to_string_pretty(&json!({ + "locale": "en-US", + "nested": { "enabled": true } + })) + .expect("serialize state"), + ) + .expect("write state"); + + write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path, &noop_log) + .expect("write close action"); + + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&state_path).expect("read updated state")) + .expect("parse updated state"); + + assert_eq!(saved.get("closeActionOnWindowClose"), Some(&json!("tray"))); + assert_eq!(saved.get("locale"), Some(&json!("en-US"))); + assert_eq!(saved.get("nested"), Some(&json!({ "enabled": true }))); + } + + #[test] + fn write_cached_close_action_resets_malformed_state_to_object() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let state_path = state_path(&temp_dir); + fs::create_dir_all(state_path.parent().expect("state parent")).expect("create state dir"); + fs::write(&state_path, "[").expect("write malformed state"); + + write_cached_close_action_at_path(Some(CloseAction::Exit), &state_path, &noop_log) + .expect("write close action"); + + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&state_path).expect("read updated state")) + .expect("parse updated state"); + + assert_eq!(saved, json!({ "closeActionOnWindowClose": "exit" })); + } + + #[test] + fn read_cached_close_action_returns_saved_value() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let state_path = state_path(&temp_dir); + + write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path, &noop_log) + .expect("write close action"); + + assert_eq!( + read_cached_close_action_at_path(&state_path, &noop_log), + Some(CloseAction::Tray) + ); + } + + #[test] + fn write_cached_close_action_none_removes_only_close_action_field() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let state_path = state_path(&temp_dir); + fs::create_dir_all(state_path.parent().expect("state parent")).expect("create state dir"); + fs::write( + &state_path, + serde_json::to_string_pretty(&json!({ + "closeActionOnWindowClose": "exit", + "locale": "zh-CN" + })) + .expect("serialize state"), + ) + .expect("write state"); + + write_cached_close_action_at_path(None, &state_path, &noop_log) + .expect("clear close action"); + + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&state_path).expect("read updated state")) + .expect("parse updated state"); + + assert_eq!(saved.get("closeActionOnWindowClose"), None); + assert_eq!(saved.get("locale"), Some(&json!("zh-CN"))); + } + + #[test] + fn read_cached_close_action_treats_malformed_state_as_empty_object() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let state_path = state_path(&temp_dir); + fs::create_dir_all(state_path.parent().expect("state parent")).expect("create state dir"); + fs::write(&state_path, "[").expect("write malformed state"); + + assert_eq!( + read_cached_close_action_at_path(&state_path, &noop_log), + None + ); + } + + #[test] + fn write_cached_close_action_errors_when_state_path_is_unavailable() { + let result = super::write_cached_close_action(Some(CloseAction::Tray), None, noop_log); + + assert_eq!( + result, + Err( + "close behavior state path is unavailable; skipping close action persistence" + .to_string() + ) + ); + } +} diff --git a/src-tauri/src/lifecycle/events.rs b/src-tauri/src/lifecycle/events.rs index 00ae1acf..2d1b7a63 100644 --- a/src-tauri/src/lifecycle/events.rs +++ b/src-tauri/src/lifecycle/events.rs @@ -2,6 +2,13 @@ use tauri::{AppHandle, Manager}; use crate::{append_shutdown_log, lifecycle::cleanup, BackendState}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ImmediateExitTrigger { + SavedExitPreference, + ClosePromptExitAction, + TrayQuitRequest, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ExitRequestedDecision { AllowImmediateExit, @@ -16,6 +23,23 @@ fn decide_exit_requested_flow(has_exit_request_allowance: bool) -> ExitRequested } } +pub(crate) fn immediate_exit_log_message(trigger: ImmediateExitTrigger) -> &'static str { + match trigger { + ImmediateExitTrigger::SavedExitPreference => { + "main window close requested with saved exit preference" + } + ImmediateExitTrigger::ClosePromptExitAction => "close prompt requested desktop exit", + ImmediateExitTrigger::TrayQuitRequest => "tray quit requested, exiting desktop process", + } +} + +pub(crate) fn request_immediate_exit(app_handle: &AppHandle, trigger: ImmediateExitTrigger) { + let state = app_handle.state::(); + state.mark_quitting(); + append_shutdown_log(immediate_exit_log_message(trigger)); + app_handle.exit(0); +} + pub fn handle_exit_requested(app_handle: &AppHandle, api: &tauri::ExitRequestApi) { let state = app_handle.state::(); match decide_exit_requested_flow(state.take_exit_request_allowance()) { @@ -68,7 +92,10 @@ pub fn handle_exit_event(app_handle: &AppHandle) { #[cfg(test)] mod tests { - use super::{decide_exit_requested_flow, ExitRequestedDecision}; + use super::{ + decide_exit_requested_flow, immediate_exit_log_message, ExitRequestedDecision, + ImmediateExitTrigger, + }; #[test] fn decide_exit_requested_flow_allows_immediate_exit_when_allowance_exists() { @@ -85,4 +112,20 @@ mod tests { ExitRequestedDecision::RunBackendCleanupFirst ); } + + #[test] + fn immediate_exit_log_message_matches_all_immediate_exit_triggers() { + assert_eq!( + immediate_exit_log_message(ImmediateExitTrigger::SavedExitPreference), + "main window close requested with saved exit preference" + ); + assert_eq!( + immediate_exit_log_message(ImmediateExitTrigger::ClosePromptExitAction), + "close prompt requested desktop exit" + ); + assert_eq!( + immediate_exit_log_message(ImmediateExitTrigger::TrayQuitRequest), + "tray quit requested, exiting desktop process" + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe7d3d44..84edf9f1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod app_types; mod backend; mod bridge; +mod close_behavior; mod desktop_state; mod exit_state; diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index 7761868c..38c661b6 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -1,7 +1,7 @@ use tauri::{AppHandle, Manager}; use crate::{ - append_desktop_log, append_restart_log, append_shutdown_log, restart_backend_flow, + append_desktop_log, append_restart_log, lifecycle, restart_backend_flow, tray::{actions, bridge_event}, ui_dispatch, window, BackendState, DEFAULT_SHELL_LOCALE, TRAY_RESTART_BACKEND_EVENT, }; @@ -72,10 +72,10 @@ pub fn handle_tray_menu_event(app_handle: &AppHandle, menu_id: &str) { }); } Some(actions::TrayMenuAction::Quit) => { - let state = app_handle.state::(); - state.mark_quitting(); - append_shutdown_log("tray quit requested, exiting desktop process"); - app_handle.exit(0); + lifecycle::events::request_immediate_exit( + app_handle, + lifecycle::events::ImmediateExitTrigger::TrayQuitRequest, + ); } None => {} } diff --git a/src-tauri/src/window/close_confirm.rs b/src-tauri/src/window/close_confirm.rs new file mode 100644 index 00000000..802437e0 --- /dev/null +++ b/src-tauri/src/window/close_confirm.rs @@ -0,0 +1,130 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +pub(crate) const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm"; +const CLOSE_CONFIRM_WINDOW_WIDTH: f64 = 404.0; +const CLOSE_CONFIRM_WINDOW_HEIGHT: f64 = 292.0; + +pub(crate) fn build_close_confirm_path(locale: &str) -> String { + format!( + "close-confirm.html?locale={locale}&trayAction={}&exitAction={}", + crate::close_behavior::CLOSE_ACTION_TRAY, + crate::close_behavior::CLOSE_ACTION_EXIT, + ) +} + +fn build_close_confirm_url(locale: &str) -> WebviewUrl { + WebviewUrl::App(build_close_confirm_path(locale).into()) +} + +pub(crate) fn close_confirm_window_size() -> (f64, f64) { + (CLOSE_CONFIRM_WINDOW_WIDTH, CLOSE_CONFIRM_WINDOW_HEIGHT) +} + +fn handle_existing_window_operation_result( + operation_result: Result<(), String>, + operation: &str, + log: F, +) -> Result<(), String> +where + F: Fn(&str), +{ + match operation_result { + Ok(()) => Ok(()), + Err(error) => { + let message = format!("failed to {operation} close confirm window: {error}"); + log(&message); + Err(message) + } + } +} + +pub(crate) fn show_close_confirm_window( + app_handle: &AppHandle, + default_shell_locale: &'static str, + log: F, +) -> Result<(), String> +where + F: Fn(&str), +{ + if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) { + handle_existing_window_operation_result( + window.unminimize().map_err(|error| error.to_string()), + "unminimize", + &log, + )?; + handle_existing_window_operation_result( + window.show().map_err(|error| error.to_string()), + "show", + &log, + )?; + handle_existing_window_operation_result( + window.set_focus().map_err(|error| error.to_string()), + "focus", + &log, + )?; + return Ok(()); + } + + let locale = crate::shell_locale::resolve_shell_locale( + default_shell_locale, + crate::runtime_paths::default_packaged_root_dir(), + ); + let url = build_close_confirm_url(locale); + let (width, height) = close_confirm_window_size(); + + WebviewWindowBuilder::new(app_handle, CLOSE_CONFIRM_WINDOW_LABEL, url) + .title("AstrBot") + .inner_size(width, height) + .resizable(false) + .maximizable(false) + .minimizable(false) + .visible(true) + .center() + .build() + .map(|_| ()) + .map_err(|error| format!("Failed to create close confirm window: {error}")) +} + +#[cfg(test)] +mod tests { + use super::{ + build_close_confirm_path, close_confirm_window_size, + handle_existing_window_operation_result, + }; + + #[test] + fn build_close_confirm_path_appends_locale_query() { + assert_eq!( + build_close_confirm_path("en-US"), + "close-confirm.html?locale=en-US&trayAction=tray&exitAction=exit" + ); + } + + #[test] + fn close_confirm_window_size_fits_desktop_dialog_without_clipping() { + assert_eq!(close_confirm_window_size(), (404.0, 292.0)); + } + + #[test] + fn handle_existing_window_operation_result_returns_err_after_logging_failure() { + let logs = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let captured_logs = std::rc::Rc::clone(&logs); + + let result = handle_existing_window_operation_result( + Err("focus failed".to_string()), + "focus", + move |message: &str| { + captured_logs.borrow_mut().push(message.to_string()); + }, + ); + + assert_eq!( + result, + Err("failed to focus close confirm window: focus failed".to_string()) + ); + assert_eq!( + logs.borrow().as_slice(), + ["failed to focus close confirm window: focus failed"] + ); + } +} diff --git a/src-tauri/src/window/mod.rs b/src-tauri/src/window/mod.rs index bf143683..d311905c 100644 --- a/src-tauri/src/window/mod.rs +++ b/src-tauri/src/window/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod actions; +pub(crate) mod close_confirm; pub(crate) mod main_window; pub(crate) mod startup_loading; diff --git a/ui/close-confirm.html b/ui/close-confirm.html new file mode 100644 index 00000000..5fca9bf1 --- /dev/null +++ b/ui/close-confirm.html @@ -0,0 +1,279 @@ + + + + + + AstrBot + + + +
+
+

+

+ +
+ + +
+
+
+
+ + +