From fd71afb4e0f38be3bc958689f63d65f04f4c1df0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:44:40 +0000 Subject: [PATCH 01/16] chore(version): sync desktop version to v4.23.0-beta.1 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index addac5df..d2eb82e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astrbot-desktop-tauri", - "version": "4.22.3", + "version": "4.23.0-beta.1", "description": "AstrBot desktop shell powered by Tauri", "private": true, "packageManager": "pnpm@10.28.2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9a791083..d27af0ae 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -58,7 +58,7 @@ dependencies = [ [[package]] name = "astrbot-desktop-tauri" -version = "4.22.3" +version = "4.23.0-beta.1" dependencies = [ "chrono", "home", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 472a6a41..3b726cff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "astrbot-desktop-tauri" -version = "4.22.3" +version = "4.23.0-beta.1" description = "AstrBot desktop shell powered by Tauri" authors = ["AstrBot"] license = "AGPL-3.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f3095f50..bc46f76a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "AstrBot", - "version": "4.22.3", + "version": "4.23.0-beta.1", "identifier": "com.astrbot.desktop.tauri", "build": { "beforeDevCommand": "", From 4b9e443702fe0f96c2790ee427f8457a9fe09cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Fri, 10 Apr 2026 15:26:34 +0900 Subject: [PATCH 02/16] fix: add backend startup heartbeat liveness probe (#114) * fix: add backend startup heartbeat liveness probe * fix: tighten startup heartbeat validation * refactor: centralize startup heartbeat metadata * fix: surface heartbeat invalidation sooner * fix: harden startup heartbeat parsing * fix: warn on stop-time heartbeat failures * refactor: simplify startup heartbeat control flow * refactor: flatten readiness heartbeat helpers * refactor: clarify heartbeat helper responsibilities * docs: clarify startup heartbeat path coupling * fix: harden startup heartbeat coordination * fix: make startup heartbeat checks monotonic * fix: clean up heartbeat test and exit handling --- docs/environment-variables.md | 4 +- scripts/backend/templates/launch_backend.py | 131 +++++- .../backend/templates/test_launch_backend.py | 136 ++++++ src-tauri/src/app_constants.rs | 12 +- src-tauri/src/app_helpers.rs | 1 + src-tauri/src/app_types.rs | 1 + src-tauri/src/backend/config.rs | 87 ++++ src-tauri/src/backend/launch.rs | 5 +- src-tauri/src/backend/readiness.rs | 389 ++++++++++++++++-- src-tauri/src/backend/restart.rs | 1 + src-tauri/src/backend/runtime.rs | 22 +- src-tauri/src/desktop_state.rs | 2 +- src-tauri/src/launch_plan.rs | 62 ++- src-tauri/src/logging.rs | 4 +- src-tauri/src/update_channel.rs | 8 +- 15 files changed, 805 insertions(+), 60 deletions(-) create mode 100644 scripts/backend/templates/test_launch_backend.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 97738dd9..cbea4c6a 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -9,7 +9,8 @@ | --- | --- | --- | | `ASTRBOT_BACKEND_URL` | 后端基础 URL | 默认 `http://127.0.0.1:6185/` | | `ASTRBOT_BACKEND_AUTO_START` | 是否自动拉起后端 | 默认 `1`(启用) | -| `ASTRBOT_BACKEND_TIMEOUT_MS` | 后端就绪等待超时 | 开发模式默认 `20000`;打包模式默认回退 `300000` | +| `ASTRBOT_BACKEND_TIMEOUT_MS` | 后端就绪等待超时 | 开发模式默认 `20000`;打包模式默认回退 `900000` | +| `ASTRBOT_BACKEND_STARTUP_IDLE_TIMEOUT_MS` | 后端启动 heartbeat 空闲超时 | 默认 `60000`,范围 `5000~900000` | | `ASTRBOT_BACKEND_READY_HTTP_PATH` | 就绪探针 HTTP 路径 | 默认 `/api/stat/start-time` | | `ASTRBOT_BACKEND_READY_PROBE_TIMEOUT_MS` | 就绪探针单次超时 | 默认回退到 `ASTRBOT_BACKEND_PING_TIMEOUT_MS` | | `ASTRBOT_BACKEND_READY_POLL_INTERVAL_MS` | 就绪轮询间隔 | 默认 `300`,并按边界 clamp | @@ -53,6 +54,7 @@ | 变量 | 用途 | 默认值/行为 | | --- | --- | --- | | `ASTRBOT_DESKTOP_CLIENT` | 标记桌面客户端环境 | 打包态启动后端时写入 `1` | +| `ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH` | 桌面端写给后端启动器的 heartbeat 文件路径 | 打包态默认写到 `ASTRBOT_ROOT/data/backend-startup-heartbeat.json` | ## 4. 发布/CI(GitHub Actions) diff --git a/scripts/backend/templates/launch_backend.py b/scripts/backend/templates/launch_backend.py index ce31c4f0..72a48f23 100644 --- a/scripts/backend/templates/launch_backend.py +++ b/scripts/backend/templates/launch_backend.py @@ -1,14 +1,22 @@ from __future__ import annotations +import atexit import ctypes +import json import os import runpy import sys +import threading +import time from pathlib import Path BACKEND_DIR = Path(__file__).resolve().parent APP_DIR = BACKEND_DIR / "app" _WINDOWS_DLL_DIRECTORY_HANDLES: list[object] = [] +# Keep this in sync with BACKEND_STARTUP_HEARTBEAT_PATH_ENV in src-tauri/src/app_constants.rs. +STARTUP_HEARTBEAT_ENV = "ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH" +STARTUP_HEARTBEAT_INTERVAL_SECONDS = 2.0 +STARTUP_HEARTBEAT_STOP_JOIN_TIMEOUT_SECONDS = 1.0 def configure_stdio_utf8() -> None: @@ -113,15 +121,120 @@ def preload_windows_runtime_dlls() -> None: continue -configure_stdio_utf8() -configure_windows_dll_search_path() -preload_windows_runtime_dlls() +def resolve_startup_heartbeat_path() -> Path | None: + raw = os.environ.get(STARTUP_HEARTBEAT_ENV, "").strip() + if not raw: + return None + return Path(raw) -sys.path.insert(0, str(APP_DIR)) -main_file = APP_DIR / "main.py" -if not main_file.is_file(): - raise FileNotFoundError(f"Backend entrypoint not found: {main_file}") +def build_heartbeat_payload(state: str) -> dict[str, object]: + return { + "pid": os.getpid(), + "state": state, + "updated_at_ms": int(time.time() * 1000), + } -sys.argv[0] = str(main_file) -runpy.run_path(str(main_file), run_name="__main__") + +def atomic_write_json(path: Path, payload: dict[str, object]) -> None: + temp_path = path.with_name(f"{path.name}.tmp") + temp_path.write_text( + json.dumps(payload, separators=(",", ":")), + encoding="utf-8", + ) + try: + temp_path.replace(path) + except Exception: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + raise + + +def write_startup_heartbeat( + path: Path, state: str, *, warn_on_error: bool = False +) -> bool: + try: + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, build_heartbeat_payload(state)) + return True + except Exception as exc: + if warn_on_error: + print( + f"[startup-heartbeat] failed to write heartbeat to {path}: {exc.__class__.__name__}: {exc}", + file=sys.stderr, + ) + return False + + +def heartbeat_loop( + path: Path, interval_seconds: float, stop_event: threading.Event +) -> None: + # At least one successful write has happened. + had_successful_write = False + # A warning has already been emitted since the last successful write. + warning_emitted_since_last_success = False + + def should_warn() -> bool: + # Before the first successful heartbeat we want every failure to surface so startup + # path/permission issues stay visible. After a success, only warn on the first failure in + # each consecutive failure run to avoid log spam. + return (not had_successful_write) or (not warning_emitted_since_last_success) + + ok = write_startup_heartbeat(path, "starting", warn_on_error=True) + if ok: + had_successful_write = True + else: + warning_emitted_since_last_success = True + + while not stop_event.wait(interval_seconds): + warn_now = should_warn() + ok = write_startup_heartbeat(path, "starting", warn_on_error=warn_now) + if ok: + had_successful_write = True + warning_emitted_since_last_success = False + elif warn_now: + warning_emitted_since_last_success = True + + +def start_startup_heartbeat() -> None: + heartbeat_path = resolve_startup_heartbeat_path() + if heartbeat_path is None: + return + + stop_event = threading.Event() + thread = threading.Thread( + target=heartbeat_loop, + args=(heartbeat_path, STARTUP_HEARTBEAT_INTERVAL_SECONDS, stop_event), + name="astrbot-startup-heartbeat", + daemon=True, + ) + + def on_exit() -> None: + stop_event.set() + thread.join(timeout=STARTUP_HEARTBEAT_STOP_JOIN_TIMEOUT_SECONDS) + write_startup_heartbeat(heartbeat_path, "stopping", warn_on_error=True) + + thread.start() + atexit.register(on_exit) + + +def main() -> None: + configure_stdio_utf8() + configure_windows_dll_search_path() + preload_windows_runtime_dlls() + start_startup_heartbeat() + + sys.path.insert(0, str(APP_DIR)) + + main_file = APP_DIR / "main.py" + if not main_file.is_file(): + raise FileNotFoundError(f"Backend entrypoint not found: {main_file}") + + sys.argv[0] = str(main_file) + runpy.run_path(str(main_file), run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/scripts/backend/templates/test_launch_backend.py b/scripts/backend/templates/test_launch_backend.py new file mode 100644 index 00000000..0fc501a8 --- /dev/null +++ b/scripts/backend/templates/test_launch_backend.py @@ -0,0 +1,136 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("launch_backend.py") +SPEC = importlib.util.spec_from_file_location("launch_backend_under_test", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Cannot load launch_backend module from {MODULE_PATH}") +launch_backend = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(launch_backend) + + +class StartupHeartbeatTests(unittest.TestCase): + def test_atomic_write_json_cleans_up_temp_file_when_replace_fails(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + heartbeat_path = Path(temp_dir) / "heartbeat.json" + temp_path = heartbeat_path.with_name(f"{heartbeat_path.name}.tmp") + + with mock.patch.object( + Path, + "replace", + autospec=True, + side_effect=OSError("replace failed"), + ): + with self.assertRaises(OSError): + launch_backend.atomic_write_json( + heartbeat_path, + {"pid": 42, "state": "starting", "updated_at_ms": 5000}, + ) + + self.assertFalse(temp_path.exists()) + + def test_repeated_failures_warn_before_first_success(self) -> None: + stop_event = mock.Mock() + stop_event.wait.side_effect = [False, True] + + with mock.patch.object( + launch_backend, + "write_startup_heartbeat", + side_effect=[False, False], + ) as write_mock: + launch_backend.heartbeat_loop(Path("/tmp/heartbeat.json"), 2.0, stop_event) + + self.assertEqual( + [call.kwargs["warn_on_error"] for call in write_mock.call_args_list], + [True, True], + ) + + def test_repeated_failures_after_success_are_suppressed(self) -> None: + stop_event = mock.Mock() + stop_event.wait.side_effect = [False, False, True] + + with mock.patch.object( + launch_backend, + "write_startup_heartbeat", + side_effect=[True, False, False], + ) as write_mock: + launch_backend.heartbeat_loop(Path("/tmp/heartbeat.json"), 2.0, stop_event) + + self.assertEqual( + [call.kwargs["warn_on_error"] for call in write_mock.call_args_list], + [True, True, False], + ) + + def test_stop_failure_still_warns_after_earlier_failure(self) -> None: + stop_event = mock.Mock() + thread = mock.Mock() + register = mock.Mock() + + with mock.patch.object( + launch_backend, + "write_startup_heartbeat", + return_value=False, + ) as write_mock: + with mock.patch.object( + launch_backend, + "resolve_startup_heartbeat_path", + return_value=Path("/tmp/heartbeat.json"), + ): + with mock.patch.object( + launch_backend.threading, "Event", return_value=stop_event + ): + with mock.patch.object( + launch_backend.threading, "Thread", return_value=thread + ): + with mock.patch.object( + launch_backend.atexit, "register", register + ): + launch_backend.start_startup_heartbeat() + thread.join.assert_not_called() + on_exit = register.call_args.args[0] + on_exit() + + thread.join.assert_called_once_with( + timeout=launch_backend.STARTUP_HEARTBEAT_STOP_JOIN_TIMEOUT_SECONDS + ) + self.assertEqual( + [call.args[1] for call in write_mock.call_args_list], + ["stopping"], + ) + self.assertEqual( + [call.kwargs["warn_on_error"] for call in write_mock.call_args_list], + [True], + ) + + def test_start_startup_heartbeat_does_not_register_exit_handler_when_thread_start_fails( + self, + ) -> None: + stop_event = mock.Mock() + thread = mock.Mock() + thread.start.side_effect = RuntimeError("thread start failed") + register = mock.Mock() + + with mock.patch.object( + launch_backend, + "resolve_startup_heartbeat_path", + return_value=Path("/tmp/heartbeat.json"), + ): + with mock.patch.object( + launch_backend.threading, "Event", return_value=stop_event + ): + with mock.patch.object( + launch_backend.threading, "Thread", return_value=thread + ): + with mock.patch.object(launch_backend.atexit, "register", register): + with self.assertRaises(RuntimeError): + launch_backend.start_startup_heartbeat() + + register.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src-tauri/src/app_constants.rs b/src-tauri/src/app_constants.rs index 4959f299..e7de6dd9 100644 --- a/src-tauri/src/app_constants.rs +++ b/src-tauri/src/app_constants.rs @@ -1,8 +1,9 @@ use std::time::Duration; pub(crate) const DEFAULT_BACKEND_URL: &str = "http://127.0.0.1:6185/"; +pub(crate) const ASTRBOT_ROOT_ENV: &str = "ASTRBOT_ROOT"; pub(crate) const BACKEND_TIMEOUT_ENV: &str = "ASTRBOT_BACKEND_TIMEOUT_MS"; -pub(crate) const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS: u64 = 5 * 60 * 1000; +pub(crate) const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS: u64 = 15 * 60 * 1000; pub(crate) const GRACEFUL_RESTART_REQUEST_TIMEOUT_MS: u64 = 2_500; pub(crate) const GRACEFUL_RESTART_START_TIME_TIMEOUT_MS: u64 = 1_800; pub(crate) const GRACEFUL_RESTART_POLL_INTERVAL_MS: u64 = 350; @@ -17,6 +18,15 @@ pub(crate) const BACKEND_READY_PROBE_TIMEOUT_ENV: &str = "ASTRBOT_BACKEND_READY_ pub(crate) const BACKEND_READY_PROBE_TIMEOUT_MIN_MS: u64 = 100; pub(crate) const BACKEND_READY_PROBE_TIMEOUT_MAX_MS: u64 = 30_000; pub(crate) const BACKEND_READY_TCP_PROBE_TIMEOUT_MAX_MS: u64 = 1_000; +pub(crate) const BACKEND_STARTUP_IDLE_TIMEOUT_ENV: &str = "ASTRBOT_BACKEND_STARTUP_IDLE_TIMEOUT_MS"; +pub(crate) const DEFAULT_BACKEND_STARTUP_IDLE_TIMEOUT_MS: u64 = 60 * 1000; +pub(crate) const BACKEND_STARTUP_IDLE_TIMEOUT_MIN_MS: u64 = 5_000; +pub(crate) const BACKEND_STARTUP_IDLE_TIMEOUT_MAX_MS: u64 = 15 * 60 * 1000; +// Keep this in sync with STARTUP_HEARTBEAT_ENV in scripts/backend/templates/launch_backend.py. +pub(crate) const BACKEND_STARTUP_HEARTBEAT_PATH_ENV: &str = + "ASTRBOT_BACKEND_STARTUP_HEARTBEAT_PATH"; +pub(crate) const DEFAULT_BACKEND_STARTUP_HEARTBEAT_RELATIVE_PATH: &str = + "data/backend-startup-heartbeat.json"; pub(crate) const DEFAULT_BACKEND_PING_TIMEOUT_MS: u64 = 800; pub(crate) const BACKEND_PING_TIMEOUT_MIN_MS: u64 = 50; pub(crate) const BACKEND_PING_TIMEOUT_MAX_MS: u64 = 30_000; diff --git a/src-tauri/src/app_helpers.rs b/src-tauri/src/app_helpers.rs index 6d85071e..11e37307 100644 --- a/src-tauri/src/app_helpers.rs +++ b/src-tauri/src/app_helpers.rs @@ -79,6 +79,7 @@ mod tests { cwd: PathBuf::from("."), root_dir: None, webui_dir: None, + startup_heartbeat_path: None, packaged_mode: false, }; diff --git a/src-tauri/src/app_types.rs b/src-tauri/src/app_types.rs index 53e0bbf7..aea509a3 100644 --- a/src-tauri/src/app_types.rs +++ b/src-tauri/src/app_types.rs @@ -33,6 +33,7 @@ pub(crate) struct LaunchPlan { pub(crate) cwd: PathBuf, pub(crate) root_dir: Option, pub(crate) webui_dir: Option, + pub(crate) startup_heartbeat_path: Option, pub(crate) packaged_mode: bool, } diff --git a/src-tauri/src/backend/config.rs b/src-tauri/src/backend/config.rs index c58676a2..a2b5e995 100644 --- a/src-tauri/src/backend/config.rs +++ b/src-tauri/src/backend/config.rs @@ -1,4 +1,5 @@ use std::env; +use std::path::{Path, PathBuf}; use std::time::Duration; use url::Url; @@ -7,6 +8,8 @@ pub struct BackendReadinessConfig { pub path: String, pub probe_timeout_ms: u64, pub poll_interval_ms: u64, + pub startup_idle_timeout_ms: u64, + pub startup_heartbeat_path: Option, } pub fn resolve_backend_ready_http_path(env_name: &str, default_path: &str, mut log: F) -> String @@ -97,6 +100,47 @@ where parse_clamped_timeout_env(raw, env_name, fallback_ms, min_ms, max_ms, log) } +pub fn resolve_backend_startup_idle_timeout_ms( + raw: &str, + env_name: &str, + fallback_ms: u64, + min_ms: u64, + max_ms: u64, + log: F, +) -> u64 +where + F: FnMut(String), +{ + parse_clamped_timeout_env(raw, env_name, fallback_ms, min_ms, max_ms, log) +} + +pub fn resolve_backend_startup_heartbeat_path( + root_dir: Option<&Path>, + packaged_root: Option, + relative_path: &str, +) -> Option { + let trimmed = relative_path.trim(); + if trimmed.is_empty() { + return None; + } + + // Prefer the launch plan's resolved root so spawn-time and readiness-time heartbeat paths + // stay aligned. Falling back to ASTRBOT_ROOT only helps older/custom call sites that do not + // pass a root dir; packaged launches may finally fall back to the default packaged root. + if let Some(root) = root_dir { + return Some(root.join(trimmed)); + } + + if let Ok(root) = env::var(crate::ASTRBOT_ROOT_ENV) { + let root = PathBuf::from(root.trim()); + if !root.as_os_str().is_empty() { + return Some(root.join(trimmed)); + } + } + + packaged_root.map(|root| root.join(trimmed)) +} + #[allow(clippy::too_many_arguments)] pub fn resolve_backend_readiness_config( ready_http_path_env: &str, @@ -221,6 +265,8 @@ where path, probe_timeout_ms, poll_interval_ms, + startup_idle_timeout_ms: 0, + startup_heartbeat_path: None, } } @@ -260,6 +306,47 @@ mod tests { assert_eq!(value, 3_000); } + #[test] + fn resolve_backend_startup_idle_timeout_clamps_large_value() { + let value = resolve_backend_startup_idle_timeout_ms( + "999999", + "TEST_STARTUP_IDLE_TIMEOUT_ENV", + 60_000, + 5_000, + 300_000, + |_| {}, + ); + assert_eq!(value, 300_000); + } + + #[test] + fn resolve_backend_startup_idle_timeout_clamps_small_value() { + let value = resolve_backend_startup_idle_timeout_ms( + "1000", + "TEST_STARTUP_IDLE_TIMEOUT_ENV", + 60_000, + 5_000, + 300_000, + |_| {}, + ); + assert_eq!(value, 5_000); + } + + #[test] + fn resolve_backend_startup_heartbeat_path_prefers_root_dir() { + let path = resolve_backend_startup_heartbeat_path( + Some(Path::new("/tmp/astrbot-root")), + Some(PathBuf::from("/tmp/packaged-root")), + "data/backend-startup-heartbeat.json", + ) + .expect("expected heartbeat path"); + + assert_eq!( + path, + PathBuf::from("/tmp/astrbot-root").join("data/backend-startup-heartbeat.json") + ); + } + #[test] fn resolve_backend_timeout_uses_packaged_fallback_when_zero() { let timeout = resolve_backend_timeout_ms(true, "TEST_TIMEOUT_ENV_MISSING", 20_000, 300_000); diff --git a/src-tauri/src/backend/launch.rs b/src-tauri/src/backend/launch.rs index ac91d7af..b161b7bf 100644 --- a/src-tauri/src/backend/launch.rs +++ b/src-tauri/src/backend/launch.rs @@ -123,7 +123,10 @@ impl BackendState { } if let Some(root_dir) = &plan.root_dir { - command.env("ASTRBOT_ROOT", root_dir); + command.env(crate::ASTRBOT_ROOT_ENV, root_dir); + } + if let Some(heartbeat_path) = plan.startup_heartbeat_path.as_ref() { + command.env(crate::BACKEND_STARTUP_HEARTBEAT_PATH_ENV, heartbeat_path); } if let Some(webui_dir) = &plan.webui_dir { command.env("ASTRBOT_WEBUI_DIR", webui_dir); diff --git a/src-tauri/src/backend/readiness.rs b/src-tauri/src/backend/readiness.rs index 7eb3793d..dc49725f 100644 --- a/src-tauri/src/backend/readiness.rs +++ b/src-tauri/src/backend/readiness.rs @@ -1,6 +1,8 @@ use std::{ - env, thread, - time::{Duration, Instant}, + env, fs, + path::Path, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tauri::AppHandle; @@ -40,10 +42,12 @@ impl BackendState { 20_000, PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS, ); - let readiness = backend::runtime::backend_readiness_config(append_desktop_log); + let readiness = backend::runtime::backend_readiness_config(plan, append_desktop_log); + let startup_idle_timeout = Duration::from_millis(readiness.startup_idle_timeout_ms); let start_time = Instant::now(); let mut tcp_ready_logged = false; let mut ever_tcp_reachable = false; + let mut startup_heartbeat_state = StartupHeartbeatTracker::new(); loop { let (http_status, tcp_reachable) = @@ -51,6 +55,21 @@ impl BackendState { if matches!(http_status, Some(status_code) if (200..400).contains(&status_code)) { return Ok(()); } + let wall_now = SystemTime::now(); + let monotonic_now = Instant::now(); + + let child_pid = self.live_child_pid()?; + + if let Some(heartbeat_path) = readiness.startup_heartbeat_path.as_deref() { + step_startup_heartbeat( + heartbeat_path, + child_pid, + wall_now, + monotonic_now, + startup_idle_timeout, + &mut startup_heartbeat_state, + )?; + } if tcp_reachable { ever_tcp_reachable = true; @@ -62,37 +81,15 @@ impl BackendState { } } - { - let mut guard = self - .child - .lock() - .map_err(|_| "Backend process lock poisoned.".to_string())?; - if let Some(child) = guard.as_mut() { - match child.try_wait() { - Ok(Some(status)) => { - *guard = None; - return Err(format!( - "Backend process exited before becoming reachable: {status}" - )); - } - Ok(None) => {} - Err(error) => { - return Err(format!("Failed to poll backend process status: {error}")); - } - } - } else { - return Err("Backend process is not running.".to_string()); - } - } - if let Some(limit) = timeout_ms { if start_time.elapsed() >= limit { self.log_backend_readiness_timeout( limit, - &readiness.path, - readiness.probe_timeout_ms, + &readiness, + wall_now, http_status, ever_tcp_reachable, + startup_heartbeat_state.last_seen_at, ); return Err(format!( "Timed out after {}ms waiting for backend startup.", @@ -117,25 +114,349 @@ impl BackendState { (http_status, tcp_reachable) } + fn live_child_pid(&self) -> Result { + let mut guard = self + .child + .lock() + .map_err(|_| "Backend process lock poisoned.".to_string())?; + + if let Some(child) = guard.as_mut() { + let pid = child.id(); + match child.try_wait() { + Ok(Some(status)) => { + *guard = None; + Err(format!( + "Backend process exited before becoming reachable: {status}" + )) + } + Ok(None) => Ok(pid), + Err(error) => Err(format!("Failed to poll backend process status: {error}")), + } + } else { + Err("Backend process is not running.".to_string()) + } + } + fn log_backend_readiness_timeout( &self, timeout: Duration, - ready_http_path: &str, - probe_timeout_ms: u64, + readiness: &backend::config::BackendReadinessConfig, + now: SystemTime, last_http_status: Option, tcp_reachable: bool, + last_startup_heartbeat_at: Option, ) { let last_http_status_text = last_http_status .map(|status| status.to_string()) .unwrap_or_else(|| "none".to_string()); + let startup_heartbeat_age_ms = describe_heartbeat_age(last_startup_heartbeat_at, now); append_desktop_log(&format!( - "backend HTTP readiness check timed out after {}ms: backend_url={}, path={}, probe_timeout_ms={}, tcp_reachable={}, last_http_status={}", + "backend HTTP readiness check timed out after {}ms: backend_url={}, path={}, probe_timeout_ms={}, tcp_reachable={}, last_http_status={}, startup_heartbeat_age_ms={}", timeout.as_millis(), self.backend_url, - ready_http_path, - probe_timeout_ms, + readiness.path, + readiness.probe_timeout_ms, tcp_reachable, - last_http_status_text + last_http_status_text, + startup_heartbeat_age_ms + )); + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct StartupHeartbeatFile { + pid: u32, + state: StartupHeartbeatState, + updated_at_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum StartupHeartbeatState { + Starting, + Stopping, +} + +#[derive(Debug, Clone, Copy)] +struct StartupHeartbeatTracker { + last_seen_at: Option, + last_progress_at: Option, + consecutive_invalid_reads: u8, + logged_fresh: bool, +} + +impl StartupHeartbeatTracker { + fn new() -> Self { + Self { + last_seen_at: None, + last_progress_at: None, + consecutive_invalid_reads: 0, + logged_fresh: false, + } + } +} + +const STARTUP_HEARTBEAT_INVALID_READ_THRESHOLD: u8 = 2; + +fn read_startup_heartbeat_updated_at(path: &Path, expected_pid: u32) -> Option { + let payload = fs::read_to_string(path).ok()?; + let heartbeat: StartupHeartbeatFile = serde_json::from_str(&payload).ok()?; + if heartbeat.pid != expected_pid || heartbeat.state != StartupHeartbeatState::Starting { + return None; + } + UNIX_EPOCH.checked_add(Duration::from_millis(heartbeat.updated_at_ms)) +} + +fn startup_heartbeat_progress_is_fresh( + last_progress_at: Option, + now: Instant, + max_age: Duration, +) -> bool { + last_progress_at.is_some_and(|updated_at| now.duration_since(updated_at) <= max_age) +} + +fn ms_since(earlier: SystemTime, now: SystemTime) -> Option { + now.duration_since(earlier) + .ok() + .map(|duration| duration.as_millis()) +} + +fn describe_heartbeat_age( + last_startup_heartbeat_at: Option, + now: SystemTime, +) -> String { + match last_startup_heartbeat_at { + Some(updated_at) => match ms_since(updated_at, now) { + Some(age) => age.to_string(), + None => format!("future ({updated_at:?})"), + }, + None => "none".to_string(), + } +} + +fn step_startup_heartbeat( + heartbeat_path: &Path, + child_pid: u32, + wall_now: SystemTime, + monotonic_now: Instant, + idle_timeout: Duration, + state: &mut StartupHeartbeatTracker, +) -> Result<(), String> { + let previous = state.last_seen_at; + let current = read_startup_heartbeat_updated_at(heartbeat_path, child_pid); + + match (previous, current) { + (Some(previous), None) => { + state.consecutive_invalid_reads = state.consecutive_invalid_reads.saturating_add(1); + if state.consecutive_invalid_reads < STARTUP_HEARTBEAT_INVALID_READ_THRESHOLD { + return Ok(()); + } + + let heartbeat_age_ms = describe_heartbeat_age(Some(previous), wall_now); + append_desktop_log(&format!( + "backend startup heartbeat disappeared or became invalid before HTTP dashboard became ready: last_valid_age_ms={heartbeat_age_ms}" + )); + Err( + "Backend startup heartbeat disappeared or became invalid before HTTP readiness." + .to_string(), + ) + } + (None, None) => { + state.consecutive_invalid_reads = 0; + Ok(()) + } + (_, Some(current)) => { + state.consecutive_invalid_reads = 0; + let updated_at = match previous { + Some(previous) if current <= previous => previous, + _ => current, + }; + state.last_seen_at = Some(updated_at); + + if previous.is_none() + || Some(updated_at) != previous + || state.last_progress_at.is_none() + { + state.last_progress_at = Some(monotonic_now); + } + + if startup_heartbeat_progress_is_fresh( + state.last_progress_at, + monotonic_now, + idle_timeout, + ) { + if !state.logged_fresh { + append_desktop_log( + "backend startup heartbeat is fresh while HTTP dashboard is not ready yet; waiting", + ); + state.logged_fresh = true; + } + Ok(()) + } else { + append_desktop_log( + "backend startup heartbeat went stale before HTTP dashboard became ready", + ); + Err(format!( + "Backend startup heartbeat went stale after {}ms without HTTP readiness.", + idle_timeout.as_millis() + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant, UNIX_EPOCH}; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn startup_heartbeat_progress_is_fresh_for_recent_instant() { + assert!(startup_heartbeat_progress_is_fresh( + Some(Instant::now()), + Instant::now() + Duration::from_millis(500), + Duration::from_secs(1), + )); + } + + #[test] + fn startup_heartbeat_progress_is_not_fresh_when_stale() { + assert!(!startup_heartbeat_progress_is_fresh( + Some(Instant::now()), + Instant::now() + Duration::from_millis(1500), + Duration::from_secs(1), )); } + + #[test] + fn startup_heartbeat_is_not_fresh_for_mismatched_pid() { + let temp_dir = TempDir::new().expect("create temp dir"); + let heartbeat_path = temp_dir.path().join("startup-heartbeat.json"); + std::fs::write( + &heartbeat_path, + r#"{"pid":7,"state":"starting","updated_at_ms":5000}"#, + ) + .expect("write heartbeat file"); + + assert_eq!(read_startup_heartbeat_updated_at(&heartbeat_path, 42), None); + } + + #[test] + fn step_startup_heartbeat_fails_when_existing_heartbeat_disappears() { + let temp_dir = TempDir::new().expect("create temp dir"); + let heartbeat_path = temp_dir.path().join("missing-startup-heartbeat.json"); + let monotonic_now = Instant::now(); + let mut tracker = StartupHeartbeatTracker { + last_seen_at: Some(UNIX_EPOCH + Duration::from_millis(5000)), + last_progress_at: Some(monotonic_now), + consecutive_invalid_reads: 0, + logged_fresh: false, + }; + + let first_result = step_startup_heartbeat( + &heartbeat_path, + 42, + UNIX_EPOCH + Duration::from_millis(5500), + monotonic_now, + Duration::from_secs(1), + &mut tracker, + ); + + let result = step_startup_heartbeat( + &heartbeat_path, + 42, + UNIX_EPOCH + Duration::from_millis(5600), + monotonic_now + Duration::from_millis(100), + Duration::from_secs(1), + &mut tracker, + ); + + assert_eq!(first_result, Ok(())); + assert_eq!( + result, + Err( + "Backend startup heartbeat disappeared or became invalid before HTTP readiness." + .to_string() + ) + ); + } + + #[test] + fn step_startup_heartbeat_tolerates_single_missing_read_after_valid_heartbeat() { + let temp_dir = TempDir::new().expect("create temp dir"); + let heartbeat_path = temp_dir.path().join("missing-startup-heartbeat.json"); + let monotonic_now = Instant::now(); + let mut tracker = StartupHeartbeatTracker { + last_seen_at: Some(UNIX_EPOCH + Duration::from_millis(5000)), + last_progress_at: Some(monotonic_now), + consecutive_invalid_reads: 0, + logged_fresh: false, + }; + + let result = step_startup_heartbeat( + &heartbeat_path, + 42, + UNIX_EPOCH + Duration::from_millis(5500), + monotonic_now, + Duration::from_secs(1), + &mut tracker, + ); + + assert_eq!(result, Ok(())); + assert_eq!(tracker.consecutive_invalid_reads, 1); + } + + #[test] + fn startup_heartbeat_file_rejects_unknown_state() { + assert!(serde_json::from_str::( + r#"{"pid":42,"state":"unexpected","updated_at_ms":5000}"# + ) + .is_err()); + } + + #[test] + fn startup_heartbeat_file_rejects_unknown_fields() { + assert!(serde_json::from_str::( + r#"{"pid":42,"state":"starting","updated_at_ms":5000,"unexpected":true}"# + ) + .is_err()); + } + + #[test] + fn read_startup_heartbeat_updated_at_handles_large_timestamp_without_panic() { + let temp_dir = TempDir::new().expect("create temp dir"); + let heartbeat_path = temp_dir.path().join("startup-heartbeat.json"); + std::fs::write( + &heartbeat_path, + format!( + r#"{{"pid":42,"state":"starting","updated_at_ms":{}}}"#, + u64::MAX + ), + ) + .expect("write heartbeat file"); + + assert_eq!( + read_startup_heartbeat_updated_at(&heartbeat_path, 42), + UNIX_EPOCH.checked_add(Duration::from_millis(u64::MAX)) + ); + } + + #[test] + fn describe_heartbeat_age_distinguishes_future_timestamp_from_missing() { + assert_eq!( + describe_heartbeat_age( + Some(UNIX_EPOCH + Duration::from_millis(6_000)), + UNIX_EPOCH + Duration::from_millis(5_500) + ), + format!("future ({:?})", UNIX_EPOCH + Duration::from_millis(6_000)) + ); + assert_eq!( + describe_heartbeat_age(None, UNIX_EPOCH + Duration::from_millis(5_500)), + "none" + ); + } } diff --git a/src-tauri/src/backend/restart.rs b/src-tauri/src/backend/restart.rs index bc93c3bd..7372d6f9 100644 --- a/src-tauri/src/backend/restart.rs +++ b/src-tauri/src/backend/restart.rs @@ -340,6 +340,7 @@ mod tests { cwd: std::path::PathBuf::from("."), root_dir: None, webui_dir: None, + startup_heartbeat_path: None, packaged_mode: true, }; let state = BackendState::default(); diff --git a/src-tauri/src/backend/runtime.rs b/src-tauri/src/backend/runtime.rs index c928c4a6..990191fd 100644 --- a/src-tauri/src/backend/runtime.rs +++ b/src-tauri/src/backend/runtime.rs @@ -15,12 +15,15 @@ pub fn backend_wait_timeout(packaged_mode: bool) -> Duration { .unwrap_or(Duration::from_millis(20_000)) } -pub fn backend_readiness_config(log: F) -> backend::config::BackendReadinessConfig +pub fn backend_readiness_config( + plan: &crate::LaunchPlan, + log: F, +) -> backend::config::BackendReadinessConfig where F: Fn(&str) + Copy, { let probe_timeout_fallback = backend_ping_timeout_ms(log); - backend::config::backend_readiness_config( + let mut readiness = backend::config::backend_readiness_config( crate::BACKEND_READY_HTTP_PATH_ENV, crate::DEFAULT_BACKEND_READY_HTTP_PATH, crate::BACKEND_READY_PROBE_TIMEOUT_ENV, @@ -32,7 +35,20 @@ where crate::BACKEND_READY_POLL_INTERVAL_MIN_MS, crate::BACKEND_READY_POLL_INTERVAL_MAX_MS, |message| log(&message), - ) + ); + readiness.startup_idle_timeout_ms = match env::var(crate::BACKEND_STARTUP_IDLE_TIMEOUT_ENV) { + Ok(raw) => backend::config::resolve_backend_startup_idle_timeout_ms( + &raw, + crate::BACKEND_STARTUP_IDLE_TIMEOUT_ENV, + crate::DEFAULT_BACKEND_STARTUP_IDLE_TIMEOUT_MS, + crate::BACKEND_STARTUP_IDLE_TIMEOUT_MIN_MS, + crate::BACKEND_STARTUP_IDLE_TIMEOUT_MAX_MS, + |message| log(&message), + ), + Err(_) => crate::DEFAULT_BACKEND_STARTUP_IDLE_TIMEOUT_MS, + }; + readiness.startup_heartbeat_path = plan.startup_heartbeat_path.clone(); + readiness } pub fn backend_ping_timeout_ms(log: F) -> u64 diff --git a/src-tauri/src/desktop_state.rs b/src-tauri/src/desktop_state.rs index c822c701..c7e011bf 100644 --- a/src-tauri/src/desktop_state.rs +++ b/src-tauri/src/desktop_state.rs @@ -5,7 +5,7 @@ use std::{ pub(crate) fn resolve_desktop_state_path(packaged_root_dir: Option<&Path>) -> Option { resolve_desktop_state_path_with_root( - env::var("ASTRBOT_ROOT").ok().as_deref(), + env::var(crate::ASTRBOT_ROOT_ENV).ok().as_deref(), packaged_root_dir, ) } diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index cb421bbe..8069ce98 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -5,7 +5,7 @@ use std::{ use tauri::AppHandle; -use crate::{packaged_webui, runtime_paths, LaunchPlan, RuntimeManifest}; +use crate::{backend, packaged_webui, runtime_paths, LaunchPlan, RuntimeManifest}; const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); @@ -14,6 +14,19 @@ fn build_packaged_resource_relative_path(resource_alias: &str, leaf_name: &str) PathBuf::from(resource_alias).join(leaf_name) } +fn resolve_launch_startup_heartbeat_path( + root_dir: Option<&Path>, + packaged_mode: bool, +) -> Option { + backend::config::resolve_backend_startup_heartbeat_path( + root_dir, + packaged_mode + .then(runtime_paths::default_packaged_root_dir) + .flatten(), + crate::DEFAULT_BACKEND_STARTUP_HEARTBEAT_RELATIVE_PATH, + ) +} + pub fn resolve_custom_launch(custom_cmd: String) -> Result { let mut pieces = shlex::split(&custom_cmd) .ok_or_else(|| format!("Invalid ASTRBOT_BACKEND_CMD: {custom_cmd}"))?; @@ -27,8 +40,9 @@ pub fn resolve_custom_launch(custom_cmd: String) -> Result { .ok() .or_else(runtime_paths::detect_astrbot_source_root) .unwrap_or_else(runtime_paths::workspace_root_dir); - let root_dir = env::var("ASTRBOT_ROOT").ok().map(PathBuf::from); + let root_dir = env::var(crate::ASTRBOT_ROOT_ENV).ok().map(PathBuf::from); let webui_dir = env::var("ASTRBOT_WEBUI_DIR").ok().map(PathBuf::from); + let startup_heartbeat_path = resolve_launch_startup_heartbeat_path(root_dir.as_deref(), false); Ok(LaunchPlan { cmd, @@ -36,6 +50,7 @@ pub fn resolve_custom_launch(custom_cmd: String) -> Result { cwd, root_dir, webui_dir, + startup_heartbeat_path, packaged_mode: false, }) } @@ -107,7 +122,7 @@ where )); } - let root_dir = env::var("ASTRBOT_ROOT") + let root_dir = env::var(crate::ASTRBOT_ROOT_ENV) .map(PathBuf::from) .ok() .or_else(runtime_paths::default_packaged_root_dir); @@ -141,6 +156,7 @@ where "--webui-dir".to_string(), webui_dir.to_string_lossy().to_string(), ]; + let startup_heartbeat_path = resolve_launch_startup_heartbeat_path(root_dir.as_deref(), true); let plan = LaunchPlan { cmd: python_path.to_string_lossy().to_string(), @@ -148,6 +164,7 @@ where cwd, root_dir, webui_dir: Some(webui_dir), + startup_heartbeat_path, packaged_mode: true, }; Ok(Some(plan)) @@ -174,6 +191,8 @@ pub fn resolve_dev_launch() -> Result { args.push("--webui-dir".to_string()); args.push(path.to_string_lossy().to_string()); } + let root_dir = env::var(crate::ASTRBOT_ROOT_ENV).ok().map(PathBuf::from); + let startup_heartbeat_path = resolve_launch_startup_heartbeat_path(root_dir.as_deref(), false); Ok(LaunchPlan { cmd: "uv".to_string(), @@ -181,8 +200,9 @@ pub fn resolve_dev_launch() -> Result { cwd: env::var("ASTRBOT_BACKEND_CWD") .map(PathBuf::from) .unwrap_or(source_root), - root_dir: env::var("ASTRBOT_ROOT").ok().map(PathBuf::from), + root_dir, webui_dir, + startup_heartbeat_path, packaged_mode: false, }) } @@ -191,6 +211,28 @@ pub fn resolve_dev_launch() -> Result { mod tests { use super::*; + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var(key).ok(); + env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + #[test] fn build_packaged_resource_relative_path_joins_alias_and_leaf_name() { assert_eq!( @@ -202,4 +244,16 @@ mod tests { PathBuf::from("runtime/webui").join("index.html") ); } + + #[test] + fn resolve_custom_launch_sets_startup_heartbeat_path_from_root_dir() { + let _root_guard = EnvVarGuard::set(crate::ASTRBOT_ROOT_ENV, "/tmp/astrbot-root"); + + let plan = resolve_custom_launch("python main.py".to_string()).expect("custom plan"); + + assert_eq!( + plan.startup_heartbeat_path, + Some(PathBuf::from("/tmp/astrbot-root").join("data/backend-startup-heartbeat.json")) + ); + } } diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index 99df4ad5..c641cf48 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -145,7 +145,7 @@ pub fn resolve_desktop_log_path(packaged_root: Option, desktop_log_file } } - if let Ok(root) = env::var("ASTRBOT_ROOT") { + if let Ok(root) = env::var(crate::ASTRBOT_ROOT_ENV) { let root = PathBuf::from(root.trim()); if !root.as_os_str().is_empty() { return root.join("logs").join(desktop_log_file); @@ -169,7 +169,7 @@ pub fn resolve_backend_log_path( if let Some(root) = root_dir { return root.join("logs").join("backend.log"); } - if let Ok(root) = env::var("ASTRBOT_ROOT") { + if let Ok(root) = env::var(crate::ASTRBOT_ROOT_ENV) { let path = PathBuf::from(root.trim()); if !path.as_os_str().is_empty() { return path.join("logs").join("backend.log"); diff --git a/src-tauri/src/update_channel.rs b/src-tauri/src/update_channel.rs index 90b1d28c..6042b4f5 100644 --- a/src-tauri/src/update_channel.rs +++ b/src-tauri/src/update_channel.rs @@ -625,7 +625,7 @@ mod tests { #[test] fn write_cached_channel_errors_when_state_path_unavailable() { - let _root_guard = EnvVarGuard::clear("ASTRBOT_ROOT"); + let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV); let result = write_cached_update_channel(Some(UpdateChannel::Nightly), None); @@ -637,7 +637,7 @@ mod tests { #[test] fn read_cached_channel_round_trips_written_value() { - let _root_guard = EnvVarGuard::clear("ASTRBOT_ROOT"); + let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV); let dir = create_temp_case_dir("round-trip"); write_cached_update_channel(Some(UpdateChannel::Nightly), Some(&dir)) .expect("write cached channel"); @@ -652,7 +652,7 @@ mod tests { #[test] fn write_cached_channel_preserves_unrelated_state_fields() { - let _root_guard = EnvVarGuard::clear("ASTRBOT_ROOT"); + let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV); let dir = create_temp_case_dir("preserve-fields"); let state_path = dir.join("data").join("desktop_state.json"); fs::create_dir_all(state_path.parent().expect("state dir")).expect("create state dir"); @@ -693,7 +693,7 @@ mod tests { #[test] fn resolve_preferred_channel_falls_back_to_installed_version_channel() { - let _root_guard = EnvVarGuard::clear("ASTRBOT_ROOT"); + let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV); let dir = create_temp_case_dir("fallback"); assert_eq!( From 7da9e76dbac88571fe1fbd0d7f7c606fac3b01ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 09:21:59 +0900 Subject: [PATCH 03/16] fix: confirm desktop close behavior before hiding to tray --- src-tauri/capabilities/default.json | 2 +- src-tauri/src/app_runtime.rs | 56 ++++- src-tauri/src/app_runtime_events.rs | 36 ++- src-tauri/src/bridge/commands.rs | 84 ++++++- src-tauri/src/close_behavior.rs | 321 ++++++++++++++++++++++++++ src-tauri/src/main.rs | 1 + src-tauri/src/window/close_confirm.rs | 78 +++++++ src-tauri/src/window/mod.rs | 1 + ui/close-confirm.html | 269 +++++++++++++++++++++ 9 files changed, 832 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/close_behavior.rs create mode 100644 src-tauri/src/window/close_confirm.rs create mode 100644 ui/close-confirm.html 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..136c5f08 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_shutdown_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,54 @@ 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()); + + 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}" + )); + window::actions::hide_main_window( + window.app_handle(), + DEFAULT_SHELL_LOCALE, + append_desktop_log, + ); + } + } app_runtime_events::MainWindowAction::PreventCloseAndHide => { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); @@ -50,6 +81,12 @@ fn configure_window_events(builder: Builder) -> Builder append_desktop_log, ); } + app_runtime_events::MainWindowAction::ExitApplication => { + let state = window.app_handle().state::(); + state.mark_quitting(); + append_shutdown_log("main window close requested with saved exit preference"); + window.app_handle().exit(0); + } app_runtime_events::MainWindowAction::HideIfMinimized => { window::actions::hide_main_window( window.app_handle(), @@ -170,6 +207,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..ea374985 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,11 @@ 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(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string()) +} + #[cfg(target_os = "macos")] fn open_url_with_system_browser(url: &str) -> Result<(), String> { Command::new("open") @@ -287,6 +293,68 @@ 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(&format!( + "failed to persist remembered close action; continuing with selected action: {error}" + )); + } + } + + match action { + CloseAction::Tray => { + window::actions::hide_main_window( + &app_handle, + DEFAULT_SHELL_LOCALE, + append_desktop_log, + ); + if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") { + if let Err(error) = prompt_window.close() { + let reason = format!("Failed to close close confirm prompt window: {error}"); + append_desktop_log(&reason); + return BackendBridgeResult { + ok: false, + reason: Some(reason), + }; + } + } + + BackendBridgeResult { + ok: true, + reason: None, + } + } + CloseAction::Exit => { + let state = app_handle.state::(); + state.mark_quitting(); + app_handle.exit(0); + BackendBridgeResult { + ok: true, + reason: None, + } + } + } +} + #[tauri::command] pub(crate) fn desktop_bridge_set_shell_locale( app_handle: AppHandle, @@ -470,6 +538,20 @@ mod tests { ); } + #[test] + fn parse_close_prompt_action_accepts_supported_values() { + assert_eq!(parse_close_prompt_action("tray"), Ok(CloseAction::Tray)); + assert_eq!(parse_close_prompt_action("exit"), Ok(CloseAction::Exit)); + } + + #[test] + fn parse_close_prompt_action_rejects_invalid_values() { + assert_eq!( + parse_close_prompt_action("minimize"), + Err("Invalid close action. Expected 'tray' or 'exit'.".to_string()) + ); + } + #[test] fn updater_check_manual_download_mode_only_reports_reason_without_forced_update_flag() { let result = crate::bridge::updater_types::map_manual_download_no_update_result( diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs new file mode 100644 index 00000000..640e77a8 --- /dev/null +++ b/src-tauri/src/close_behavior.rs @@ -0,0 +1,321 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +const CLOSE_ACTION_FIELD: &str = "closeActionOnWindowClose"; + +fn empty_state_object() -> Value { + Value::Object(Map::new()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CloseAction { + Tray, + Exit, +} + +impl CloseAction { + fn parse(raw: &str) -> Option { + match raw { + "tray" => Some(Self::Tray), + "exit" => Some(Self::Exit), + _ => None, + } + } + + fn as_state_value(self) -> &'static str { + match self { + Self::Tray => "tray", + Self::Exit => "exit", + } + } +} + +pub(crate) fn parse_close_action(raw: &str) -> Option { + CloseAction::parse(raw) +} + +pub(crate) fn read_cached_close_action(packaged_root_dir: Option<&Path>) -> Option { + read_cached_close_action_from_state_path(crate::desktop_state::resolve_desktop_state_path( + packaged_root_dir, + )) +} + +fn read_cached_close_action_from_state_path(state_path: Option) -> Option { + let raw = fs::read_to_string(state_path?).ok()?; + let parsed = load_state_value(&raw, "desktop close behavior state"); + let action = parsed.get(CLOSE_ACTION_FIELD)?.as_str()?; + parse_close_action(action) +} + +fn load_state_value(raw: &str, log_subject: &str) -> Value { + match serde_json::from_str::(raw) { + Ok(value) if value.is_object() => value, + Ok(_) => { + crate::append_desktop_log(&format!( + "{log_subject} has non-object root; resetting state semantics" + )); + empty_state_object() + } + Err(error) => { + crate::append_desktop_log(&format!( + "failed to parse {log_subject}: {error}. resetting state semantics" + )); + empty_state_object() + } + } +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if let Value::Object(map) = value { + return map; + } + + *value = empty_state_object(); + value + .as_object_mut() + .expect("value was just normalized into a JSON object") +} + +fn ensure_parent_dir(path: &Path) -> Result<(), String> { + if let Some(parent_dir) = path.parent() { + fs::create_dir_all(parent_dir).map_err(|error| { + format!( + "Failed to create close behavior directory {}: {}", + parent_dir.display(), + error + ) + })?; + } + + Ok(()) +} + +fn save_state(path: &Path, state: &Value) -> Result<(), String> { + ensure_parent_dir(path)?; + + let serialized = serde_json::to_string_pretty(state) + .map_err(|error| format!("Failed to serialize close behavior 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 close behavior 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 close behavior state file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + format!( + "Failed to atomically replace close behavior state file {}: {}", + path.display(), + error + ) + }) +} + +pub(crate) fn write_cached_close_action( + action: Option, + packaged_root_dir: Option<&Path>, +) -> Result<(), String> { + write_cached_close_action_to_state_path( + action, + crate::desktop_state::resolve_desktop_state_path(packaged_root_dir), + ) +} + +fn write_cached_close_action_to_state_path( + action: Option, + state_path: Option, +) -> Result<(), String> { + let Some(state_path) = state_path else { + crate::append_desktop_log( + "close behavior state path is unavailable; skipping close action persistence", + ); + return Ok(()); + }; + + let mut parsed = match fs::read_to_string(&state_path) { + Ok(raw) => load_state_value( + &raw, + &format!("close behavior state {}", state_path.display()), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => empty_state_object(), + Err(error) => { + return Err(format!( + "Failed to read close behavior state {}: {}", + state_path.display(), + error + )); + } + }; + let object = ensure_object(&mut parsed); + + if let Some(action) = action { + object.insert( + CLOSE_ACTION_FIELD.to_string(), + Value::String(action.as_state_value().to_string()), + ); + } else { + object.remove(CLOSE_ACTION_FIELD); + } + + save_state(&state_path, &parsed)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + parse_close_action, read_cached_close_action_from_state_path, + write_cached_close_action_to_state_path, CloseAction, + }; + use serde_json::json; + use std::{fs, path::PathBuf}; + + 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_from_state_path(Some(state_path(&temp_dir))), + 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 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_to_state_path(Some(CloseAction::Tray), Some(state_path.clone())) + .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_to_state_path(Some(CloseAction::Exit), Some(state_path.clone())) + .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_to_state_path(Some(CloseAction::Tray), Some(state_path.clone())) + .expect("write close action"); + + assert_eq!( + read_cached_close_action_from_state_path(Some(state_path)), + 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_to_state_path(None, Some(state_path.clone())) + .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_from_state_path(Some(state_path)), + None + ); + } +} 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/window/close_confirm.rs b/src-tauri/src/window/close_confirm.rs new file mode 100644 index 00000000..ff704c76 --- /dev/null +++ b/src-tauri/src/window/close_confirm.rs @@ -0,0 +1,78 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm"; +const CLOSE_CONFIRM_WINDOW_WIDTH: f64 = 420.0; +const CLOSE_CONFIRM_WINDOW_HEIGHT: f64 = 320.0; + +pub(crate) fn build_close_confirm_path(locale: &str) -> String { + format!("close-confirm.html?locale={locale}") +} + +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) +} + +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) { + if let Err(error) = window.unminimize() { + log(&format!( + "failed to unminimize close confirm window: {error}" + )); + } + if let Err(error) = window.show() { + log(&format!("failed to show close confirm window: {error}")); + } + if let Err(error) = window.set_focus() { + log(&format!("failed to focus close confirm window: {error}")); + } + 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}; + + #[test] + fn build_close_confirm_path_appends_locale_query() { + assert_eq!( + build_close_confirm_path("en-US"), + "close-confirm.html?locale=en-US" + ); + } + + #[test] + fn close_confirm_window_size_fits_desktop_dialog_without_clipping() { + assert_eq!(close_confirm_window_size(), (420.0, 320.0)); + } +} 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..0ef9c650 --- /dev/null +++ b/ui/close-confirm.html @@ -0,0 +1,269 @@ + + + + + + AstrBot + + + +
+
+

+

+ +
+ + +
+
+
+
+ + + From dee9ab5800fd7b525c1f759942f1a6e1cfb273c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:04:23 +0900 Subject: [PATCH 04/16] refactor: simplify close prompt action persistence --- scripts/ui/close-confirm.test.mjs | 25 ++++ src-tauri/src/bridge/commands.rs | 72 +++++++++--- src-tauri/src/close_behavior.rs | 187 ++++++++++++++---------------- ui/close-confirm.html | 12 +- 4 files changed, 176 insertions(+), 120 deletions(-) create mode 100644 scripts/ui/close-confirm.test.mjs diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs new file mode 100644 index 00000000..28329f6c --- /dev/null +++ b/scripts/ui/close-confirm.test.mjs @@ -0,0 +1,25 @@ +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;/); +}); diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index ea374985..78b97390 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -166,6 +166,25 @@ fn parse_close_prompt_action(raw_action: &str) -> Result { .ok_or_else(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string()) } +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 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") @@ -327,21 +346,14 @@ pub(crate) fn desktop_bridge_submit_close_prompt( DEFAULT_SHELL_LOCALE, append_desktop_log, ); - if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") { - if let Err(error) = prompt_window.close() { - let reason = format!("Failed to close close confirm prompt window: {error}"); - append_desktop_log(&reason); - return BackendBridgeResult { - ok: false, - reason: Some(reason), - }; - } - } - - BackendBridgeResult { - ok: true, - reason: None, - } + let cleanup_result = + if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") { + prompt_window.close().map_err(|error| error.to_string()) + } else { + Ok(()) + }; + + finish_tray_close_prompt_cleanup(cleanup_result, append_desktop_log) } CloseAction::Exit => { let state = app_handle.state::(); @@ -552,6 +564,36 @@ mod tests { ); } + #[test] + fn finish_tray_close_prompt_cleanup_logs_failures_without_failing_result() { + let logs = Rc::new(RefCell::new(Vec::new())); + let captured_logs = Rc::clone(&logs); + + let result = + finish_tray_close_prompt_cleanup(Err("close failed".to_string()), move |message| { + captured_logs.borrow_mut().push(message.to_string()); + }); + + assert!(result.ok); + assert_eq!(result.reason, None); + assert_eq!(logs.borrow().len(), 1); + assert!(logs.borrow()[0].contains("close failed")); + } + + #[test] + fn finish_tray_close_prompt_cleanup_returns_success_without_logs_for_clean_close() { + let logs = Rc::new(RefCell::new(Vec::new())); + let captured_logs = Rc::clone(&logs); + + let result = finish_tray_close_prompt_cleanup(Ok(()), move |message| { + captured_logs.borrow_mut().push(message.to_string()); + }); + + assert!(result.ok); + assert_eq!(result.reason, None); + assert!(logs.borrow().is_empty()); + } + #[test] fn updater_check_manual_download_mode_only_reports_reason_without_forced_update_flag() { let result = crate::bridge::updater_types::map_manual_download_no_update_result( diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 640e77a8..180997db 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -1,16 +1,6 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; - -const CLOSE_ACTION_FIELD: &str = "closeActionOnWindowClose"; - -fn empty_state_object() -> Value { - Value::Object(Map::new()) -} +use std::{fs, io::Write, path::Path}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -19,67 +9,53 @@ pub(crate) enum CloseAction { Exit, } -impl CloseAction { - fn parse(raw: &str) -> Option { - match raw { - "tray" => Some(Self::Tray), - "exit" => Some(Self::Exit), - _ => None, - } - } - - fn as_state_value(self) -> &'static str { - match self { - Self::Tray => "tray", - Self::Exit => "exit", - } - } -} - -pub(crate) fn parse_close_action(raw: &str) -> Option { - CloseAction::parse(raw) +fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|value| serde_json::from_value::(value).ok())) } -pub(crate) fn read_cached_close_action(packaged_root_dir: Option<&Path>) -> Option { - read_cached_close_action_from_state_path(crate::desktop_state::resolve_desktop_state_path( - packaged_root_dir, - )) +#[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, } -fn read_cached_close_action_from_state_path(state_path: Option) -> Option { - let raw = fs::read_to_string(state_path?).ok()?; - let parsed = load_state_value(&raw, "desktop close behavior state"); - let action = parsed.get(CLOSE_ACTION_FIELD)?.as_str()?; - parse_close_action(action) +pub(crate) fn parse_close_action(raw: &str) -> Option { + serde_json::from_value(Value::String(raw.to_string())).ok() } -fn load_state_value(raw: &str, log_subject: &str) -> Value { - match serde_json::from_str::(raw) { - Ok(value) if value.is_object() => value, - Ok(_) => { - crate::append_desktop_log(&format!( - "{log_subject} has non-object root; resetting state semantics" - )); - empty_state_object() - } +fn load_desktop_state(raw: &str, log_subject: &str) -> DesktopState { + match serde_json::from_str::(raw) { + Ok(state) => state, Err(error) => { crate::append_desktop_log(&format!( "failed to parse {log_subject}: {error}. resetting state semantics" )); - empty_state_object() + DesktopState::default() } } } -fn ensure_object(value: &mut Value) -> &mut Map { - if let Value::Object(map) = value { - return map; - } +pub(crate) fn read_cached_close_action(packaged_root_dir: Option<&Path>) -> Option { + let state_path = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir)?; + read_cached_close_action_at_path(&state_path) +} - *value = empty_state_object(); - value - .as_object_mut() - .expect("value was just normalized into a JSON object") +fn read_cached_close_action_at_path(state_path: &Path) -> Option { + let raw = fs::read_to_string(state_path).ok()?; + let state = load_desktop_state(&raw, "desktop close behavior state"); + state.close_action } fn ensure_parent_dir(path: &Path) -> Result<(), String> { @@ -96,7 +72,7 @@ fn ensure_parent_dir(path: &Path) -> Result<(), String> { Ok(()) } -fn save_state(path: &Path, state: &Value) -> Result<(), String> { +fn save_state(path: &Path, state: &T) -> Result<(), String> { ensure_parent_dir(path)?; let serialized = serde_json::to_string_pretty(state) @@ -138,29 +114,27 @@ pub(crate) fn write_cached_close_action( action: Option, packaged_root_dir: Option<&Path>, ) -> Result<(), String> { - write_cached_close_action_to_state_path( - action, - crate::desktop_state::resolve_desktop_state_path(packaged_root_dir), - ) -} - -fn write_cached_close_action_to_state_path( - action: Option, - state_path: Option, -) -> Result<(), String> { - let Some(state_path) = state_path else { + let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) + else { crate::append_desktop_log( "close behavior state path is unavailable; skipping close action persistence", ); return Ok(()); }; - let mut parsed = match fs::read_to_string(&state_path) { - Ok(raw) => load_state_value( + write_cached_close_action_at_path(action, &state_path) +} + +fn write_cached_close_action_at_path( + action: Option, + state_path: &Path, +) -> Result<(), String> { + let mut state = match fs::read_to_string(state_path) { + Ok(raw) => load_desktop_state( &raw, &format!("close behavior state {}", state_path.display()), ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => empty_state_object(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => DesktopState::default(), Err(error) => { return Err(format!( "Failed to read close behavior state {}: {}", @@ -169,18 +143,9 @@ fn write_cached_close_action_to_state_path( )); } }; - let object = ensure_object(&mut parsed); - - if let Some(action) = action { - object.insert( - CLOSE_ACTION_FIELD.to_string(), - Value::String(action.as_state_value().to_string()), - ); - } else { - object.remove(CLOSE_ACTION_FIELD); - } + state.close_action = action; - save_state(&state_path, &parsed)?; + save_state(state_path, &state)?; Ok(()) } @@ -188,8 +153,8 @@ fn write_cached_close_action_to_state_path( #[cfg(test)] mod tests { use super::{ - parse_close_action, read_cached_close_action_from_state_path, - write_cached_close_action_to_state_path, CloseAction, + 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}; @@ -203,7 +168,7 @@ mod tests { let temp_dir = tempfile::tempdir().expect("temp dir"); assert_eq!( - read_cached_close_action_from_state_path(Some(state_path(&temp_dir))), + read_cached_close_action_at_path(&state_path(&temp_dir)), None ); } @@ -222,6 +187,38 @@ mod tests { 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", + ); + + 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", + ); + + 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 write_cached_close_action_preserves_unrelated_state_fields() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -237,7 +234,7 @@ mod tests { ) .expect("write state"); - write_cached_close_action_to_state_path(Some(CloseAction::Tray), Some(state_path.clone())) + write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path) .expect("write close action"); let saved: serde_json::Value = @@ -256,7 +253,7 @@ mod tests { 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_to_state_path(Some(CloseAction::Exit), Some(state_path.clone())) + write_cached_close_action_at_path(Some(CloseAction::Exit), &state_path) .expect("write close action"); let saved: serde_json::Value = @@ -271,11 +268,11 @@ mod tests { let temp_dir = tempfile::tempdir().expect("temp dir"); let state_path = state_path(&temp_dir); - write_cached_close_action_to_state_path(Some(CloseAction::Tray), Some(state_path.clone())) + write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path) .expect("write close action"); assert_eq!( - read_cached_close_action_from_state_path(Some(state_path)), + read_cached_close_action_at_path(&state_path), Some(CloseAction::Tray) ); } @@ -295,8 +292,7 @@ mod tests { ) .expect("write state"); - write_cached_close_action_to_state_path(None, Some(state_path.clone())) - .expect("clear close action"); + write_cached_close_action_at_path(None, &state_path).expect("clear close action"); let saved: serde_json::Value = serde_json::from_str(&fs::read_to_string(&state_path).expect("read updated state")) @@ -313,9 +309,6 @@ mod tests { 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_from_state_path(Some(state_path)), - None - ); + assert_eq!(read_cached_close_action_at_path(&state_path), None); } } diff --git a/ui/close-confirm.html b/ui/close-confirm.html index 0ef9c650..99784c08 100644 --- a/ui/close-confirm.html +++ b/ui/close-confirm.html @@ -201,8 +201,8 @@

document.getElementById("title").textContent = copy.title; document.getElementById("description").textContent = copy.description; document.getElementById("remember-label").textContent = copy.remember; - trayButton.textContent = locale === "zh-CN" ? copy.tray : "Minimize to tray"; - exitButton.textContent = locale === "zh-CN" ? copy.exit : "Exit now"; + trayButton.textContent = copy.tray; + exitButton.textContent = copy.exit; function setPending(pending) { trayButton.disabled = pending; @@ -245,12 +245,8 @@

if (typeof window.close === "function") { window.close(); } - } catch (invokeError) { - const message = - invokeError && typeof invokeError === "object" && "message" in invokeError - ? String(invokeError.message) - : String(invokeError || copy.submitError); - error.textContent = message; + } catch (_invokeError) { + error.textContent = copy.submitError; setPending(false); } } From b74fcdfcb38a835c7d66c3943fe7a5ea2cb6964e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:10:20 +0900 Subject: [PATCH 05/16] refactor: inline close prompt action handling --- src-tauri/src/bridge/commands.rs | 44 -------------------------------- src-tauri/src/close_behavior.rs | 10 +++++--- 2 files changed, 7 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index 78b97390..6ba87f24 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -550,50 +550,6 @@ mod tests { ); } - #[test] - fn parse_close_prompt_action_accepts_supported_values() { - assert_eq!(parse_close_prompt_action("tray"), Ok(CloseAction::Tray)); - assert_eq!(parse_close_prompt_action("exit"), Ok(CloseAction::Exit)); - } - - #[test] - fn parse_close_prompt_action_rejects_invalid_values() { - assert_eq!( - parse_close_prompt_action("minimize"), - Err("Invalid close action. Expected 'tray' or 'exit'.".to_string()) - ); - } - - #[test] - fn finish_tray_close_prompt_cleanup_logs_failures_without_failing_result() { - let logs = Rc::new(RefCell::new(Vec::new())); - let captured_logs = Rc::clone(&logs); - - let result = - finish_tray_close_prompt_cleanup(Err("close failed".to_string()), move |message| { - captured_logs.borrow_mut().push(message.to_string()); - }); - - assert!(result.ok); - assert_eq!(result.reason, None); - assert_eq!(logs.borrow().len(), 1); - assert!(logs.borrow()[0].contains("close failed")); - } - - #[test] - fn finish_tray_close_prompt_cleanup_returns_success_without_logs_for_clean_close() { - let logs = Rc::new(RefCell::new(Vec::new())); - let captured_logs = Rc::clone(&logs); - - let result = finish_tray_close_prompt_cleanup(Ok(()), move |message| { - captured_logs.borrow_mut().push(message.to_string()); - }); - - assert!(result.ok); - assert_eq!(result.reason, None); - assert!(logs.borrow().is_empty()); - } - #[test] fn updater_check_manual_download_mode_only_reports_reason_without_forced_update_flag() { let result = crate::bridge::updater_types::map_manual_download_no_update_result( diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 180997db..28105ade 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -13,8 +13,8 @@ fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result, { - let value = Option::::deserialize(deserializer)?; - Ok(value.and_then(|value| serde_json::from_value::(value).ok())) + let raw = Option::::deserialize(deserializer)?; + Ok(raw.as_deref().and_then(parse_close_action)) } #[derive(Debug, Default, Serialize, Deserialize)] @@ -32,7 +32,11 @@ struct DesktopState { } pub(crate) fn parse_close_action(raw: &str) -> Option { - serde_json::from_value(Value::String(raw.to_string())).ok() + match raw { + "tray" => Some(CloseAction::Tray), + "exit" => Some(CloseAction::Exit), + _ => None, + } } fn load_desktop_state(raw: &str, log_subject: &str) -> DesktopState { From b8873db0465cf29dd8e853e9a62a0a6be6978de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:19:51 +0900 Subject: [PATCH 06/16] refactor: share desktop close exit handling --- scripts/ui/close-confirm.test.mjs | 8 ++++++ src-tauri/src/app_runtime.rs | 14 +++++----- src-tauri/src/bridge/commands.rs | 7 +++-- src-tauri/src/close_behavior.rs | 28 ++++++++++++------- src-tauri/src/lifecycle/events.rs | 45 +++++++++++++++++++++++++++++- src-tauri/src/tray/menu_handler.rs | 10 +++---- ui/close-confirm.html | 9 +++++- 7 files changed, 94 insertions(+), 27 deletions(-) diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs index 28329f6c..152e7e46 100644 --- a/scripts/ui/close-confirm.test.mjs +++ b/scripts/ui/close-confirm.test.mjs @@ -23,3 +23,11 @@ test('close confirm dialog avoids exposing raw invoke errors to users', async () 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\(/); +}); diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 136c5f08..11d999ac 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -4,9 +4,9 @@ use tauri::{ }; use crate::{ - app_runtime_events, append_desktop_log, append_shutdown_log, append_startup_log, bridge, - close_behavior, 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 { @@ -82,10 +82,10 @@ fn configure_window_events(builder: Builder) -> Builder ); } app_runtime_events::MainWindowAction::ExitApplication => { - let state = window.app_handle().state::(); - state.mark_quitting(); - append_shutdown_log("main window close requested with saved exit preference"); - window.app_handle().exit(0); + lifecycle::events::request_immediate_exit( + window.app_handle(), + lifecycle::events::ImmediateExitTrigger::SavedExitPreference, + ); } app_runtime_events::MainWindowAction::HideIfMinimized => { window::actions::hide_main_window( diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index 6ba87f24..dad5785c 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -356,9 +356,10 @@ pub(crate) fn desktop_bridge_submit_close_prompt( finish_tray_close_prompt_cleanup(cleanup_result, append_desktop_log) } CloseAction::Exit => { - let state = app_handle.state::(); - state.mark_quitting(); - app_handle.exit(0); + crate::lifecycle::events::request_immediate_exit( + &app_handle, + crate::lifecycle::events::ImmediateExitTrigger::ClosePromptExitAction, + ); BackendBridgeResult { ok: true, reason: None, diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 28105ade..801ffb0a 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -13,8 +13,11 @@ fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result, { - let raw = Option::::deserialize(deserializer)?; - Ok(raw.as_deref().and_then(parse_close_action)) + let raw = Option::::deserialize(deserializer)?; + Ok(match raw { + Some(Value::String(raw)) => parse_close_action(&raw), + _ => None, + }) } #[derive(Debug, Default, Serialize, Deserialize)] @@ -62,7 +65,7 @@ fn read_cached_close_action_at_path(state_path: &Path) -> Option { state.close_action } -fn ensure_parent_dir(path: &Path) -> Result<(), String> { +fn save_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!( @@ -73,12 +76,6 @@ fn ensure_parent_dir(path: &Path) -> Result<(), String> { })?; } - Ok(()) -} - -fn save_state(path: &Path, state: &T) -> Result<(), String> { - ensure_parent_dir(path)?; - let serialized = serde_json::to_string_pretty(state) .map_err(|error| format!("Failed to serialize close behavior state: {error}"))?; let tmp_name = format!( @@ -149,7 +146,7 @@ fn write_cached_close_action_at_path( }; state.close_action = action; - save_state(state_path, &state)?; + save_desktop_state(state_path, &state)?; Ok(()) } @@ -213,6 +210,17 @@ mod tests { 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", + ); + + 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(); 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/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/ui/close-confirm.html b/ui/close-confirm.html index 99784c08..92aef4dc 100644 --- a/ui/close-confirm.html +++ b/ui/close-confirm.html @@ -195,6 +195,7 @@

const trayButton = document.getElementById("tray-button"); const exitButton = document.getElementById("exit-button"); const error = document.getElementById("error"); + const invokeTauri = window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke || window.__TAURI_INTERNALS__?.invoke; let recoveryTimer = null; document.documentElement.lang = copy.lang; @@ -223,7 +224,13 @@

setPending(true); try { - const result = await window.__TAURI_INTERNALS__.invoke( + if (typeof invokeTauri !== "function") { + error.textContent = copy.submitError; + setPending(false); + return; + } + + const result = await invokeTauri( "desktop_bridge_submit_close_prompt", { action, remember: remember.checked } ); From 1e242899f3ec995c5e5e24e62728e6b83e69b45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:31:53 +0900 Subject: [PATCH 07/16] fix: clarify close prompt cleanup log --- src-tauri/src/bridge/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index dad5785c..d73c245c 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -175,7 +175,7 @@ where { if let Err(error) = cleanup_result { log(&format!( - "Failed to close close confirm prompt window: {error}" + "Failed to close confirm prompt window: {error}" )); } From dfc0ce2813757cb4d0c1f6957b9f34f6f185b264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:40:40 +0900 Subject: [PATCH 08/16] refactor: sync close confirm action values --- scripts/ui/close-confirm.test.mjs | 11 ++++++++++- src-tauri/src/bridge/commands.rs | 17 ++++++++--------- src-tauri/src/close_behavior.rs | 20 +++++++++++++++----- src-tauri/src/window/close_confirm.rs | 10 +++++++--- ui/close-confirm.html | 10 ++++++---- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs index 152e7e46..9305ea2b 100644 --- a/scripts/ui/close-confirm.test.mjs +++ b/scripts/ui/close-confirm.test.mjs @@ -28,6 +28,15 @@ test('close confirm dialog routes Tauri command calls through a local invoke wra const html = await readHtml(); assert.match(html, /const invokeTauri =/); - assert.doesNotMatch(html, /window\.__TAURI_INTERNALS__\.invoke\(/); + 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"\)/); +}); diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index d73c245c..f6218b6a 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -174,9 +174,7 @@ where Log: Fn(&str), { if let Err(error) = cleanup_result { - log(&format!( - "Failed to close confirm prompt window: {error}" - )); + log(&format!("Failed to close confirm prompt window: {error}")); } BackendBridgeResult { @@ -346,12 +344,13 @@ pub(crate) fn desktop_bridge_submit_close_prompt( DEFAULT_SHELL_LOCALE, append_desktop_log, ); - let cleanup_result = - if let Some(prompt_window) = app_handle.get_webview_window("close-confirm") { - prompt_window.close().map_err(|error| error.to_string()) - } else { - Ok(()) - }; + 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) } diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 801ffb0a..c412e46b 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -1,7 +1,10 @@ -use serde::{Deserialize, Deserializer, Serialize}; +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 { @@ -13,9 +16,16 @@ fn deserialize_close_action_option<'de, D>(deserializer: D) -> Result, { - let raw = Option::::deserialize(deserializer)?; + #[derive(Deserialize)] + #[serde(untagged)] + enum RawCloseAction { + String(String), + Other(IgnoredAny), + } + + let raw = Option::::deserialize(deserializer)?; Ok(match raw { - Some(Value::String(raw)) => parse_close_action(&raw), + Some(RawCloseAction::String(raw)) => parse_close_action(&raw), _ => None, }) } @@ -36,8 +46,8 @@ struct DesktopState { pub(crate) fn parse_close_action(raw: &str) -> Option { match raw { - "tray" => Some(CloseAction::Tray), - "exit" => Some(CloseAction::Exit), + CLOSE_ACTION_TRAY => Some(CloseAction::Tray), + CLOSE_ACTION_EXIT => Some(CloseAction::Exit), _ => None, } } diff --git a/src-tauri/src/window/close_confirm.rs b/src-tauri/src/window/close_confirm.rs index ff704c76..a5a9a29a 100644 --- a/src-tauri/src/window/close_confirm.rs +++ b/src-tauri/src/window/close_confirm.rs @@ -1,11 +1,15 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; -const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm"; +pub(crate) const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm"; const CLOSE_CONFIRM_WINDOW_WIDTH: f64 = 420.0; const CLOSE_CONFIRM_WINDOW_HEIGHT: f64 = 320.0; pub(crate) fn build_close_confirm_path(locale: &str) -> String { - format!("close-confirm.html?locale={locale}") + 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 { @@ -67,7 +71,7 @@ mod tests { fn build_close_confirm_path_appends_locale_query() { assert_eq!( build_close_confirm_path("en-US"), - "close-confirm.html?locale=en-US" + "close-confirm.html?locale=en-US&trayAction=tray&exitAction=exit" ); } diff --git a/ui/close-confirm.html b/ui/close-confirm.html index 92aef4dc..a27abfbb 100644 --- a/ui/close-confirm.html +++ b/ui/close-confirm.html @@ -190,12 +190,14 @@

const params = new URLSearchParams(window.location.search); const locale = params.get("locale") === "zh-CN" ? "zh-CN" : "en-US"; + const trayAction = params.get("trayAction"); + const exitAction = params.get("exitAction"); const copy = messages[locale]; const remember = document.getElementById("remember"); const trayButton = document.getElementById("tray-button"); const exitButton = document.getElementById("exit-button"); const error = document.getElementById("error"); - const invokeTauri = window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke || window.__TAURI_INTERNALS__?.invoke; + const invokeTauri = window.__TAURI__?.core?.invoke || window.__TAURI__?.invoke; let recoveryTimer = null; document.documentElement.lang = copy.lang; @@ -224,7 +226,7 @@

setPending(true); try { - if (typeof invokeTauri !== "function") { + if (typeof action !== "string" || action.length === 0 || typeof invokeTauri !== "function") { error.textContent = copy.submitError; setPending(false); return; @@ -265,8 +267,8 @@

}); window.addEventListener("pagehide", clearRecoveryTimer); - trayButton.addEventListener("click", () => submit("tray")); - exitButton.addEventListener("click", () => submit("exit")); + trayButton.addEventListener("click", () => submit(trayAction)); + exitButton.addEventListener("click", () => submit(exitAction)); From 8adb9ea5ace167463a1899fa557357888e48ed90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 11:53:41 +0900 Subject: [PATCH 09/16] refactor: inject close prompt logging callbacks --- src-tauri/src/app_runtime.rs | 6 +- src-tauri/src/bridge/commands.rs | 17 ++++-- src-tauri/src/close_behavior.rs | 84 ++++++++++++++++++++------- src-tauri/src/window/close_confirm.rs | 72 +++++++++++++++++++---- 4 files changed, 139 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 11d999ac..3451179a 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -25,8 +25,10 @@ fn configure_window_events(builder: Builder) -> Builder let action = match &event { 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()); + 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(), diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index f6218b6a..7ff4ce73 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -162,8 +162,13 @@ 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(|| "Invalid close action. Expected 'tray' or 'exit'.".to_string()) + 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( @@ -328,9 +333,11 @@ pub(crate) fn desktop_bridge_submit_close_prompt( 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()) - { + 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; continuing with selected action: {error}" )); diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index c412e46b..5a131c07 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -52,11 +52,14 @@ pub(crate) fn parse_close_action(raw: &str) -> Option { } } -fn load_desktop_state(raw: &str, log_subject: &str) -> DesktopState { +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) => { - crate::append_desktop_log(&format!( + log(&format!( "failed to parse {log_subject}: {error}. resetting state semantics" )); DesktopState::default() @@ -64,14 +67,23 @@ fn load_desktop_state(raw: &str, log_subject: &str) -> DesktopState { } } -pub(crate) fn read_cached_close_action(packaged_root_dir: Option<&Path>) -> Option { +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) + read_cached_close_action_at_path(&state_path, &log) } -fn read_cached_close_action_at_path(state_path: &Path) -> Option { +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"); + let state = load_desktop_state(&raw, "desktop close behavior state", log); state.close_action } @@ -121,29 +133,36 @@ fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { }) } -pub(crate) fn write_cached_close_action( +pub(crate) fn write_cached_close_action( action: Option, packaged_root_dir: Option<&Path>, -) -> Result<(), String> { + log: F, +) -> Result<(), String> +where + F: Fn(&str), +{ let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) else { - crate::append_desktop_log( - "close behavior state path is unavailable; skipping close action persistence", - ); + log("close behavior state path is unavailable; skipping close action persistence"); return Ok(()); }; - write_cached_close_action_at_path(action, &state_path) + write_cached_close_action_at_path(action, &state_path, &log) } -fn write_cached_close_action_at_path( +fn write_cached_close_action_at_path( action: Option, state_path: &Path, -) -> Result<(), String> { + 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) => { @@ -170,6 +189,8 @@ mod tests { 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") } @@ -179,7 +200,7 @@ mod tests { let temp_dir = tempfile::tempdir().expect("temp dir"); assert_eq!( - read_cached_close_action_at_path(&state_path(&temp_dir)), + read_cached_close_action_at_path(&state_path(&temp_dir), &noop_log), None ); } @@ -203,6 +224,7 @@ mod tests { let state = load_desktop_state( r#"{"closeActionOnWindowClose":"tray","locale":"zh-CN"}"#, "test desktop state", + &noop_log, ); assert_eq!(state.close_action, Some(CloseAction::Tray)); @@ -214,6 +236,7 @@ mod tests { let state = load_desktop_state( r#"{"closeActionOnWindowClose":"bogus","locale":"en-US"}"#, "test desktop state", + &noop_log, ); assert_eq!(state.close_action, None); @@ -225,6 +248,7 @@ mod tests { let state = load_desktop_state( r#"{"closeActionOnWindowClose":true,"locale":"en-US"}"#, "test desktop state", + &noop_log, ); assert_eq!(state.close_action, None); @@ -241,6 +265,20 @@ mod tests { 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"); @@ -256,7 +294,7 @@ mod tests { ) .expect("write state"); - write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path) + write_cached_close_action_at_path(Some(CloseAction::Tray), &state_path, &noop_log) .expect("write close action"); let saved: serde_json::Value = @@ -275,7 +313,7 @@ mod tests { 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) + write_cached_close_action_at_path(Some(CloseAction::Exit), &state_path, &noop_log) .expect("write close action"); let saved: serde_json::Value = @@ -290,11 +328,11 @@ mod tests { 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) + 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), + read_cached_close_action_at_path(&state_path, &noop_log), Some(CloseAction::Tray) ); } @@ -314,7 +352,8 @@ mod tests { ) .expect("write state"); - write_cached_close_action_at_path(None, &state_path).expect("clear close action"); + 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")) @@ -331,6 +370,9 @@ mod tests { 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), None); + assert_eq!( + read_cached_close_action_at_path(&state_path, &noop_log), + None + ); } } diff --git a/src-tauri/src/window/close_confirm.rs b/src-tauri/src/window/close_confirm.rs index a5a9a29a..814ec774 100644 --- a/src-tauri/src/window/close_confirm.rs +++ b/src-tauri/src/window/close_confirm.rs @@ -20,6 +20,24 @@ 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, @@ -29,17 +47,21 @@ where F: Fn(&str), { if let Some(window) = app_handle.get_webview_window(CLOSE_CONFIRM_WINDOW_LABEL) { - if let Err(error) = window.unminimize() { - log(&format!( - "failed to unminimize close confirm window: {error}" - )); - } - if let Err(error) = window.show() { - log(&format!("failed to show close confirm window: {error}")); - } - if let Err(error) = window.set_focus() { - log(&format!("failed to focus close confirm window: {error}")); - } + 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(()); } @@ -65,7 +87,10 @@ where #[cfg(test)] mod tests { - use super::{build_close_confirm_path, close_confirm_window_size}; + use super::{ + build_close_confirm_path, close_confirm_window_size, + handle_existing_window_operation_result, + }; #[test] fn build_close_confirm_path_appends_locale_query() { @@ -79,4 +104,27 @@ mod tests { fn close_confirm_window_size_fits_desktop_dialog_without_clipping() { assert_eq!(close_confirm_window_size(), (420.0, 320.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"] + ); + } } From 99a484568e91f661b8e6abcf1db6bec98144087d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 17:10:18 +0900 Subject: [PATCH 10/16] refactor: streamline close behavior persistence --- src-tauri/src/close_behavior.rs | 39 +++++++++++++++------------------ 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 5a131c07..abd98987 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -5,6 +5,8 @@ use std::{fs, io::Write, path::Path}; pub(crate) const CLOSE_ACTION_TRAY: &str = "tray"; pub(crate) const CLOSE_ACTION_EXIT: &str = "exit"; +type Logger<'a> = dyn Fn(&str) + 'a; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum CloseAction { @@ -52,10 +54,7 @@ pub(crate) fn parse_close_action(raw: &str) -> Option { } } -fn load_desktop_state(raw: &str, log_subject: &str, log: &F) -> DesktopState -where - F: Fn(&str), -{ +fn load_desktop_state(raw: &str, log_subject: &str, log: &Logger<'_>) -> DesktopState { match serde_json::from_str::(raw) { Ok(state) => state, Err(error) => { @@ -78,28 +77,25 @@ where 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), -{ +fn read_cached_close_action_at_path(state_path: &Path, log: &Logger<'_>) -> Option { let raw = fs::read_to_string(state_path).ok()?; let state = load_desktop_state(&raw, "desktop close behavior state", log); state.close_action } -fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { +fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result<(), String> { if let Some(parent_dir) = path.parent() { fs::create_dir_all(parent_dir).map_err(|error| { format!( - "Failed to create close behavior directory {}: {}", + "Failed to create {what} directory {}: {}", parent_dir.display(), error ) })?; } - let serialized = serde_json::to_string_pretty(state) - .map_err(|error| format!("Failed to serialize close behavior state: {error}"))?; + let serialized = serde_json::to_string_pretty(value) + .map_err(|error| format!("Failed to serialize {what}: {error}"))?; let tmp_name = format!( "{}.tmp", path.file_name() @@ -110,7 +106,7 @@ fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { let mut file = fs::File::create(&tmp_path).map_err(|error| { format!( - "Failed to create temporary close behavior state file {}: {}", + "Failed to create temporary {what} file {}: {}", tmp_path.display(), error ) @@ -119,20 +115,24 @@ fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { .and_then(|_| file.sync_all()) .map_err(|error| { format!( - "Failed to write temporary close behavior state file {}: {}", + "Failed to write temporary {what} file {}: {}", tmp_path.display(), error ) })?; fs::rename(&tmp_path, path).map_err(|error| { format!( - "Failed to atomically replace close behavior state file {}: {}", + "Failed to atomically replace {what} file {}: {}", path.display(), error ) }) } +fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { + atomic_write_json(path, state, "close behavior state") +} + pub(crate) fn write_cached_close_action( action: Option, packaged_root_dir: Option<&Path>, @@ -150,14 +150,11 @@ where write_cached_close_action_at_path(action, &state_path, &log) } -fn write_cached_close_action_at_path( +fn write_cached_close_action_at_path( action: Option, state_path: &Path, - log: &F, -) -> Result<(), String> -where - F: Fn(&str), -{ + log: &Logger<'_>, +) -> Result<(), String> { let mut state = match fs::read_to_string(state_path) { Ok(raw) => load_desktop_state( &raw, From ac4e7d1b8b88fd7d298c88eeaa6de6d7ad432fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 17:22:47 +0900 Subject: [PATCH 11/16] refactor: simplify close behavior io helpers --- src-tauri/src/close_behavior.rs | 37 ++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index abd98987..7a9b6341 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -5,8 +5,6 @@ use std::{fs, io::Write, path::Path}; pub(crate) const CLOSE_ACTION_TRAY: &str = "tray"; pub(crate) const CLOSE_ACTION_EXIT: &str = "exit"; -type Logger<'a> = dyn Fn(&str) + 'a; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum CloseAction { @@ -54,7 +52,10 @@ pub(crate) fn parse_close_action(raw: &str) -> Option { } } -fn load_desktop_state(raw: &str, log_subject: &str, log: &Logger<'_>) -> DesktopState { +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) => { @@ -77,25 +78,28 @@ where read_cached_close_action_at_path(&state_path, &log) } -fn read_cached_close_action_at_path(state_path: &Path, log: &Logger<'_>) -> Option { +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_json(path: &Path, value: &impl Serialize, what: &str) -> Result<(), String> { +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 {what} directory {}: {}", + "Failed to create desktop state directory {}: {}", parent_dir.display(), error ) })?; } - let serialized = serde_json::to_string_pretty(value) - .map_err(|error| format!("Failed to serialize {what}: {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() @@ -106,7 +110,7 @@ fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result< let mut file = fs::File::create(&tmp_path).map_err(|error| { format!( - "Failed to create temporary {what} file {}: {}", + "Failed to create temporary desktop state file {}: {}", tmp_path.display(), error ) @@ -115,14 +119,14 @@ fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result< .and_then(|_| file.sync_all()) .map_err(|error| { format!( - "Failed to write temporary {what} file {}: {}", + "Failed to write temporary desktop state file {}: {}", tmp_path.display(), error ) })?; fs::rename(&tmp_path, path).map_err(|error| { format!( - "Failed to atomically replace {what} file {}: {}", + "Failed to atomically replace desktop state file {}: {}", path.display(), error ) @@ -130,7 +134,7 @@ fn atomic_write_json(path: &Path, value: &impl Serialize, what: &str) -> Result< } fn save_desktop_state(path: &Path, state: &DesktopState) -> Result<(), String> { - atomic_write_json(path, state, "close behavior state") + atomic_write_desktop_state(path, state) } pub(crate) fn write_cached_close_action( @@ -150,11 +154,14 @@ where write_cached_close_action_at_path(action, &state_path, &log) } -fn write_cached_close_action_at_path( +fn write_cached_close_action_at_path( action: Option, state_path: &Path, - log: &Logger<'_>, -) -> Result<(), String> { + log: &F, +) -> Result<(), String> +where + F: Fn(&str), +{ let mut state = match fs::read_to_string(state_path) { Ok(raw) => load_desktop_state( &raw, From ee14baeeb741090ca844aca2150740ce795c531e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 17:59:25 +0900 Subject: [PATCH 12/16] fix: surface close preference save failures --- scripts/ui/close-confirm.test.mjs | 8 ++++++++ src-tauri/src/bridge/commands.rs | 15 ++++++++++++++- src-tauri/src/close_behavior.rs | 18 ++++++++++++++++-- ui/close-confirm.html | 20 +++++++++++--------- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs index 9305ea2b..15e2fec1 100644 --- a/scripts/ui/close-confirm.test.mjs +++ b/scripts/ui/close-confirm.test.mjs @@ -40,3 +40,11 @@ test('close confirm dialog reads close action values from query params instead o 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\(\);/); +}); diff --git a/src-tauri/src/bridge/commands.rs b/src-tauri/src/bridge/commands.rs index 7ff4ce73..edac05ff 100644 --- a/src-tauri/src/bridge/commands.rs +++ b/src-tauri/src/bridge/commands.rs @@ -339,8 +339,12 @@ pub(crate) fn desktop_bridge_submit_close_prompt( append_desktop_log, ) { append_desktop_log(&format!( - "failed to persist remembered close action; continuing with selected action: {error}" + "failed to persist remembered close action; aborting selected action: {error}" )); + return BackendBridgeResult { + ok: false, + reason: Some(error), + }; } } @@ -362,6 +366,15 @@ pub(crate) fn desktop_bridge_submit_close_prompt( 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, diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index 7a9b6341..dd50c26b 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -147,8 +147,9 @@ where { let Some(state_path) = crate::desktop_state::resolve_desktop_state_path(packaged_root_dir) else { - log("close behavior state path is unavailable; skipping close action persistence"); - return Ok(()); + 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) @@ -379,4 +380,17 @@ mod tests { 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/ui/close-confirm.html b/ui/close-confirm.html index a27abfbb..60e8e01e 100644 --- a/ui/close-confirm.html +++ b/ui/close-confirm.html @@ -243,16 +243,18 @@

return; } - recoveryTimer = window.setTimeout(() => { - recoveryTimer = null; - if (document.visibilityState !== "hidden") { - error.textContent = copy.submitStalled; - setPending(false); + if (action === trayAction) { + recoveryTimer = window.setTimeout(() => { + recoveryTimer = null; + if (document.visibilityState !== "hidden") { + error.textContent = copy.submitStalled; + setPending(false); + } + }, 1500); + + if (typeof window.close === "function") { + window.close(); } - }, 1500); - - if (typeof window.close === "function") { - window.close(); } } catch (_invokeError) { error.textContent = copy.submitError; From 03929434cdfda6dfcc3f603c4ea9dfb27ad18edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 18:31:38 +0900 Subject: [PATCH 13/16] test: fix clippy needless borrow in close behavior --- src-tauri/src/close_behavior.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/close_behavior.rs b/src-tauri/src/close_behavior.rs index dd50c26b..7d5c0362 100644 --- a/src-tauri/src/close_behavior.rs +++ b/src-tauri/src/close_behavior.rs @@ -383,7 +383,7 @@ mod tests { #[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); + let result = super::write_cached_close_action(Some(CloseAction::Tray), None, noop_log); assert_eq!( result, From 45230cdf7f2a75149cfd24f73a19a67be1870490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 19:12:30 +0900 Subject: [PATCH 14/16] fix: avoid false exit errors in close prompt --- scripts/ui/close-confirm.test.mjs | 8 ++++++++ src-tauri/src/window/close_confirm.rs | 6 +++--- ui/close-confirm.html | 9 ++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/ui/close-confirm.test.mjs b/scripts/ui/close-confirm.test.mjs index 15e2fec1..7b070c06 100644 --- a/scripts/ui/close-confirm.test.mjs +++ b/scripts/ui/close-confirm.test.mjs @@ -48,3 +48,11 @@ test('close confirm dialog only schedules frontend close fallback for tray actio 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/src/window/close_confirm.rs b/src-tauri/src/window/close_confirm.rs index 814ec774..802437e0 100644 --- a/src-tauri/src/window/close_confirm.rs +++ b/src-tauri/src/window/close_confirm.rs @@ -1,8 +1,8 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; pub(crate) const CLOSE_CONFIRM_WINDOW_LABEL: &str = "close-confirm"; -const CLOSE_CONFIRM_WINDOW_WIDTH: f64 = 420.0; -const CLOSE_CONFIRM_WINDOW_HEIGHT: f64 = 320.0; +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!( @@ -102,7 +102,7 @@ mod tests { #[test] fn close_confirm_window_size_fits_desktop_dialog_without_clipping() { - assert_eq!(close_confirm_window_size(), (420.0, 320.0)); + assert_eq!(close_confirm_window_size(), (404.0, 292.0)); } #[test] diff --git a/ui/close-confirm.html b/ui/close-confirm.html index 60e8e01e..5fca9bf1 100644 --- a/ui/close-confirm.html +++ b/ui/close-confirm.html @@ -28,8 +28,8 @@ .dialog { box-sizing: border-box; - width: min(100%, 420px); - padding: 20px; + width: 100%; + padding: 12px; } .card { @@ -108,7 +108,7 @@ @media (max-width: 380px) { .dialog { - padding: 16px; + padding: 10px; } .actions { @@ -257,6 +257,9 @@

} } } catch (_invokeError) { + if (action === exitAction) { + return; + } error.textContent = copy.submitError; setPending(false); } From 7acc59cba257fa85cf43a7e10e8c5d9487ccfb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 19:17:56 +0900 Subject: [PATCH 15/16] fix: keep main window visible when prompt fails --- src-tauri/src/app_runtime.rs | 5 ----- src-tauri/src/app_runtime_events.rs | 21 +++++++++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index 3451179a..c57f9c22 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -66,11 +66,6 @@ fn configure_window_events(builder: Builder) -> Builder append_desktop_log(&format!( "failed to open close confirm prompt window: {error}" )); - window::actions::hide_main_window( - window.app_handle(), - DEFAULT_SHELL_LOCALE, - append_desktop_log, - ); } } app_runtime_events::MainWindowAction::PreventCloseAndHide => { diff --git a/src-tauri/src/app_runtime_events.rs b/src-tauri/src/app_runtime_events.rs index 52d94eaf..39574aa6 100644 --- a/src-tauri/src/app_runtime_events.rs +++ b/src-tauri/src/app_runtime_events.rs @@ -27,6 +27,11 @@ pub(crate) enum RunEventAction { HandleExit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClosePromptFailureAction { + KeepMainWindowVisible, +} + pub(crate) fn main_window_action( window_label: &str, is_quitting: bool, @@ -96,11 +101,15 @@ pub(crate) fn run_event_action(event: &RunEvent) -> RunEventAction { } } +pub(crate) fn close_prompt_failure_action() -> ClosePromptFailureAction { + ClosePromptFailureAction::KeepMainWindowVisible +} + #[cfg(test)] mod tests { use super::{ - main_window_action, page_load_action, run_event_action, MainWindowAction, PageLoadAction, - RunEventAction, + close_prompt_failure_action, main_window_action, page_load_action, run_event_action, + ClosePromptFailureAction, MainWindowAction, PageLoadAction, RunEventAction, }; use crate::close_behavior::CloseAction; use tauri::{webview::PageLoadEvent, RunEvent}; @@ -178,4 +187,12 @@ mod tests { RunEventAction::HandleExit ); } + + #[test] + fn close_prompt_failure_action_keeps_main_window_visible() { + assert_eq!( + close_prompt_failure_action(), + ClosePromptFailureAction::KeepMainWindowVisible + ); + } } From e5d7b14dc5bb41b4ce3e94a4521d66dc1d6985eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Tue, 14 Apr 2026 20:20:25 +0900 Subject: [PATCH 16/16] test: remove unused close prompt fallback helpers --- src-tauri/src/app_runtime_events.rs | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/app_runtime_events.rs b/src-tauri/src/app_runtime_events.rs index 39574aa6..52d94eaf 100644 --- a/src-tauri/src/app_runtime_events.rs +++ b/src-tauri/src/app_runtime_events.rs @@ -27,11 +27,6 @@ pub(crate) enum RunEventAction { HandleExit, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ClosePromptFailureAction { - KeepMainWindowVisible, -} - pub(crate) fn main_window_action( window_label: &str, is_quitting: bool, @@ -101,15 +96,11 @@ pub(crate) fn run_event_action(event: &RunEvent) -> RunEventAction { } } -pub(crate) fn close_prompt_failure_action() -> ClosePromptFailureAction { - ClosePromptFailureAction::KeepMainWindowVisible -} - #[cfg(test)] mod tests { use super::{ - close_prompt_failure_action, main_window_action, page_load_action, run_event_action, - ClosePromptFailureAction, MainWindowAction, PageLoadAction, RunEventAction, + main_window_action, page_load_action, run_event_action, MainWindowAction, PageLoadAction, + RunEventAction, }; use crate::close_behavior::CloseAction; use tauri::{webview::PageLoadEvent, RunEvent}; @@ -187,12 +178,4 @@ mod tests { RunEventAction::HandleExit ); } - - #[test] - fn close_prompt_failure_action_keeps_main_window_visible() { - assert_eq!( - close_prompt_failure_action(), - ClosePromptFailureAction::KeepMainWindowVisible - ); - } }