From 6b8c77a578ec48c0a917f1f69876f252b53ca0f7 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:33 +0800 Subject: [PATCH 1/6] chore: reserve implementation for #166 From fd52fbb1e92dd19c90aaa24ee57ce189993740fe Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:36 +0800 Subject: [PATCH 2/6] chore: reserve implementation for #167 From a53750a9f8befed62f10ee1a217acb539ee383a7 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:38 +0800 Subject: [PATCH 3/6] chore: reserve implementation for #168 From 4518207d6961f0c3c0cec4bb45fd570cbeeb0d53 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:20:31 +0800 Subject: [PATCH 4/6] fix(shell): harden webview and crash recovery --- package.json | 2 +- scripts/check-renderer-entry.mjs | 43 ++++ src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 6 + src-tauri/capabilities/default.json | 8 +- src-tauri/src/lib.rs | 341 ++++++++++++++++++++++++++++ src-tauri/src/state.rs | 68 ++++++ src-tauri/tauri.conf.json | 4 +- src/app/App.tsx | 7 +- src/app/ErrorBoundary.test.tsx | 47 ++-- src/app/ErrorBoundary.tsx | 48 ++-- src/app/RootCrashBoundary.test.tsx | 150 ++++++++++++ src/app/RootCrashBoundary.tsx | 235 +++++++++++++++++++ src/app/bootstrap.tsx | 34 +++ src/app/contextMenuPolicy.test.ts | 115 ++++++++++ src/app/contextMenuPolicy.ts | 87 ++++++- src/app/rendererEntry.test.ts | 85 +++++++ src/app/rendererEntry.ts | 33 +++ src/app/securityConfig.test.ts | 55 +++++ src/app/staticCrashFallback.test.ts | 153 +++++++++++++ src/app/staticCrashFallback.ts | 232 +++++++++++++++++++ src/main.tsx | 29 +-- 22 files changed, 1710 insertions(+), 74 deletions(-) create mode 100644 scripts/check-renderer-entry.mjs create mode 100644 src/app/RootCrashBoundary.test.tsx create mode 100644 src/app/RootCrashBoundary.tsx create mode 100644 src/app/bootstrap.tsx create mode 100644 src/app/rendererEntry.test.ts create mode 100644 src/app/rendererEntry.ts create mode 100644 src/app/securityConfig.test.ts create mode 100644 src/app/staticCrashFallback.test.ts create mode 100644 src/app/staticCrashFallback.ts diff --git a/package.json b/package.json index 89e1ada..df08bd3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && node scripts/check-renderer-entry.mjs", "preview": "vite preview", "check": "tsc --noEmit", "lint": "eslint . --max-warnings=0", diff --git a/scripts/check-renderer-entry.mjs b/scripts/check-renderer-entry.mjs new file mode 100644 index 0000000..b77dcf7 --- /dev/null +++ b/scripts/check-renderer-entry.mjs @@ -0,0 +1,43 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dist = new URL("../dist/", import.meta.url); +const html = readFileSync(new URL("index.html", dist), "utf8"); +const entryMatch = html.match(/]+type="module"[^>]+src="([^"]+)"/); +if (!entryMatch) throw new Error("renderer entry script was not found in dist/index.html"); + +const entryName = basename(entryMatch[1]); +const assetsDir = new URL("assets/", dist); +const assetsPath = fileURLToPath(assetsDir); +const entry = readFileSync(new URL(entryName, assetsDir), "utf8"); + +const forbiddenEntryMarkers = [ + "react.transitional.element", + "__REACT_DEVTOOLS_GLOBAL_HOOK__", + "createRoot=function", + "The app shell could not start", +]; +for (const marker of forbiddenEntryMarkers) { + if (entry.includes(marker)) { + throw new Error(`dependency-light renderer entry unexpectedly contains ${marker}`); + } +} +if (!entry.includes("管理器界面无法启动")) { + throw new Error("dependency-light renderer entry does not contain the static crash fallback"); +} +if (!entry.includes("bootstrap-") || !entry.includes("import(")) { + throw new Error("renderer entry does not dynamically load a separate bootstrap chunk"); +} +if (/rel="modulepreload"[^>]+bootstrap-/i.test(html)) { + throw new Error("bootstrap chunk is eagerly preloaded with the dependency-light entry"); +} + +const otherJavaScript = readdirSync(assetsPath) + .filter((name) => name.endsWith(".js") && name !== entryName) + .map((name) => readFileSync(join(assetsPath, name), "utf8")); +if (!otherJavaScript.some((source) => source.includes("react.transitional.element"))) { + throw new Error("React was not isolated into a non-entry renderer chunk"); +} + +console.log(`renderer entry isolation verified: ${entryName}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6767d44..d9dfefb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -631,6 +631,8 @@ dependencies = [ "thiserror 2.0.18", "url", "uuid", + "webview2-com", + "windows-core 0.61.2", "windows-sys 0.61.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc12a57..702623b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,3 +43,9 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Stora [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(windows)'.dependencies] +# Keep these pinned to Tauri/Wry's WebView2 ABI: the platform handle returned +# by `WebviewWindow::with_webview` exposes these exact COM interfaces. +webview2-com = "=0.38.2" +windows-core = "0.61.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 354beb2..8d1b5c5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,12 +4,14 @@ "description": "Default permissions for the main desktop window.", "windows": ["main"], "permissions": [ - "core:default", + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "core:window:allow-internal-toggle-maximize", "core:window:allow-start-dragging", "core:window:allow-close", "core:window:allow-minimize", "dialog:allow-open", - "updater:default", - "process:default" + "process:allow-restart" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aff96e3..31b5be1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,6 +53,233 @@ fn apply_quit_policy(app: &tauri::AppHandle, policy: &QuitPolicy) -> bool { } } +/// The bundled custom-protocol scheme differs by platform. Windows uses the +/// HTTP(S) compatibility origin selected by `useHttpsScheme`; desktop WebKit +/// uses the `tauri:` origin. Keep this decision next to the navigation gate so +/// a future config change cannot silently widen the allowlist. +fn bundled_app_scheme(use_https_scheme: bool) -> &'static str { + #[cfg(target_os = "windows")] + { + if use_https_scheme { + "https" + } else { + "http" + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = use_https_scheme; + "tauri" + } +} + +/// Only the exact bundled app origin may replace the top-level document. +/// External links already go through the validated `open_url` command and +/// system shell. +fn is_allowed_app_navigation(url: &url::Url, allow_dev_server: bool, bundled_scheme: &str) -> bool { + let expected_host = if bundled_scheme == "tauri" { + "localhost" + } else { + "tauri.localhost" + }; + let expected_port = match bundled_scheme { + "http" => Some(80), + "https" => Some(443), + _ => None, + }; + let bundled_origin = url.scheme() == bundled_scheme + && url.host_str() == Some(expected_host) + && url.username().is_empty() + && url.password().is_none() + && url.port_or_known_default() == expected_port; + if bundled_origin { + return true; + } + + allow_dev_server + && url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1") | Some("localhost")) + && url.username().is_empty() + && url.password().is_none() + && url.port_or_known_default() == Some(1420) +} + +#[cfg(any(target_os = "windows", test))] +fn browser_accelerators_enabled(is_dev: bool) -> bool { + is_dev +} + +fn initial_main_window_visibility( + configured_visible: bool, + is_windows: bool, + is_dev: bool, +) -> bool { + configured_visible && (!is_windows || is_dev) +} + +#[cfg(target_os = "windows")] +fn show_windows_startup_error(detail: &str) { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + + let title: Vec = "Codex App Manager startup error\0".encode_utf16().collect(); + let body: Vec = format!( + "The secure Windows interface could not be initialized. The app will close.\n\n{detail}\0" + ) + .encode_utf16() + .collect(); + unsafe { + MessageBoxW( + std::ptr::null_mut(), + body.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR, + ); + } +} + +#[cfg(target_os = "windows")] +fn abort_for_unsafe_windows_webview(app: &tauri::AppHandle, detail: &str) { + log::error!("WebView2 release safety gate failed: {detail}"); + let state = app.state::(); + state.webview_gate_failed.store(true, Ordering::SeqCst); + show_windows_startup_error(detail); + state.force_quit.store(true, Ordering::SeqCst); + app.exit(1); +} + +/// WebView2 handles browser accelerators before DOM `keydown`, so renderer +/// `preventDefault()` cannot stop Ctrl+P, Ctrl+R or F5. Disable that native +/// layer in release while retaining Tauri's built-in shortcuts for `tauri dev`. +#[cfg(target_os = "windows")] +fn configure_windows_browser_accelerators( + app: &tauri::App, + window: &tauri::WebviewWindow, + enabled: bool, + show_after_gate: bool, +) -> tauri::Result<()> { + use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3; + use windows_core::Interface; + + if enabled { + return Ok(()); + } + + let label = window.label().to_string(); + let callback_label = label.clone(); + let app_handle = app.handle().clone(); + let gated_window = window.clone(); + let callback_result = window.with_webview(move |platform_webview| { + let result = unsafe { + (|| -> windows_core::Result<()> { + let webview = platform_webview.controller().CoreWebView2()?; + let settings = webview.Settings()?; + let settings3 = settings.cast::()?; + settings3.SetAreBrowserAcceleratorKeysEnabled(false) + })() + }; + match result { + Ok(()) => { + if !show_after_gate { + app_handle + .state::() + .webview_safe_to_show + .store(true, Ordering::SeqCst); + log::info!( + "disabled native WebView2 browser accelerators window={callback_label}" + ); + } else { + match gated_window.show() { + Ok(()) => { + app_handle + .state::() + .webview_safe_to_show + .store(true, Ordering::SeqCst); + log::info!( + "disabled native WebView2 browser accelerators and opened window={callback_label}" + ); + } + Err(error) => abort_for_unsafe_windows_webview( + &app_handle, + &format!("failed to show gated window={callback_label}: {error}"), + ), + } + } + } + Err(error) => abort_for_unsafe_windows_webview( + &app_handle, + &format!( + "failed to disable browser accelerators window={callback_label}: {error}" + ), + ), + } + }); + if let Err(error) = callback_result { + let detail = format!("failed to schedule WebView2 safety gate window={label}: {error}"); + log::error!("{detail}"); + show_windows_startup_error(&detail); + return Err(error); + } + Ok(()) +} + +/// Build the configured main window ourselves so the native webview receives +/// navigation and new-window handlers before its first document is loaded. +fn build_main_window(app: &tauri::App) -> tauri::Result<()> { + let mut config = app + .config() + .app + .windows + .iter() + .find(|window| window.label == "main") + .ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing main window config", + )) + })? + .clone(); + + #[cfg(target_os = "windows")] + let configured_visible = config.visible; + config.visible = + initial_main_window_visibility(config.visible, cfg!(target_os = "windows"), cfg!(dev)); + + let bundled_scheme = bundled_app_scheme(config.use_https_scheme); + let window = tauri::WebviewWindowBuilder::from_config(app, &config)? + .on_navigation(move |url| { + // `cfg(dev)` is emitted by Tauri itself and remains true for the + // supported `tauri dev --release` mode. `debug_assertions` does not. + let allowed = is_allowed_app_navigation(url, cfg!(dev), bundled_scheme); + if !allowed { + log::warn!( + "blocked top-level webview navigation scheme={} host={}", + url.scheme(), + url.host_str().unwrap_or("") + ); + } + allowed + }) + .on_new_window(|url, _features| { + log::warn!( + "blocked webview new-window request scheme={} host={}", + url.scheme(), + url.host_str().unwrap_or("") + ); + tauri::webview::NewWindowResponse::Deny + }) + .build()?; + #[cfg(target_os = "windows")] + configure_windows_browser_accelerators( + app, + &window, + browser_accelerators_enabled(cfg!(dev)), + configured_visible, + )?; + #[cfg(not(target_os = "windows"))] + let _ = window; + Ok(()) +} + /// macOS routes Cmd+Q through the app-menu Quit item, which terminates *below* /// the RunEvent loop (so ExitRequested can't hold it). Replace the default menu /// with one whose Quit item is ours — its activation lands in `on_menu_event` @@ -100,6 +327,15 @@ fn install_macos_menu(app: &tauri::App) -> tauri::Result<()> { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + #[cfg(target_os = "windows")] + if !app + .state::() + .webview_safe_to_show + .load(Ordering::SeqCst) + { + log::warn!("ignored second-instance focus before WebView2 safety gate completed"); + return; + } if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); @@ -199,6 +435,7 @@ pub fn run() { commands::log_frontend_error, ]) .setup(|app| { + build_main_window(app)?; #[cfg(target_os = "macos")] install_macos_menu(app)?; log::info!( @@ -216,7 +453,27 @@ pub fn run() { }); } let operations = app.state::().operations.clone(); + #[cfg(target_os = "windows")] + let recovery_app = app.handle().clone(); tauri::async_runtime::spawn_blocking(move || { + #[cfg(target_os = "windows")] + loop { + match recovery_app + .state::() + .webview_startup_gate() + { + state::WebviewStartupGate::Proceed => break, + state::WebviewStartupGate::Abort => { + log::warn!( + "startup recovery skipped after WebView2 safety gate failure" + ); + return; + } + state::WebviewStartupGate::Wait => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } // Crash-safe install recovery MUST run before ordinary staging // cleanup so recovery materials (backup / staged new) are not // deleted out from under an incomplete swap. @@ -305,3 +562,87 @@ pub fn run() { } }); } + +#[cfg(test)] +mod tests { + use super::{ + browser_accelerators_enabled, initial_main_window_visibility, is_allowed_app_navigation, + }; + + #[test] + fn native_browser_accelerators_are_release_only_disabled() { + assert!(!browser_accelerators_enabled(false)); + assert!(browser_accelerators_enabled(true)); + } + + #[test] + fn windows_release_window_stays_hidden_until_the_native_gate_succeeds() { + assert!(!initial_main_window_visibility(true, true, false)); + assert!(initial_main_window_visibility(true, true, true)); + assert!(initial_main_window_visibility(true, false, false)); + assert!(!initial_main_window_visibility(false, true, true)); + } + + #[test] + fn navigation_policy_allows_only_the_selected_bundled_origin_in_release() { + for allowed in ["tauri://localhost/", "tauri://localhost/assets/app.js"] { + let url = url::Url::parse(allowed).unwrap(); + assert!(is_allowed_app_navigation(&url, false, "tauri"), "{allowed}"); + } + + for blocked in [ + "http://tauri.localhost/", + "https://tauri.localhost/", + "tauri://localhost:1420/", + "tauri://user@localhost/", + "https://github.com/Wangnov/Codex-App-Manager", + "javascript:alert(1)", + "data:text/html,boom", + "file:///tmp/unsafe.html", + "http://127.0.0.1:1420/", + ] { + let url = url::Url::parse(blocked).unwrap(); + assert!( + !is_allowed_app_navigation(&url, false, "tauri"), + "{blocked}" + ); + } + } + + #[test] + fn navigation_policy_honors_the_configured_windows_scheme_and_port() { + let http = url::Url::parse("http://tauri.localhost/assets/app.js").unwrap(); + let https = url::Url::parse("https://tauri.localhost/assets/app.js").unwrap(); + let alternate_port = url::Url::parse("http://tauri.localhost:1420/").unwrap(); + + assert!(is_allowed_app_navigation(&http, false, "http")); + assert!(!is_allowed_app_navigation(&https, false, "http")); + assert!(!is_allowed_app_navigation(&alternate_port, false, "http")); + assert!(is_allowed_app_navigation(&https, false, "https")); + assert!(!is_allowed_app_navigation(&http, false, "https")); + } + + #[test] + fn navigation_policy_limits_development_to_the_configured_loopback_port() { + assert!(is_allowed_app_navigation( + &url::Url::parse("http://127.0.0.1:1420/").unwrap(), + true, + "tauri" + )); + assert!(is_allowed_app_navigation( + &url::Url::parse("http://localhost:1420/src/main.tsx").unwrap(), + true, + "tauri" + )); + assert!(!is_allowed_app_navigation( + &url::Url::parse("http://127.0.0.1:3000/").unwrap(), + true, + "tauri" + )); + assert!(!is_allowed_app_navigation( + &url::Url::parse("https://example.com:1420/").unwrap(), + true, + "tauri" + )); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 466174e..3a31df7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -17,10 +17,43 @@ pub struct ManagerState { /// Set once the user confirms quitting (or has the guard off) so the close / /// exit handlers stop intercepting and let the process go. pub force_quit: AtomicBool, + /// Windows release windows stay hidden until WebView2's native browser + /// accelerators have been disabled. The single-instance focus path must not + /// bypass that startup gate. + #[cfg(target_os = "windows")] + pub webview_safe_to_show: AtomicBool, + /// Failure wins over readiness so startup recovery never begins after the + /// release WebView safety gate has failed. + #[cfg(target_os = "windows")] + pub webview_gate_failed: AtomicBool, pub operations: OperationManager, pub config_health: Mutex, } +#[cfg(any(target_os = "windows", test))] +fn initial_webview_safe_to_show(is_dev: bool) -> bool { + is_dev +} + +#[cfg(any(target_os = "windows", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WebviewStartupGate { + Wait, + Proceed, + Abort, +} + +#[cfg(any(target_os = "windows", test))] +fn webview_startup_gate(safe_to_show: bool, failed: bool) -> WebviewStartupGate { + if failed { + WebviewStartupGate::Abort + } else if safe_to_show { + WebviewStartupGate::Proceed + } else { + WebviewStartupGate::Wait + } +} + impl ManagerState { pub fn new() -> Self { let target = Target::current(); @@ -47,10 +80,24 @@ impl ManagerState { settings, endpoints, force_quit: AtomicBool::new(false), + #[cfg(target_os = "windows")] + webview_safe_to_show: AtomicBool::new(initial_webview_safe_to_show(cfg!(dev))), + #[cfg(target_os = "windows")] + webview_gate_failed: AtomicBool::new(false), operations, config_health, } } + + #[cfg(target_os = "windows")] + pub(crate) fn webview_startup_gate(&self) -> WebviewStartupGate { + webview_startup_gate( + self.webview_safe_to_show + .load(std::sync::atomic::Ordering::SeqCst), + self.webview_gate_failed + .load(std::sync::atomic::Ordering::SeqCst), + ) + } } impl Default for ManagerState { @@ -58,3 +105,24 @@ impl Default for ManagerState { Self::new() } } + +#[cfg(test)] +mod tests { + use super::{ + initial_webview_safe_to_show, webview_startup_gate, WebviewStartupGate, + }; + + #[test] + fn windows_release_starts_with_the_webview_show_gate_closed() { + assert!(!initial_webview_safe_to_show(false)); + assert!(initial_webview_safe_to_show(true)); + } + + #[test] + fn failure_dominates_the_windows_startup_gate_state() { + assert_eq!(webview_startup_gate(false, false), WebviewStartupGate::Wait); + assert_eq!(webview_startup_gate(true, false), WebviewStartupGate::Proceed); + assert_eq!(webview_startup_gate(false, true), WebviewStartupGate::Abort); + assert_eq!(webview_startup_gate(true, true), WebviewStartupGate::Abort); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 876526c..5713c09 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,6 +15,7 @@ "windows": [ { "label": "main", + "create": false, "title": "Codex App 管理器", "width": 400, "height": 640, @@ -27,7 +28,8 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost https://codexapp.agentsmirror.com https://github.com https://persistent.oaistatic.com" + "csp": "default-src 'self'; script-src 'self'; img-src 'self' data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'", + "devCsp": "default-src 'self'; script-src 'self'; img-src 'self' data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:1420 ws://127.0.0.1:1420 ws://localhost:1420; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'" } }, "bundle": { diff --git a/src/app/App.tsx b/src/app/App.tsx index c538f4f..84168b8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -94,9 +94,10 @@ function Shell() { } export function App() { - // QuitConfirm sits *outside* ErrorBoundary so a render crash that replaces - // Shell still leaves a live listener for app://confirm-quit / quit-blocked. - // Theme + i18n wrap both so the confirm sheet keeps working on the crash path. + // Keep QuitConfirm outside the rich boundary so native close / Cmd+Q events + // still have a listener when Shell is replaced by its crash page. The + // dependency-light RootCrashBoundary in bootstrap.tsx wraps this whole tree, + // so a provider or QuitConfirm failure still reaches the final fallback. return ( diff --git a/src/app/ErrorBoundary.test.tsx b/src/app/ErrorBoundary.test.tsx index 6b1919c..d16073d 100644 --- a/src/app/ErrorBoundary.test.tsx +++ b/src/app/ErrorBoundary.test.tsx @@ -10,11 +10,15 @@ import { ErrorBoundary, type CrashStrings, } from "./ErrorBoundary"; -import { I18nProvider } from "./i18n"; -import { ThemeProvider } from "./theme"; -import { QuitConfirm } from "./components"; vi.mock("../services/managerApi", () => ({ + errorMessage: (cause: unknown) => { + if (cause instanceof Error) return cause.message; + if (cause && typeof cause === "object" && "message" in cause) { + return String((cause as { message: unknown }).message); + } + return String(cause); + }, managerApi: { getDiagnostics: vi.fn(), reportFrontendError: vi.fn(() => Promise.resolve()), @@ -202,34 +206,41 @@ describe("ErrorBoundary", () => { consoleError.mockRestore(); }); - it("keeps QuitConfirm available outside the boundary so crash-path quit works", async () => { + it("uses the backend phase-aware quit command without depending on QuitConfirm", async () => { const user = userEvent.setup(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); render( - - - - - - - - , + + + , ); expect(screen.getByRole("button", { name: CATALOG.en["crash.quit"] })).toBeInTheDocument(); - // Browser path: Quit dispatches cam:confirm-quit → QuitConfirm opens. await user.click(screen.getByRole("button", { name: CATALOG.en["crash.quit"] })); + await waitFor(() => expect(confirmQuit).toHaveBeenCalled()); - await waitFor(() => { - expect(screen.getByText(/Close the manager/i)).toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("surfaces a serialized backend refusal message", async () => { + const user = userEvent.setup(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + confirmQuit.mockRejectedValueOnce({ + code: "operation_not_interruptible", + message: "install is committing", }); - // Confirm sheet primary action is "Close" (not the crash-page Quit). - await user.click(screen.getByRole("button", { name: "Close" })); - await waitFor(() => expect(confirmQuit).toHaveBeenCalled()); + render( + + + , + ); + await user.click(screen.getByRole("button", { name: CATALOG.en["crash.quit"] })); + await waitFor(() => expect(screen.getByText("install is committing")).toBeInTheDocument()); + expect(screen.queryByText("[object Object]")).not.toBeInTheDocument(); consoleError.mockRestore(); }); }); diff --git a/src/app/ErrorBoundary.tsx b/src/app/ErrorBoundary.tsx index 745bff4..9203b2c 100644 --- a/src/app/ErrorBoundary.tsx +++ b/src/app/ErrorBoundary.tsx @@ -1,6 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; -import { managerApi } from "../services/managerApi"; +import { errorMessage, managerApi } from "../services/managerApi"; import type { OperationSnapshot } from "../shared/types"; import { Ring } from "./components"; import { formatDiagnostics } from "./diagnostics"; @@ -13,6 +13,8 @@ interface State { /** Backend operation lease, if any — drives crash-page copy. */ opSnapshot: OperationSnapshot | null; opLoaded: boolean; + quitPending: boolean; + quitFailure: string | null; } const CRASH_KEYS = [ @@ -61,13 +63,6 @@ export function crashBodyForSnapshot( return strings["crash.body"]; } -function isTauri(): boolean { - return ( - typeof window !== "undefined" && - Boolean((window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__) - ); -} - export class ErrorBoundary extends Component<{ children: ReactNode }, State> { state: State = { error: null, @@ -75,6 +70,8 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> { showDetails: false, opSnapshot: null, opLoaded: false, + quitPending: false, + quitFailure: null, }; static getDerivedStateFromError(error: Error): Partial { @@ -149,17 +146,23 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> { } }; - /** Ask the shared QuitConfirm (mounted outside this boundary) to handle exit. */ + /** + * The crash page itself is an explicit confirmation. Ask the backend command + * directly so quitting remains phase-aware even when QuitConfirm was the + * component that crashed. The backend refuses point-of-no-return phases. + */ private requestQuit = () => { - if (isTauri()) { - void import("@tauri-apps/api/window") - .then(({ getCurrentWindow }) => getCurrentWindow().close()) - .catch(() => { - void managerApi.confirmQuit().catch(() => undefined); + if (this.state.quitPending) return; + this.setState({ quitPending: true, quitFailure: null }); + void managerApi + .confirmQuit() + .catch((cause) => { + const message = errorMessage(cause); + this.setState({ + quitFailure: message || crashStrings()["crash.bodyCritical"], }); - } else { - window.dispatchEvent(new Event("cam:confirm-quit")); - } + }) + .finally(() => this.setState({ quitPending: false })); }; render() { @@ -207,9 +210,18 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, State> { - + {this.state.quitFailure ? ( +
+ {this.state.quitFailure} +
+ ) : null} diff --git a/src/app/RootCrashBoundary.test.tsx b/src/app/RootCrashBoundary.test.tsx new file mode 100644 index 0000000..94fe94a --- /dev/null +++ b/src/app/RootCrashBoundary.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { managerApi } from "../services/managerApi"; +import { + invokeRootBackend, + operationRiskForSnapshot, + RootCrashBoundary, +} from "./RootCrashBoundary"; + +vi.mock("../services/managerApi", () => ({ + errorMessage: (cause: unknown) => { + if (cause instanceof Error) return cause.message; + if (cause && typeof cause === "object" && "message" in cause) { + return String((cause as { message: unknown }).message); + } + return String(cause); + }, + managerApi: { + reportFrontendError: vi.fn(() => Promise.resolve()), + getOperationSnapshot: vi.fn(() => Promise.resolve(null)), + confirmQuit: vi.fn(() => Promise.resolve()), + }, +})); + +function Boom(): never { + throw new Error("provider exploded"); +} + +function StringBoom(): never { + throw "plain string failure"; +} + +beforeEach(() => { + localStorage.setItem("cam.lang", "en"); + vi.mocked(managerApi.reportFrontendError).mockResolvedValue(undefined); + vi.mocked(managerApi.getOperationSnapshot).mockResolvedValue(null); + vi.mocked(managerApi.confirmQuit).mockResolvedValue(undefined); +}); + +describe("RootCrashBoundary", () => { + it("renders without providers and reports initial render failures", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("The app shell could not start"); + expect(screen.getByRole("button", { name: "Reload interface" })).toBeInTheDocument(); + await waitFor(() => + expect(managerApi.reportFrontendError).toHaveBeenCalledWith( + expect.objectContaining({ kind: "root.render", message: "provider exploded" }), + ), + ); + consoleError.mockRestore(); + }); + + it("quits through the backend policy and surfaces a refusal", async () => { + const user = userEvent.setup(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(managerApi.confirmQuit).mockRejectedValueOnce({ + code: "operation_not_interruptible", + message: "install is committing", + }); + render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "Quit safely" })); + await waitFor(() => expect(screen.getByText("install is committing")).toBeInTheDocument()); + expect(screen.queryByText("[object Object]")).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); + + it("normalizes non-Error render failures before reporting them", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + + expect(screen.getByText(/plain string failure/)).toBeInTheDocument(); + await waitFor(() => + expect(managerApi.reportFrontendError).toHaveBeenCalledWith( + expect.objectContaining({ kind: "root.render", message: "plain string failure" }), + ), + ); + consoleError.mockRestore(); + }); + + it("still renders recovery controls when local storage is unavailable", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const getItem = vi.spyOn(localStorage, "getItem").mockImplementation(() => { + throw new DOMException("storage unavailable", "SecurityError"); + }); + + try { + render( + + + , + ); + expect(screen.getByRole("button", { name: "Reload interface" })).toBeInTheDocument(); + } finally { + getItem.mockRestore(); + consoleError.mockRestore(); + } + }); +}); + +describe("operationRiskForSnapshot", () => { + it("distinguishes active, paused and protected phases", () => { + expect(operationRiskForSnapshot(null)).toBe("idle"); + expect(operationRiskForSnapshot({ kind: "update", phase: "downloading" })).toBe("active"); + expect(operationRiskForSnapshot({ kind: "update", paused: true })).toBe("paused"); + expect(operationRiskForSnapshot({ kind: "install", phase: "committing" })).toBe("critical"); + expect(operationRiskForSnapshot({ kind: "install", interruptible: false })).toBe("critical"); + }); +}); + +describe("invokeRootBackend", () => { + it("uses the dependency-free Tauri bridge and rejects when it is unavailable", async () => { + const host = window as typeof window & { + __TAURI_INTERNALS__?: { invoke: ReturnType }; + }; + const previous = host.__TAURI_INTERNALS__; + const invoke = vi.fn().mockResolvedValue({ kind: "update" }); + host.__TAURI_INTERNALS__ = { invoke }; + + try { + await expect(invokeRootBackend("get_operation_snapshot")).resolves.toEqual({ + kind: "update", + }); + expect(invoke).toHaveBeenCalledWith("get_operation_snapshot", undefined); + delete host.__TAURI_INTERNALS__; + await expect(invokeRootBackend("confirm_quit")).rejects.toThrow( + "Desktop backend unavailable", + ); + } finally { + if (previous === undefined) delete host.__TAURI_INTERNALS__; + else host.__TAURI_INTERNALS__ = previous; + } + }); +}); diff --git a/src/app/RootCrashBoundary.tsx b/src/app/RootCrashBoundary.tsx new file mode 100644 index 0000000..2aa8c58 --- /dev/null +++ b/src/app/RootCrashBoundary.tsx @@ -0,0 +1,235 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +type OperationRisk = "unknown" | "idle" | "active" | "paused" | "critical"; + +interface State { + error: Error | null; + operationRisk: OperationRisk; + quitPending: boolean; + quitFailure: string | null; +} + +interface SnapshotLike { + kind?: unknown; + phase?: unknown; + paused?: unknown; + interruptible?: unknown; +} + +type RootTauriInternals = { + invoke?: (command: string, args?: Record) => Promise; +}; + +function rootErrorMessage(value: unknown): string { + if (value instanceof Error) return value.message; + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message; + } + return String(value ?? "The backend kept the app open."); +} + +/** + * Minimal IPC path used only when the normal managerApi chunk cannot load. + * Keeping it here makes the outer boundary independent of the chunk whose + * failure may have caused the recovery screen in the first place. + */ +export function invokeRootBackend( + command: string, + args?: Record, +): Promise { + const internals = ( + window as typeof window & { __TAURI_INTERNALS__?: RootTauriInternals } + ).__TAURI_INTERNALS__; + if (typeof internals?.invoke !== "function") { + return Promise.reject(new Error("Desktop backend unavailable.")); + } + return internals.invoke(command, args); +} + +const COPY = { + en: { + title: "The app shell could not start", + idle: "Reload the interface or quit safely. Your installed Codex was not changed by this screen.", + active: "A Codex operation is still running in the background. Reloading will reconnect to it.", + paused: "A paused download is preserved. Reloading will restore its recovery controls.", + critical: "A protected install step is active. Reloading is safe; quitting will remain blocked until it finishes.", + reload: "Reload interface", + quit: "Quit safely", + detail: "Technical detail", + }, + zh: { + title: "应用界面无法启动", + idle: "你可以重新加载界面或安全退出;此页面不会修改已安装的 Codex。", + active: "Codex 操作仍在后台运行。重新加载后会自动重新连接。", + paused: "暂停下载已保留。重新加载后会恢复继续与取消入口。", + critical: "安装正处于受保护阶段。重新加载是安全的;阶段完成前退出仍会被阻止。", + reload: "重新加载界面", + quit: "安全退出", + detail: "技术详情", + }, + ar: { + title: "تعذر بدء واجهة التطبيق", + idle: "يمكنك إعادة تحميل الواجهة أو الخروج بأمان. لم تغيّر هذه الشاشة تثبيت Codex.", + active: "لا تزال عملية Codex تعمل في الخلفية. ستعيد الواجهة الاتصال بها بعد إعادة التحميل.", + paused: "تم الاحتفاظ بالتنزيل المتوقف مؤقتًا. ستعود خيارات الاستئناف والإلغاء بعد إعادة التحميل.", + critical: "توجد خطوة تثبيت محمية قيد التنفيذ. إعادة التحميل آمنة، وسيظل الخروج محظورًا حتى تنتهي.", + reload: "إعادة تحميل الواجهة", + quit: "خروج آمن", + detail: "تفاصيل تقنية", + }, +} as const; + +function toError(value: unknown): Error { + if (value instanceof Error) return value; + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return new Error(message); + } + return new Error(String(value ?? "Unknown root error")); +} + +function rootCopy() { + let saved = ""; + try { + saved = typeof localStorage === "undefined" ? "" : localStorage.getItem("cam.lang") ?? ""; + } catch { + // Storage failures can be the reason a provider crashed. The recovery + // surface must remain renderable without the same dependency. + } + const browser = typeof navigator === "undefined" ? "" : navigator.language; + const lang = (saved || browser).toLowerCase(); + if (lang.startsWith("zh")) return { ...COPY.zh, dir: "ltr" as const }; + if (lang.startsWith("ar")) return { ...COPY.ar, dir: "rtl" as const }; + return { ...COPY.en, dir: "ltr" as const }; +} + +export function operationRiskForSnapshot(value: unknown): OperationRisk { + if (!value || typeof value !== "object") return "idle"; + const snapshot = value as SnapshotLike; + if (snapshot.paused === true) return "paused"; + if ( + snapshot.interruptible === false || + snapshot.phase === "committing" || + snapshot.phase === "finishing" + ) { + return "critical"; + } + if (snapshot.kind === "install" || snapshot.kind === "update" || snapshot.kind === "uninstall") { + return "active"; + } + return "idle"; +} + +/** + * Last-resort React boundary. It deliberately avoids providers, icons and the + * normal i18n catalog so a failure in any of those dependencies cannot turn + * the recovery surface into a blank window. + */ +export class RootCrashBoundary extends Component<{ children: ReactNode }, State> { + state: State = { + error: null, + operationRisk: "unknown", + quitPending: false, + quitFailure: null, + }; + + static getDerivedStateFromError(error: unknown): Partial { + return { error: toError(error) }; + } + + componentDidCatch(error: unknown, info: ErrorInfo) { + const normalized = toError(error); + const payload = { + kind: "root.render", + message: normalized.message, + stack: normalized.stack ?? null, + componentStack: info.componentStack ?? null, + }; + void import("../services/managerApi") + .then(async ({ managerApi }) => { + await managerApi + .reportFrontendError(payload) + .catch(() => undefined); + const snapshot = await managerApi.getOperationSnapshot().catch(() => null); + this.setState({ operationRisk: operationRiskForSnapshot(snapshot) }); + }) + .catch(async () => { + await invokeRootBackend("log_frontend_error", { payload }).catch(() => undefined); + const snapshot = await invokeRootBackend("get_operation_snapshot").catch( + () => null, + ); + this.setState({ operationRisk: operationRiskForSnapshot(snapshot) }); + }); + } + + private reload = () => { + window.location.reload(); + }; + + private quit = () => { + if (this.state.quitPending) return; + this.setState({ quitPending: true, quitFailure: null }); + void import("../services/managerApi") + .then(async ({ errorMessage, managerApi }) => { + try { + await managerApi.confirmQuit(); + } catch (cause) { + this.setState({ + quitFailure: errorMessage(cause) || "The backend kept the app open.", + }); + } + }) + .catch(async () => { + try { + await invokeRootBackend("confirm_quit"); + } catch (cause) { + this.setState({ quitFailure: rootErrorMessage(cause) }); + } + }) + .finally(() => this.setState({ quitPending: false })); + }; + + render() { + if (!this.state.error) return this.props.children; + + const copy = rootCopy(); + const risk = this.state.operationRisk === "unknown" ? "idle" : this.state.operationRisk; + return ( +
+
+
+
+

{copy.title}

+

{copy[risk]}

+
+
+ {copy.detail} +
+                {this.state.error.name}: {this.state.error.message}
+              
+
+
+
+ + + {this.state.quitFailure ? ( +

+ {this.state.quitFailure} +

+ ) : null} +
+
+
+ ); + } +} diff --git a/src/app/bootstrap.tsx b/src/app/bootstrap.tsx new file mode 100644 index 0000000..bfbdbce --- /dev/null +++ b/src/app/bootstrap.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { RootCrashBoundary } from "./RootCrashBoundary"; +import "./styles.css"; + +/** Start the renderer while keeping App and its providers behind a catchable import. */ +export async function bootstrap(): Promise { + const root = document.getElementById("root"); + if (!(root instanceof HTMLElement)) { + throw new Error("Missing #root mount element"); + } + + const [platform, theme, contextMenu, globalErrors] = await Promise.all([ + import("./platform"), + import("./theme"), + import("./contextMenuPolicy"), + import("./globalErrors"), + ]); + + document.documentElement.dataset.platform = platform.currentPlatform(); + document.documentElement.dataset.theme = theme.resolveInitialTheme(); + globalErrors.installGlobalErrorHandlers(); + contextMenu.installContextMenuPolicy(); + + const { App } = await import("./App"); + ReactDOM.createRoot(root).render( + + + + + , + ); +} diff --git a/src/app/contextMenuPolicy.test.ts b/src/app/contextMenuPolicy.test.ts index 9819018..4f76401 100644 --- a/src/app/contextMenuPolicy.test.ts +++ b/src/app/contextMenuPolicy.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { installContextMenuPolicy, isEditableContextTarget, + shouldBlockWebviewShortcut, } from "./contextMenuPolicy"; describe("isEditableContextTarget", () => { @@ -52,6 +53,86 @@ describe("isEditableContextTarget", () => { }); }); +describe("shouldBlockWebviewShortcut", () => { + it.each([ + ["F5", {}, "windows"], + ["F3", {}, "windows"], + ["F12", {}, "macos"], + ["f", { ctrlKey: true }, "windows"], + ["r", { ctrlKey: true }, "windows"], + ["R", { metaKey: true, shiftKey: true }, "macos"], + ["p", { metaKey: true }, "macos"], + ["s", { ctrlKey: true }, "windows"], + ["u", { ctrlKey: true }, "windows"], + ["i", { ctrlKey: true, shiftKey: true }, "windows"], + ["j", { metaKey: true, altKey: true }, "macos"], + ["BrowserBack", {}, "windows"], + ["BrowserForward", {}, "macos"], + ] as const)("blocks browser command %s", (key, init, platform) => { + expect( + shouldBlockWebviewShortcut(new KeyboardEvent("keydown", { key, ...init }), platform), + ).toBe(true); + }); + + it("uses the physical key code for browser accelerators on non-Latin layouts", () => { + expect( + shouldBlockWebviewShortcut( + new KeyboardEvent("keydown", { key: "к", code: "KeyR", ctrlKey: true }), + "windows", + ), + ).toBe(true); + expect( + shouldBlockWebviewShortcut( + new KeyboardEvent("keydown", { key: "ش", code: "KeyP", ctrlKey: true }), + "windows", + ), + ).toBe(true); + expect( + shouldBlockWebviewShortcut( + new KeyboardEvent("keydown", { key: "х", code: "BracketLeft", metaKey: true }), + "macos", + ), + ).toBe(true); + }); + + it("uses Command for browser commands on macOS and preserves Control editing chords", () => { + document.body.innerHTML = ``; + const edit = document.getElementById("edit")!; + const controlU = new KeyboardEvent("keydown", { key: "u", ctrlKey: true }); + const commandU = new KeyboardEvent("keydown", { key: "u", metaKey: true }); + Object.defineProperty(controlU, "target", { value: edit }); + Object.defineProperty(commandU, "target", { value: edit }); + + expect(shouldBlockWebviewShortcut(controlU, "macos")).toBe(false); + expect(shouldBlockWebviewShortcut(commandU, "macos")).toBe(true); + }); + + it("blocks history chords on chrome but preserves text navigation", () => { + document.body.innerHTML = `
`; + const plain = document.getElementById("plain")!; + const edit = document.getElementById("edit")!; + const plainEvent = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }); + Object.defineProperty(plainEvent, "target", { value: plain }); + const editEvent = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }); + Object.defineProperty(editEvent, "target", { value: edit }); + + expect(shouldBlockWebviewShortcut(plainEvent, "macos")).toBe(true); + expect(shouldBlockWebviewShortcut(editEvent, "macos")).toBe(false); + }); + + it("blocks platform history chords even while a text input is focused", () => { + document.body.innerHTML = ``; + const edit = document.getElementById("edit")!; + const windowsBack = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }); + const macBack = new KeyboardEvent("keydown", { key: "[", metaKey: true }); + Object.defineProperty(windowsBack, "target", { value: edit }); + Object.defineProperty(macBack, "target", { value: edit }); + + expect(shouldBlockWebviewShortcut(windowsBack, "windows")).toBe(true); + expect(shouldBlockWebviewShortcut(macBack, "macos")).toBe(true); + }); +}); + describe("installContextMenuPolicy", () => { let dispose: (() => void) | null = null; @@ -99,5 +180,39 @@ describe("installContextMenuPolicy", () => { const plain = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); document.getElementById("d")!.dispatchEvent(plain); expect(plain.defaultPrevented).toBe(false); + + for (const type of ["mousedown", "mouseup", "auxclick"]) { + const sideButton = new MouseEvent(type, { + button: 3, + bubbles: true, + cancelable: true, + }); + document.getElementById("d")!.dispatchEvent(sideButton); + expect(sideButton.defaultPrevented, type).toBe(false); + } + }); + + it("blocks release keyboard and mouse navigation while installed", () => { + document.body.innerHTML = `
x
`; + dispose = installContextMenuPolicy(true); + + const reload = new KeyboardEvent("keydown", { + key: "r", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + document.getElementById("d")!.dispatchEvent(reload); + expect(reload.defaultPrevented).toBe(true); + + for (const type of ["mousedown", "mouseup", "auxclick"]) { + const back = new MouseEvent(type, { + button: 3, + bubbles: true, + cancelable: true, + }); + document.getElementById("d")!.dispatchEvent(back); + expect(back.defaultPrevented, type).toBe(true); + } }); }); diff --git a/src/app/contextMenuPolicy.ts b/src/app/contextMenuPolicy.ts index 2092ba5..5ec4a20 100644 --- a/src/app/contextMenuPolicy.ts +++ b/src/app/contextMenuPolicy.ts @@ -1,11 +1,15 @@ +import { currentPlatform, type Platform } from "./platform"; + /** - * Production WebView context-menu policy. + * Production WebView browser-chrome policy. * * Release builds must not expose browser chrome (Print / Reload / Inspect). * Editable controls keep the platform text-editing affordances so copy/cut/paste * still work via keyboard and, where the engine shows one, a native edit menu. * - * Dev builds leave the default menu alone so Reload / DevTools stay available. + * Dev builds leave the default menu and shortcuts alone so Reload / DevTools + * stay available. The explicit crash-screen reload button is not a keyboard + * event, so it remains available in release builds. */ const EDITABLE_SELECTOR = [ @@ -62,6 +66,60 @@ export function isEditableContextTarget(target: EventTarget | null): boolean { return ce === "" || ce === "true" || ce === "plaintext-only"; } +/** + * Browser-level accelerators that are not product commands. Keep navigation + * chords available inside editable controls when they are also text-editing + * shortcuts (for example Option+Arrow on macOS). + */ +export function shouldBlockWebviewShortcut( + event: KeyboardEvent, + platform: Platform = currentPlatform(), +): boolean { + const key = event.key.toLowerCase(); + const code = event.code.toLowerCase(); + const mac = platform === "macos"; + const primary = mac ? event.metaKey : event.ctrlKey; + + const matchesPhysicalKey = (letter: string) => + key === letter || code === `key${letter}`; + + if (["f3", "f5", "f12"].includes(key) || ["f3", "f5", "f12"].includes(code)) { + return true; + } + if ( + key === "browserback" || + key === "browserforward" || + code === "browserback" || + code === "browserforward" + ) { + return true; + } + + if (primary && ["f", "p", "r", "s", "u"].some(matchesPhysicalKey)) return true; + if ( + (!mac && event.ctrlKey && event.shiftKey && ["i", "j", "c"].some(matchesPhysicalKey)) || + (mac && event.metaKey && event.altKey && ["i", "j", "c"].some(matchesPhysicalKey)) + ) { + return true; + } + + const editable = isEditableContextTarget(event.target); + // Cmd+[ / ] is WebKit history even in a text field. Alt+Arrow is history on + // Windows, but Option+Arrow is standard word navigation in macOS editors. + if ( + event.metaKey && + (key === "[" || key === "]" || code === "bracketleft" || code === "bracketright") + ) { + return true; + } + if (event.altKey && (key === "arrowleft" || key === "arrowright")) { + if (mac && editable) return false; + return true; + } + if (editable) return false; + return false; +} + /** * Install the production policy. No-op when `enabled` is false (dev builds). * Returns a disposer for tests. @@ -76,6 +134,29 @@ export function installContextMenuPolicy(enabled = !import.meta.env.DEV): () => event.preventDefault(); }; + const onKeyDown = (event: KeyboardEvent) => { + if (!shouldBlockWebviewShortcut(event)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const onMouseNavigation = (event: MouseEvent) => { + // Mouse buttons 3/4 map to browser back/forward on WebView2 and WebKit. + if (event.button !== 3 && event.button !== 4) return; + event.preventDefault(); + event.stopPropagation(); + }; + document.addEventListener("contextmenu", onContextMenu, true); - return () => document.removeEventListener("contextmenu", onContextMenu, true); + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("mousedown", onMouseNavigation, true); + document.addEventListener("mouseup", onMouseNavigation, true); + document.addEventListener("auxclick", onMouseNavigation, true); + return () => { + document.removeEventListener("contextmenu", onContextMenu, true); + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("mousedown", onMouseNavigation, true); + document.removeEventListener("mouseup", onMouseNavigation, true); + document.removeEventListener("auxclick", onMouseNavigation, true); + }; } diff --git a/src/app/rendererEntry.test.ts b/src/app/rendererEntry.test.ts new file mode 100644 index 0000000..e1af5b8 --- /dev/null +++ b/src/app/rendererEntry.test.ts @@ -0,0 +1,85 @@ +import { waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { startRenderer } from "./rendererEntry"; +import { disposeStaticCrashPolicy } from "./staticCrashFallback"; + +describe("startRenderer", () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + disposeStaticCrashPolicy(); + delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + vi.restoreAllMocks(); + }); + + it("renders the static surface when the bootstrap module cannot load or evaluate", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + setInternals(invoke); + + await startRenderer(async () => { + throw new Error("bootstrap chunk evaluation failed"); + }); + + expect(document.querySelector("[data-static-crash=true]")).toHaveTextContent( + "bootstrap chunk evaluation failed", + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + "log_frontend_error", + expect.objectContaining({ + payload: expect.objectContaining({ message: "bootstrap chunk evaluation failed" }), + }), + ), + ); + }); + + it("renders the same surface for a synchronous pre-createRoot bootstrap failure", async () => { + await startRenderer(async () => ({ + bootstrap: () => { + throw new Error("pre-createRoot failure"); + }, + })); + + expect(document.querySelector("[data-static-crash=true]")).toHaveTextContent( + "pre-createRoot failure", + ); + }); + + it("keeps the startup browser guard until bootstrap installs the normal policy", async () => { + const dispose = vi.fn(); + const bootstrap = vi.fn().mockResolvedValue(undefined); + + await startRenderer(async () => ({ bootstrap }), () => dispose); + + expect(bootstrap).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it("retains the strict startup guard when bootstrap fails", async () => { + const dispose = vi.fn(); + + await startRenderer( + async () => ({ + bootstrap: () => { + throw new Error("bootstrap failed under guard"); + }, + }), + () => dispose, + ); + + expect(dispose).not.toHaveBeenCalled(); + expect(document.querySelector("[data-static-crash=true]")).toBeInTheDocument(); + }); +}); + +function setInternals(invoke: ReturnType): void { + ( + window as typeof window & { + __TAURI_INTERNALS__?: { invoke: typeof invoke }; + } + ).__TAURI_INTERNALS__ = { invoke }; +} diff --git a/src/app/rendererEntry.ts b/src/app/rendererEntry.ts new file mode 100644 index 0000000..6161108 --- /dev/null +++ b/src/app/rendererEntry.ts @@ -0,0 +1,33 @@ +import { installStaticCrashPolicy, renderStaticCrashFallback } from "./staticCrashFallback"; + +interface BootstrapModule { + bootstrap: () => void | Promise; +} + +export type BootstrapLoader = () => Promise; +export type BootstrapPolicyInstaller = () => () => void; + +const loadBootstrap: BootstrapLoader = () => import("./bootstrap"); +const installBootstrapPolicy: BootstrapPolicyInstaller = () => installStaticCrashPolicy(); + +/** + * Dependency-light renderer entry. Keep React, ReactDOM and every provider in + * the dynamically loaded bootstrap chunk so a failed chunk load or top-level + * module evaluation still lands on the plain-DOM recovery surface. + */ +export async function startRenderer( + loader: BootstrapLoader = loadBootstrap, + installPolicy: BootstrapPolicyInstaller = installBootstrapPolicy, +): Promise { + const disposeBootstrapPolicy = installPolicy(); + try { + const { bootstrap } = await loader(); + await bootstrap(); + // bootstrap installs the normal policy, which preserves editable context + // menus. Drop the stricter startup guard only after that handoff succeeds. + disposeBootstrapPolicy(); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + renderStaticCrashFallback(error); + } +} diff --git a/src/app/securityConfig.test.ts b/src/app/securityConfig.test.ts new file mode 100644 index 0000000..b610c04 --- /dev/null +++ b/src/app/securityConfig.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import capabilityJson from "../../src-tauri/capabilities/default.json"; +import tauriConfigJson from "../../src-tauri/tauri.conf.json"; + +interface TauriConfig { + app: { + windows: Array<{ label: string; create?: boolean }>; + security: { csp: string; devCsp: string }; + }; +} + +interface Capability { + permissions: string[]; +} + +describe("desktop trust-boundary config", () => { + it("keeps the main webview under the native navigation builder", () => { + const config = tauriConfigJson as TauriConfig; + expect(config.app.windows.find((window) => window.label === "main")?.create).toBe(false); + }); + + it("uses a local-only production CSP and a loopback-only HMR policy", () => { + const { app } = tauriConfigJson as TauriConfig; + const production = app.security.csp; + expect(production).toContain("script-src 'self'"); + expect(production).toContain("connect-src 'self' ipc: http://ipc.localhost"); + expect(production).toContain("base-uri 'none'"); + expect(production).toContain("object-src 'none'"); + expect(production).toContain("form-action 'none'"); + expect(production).toContain("frame-ancestors 'none'"); + expect(production).not.toMatch(/github\.com|agentsmirror\.com|oaistatic\.com/); + + expect(app.security.devCsp).toContain("ws://127.0.0.1:1420"); + expect(app.security.devCsp).not.toContain("ws://0.0.0.0"); + }); + + it("grants only the renderer APIs the app calls", () => { + const capability = capabilityJson as Capability; + expect(capability.permissions).toEqual([ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", + "core:window:allow-internal-toggle-maximize", + "core:window:allow-start-dragging", + "core:window:allow-close", + "core:window:allow-minimize", + "dialog:allow-open", + "process:allow-restart", + ]); + expect(capability.permissions).not.toContain("core:default"); + expect(capability.permissions).not.toContain("updater:default"); + expect(capability.permissions).not.toContain("process:default"); + }); +}); diff --git a/src/app/staticCrashFallback.test.ts b/src/app/staticCrashFallback.test.ts new file mode 100644 index 0000000..d4e2607 --- /dev/null +++ b/src/app/staticCrashFallback.test.ts @@ -0,0 +1,153 @@ +import { fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + disposeStaticCrashPolicy, + installStaticCrashPolicy, + renderStaticCrashFallback, + shouldBlockStaticCrashShortcut, +} from "./staticCrashFallback"; + +describe("renderStaticCrashFallback", () => { + afterEach(() => { + disposeStaticCrashPolicy(); + document.body.innerHTML = '
'; + delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + vi.restoreAllMocks(); + }); + + it("renders and logs with dependency-free APIs when createRoot has not run", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const invoke = vi.fn().mockResolvedValue(undefined); + ( + window as typeof window & { __TAURI_INTERNALS__?: StaticTauriInternalsForTest } + ).__TAURI_INTERNALS__ = { invoke }; + document.getElementById("root")?.remove(); + + const surface = renderStaticCrashFallback(new Error("createRoot failed"), document, true); + + expect(surface.dataset.staticCrash).toBe("true"); + expect(surface).toHaveTextContent("createRoot failed"); + expect(document.getElementById("root")).toContainElement(surface); + expect(surface).toHaveStyle({ background: "#17171d", color: "#f7f6fa" }); + expect(surface.querySelectorAll("button")).toHaveLength(2); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + "log_frontend_error", + expect.objectContaining({ + payload: expect.objectContaining({ kind: "bootstrap", message: "createRoot failed" }), + }), + ), + ); + expect(consoleError).toHaveBeenCalled(); + }); + + it("blocks context menus across the whole document, including outside #root", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + renderStaticCrashFallback(new Error("bootstrap failed"), document, true); + const outside = document.createElement("aside"); + document.body.appendChild(outside); + const menu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + + outside.dispatchEvent(menu); + + expect(menu.defaultPrevented).toBe(true); + }); + + it.each([ + ["F5", {}], + ["F12", {}], + ["r", { ctrlKey: true }], + ["p", { metaKey: true }], + ["R", { metaKey: true, shiftKey: true }], + ["i", { ctrlKey: true, shiftKey: true }], + ["j", { metaKey: true, altKey: true }], + ["BrowserBack", {}], + ["ArrowLeft", { altKey: true }], + ["х", { code: "BracketLeft", metaKey: true }], + ] as const)("blocks release browser shortcut %s on the fallback document", (key, init) => { + vi.spyOn(console, "error").mockImplementation(() => {}); + renderStaticCrashFallback(new Error("bootstrap failed"), document, true); + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...init, + }); + + document.body.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(shouldBlockStaticCrashShortcut(event)).toBe(true); + }); + + it("blocks mouse back/forward at every event phase used by WebViews", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + renderStaticCrashFallback(new Error("bootstrap failed"), document, true); + + for (const type of ["mousedown", "mouseup", "auxclick"]) { + const event = new MouseEvent(type, { button: 3, bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + expect(event.defaultPrevented, type).toBe(true); + } + }); + + it("keeps browser debugging behavior in development mode", () => { + installStaticCrashPolicy(document, false); + const menu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + const reload = new KeyboardEvent("keydown", { + key: "r", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + document.body.dispatchEvent(menu); + document.body.dispatchEvent(reload); + + expect(menu.defaultPrevented).toBe(false); + expect(reload.defaultPrevented).toBe(false); + }); + + it("quits through the backend phase policy and displays a serialized refusal", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const invoke = vi.fn((command: string) => { + if (command === "confirm_quit") { + return Promise.reject({ + code: "operation_not_interruptible", + message: "install is committing", + }); + } + return Promise.resolve(); + }); + ( + window as typeof window & { __TAURI_INTERNALS__?: StaticTauriInternalsForTest } + ).__TAURI_INTERNALS__ = { invoke }; + const surface = renderStaticCrashFallback(new Error("bootstrap failed"), document, true); + + fireEvent.click(Array.from(surface.querySelectorAll("button"))[1]); + + await waitFor(() => expect(invoke).toHaveBeenCalledWith("confirm_quit", undefined)); + await waitFor(() => expect(surface).toHaveTextContent("install is committing")); + expect(surface).not.toHaveTextContent("[object Object]"); + }); + + it("still renders and surfaces quit failure when the raw IPC bridge throws synchronously", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const invoke = vi.fn(() => { + throw new Error("bridge failed synchronously"); + }); + ( + window as typeof window & { __TAURI_INTERNALS__?: StaticTauriInternalsForTest } + ).__TAURI_INTERNALS__ = { invoke }; + + const surface = renderStaticCrashFallback(new Error("bootstrap failed"), document, true); + fireEvent.click(Array.from(surface.querySelectorAll("button"))[1]); + + await waitFor(() => expect(surface).toHaveTextContent("bridge failed synchronously")); + }); +}); + +type StaticTauriInternalsForTest = { + invoke: (command: string, args?: Record) => Promise; +}; diff --git a/src/app/staticCrashFallback.ts b/src/app/staticCrashFallback.ts new file mode 100644 index 0000000..d54ac18 --- /dev/null +++ b/src/app/staticCrashFallback.ts @@ -0,0 +1,232 @@ +function toStaticError(value: unknown): Error { + if (value instanceof Error) return value; + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return new Error(message); + } + return new Error(String(value ?? "Unknown bootstrap error")); +} + +function errorSummary(value: unknown): string { + const error = toStaticError(value); + return `${error.name}: ${error.message}`; +} + +function recoveryRoot(doc: Document): HTMLElement { + const existing = doc.getElementById("root"); + if (existing instanceof HTMLElement) return existing; + const root = doc.createElement("div"); + root.id = "root"; + (doc.body ?? doc.documentElement).appendChild(root); + return root; +} + +function staticErrorMessage(value: unknown): string { + return toStaticError(value).message || "The backend kept the app open."; +} + +type StaticTauriInternals = { + invoke?: (command: string, args?: Record) => Promise; +}; + +function invokeStaticBackend( + command: string, + args?: Record, +): Promise { + const internals = ( + window as typeof window & { __TAURI_INTERNALS__?: StaticTauriInternals } + ).__TAURI_INTERNALS__; + if (typeof internals?.invoke !== "function") { + return Promise.reject(new Error("Desktop backend unavailable.")); + } + try { + return Promise.resolve(internals.invoke(command, args)); + } catch (cause) { + return Promise.reject(cause); + } +} + +function reportStaticCrash(value: unknown): void { + const error = toStaticError(value); + try { + console.error("[bootstrap]", error); + } catch { + // Logging itself must not become another blank-window path. + } + void invokeStaticBackend("log_frontend_error", { + payload: { + kind: "bootstrap", + message: error.message, + stack: error.stack ?? null, + componentStack: null, + }, + }).catch(() => undefined); +} + +function phaseAwareQuit(): Promise { + // Call the custom backend command directly: this page exists precisely when + // normal React/service imports may be broken. The backend still rejects + // committing/finishing phases, so the recovery control cannot corrupt work. + return invokeStaticBackend("confirm_quit"); +} + +function matchesPhysicalKey(event: KeyboardEvent, value: string): boolean { + const key = event.key.toLowerCase(); + const code = event.code.toLowerCase(); + if (value.length === 1 && /[a-z]/.test(value)) { + return key === value || code === `key${value}`; + } + return key === value || code === value; +} + +/** Browser-only commands that must never escape the static release surface. */ +export function shouldBlockStaticCrashShortcut(event: KeyboardEvent): boolean { + const key = event.key.toLowerCase(); + const code = event.code.toLowerCase(); + if ( + ["f3", "f5", "f7", "f12", "browserback", "browserforward", "browserrefresh", "browsersearch"].includes( + key, + ) || + ["f3", "f5", "f7", "f12", "browserback", "browserforward", "browserrefresh", "browsersearch"].includes( + code, + ) + ) { + return true; + } + + const primary = event.ctrlKey || event.metaKey; + if ( + primary && + ["f", "g", "j", "l", "o", "p", "r", "s", "u"].some((value) => + matchesPhysicalKey(event, value), + ) + ) { + return true; + } + if ( + primary && + (["0", "+", "-", "="].includes(key) || + ["digit0", "equal", "minus", "numpadadd", "numpadsubtract", "numpad0"].includes(code)) + ) { + return true; + } + if ( + (event.ctrlKey && event.shiftKey && ["i", "j", "c"].some((value) => matchesPhysicalKey(event, value))) || + (event.metaKey && event.altKey && ["i", "j", "c"].some((value) => matchesPhysicalKey(event, value))) + ) { + return true; + } + if ( + event.metaKey && + (key === "[" || key === "]" || code === "bracketleft" || code === "bracketright") + ) { + return true; + } + return event.altKey && (key === "arrowleft" || key === "arrowright"); +} + +const staticPolicyDisposers = new WeakMap void>(); + +/** Install the full release browser-chrome policy at the document boundary. */ +export function installStaticCrashPolicy( + doc: Document = document, + enabled = !import.meta.env.DEV, +): () => void { + if (!enabled) return () => {}; + const installed = staticPolicyDisposers.get(doc); + if (installed) return installed; + + const stop = (event: Event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (shouldBlockStaticCrashShortcut(event)) stop(event); + }; + const onMouseNavigation = (event: MouseEvent) => { + if (event.button === 3 || event.button === 4) stop(event); + }; + + doc.addEventListener("contextmenu", stop, true); + doc.addEventListener("keydown", onKeyDown, true); + doc.addEventListener("mousedown", onMouseNavigation, true); + doc.addEventListener("mouseup", onMouseNavigation, true); + doc.addEventListener("auxclick", onMouseNavigation, true); + + const dispose = () => { + doc.removeEventListener("contextmenu", stop, true); + doc.removeEventListener("keydown", onKeyDown, true); + doc.removeEventListener("mousedown", onMouseNavigation, true); + doc.removeEventListener("mouseup", onMouseNavigation, true); + doc.removeEventListener("auxclick", onMouseNavigation, true); + if (staticPolicyDisposers.get(doc) === dispose) staticPolicyDisposers.delete(doc); + }; + staticPolicyDisposers.set(doc, dispose); + return dispose; +} + +/** Remove a previously installed policy (used by tests and hot reload). */ +export function disposeStaticCrashPolicy(doc: Document = document): void { + staticPolicyDisposers.get(doc)?.(); +} + +/** Plain-DOM fallback for failures before React can load, create or render its root. */ +export function renderStaticCrashFallback( + value: unknown, + doc: Document = document, + productionPolicy = !import.meta.env.DEV, +): HTMLElement { + reportStaticCrash(value); + installStaticCrashPolicy(doc, productionPolicy); + const root = recoveryRoot(doc); + const main = doc.createElement("main"); + main.dataset.staticCrash = "true"; + main.setAttribute("role", "alert"); + // Do not depend on the normal Shell, theme tokens or even the stylesheet for + // legibility: the Tauri window and body are transparent by design. + Object.assign(main.style, { + minHeight: "100vh", + padding: "24px", + borderRadius: "16px", + background: "#17171d", + color: "#f7f6fa", + fontFamily: "system-ui, sans-serif", + display: "flex", + flexDirection: "column", + gap: "14px", + overflow: "auto", + }); + + const title = doc.createElement("h1"); + title.textContent = "Codex App Manager could not start / 管理器界面无法启动"; + const body = doc.createElement("p"); + body.textContent = + "Reload the interface. Any backend operation remains protected. / 请重新加载界面;后台操作仍受保护。"; + const detail = doc.createElement("pre"); + detail.textContent = errorSummary(value); + const reload = doc.createElement("button"); + reload.type = "button"; + reload.textContent = "Reload / 重新加载"; + reload.addEventListener("click", () => window.location.reload()); + const quit = doc.createElement("button"); + quit.type = "button"; + quit.textContent = "Quit safely / 安全退出"; + const quitFailure = doc.createElement("p"); + quitFailure.setAttribute("role", "status"); + quit.addEventListener("click", () => { + quit.disabled = true; + quitFailure.textContent = ""; + void phaseAwareQuit() + .catch((cause) => { + quitFailure.setAttribute("role", "alert"); + quitFailure.textContent = staticErrorMessage(cause); + }) + .finally(() => { + quit.disabled = false; + }); + }); + + main.append(title, body, detail, reload, quit, quitFailure); + root.replaceChildren(main); + return main; +} diff --git a/src/main.tsx b/src/main.tsx index 9755188..74dc003 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,28 +1,3 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; +import { startRenderer } from "./app/rendererEntry"; -import { App } from "./app/App"; -import { installContextMenuPolicy } from "./app/contextMenuPolicy"; -import { installGlobalErrorHandlers } from "./app/globalErrors"; -import { currentPlatform } from "./app/platform"; -import { resolveInitialTheme } from "./app/theme"; -import "./app/styles.css"; - -// Tag the platform so the stylesheet can tame Windows' heavier system fonts -// (Segoe UI / Microsoft YaHei) — see the [data-platform="windows"] block. -document.documentElement.dataset.platform = currentPlatform(); -// Resolve the theme BEFORE first paint. The ThemeProvider re-derives (and -// keeps) this value, but it lands in a passive effect — after paint — and the -// bare `:root` defaults to the dark tokens, so without this a light-theme -// user gets one dark frame on every launch. -document.documentElement.dataset.theme = resolveInitialTheme(); -installGlobalErrorHandlers(); -// Production WebViews: no browser Print/Reload menu; editable fields keep -// copy/paste. Dev builds leave the default menu for Reload/DevTools. -installContextMenuPolicy(); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , -); +void startRenderer(); From 41223363cd0e52ed8994e74c43629172ffde5cc9 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:07:25 +0800 Subject: [PATCH 5/6] fix(reliability): keep download stop failures recoverable --- src-tauri/src/app/mac_update.rs | 90 +++-- src-tauri/src/app/oplock.rs | 380 +++++++++++++++++++++ src-tauri/src/app/win_update.rs | 47 ++- src-tauri/src/commands.rs | 105 ++++-- src-tauri/src/lib.rs | 11 +- src/app/errorCopy.ts | 16 + src/app/i18n.tsx | 44 +++ src/app/views/Home.test.tsx | 124 ++++++- src/app/views/Home.tsx | 37 +- src/app/views/ProgressScreen.tsx | 18 +- src/app/views/WinHome.test.tsx | 111 ++++++ src/app/views/WinHome.tsx | 37 +- src/app/views/useDownloadProgress.test.tsx | 254 +++++++++++++- src/app/views/useDownloadProgress.ts | 142 +++++++- src/services/managerApi.ts | 16 +- 15 files changed, 1318 insertions(+), 114 deletions(-) diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index cc45c1c..e9b71cb 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -12,7 +12,7 @@ //! already on the latest build (the user's case during development). use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde::Serialize; @@ -577,7 +577,7 @@ pub fn stage_macos_update_with_network( // A standalone stage can also be cancelled (its download sets the abort // latch). Own a guard so a cancelled stage can't leave the latch set and // make the NEXT perform/install abort itself at its first checkpoint. - let _abort_guard = AbortGuard; + let _abort_guard = AbortGuard::new(); let installed = detect_managed_installed(); let (_, xml) = fetch_appcast_for_arch(arch_of(&installed), network)?; let appcast = parse_appcast(&xml).map_err(|e| AppError::Engine(e.to_string()))?; @@ -838,7 +838,7 @@ pub fn perform_macos_update_with_network_and_phase( }; // Reset the latch when THIS op ends (not at entry) so a cancel racing the // op's startup isn't wiped. See AbortGuard. - let _abort_guard = AbortGuard; + let _abort_guard = AbortGuard::new(); set_phase(OperationPhase::Preparing); // A vanished install is itself a stale snapshot: the user confirmed an // update against a Codex that is no longer there (deleted / moved between @@ -999,10 +999,9 @@ pub fn perform_macos_update_with_network_and_phase( )); } - // Point of no return. Honor a cancel one last time BEFORE we touch the - // user's running Codex — this also closes the gap after the preparing- - // phase checkpoint where a fully-cached artifact skips the download loop - // (so its cancel flag never arms) yet reconstruct/gate still ran. + // Honor a cancel before touching the user's running Codex. A second, + // phase-linearized checkpoint immediately before the swap closes the + // later quit/cancel window. check_update_abort()?; // Re-verify the TARGET right before the destructive tail: the early @@ -1038,6 +1037,15 @@ pub fn perform_macos_update_with_network_and_phase( quit_codex_gracefully(&install_path)?; log::info!("macOS perform step=swap"); set_phase(OperationPhase::Committing); + // The phase transition and the app quit preparation share the operation + // mutex. If quit won first its latch is now visible; if commit won first, + // quit was blocked. No destructive rename occurs between those outcomes. + if let Err(err) = check_update_abort() { + if was_running { + let _ = relaunch(&install_path); + } + return Err(err); + } let had_previous = install_path.exists(); let mut tx = ActiveInstallTx::begin( InstallTxKind::MacosSwap, @@ -1331,10 +1339,18 @@ pub fn install_macos(progress: &dyn Fn(DownloadProgress)) -> Result Result { + install_macos_with_network_and_phase(progress, network, None) +} + +pub fn install_macos_with_network_and_phase( + progress: &dyn Fn(DownloadProgress), + network: &NetworkConfig, + phase_hook: Option<&PhaseHook<'_>>, ) -> Result { log::info!("macOS install start"); // Reset on op end, not entry — race-free cancel (see AbortGuard). - let _abort_guard = AbortGuard; + let _abort_guard = AbortGuard::new(); if detect_installed().is_some() { return Err(AppError::Engine( "已检测到 Codex,请使用更新而非安装".to_string(), @@ -1369,6 +1385,7 @@ pub fn install_macos_with_network( &staging, progress, network, + phase_hook, ); match install_result { Ok(status) => { @@ -1396,6 +1413,7 @@ fn install_macos_in_staging( staging: &StagingDir, progress: &dyn Fn(DownloadProgress), network: &NetworkConfig, + phase_hook: Option<&PhaseHook<'_>>, ) -> Result { let work = staging.path(); let out_app = staging.join("Codex.app"); @@ -1403,6 +1421,9 @@ fn install_macos_in_staging( // Full package only — no basis bundle to delta against. reconstruct_full(appcast, work, &out_app, progress, network)?; + if let Some(hook) = phase_hook { + hook(OperationPhase::Verifying); + } gate_reconstructed(&out_app) .map_err(|e| AppError::Engine(format!("codesign 闸失败(拒绝安装): {e}")))?; @@ -1412,11 +1433,19 @@ fn install_macos_in_staging( install_dir.display() ))); } - // Point of no return. Last cancel check before writing into the install - // location — covers a cached-artifact path that skipped the download loop. + // Publish the point-of-no-return while the same operation token is still + // held, then perform one final cancel checkpoint. A cancel that won the + // operation mutex first leaves its latch for this checkpoint; a later cancel + // sees Committing and is rejected instead of racing the rename. + if let Some(hook) = phase_hook { + hook(OperationPhase::Committing); + } check_update_abort()?; std::fs::rename(&out_app, install_path) .map_err(|e| AppError::Engine(format!("写入 {} 失败: {e}", install_dir.display())))?; + if let Some(hook) = phase_hook { + hook(OperationPhase::Finishing); + } let mut outcome = OperationOutcome::full_success("present", Some("managed")); outcome.cleanup = StepOutcome::not_applicable(); @@ -1487,7 +1516,7 @@ pub fn launch_codex() -> Result<(), AppError> { /// a cancel pressed during "正在准备" be honored at the next checkpoint. static UPDATE_ABORT: AtomicBool = AtomicBool::new(false); -fn clear_update_abort() { +pub(crate) fn clear_update_abort() { UPDATE_ABORT.store(false, Ordering::SeqCst); } @@ -1497,13 +1526,27 @@ fn clear_update_abort() { /// showing its cancel button and this operation reaching its first checkpoint is /// NOT wiped by an entry-clear, so the checkpoint still observes it; the latch is /// reset only once this operation is done, leaving the next one clean. The cancel -/// command does not hold the op lock, so this startup window is real — hence the -/// guard instead of an entry reset. +/// command now binds the latch to the owning operation under the op lock, but the +/// worker may still be between entry and its first checkpoint — hence the guard +/// instead of an entry reset. struct AbortGuard; +static UPDATE_ABORT_GUARD_DEPTH: AtomicUsize = AtomicUsize::new(0); + +impl AbortGuard { + fn new() -> Self { + UPDATE_ABORT_GUARD_DEPTH.fetch_add(1, Ordering::SeqCst); + Self + } +} + impl Drop for AbortGuard { fn drop(&mut self) { - clear_update_abort(); + let previous = UPDATE_ABORT_GUARD_DEPTH.fetch_sub(1, Ordering::SeqCst); + debug_assert!(previous > 0, "AbortGuard depth underflow"); + if previous == 1 { + clear_update_abort(); + } } } @@ -1815,20 +1858,29 @@ mod tests { #[test] fn abort_guard_preserves_a_startup_race_cancel_and_resets_on_drop() { + let _serial = crate::app::oplock::CANCEL_LATCH_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // The race a reviewer flagged: a cancel can land BEFORE perform reaches - // its first checkpoint — the cancel command holds no op lock, and the UI - // shows the cancel button the moment it enters the progress state, before - // the backend call returns. Clearing the latch at op ENTRY would wipe that + // its first checkpoint even though the command now validates the owning + // token under the op lock. Clearing the latch at op ENTRY would wipe that // cancel; AbortGuard clears on DROP instead, so a pending cancel survives // the guard's creation and is still observed, while the next op starts - // clean. (Only this test touches UPDATE_ABORT, so it can't race others.) + // clean. OperationManager cleanup shares the same test-only serial lock. UPDATE_ABORT.store(true, Ordering::SeqCst); // a cancel that beat the op { - let _guard = AbortGuard; + let outer = AbortGuard::new(); + let inner = AbortGuard::new(); assert!( check_update_abort().is_err(), "guard creation must not wipe a pending cancel" ); + drop(inner); + assert!( + check_update_abort().is_err(), + "a nested guard must not clear the owning operation's cancel" + ); + drop(outer); } assert!( check_update_abort().is_ok(), diff --git a/src-tauri/src/app/oplock.rs b/src-tauri/src/app/oplock.rs index 09a4856..8f425f6 100644 --- a/src-tauri/src/app/oplock.rs +++ b/src-tauri/src/app/oplock.rs @@ -135,6 +135,11 @@ impl Drop for OperationGuard { .is_some_and(|active| active.token == self.token.0) { let _ = OperationManager::unlock_lock_file(&mut inner); + // Linearize latch cleanup with removal of the owning lease. A cancel + // command takes the same mutex for both snapshots, so it either sees + // this owner and is followed by this cleanup, or sees no owner and + // immediately rolls its speculative latch back. + clear_cancel_latches(); inner.active.take(); log::info!( "released operation lock kind={} token_prefix={}", @@ -198,6 +203,7 @@ impl OperationManager { return Ok(()); } Self::unlock_lock_file(&mut inner)?; + clear_cancel_latches(); log::info!( "ended operation lock kind={} token_prefix={}", active_kind.as_str(), @@ -213,6 +219,24 @@ impl OperationManager { } pub fn validate(&self, token: &OperationToken) -> Result<(), OperationError> { + self.validate_inner(token, None) + } + + /// Claim a detached token and publish its first phase in one critical section. + /// Destructive operations use this to eliminate the validate→commit window. + pub fn validate_with_phase( + &self, + token: &OperationToken, + phase: OperationPhase, + ) -> Result<(), OperationError> { + self.validate_inner(token, Some(phase)) + } + + fn validate_inner( + &self, + token: &OperationToken, + phase: Option, + ) -> Result<(), OperationError> { let mut inner = self .inner .lock() @@ -233,6 +257,15 @@ impl OperationManager { ); } active.holders = active.holders.saturating_add(1); + if let Some(phase) = phase { + log::info!( + "claimed operation at phase kind={} phase={} token_prefix={}", + active.kind.as_str(), + phase.as_str(), + token_prefix(&token.0) + ); + active.phase = phase; + } return Ok(()); } } @@ -359,6 +392,71 @@ impl OperationManager { QuitPolicy::evaluate(force_quit, confirm_close, busy, phase, kind) } + /// Linearize an allowed or user-confirmed quit with the active phase. + /// + /// `prepare_exit` runs while the operation mutex is held when policy is + /// already `Allow`, or after an explicit confirmation. Callers use it to arm + /// platform cancellation latches and the force-exit flag before a worker can + /// advance to `Committing`. A worker that won the mutex first is observed as + /// blocked; a worker that advances afterward observes the final checkpoint. + pub fn prepare_quit( + &self, + confirm_close: bool, + confirmed: bool, + prepare_exit: impl FnOnce(), + ) -> QuitPolicy { + let Ok(mut inner) = self.inner.lock() else { + return QuitPolicy::evaluate( + false, + confirm_close, + true, + OperationPhase::Finishing, + None, + ); + }; + let _ = self.reclaim_stale_detached(&mut inner); + + let (busy, phase, kind) = if let Some(active) = inner.active.as_ref() { + (true, active.phase, Some(active.kind)) + } else { + let other_busy = match Self::lock_file_mut(&mut inner) { + Ok(lock_file) => match Fs4FileExt::try_lock(lock_file) { + Ok(()) => { + let _ = Fs4FileExt::unlock(lock_file); + false + } + Err(TryLockError::WouldBlock) => true, + Err(TryLockError::Error(_)) => false, + }, + Err(_) => false, + }; + if other_busy { + (true, OperationPhase::Finishing, None) + } else { + (false, OperationPhase::Idle, None) + } + }; + let policy = QuitPolicy::evaluate(false, confirm_close, busy, phase, kind); + let should_prepare = matches!(policy, QuitPolicy::Allow) + || (confirmed && matches!(policy, QuitPolicy::Confirm)); + if should_prepare { + // An armed detached token has not entered its command yet. Remove it + // under this same mutex so a delayed validate cannot start destructive + // work after force-exit preparation. Claimed workers keep their lease + // and must honor the phase-linearized abort checkpoint instead. + let abandon_unclaimed = inner + .active + .as_ref() + .is_some_and(|active| active.detached && !active.claimed); + if abandon_unclaimed { + let _ = Self::unlock_lock_file(&mut inner); + inner.active.take(); + } + prepare_exit(); + } + policy + } + /// Snapshot of the local active operation, for frontend reattach after /// renderer reload / remount. `None` when free (or only a cross-process lock). pub fn snapshot(&self) -> Option { @@ -382,6 +480,48 @@ impl OperationManager { }) } + /// Run a platform cancel signal while the active lease is locked and still + /// known to be interruptible. This is the linearization point between an + /// operation ending and a new one beginning: a process-global engine latch + /// can never be armed for operation A after operation B has taken its lease. + pub fn request_cancellation( + &self, + token: &OperationToken, + request: impl FnOnce() -> bool, + ) -> bool { + let Ok(inner) = self.inner.lock() else { + return false; + }; + if !inner + .active + .as_ref() + .is_some_and(|active| active.token == token.0 && active.phase.interruptible()) + { + return false; + } + request() + } + + /// Signal pause and mark the same active lease while holding the operation + /// mutex. Without this critical section a late pause for operation A could + /// observe the process-global downloader of newly-started operation B. + pub fn request_pause(&self, token: &OperationToken, request: impl FnOnce() -> bool) -> bool { + let Ok(mut inner) = self.inner.lock() else { + return false; + }; + let Some(active) = inner.active.as_mut() else { + return false; + }; + if active.token != token.0 || !active.phase.interruptible() { + return false; + } + let requested = request(); + if requested { + active.paused = true; + } + requested + } + /// Record the latest download progress for a validated token. pub fn set_progress( &self, @@ -522,6 +662,7 @@ impl OperationManager { active.kind.as_str() ); Self::unlock_lock_file(inner)?; + clear_cancel_latches(); inner.active.take(); } Ok(()) @@ -538,6 +679,22 @@ impl OperationManager { } } +#[cfg(test)] +pub(crate) static CANCEL_LATCH_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn clear_cancel_latches() { + // The abort latches are process-global. Production OperationManager access is + // serialized by `inner`, while Rust's test harness may run unrelated managers + // and direct latch tests concurrently in one process. Share a test-only lock + // with those direct tests so a guard drop cannot clear their asserted value. + #[cfg(test)] + let _test_guard = CANCEL_LATCH_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + crate::app::mac_update::clear_update_abort(); + crate::app::win_update::clear_win_update_abort(); +} + fn token_prefix(token: &str) -> &str { token.get(..8).unwrap_or(token) } @@ -897,4 +1054,227 @@ mod tests { let _ = fs::remove_dir_all(path.parent().unwrap()); } + + #[test] + fn cancellation_signal_runs_only_inside_a_live_interruptible_lease() { + use crate::app::op_phase::OperationPhase; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let path = lock_path("cancel-linearization"); + let manager = OperationManager::new(path.clone()); + let calls = AtomicUsize::new(0); + let missing = OperationToken("missing".to_string()); + assert!(!manager.request_cancellation(&missing, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + + let guard = manager.begin(OperationKind::Update).unwrap(); + let owner = guard.token().clone(); + assert!(manager.request_cancellation(&owner, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + manager + .set_phase(guard.token(), OperationPhase::Committing) + .unwrap(); + assert!(!manager.request_cancellation(&owner, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + drop(guard); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn pause_signal_and_paused_marker_share_the_same_live_lease() { + use crate::app::op_phase::OperationPhase; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let path = lock_path("pause-linearization"); + let manager = OperationManager::new(path.clone()); + let calls = AtomicUsize::new(0); + let missing = OperationToken("missing".to_string()); + assert!(!manager.request_pause(&missing, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + + let guard = manager.begin(OperationKind::Update).unwrap(); + let owner = guard.token().clone(); + assert!(!manager.request_pause(&owner, || { + calls.fetch_add(1, Ordering::SeqCst); + false + })); + assert!(!manager.snapshot().unwrap().paused); + assert!(manager.request_pause(&owner, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert!(manager.snapshot().unwrap().paused); + + manager + .set_phase(guard.token(), OperationPhase::Committing) + .unwrap(); + assert!(!manager.request_pause(&owner, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert_eq!(calls.load(Ordering::SeqCst), 2); + + drop(guard); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn stale_stop_token_cannot_target_the_next_operation() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let path = lock_path("stale-stop-token"); + let manager = OperationManager::new(path.clone()); + let first = manager.begin(OperationKind::Update).unwrap(); + let stale = first.token().clone(); + drop(first); + + let second = manager.begin(OperationKind::Install).unwrap(); + let calls = AtomicUsize::new(0); + assert!(!manager.request_cancellation(&stale, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert!(!manager.request_pause(&stale, || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert!(!manager.snapshot().unwrap().paused); + + assert!(manager.request_pause(second.token(), || { + calls.fetch_add(1, Ordering::SeqCst); + true + })); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(manager.snapshot().unwrap().paused); + + drop(second); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn confirmed_quit_preparation_linearizes_before_commit_transition() { + use crate::app::op_phase::{OperationPhase, QuitPolicy}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Barrier}; + + let path = lock_path("confirmed-quit-linearization"); + let manager = OperationManager::new(path.clone()); + let guard = manager.begin(OperationKind::Install).unwrap(); + manager + .set_phase(guard.token(), OperationPhase::Verifying) + .unwrap(); + + let attempted = Arc::new(AtomicBool::new(false)); + let completed = Arc::new(AtomicBool::new(false)); + let prepared = Arc::new(AtomicBool::new(false)); + let start = Arc::new(Barrier::new(2)); + let worker_manager = manager.clone(); + let worker_token = guard.token().clone(); + let worker_attempted = Arc::clone(&attempted); + let worker_completed = Arc::clone(&completed); + let worker_start = Arc::clone(&start); + let worker = std::thread::spawn(move || { + worker_start.wait(); + worker_attempted.store(true, Ordering::SeqCst); + worker_manager + .set_phase(&worker_token, OperationPhase::Committing) + .unwrap(); + worker_completed.store(true, Ordering::SeqCst); + }); + + let prepared_for_quit = Arc::clone(&prepared); + let policy = manager.prepare_quit(true, true, || { + start.wait(); + while !attempted.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + assert!( + !completed.load(Ordering::SeqCst), + "phase transition must stay behind confirmed-quit preparation" + ); + prepared_for_quit.store(true, Ordering::SeqCst); + }); + assert_eq!(policy, QuitPolicy::Confirm); + assert!(prepared.load(Ordering::SeqCst)); + worker.join().unwrap(); + assert!(completed.load(Ordering::SeqCst)); + + let blocked_preparation = AtomicBool::new(false); + let blocked = manager.prepare_quit(true, true, || { + blocked_preparation.store(true, Ordering::SeqCst); + }); + assert!(matches!(blocked, QuitPolicy::Block { .. })); + assert!(!blocked_preparation.load(Ordering::SeqCst)); + + let needs_confirmation = AtomicBool::new(false); + manager + .set_phase(guard.token(), OperationPhase::Verifying) + .unwrap(); + let confirm = manager.prepare_quit(true, false, || { + needs_confirmation.store(true, Ordering::SeqCst); + }); + assert_eq!(confirm, QuitPolicy::Confirm); + assert!(!needs_confirmation.load(Ordering::SeqCst)); + + let automatic_preparation = AtomicBool::new(false); + let allow = manager.prepare_quit(false, false, || { + automatic_preparation.store(true, Ordering::SeqCst); + }); + assert_eq!(allow, QuitPolicy::Allow); + assert!(automatic_preparation.load(Ordering::SeqCst)); + + drop(guard); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn quit_abandons_unclaimed_destructive_token_or_commit_claim_blocks_quit() { + use crate::app::op_phase::{OperationPhase, QuitPolicy}; + use std::sync::atomic::{AtomicBool, Ordering}; + + let path = lock_path("quit-vs-destructive-claim"); + let manager = OperationManager::new(path.clone()); + + // Quit wins: the still-unclaimed token is removed, so its delayed command + // cannot validate and begin destructive work after force-exit preparation. + let abandoned = manager.begin_detached(OperationKind::Uninstall).unwrap(); + let prepared = AtomicBool::new(false); + assert_eq!( + manager.prepare_quit(false, false, || prepared.store(true, Ordering::SeqCst)), + QuitPolicy::Allow + ); + assert!(prepared.load(Ordering::SeqCst)); + assert!(matches!( + manager.validate_with_phase(&abandoned, OperationPhase::Committing), + Err(OperationError::InvalidToken) + )); + assert!(!manager.is_busy()); + + // Destructive claim wins: validation and Committing are atomic, so quit + // observes the point of no return and does not run its preparation closure. + let committed = manager.begin_detached(OperationKind::Uninstall).unwrap(); + manager + .validate_with_phase(&committed, OperationPhase::Committing) + .unwrap(); + let should_not_prepare = AtomicBool::new(false); + let blocked = manager.prepare_quit(false, false, || { + should_not_prepare.store(true, Ordering::SeqCst); + }); + assert!(matches!(blocked, QuitPolicy::Block { .. })); + assert!(!should_not_prepare.load(Ordering::SeqCst)); + manager.end(committed).unwrap(); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } } diff --git a/src-tauri/src/app/win_update.rs b/src-tauri/src/app/win_update.rs index 9736a07..43182e0 100644 --- a/src-tauri/src/app/win_update.rs +++ b/src-tauri/src/app/win_update.rs @@ -7,7 +7,7 @@ //! verification into staging. Non-destructive; it does not install yet. use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde::{Deserialize, Serialize}; @@ -568,7 +568,7 @@ pub fn stage_windows_update_with_install_mode_and_network( // (Nesting under perform's guard can't lose a reachable cancel: once the // download completes the UI is in the "finishing" state with cancel disabled, // so no cancel lands during stage's post-download verify.) - let _abort_guard = WinAbortGuard; + let _abort_guard = WinAbortGuard::new(); let report = plan_windows_update_with_install_mode_and_network( endpoints, settings, @@ -896,7 +896,7 @@ pub fn auto_stage_windows_update_with_install_mode_and_network( /// cancel flag can't reach. Reset on op end via `WinAbortGuard`, not at entry. static WIN_UPDATE_ABORT: AtomicBool = AtomicBool::new(false); -fn clear_win_update_abort() { +pub(crate) fn clear_win_update_abort() { WIN_UPDATE_ABORT.store(false, Ordering::SeqCst); } @@ -904,16 +904,30 @@ fn clear_win_update_abort() { /// DROP (not at entry) keeps the cancel race-free: a cancel landing between the /// UI showing its button and the op reaching its first checkpoint isn't wiped, so /// the checkpoint observes it; the next op still starts clean. The cancel command -/// doesn't hold the op lock, so this startup window is real. Owned by both +/// now validates the owning token under the op lock, but the worker can still be +/// between entry and its first checkpoint. Owned by both /// `perform` and `stage` (so background `auto_stage` and the standalone -/// `win_stage_update` can't leak a set latch into the next op). The perform→stage -/// nesting is harmless: clears are idempotent, and the only window the inner clear -/// could touch (stage's post-download verify) has the UI cancel already disabled. +/// `win_stage_update` can't leak a set latch into the next op). Nested guards are +/// reference-counted: an inner stage return cannot clear a quit/cancel owned by +/// the still-running outer perform operation. struct WinAbortGuard; +static WIN_ABORT_GUARD_DEPTH: AtomicUsize = AtomicUsize::new(0); + +impl WinAbortGuard { + fn new() -> Self { + WIN_ABORT_GUARD_DEPTH.fetch_add(1, Ordering::SeqCst); + Self + } +} + impl Drop for WinAbortGuard { fn drop(&mut self) { - clear_win_update_abort(); + let previous = WIN_ABORT_GUARD_DEPTH.fetch_sub(1, Ordering::SeqCst); + debug_assert!(previous > 0, "WinAbortGuard depth underflow"); + if previous == 1 { + clear_win_update_abort(); + } } } @@ -1033,7 +1047,7 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( // Reset the latch when THIS perform ends (not at stage entry) so a cancel // racing the op's startup isn't wiped, and `auto_stage` never clears it. See // WinAbortGuard. - let _abort_guard = WinAbortGuard; + let _abort_guard = WinAbortGuard::new(); set_phase(OperationPhase::Preparing); if !confirm { return Err(AppError::Internal( @@ -1086,8 +1100,9 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( // Point of no return. Honor a cancel one last time BEFORE closing Codex or // sideloading — closes the gap after staging where a fully-cached MSIX skips // the download loop (so its cancel flag never arms) yet still reaches here. - check_win_update_abort()?; set_phase(OperationPhase::Committing); + // Linearized with every native/window quit path by OperationManager. + check_win_update_abort()?; if stage.route == "portable-fallback" { log::warn!("Windows route changed to portable fallback from_route=msix-sideload to_route=portable-fallback"); @@ -1858,17 +1873,27 @@ mod tests { #[test] fn win_abort_guard_preserves_a_startup_race_cancel_and_resets_on_drop() { + let _serial = crate::app::oplock::CANCEL_LATCH_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // Mirrors the macOS guard test: a cancel landing before `perform` reaches // its first checkpoint must survive the guard's creation (no entry-clear) // and still be observed; the guard resets the latch on drop so the next // op — and background auto_stage — start clean. WIN_UPDATE_ABORT.store(true, Ordering::SeqCst); { - let _guard = WinAbortGuard; + let outer = WinAbortGuard::new(); + let inner = WinAbortGuard::new(); assert!( check_win_update_abort().is_err(), "guard creation must not wipe a pending cancel" ); + drop(inner); + assert!( + check_win_update_abort().is_err(), + "a nested guard must not clear the owning operation's cancel" + ); + drop(outer); } assert!( check_win_update_abort().is_ok(), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b7e1d3b..b7df826 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,8 +14,9 @@ use crate::app::disk::available_space; use crate::app::logging::redact_url; use crate::app::mac_update::{ cancel_macos_download, detect_existing_install_at_path as detect_macos_install_at_path, - discard_macos_download, install_macos_with_network, mac_adopt_path as adopt_macos_path, - pause_macos_download, perform_macos_update_with_network_and_phase, + discard_macos_download, install_macos_with_network_and_phase, + mac_adopt_path as adopt_macos_path, pause_macos_download, + perform_macos_update_with_network_and_phase, plan_macos_update_with_network, retry_macos_ancillary, stage_macos_update_with_network, uninstall_macos, InstalledCodex, MacInstallStatus, MacPerformReport, MacStageReport, MacUninstallReport, MacUpdateReport, PerformExpectation, @@ -304,6 +305,21 @@ impl DetachedGuard { }) } + fn validate_with_phase( + state: &ManagerState, + token: OperationToken, + phase: OperationPhase, + ) -> Result { + let operations = state.operations.clone(); + operations + .validate_with_phase(&token, phase) + .map_err(destructive_token_error)?; + Ok(Self { + operations, + token: Some(token), + }) + } + fn set_phase(&self, phase: OperationPhase) { if let Some(token) = self.token.as_ref() { let _ = self.operations.set_phase(token, phase); @@ -806,6 +822,8 @@ pub async fn mac_install( let network = mac_network_config_for_settings()?; let progress_token = token.clone(); let progress_ops = ops.clone(); + let phase_token = token.clone(); + let phase_ops = ops.clone(); tauri::async_runtime::spawn_blocking(move || { let report = move |p: crate::app::mac_update::DownloadProgress| { emit_op_download_progress( @@ -818,7 +836,10 @@ pub async fn mac_install( p.source, ); }; - install_macos_with_network(&report, &network) + let phase_hook = |phase: OperationPhase| { + let _ = phase_ops.set_phase(&phase_token, phase); + }; + install_macos_with_network_and_phase(&report, &network, Some(&phase_hook)) }) .await .map_err(|e| AppError::Internal(format!("join: {e}")))? @@ -828,26 +849,31 @@ pub async fn mac_install( /// macOS-only: request pausing an active package download. /// Partial bytes are left in place for the next resume-capable run. #[tauri::command] -pub fn mac_pause_download(state: State<'_, ManagerState>) -> Result { +pub fn mac_pause_download( + state: State<'_, ManagerState>, + operation_id: String, +) -> Result { if !cfg!(target_os = "macos") { return Err(AppError::UnsupportedPlatform.into()); } - if let Some(snap) = state.operations.snapshot() { - let _ = state - .operations - .set_paused(&OperationToken(snap.id), true); - } - Ok(pause_macos_download()) + Ok(state + .operations + .request_pause(&OperationToken(operation_id), pause_macos_download)) } /// macOS-only: request cancellation of an active package download. /// Partial bytes are discarded. #[tauri::command] -pub fn mac_cancel_download() -> Result { +pub fn mac_cancel_download( + state: State<'_, ManagerState>, + operation_id: String, +) -> Result { if !cfg!(target_os = "macos") { return Err(AppError::UnsupportedPlatform.into()); } - Ok(cancel_macos_download()) + Ok(state + .operations + .request_cancellation(&OperationToken(operation_id), cancel_macos_download)) } /// macOS-only: discard a PAUSED download. After a pause the curl process is gone @@ -1090,7 +1116,9 @@ pub fn retry_ancillary( "清除用户数据需要破坏性令牌(先 arm_destructive uninstall)".to_string(), ) })?; - RetryGuard::Detached(DetachedGuard::validate(&state, token)?) + let guard = + DetachedGuard::validate_with_phase(&state, token, OperationPhase::Committing)?; + RetryGuard::Detached(guard) } else { RetryGuard::Scoped(begin_guard(&state, OperationKind::Adopt)?) }; @@ -1174,9 +1202,17 @@ pub fn get_operation_snapshot( #[tauri::command] pub fn confirm_quit(app: tauri::AppHandle, state: State<'_, ManagerState>) -> Result<(), CommandError> { let confirm_close = crate::app::settings_store::AppSettings::load().confirm_close; - // Evaluate as if force_quit is not yet set so a point-of-no-return phase - // still blocks even after the user clicks the confirm dialog. - let policy = state.operations.quit_policy(false, confirm_close); + // Decide and arm exit under the SAME operation mutex used by phase changes. + // If the worker already reached commit this returns Block. Otherwise both app + // abort latches and force_quit are set before it can cross that boundary, so + // the final pre-commit checkpoint observes the cancellation. + let policy = state.operations.prepare_quit(confirm_close, true, || { + let _ = cancel_macos_download(); + let _ = cancel_windows_download(); + state + .force_quit + .store(true, std::sync::atomic::Ordering::SeqCst); + }); if let QuitPolicy::Block { phase, reason_code, @@ -1192,12 +1228,6 @@ pub fn confirm_quit(app: tauri::AppHandle, state: State<'_, ManagerState>) -> Re let _ = app.emit("app://quit-blocked", &policy); return Err(AppError::Busy(reason.clone()).into()); } - // Interruptible phases: best-effort cancel so partial downloads settle cleanly. - let _ = codex_mac_engine::cancel_active_download(); - let _ = codex_win_engine::cancel_active_download(); - state - .force_quit - .store(true, std::sync::atomic::Ordering::SeqCst); app.exit(0); Ok(()) } @@ -1344,7 +1374,10 @@ pub async fn mac_uninstall( if !confirm { return Err(AppError::Internal("拒绝执行:卸载必须带显式 confirm".to_string()).into()); } - let _op = DetachedGuard::validate(&state, token)?; + let _op = DetachedGuard::validate_with_phase(&state, token, OperationPhase::Committing)?; + // Uninstall has no resumable cancellation protocol. Treat the whole worker + // as point-of-no-return so every native/window quit path blocks until the + // removal and ancillary bookkeeping have settled. tauri::async_runtime::spawn_blocking(move || uninstall_macos(keep_codex_home)) .await .map_err(|e| AppError::Internal(format!("join: {e}")))? @@ -1395,26 +1428,31 @@ pub async fn win_auto_stage_update( /// Windows-only: request pausing an active background/manual download. /// Partial bytes are left in place for the next resume-capable staging run. #[tauri::command] -pub fn win_pause_download(state: State<'_, ManagerState>) -> Result { +pub fn win_pause_download( + state: State<'_, ManagerState>, + operation_id: String, +) -> Result { if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } - if let Some(snap) = state.operations.snapshot() { - let _ = state - .operations - .set_paused(&OperationToken(snap.id), true); - } - Ok(pause_windows_download()) + Ok(state + .operations + .request_pause(&OperationToken(operation_id), pause_windows_download)) } /// Windows-only: request cancellation of an active background/manual download. /// Partial bytes are discarded. #[tauri::command] -pub fn win_cancel_download(state: State<'_, ManagerState>) -> Result { +pub fn win_cancel_download( + state: State<'_, ManagerState>, + operation_id: String, +) -> Result { if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } - Ok(cancel_windows_download()) + Ok(state + .operations + .request_cancellation(&OperationToken(operation_id), cancel_windows_download)) } /// Windows-only: discard a PAUSED download. Drops the cached `.part` left for @@ -1805,7 +1843,8 @@ pub async fn win_uninstall( AppError::Internal("拒绝执行:Windows 卸载必须带显式 confirm".to_string()).into(), ); } - let _op = DetachedGuard::validate(&state, token)?; + let _op = DetachedGuard::validate_with_phase(&state, token, OperationPhase::Committing)?; + // Remove-AppxPackage / portable tree removal must not be killed mid-call. let settings = windows_domain_settings_for_persisted(&state); tauri::async_runtime::spawn_blocking(move || { uninstall_windows_codex(&settings, confirm, purge_user_data) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aff96e3..ce9137d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,9 +22,18 @@ fn confirm_close_enabled() -> bool { fn quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { let state = app.state::(); let force = state.force_quit.load(Ordering::SeqCst); + if force { + return QuitPolicy::Allow; + } state .operations - .quit_policy(force, confirm_close_enabled()) + .prepare_quit(confirm_close_enabled(), false, || { + // Arm both platform latches while the operation phase mutex is still + // held. Only the active platform has work; the other store is harmless. + let _ = crate::app::mac_update::cancel_macos_download(); + let _ = crate::app::win_update::cancel_windows_download(); + state.force_quit.store(true, Ordering::SeqCst); + }) } /// Apply a quit policy decision for window/menu/exit paths. diff --git a/src/app/errorCopy.ts b/src/app/errorCopy.ts index 0cd0f69..754f320 100644 --- a/src/app/errorCopy.ts +++ b/src/app/errorCopy.ts @@ -84,6 +84,22 @@ export function messageFailure( return { code, message, detail: null, recoverable }; } +/** + * Add action-specific recovery copy without throwing away the backend / bridge + * diagnostic. The contextual code is intentionally stable so callers can + * alter controls for a known state without parsing localized prose. + */ +export function contextualFailure( + cause: unknown, + t: TFn, + message: string, + code: string, + recoverable = true, +): FailureSurface { + const failure = resolveFailure(cause, t); + return { ...failure, code, message, recoverable }; +} + /** True when the failure is a connectivity class (DNS / TLS / timeout). */ export function isConnectivityFailure(cause: unknown): boolean { const code = errorCode(cause); diff --git a/src/app/i18n.tsx b/src/app/i18n.tsx index 96355f4..dedfb5d 100644 --- a/src/app/i18n.tsx +++ b/src/app/i18n.tsx @@ -128,6 +128,10 @@ const ZH = { "progress.cancelPending": "正在取消…", "progress.cancelled": "下载已取消。", "progress.cannotCancel": "当前阶段不能取消。", + "progress.stopRejected": "{action}请求被后端拒绝。任务仍在继续,可重试。", + "progress.stopDeliveryFailed": "{action}请求未送达。任务仍在继续,可重试。", + "progress.stopUninterruptible": "任务已进入不可中断的安装阶段,请等待完成。", + "progress.discardFailed": "取消未完成。下载仍处于暂停状态;你可以继续下载或重试取消。", "progress.resume": "继续", "progress.paused.title": "下载已暂停", "progress.finishing": "下载完成,正在安装,请勿关闭", @@ -406,6 +410,10 @@ const EN: Record = { "progress.cancelPending": "Cancelling…", "progress.cancelled": "Download cancelled.", "progress.cannotCancel": "This phase cannot be cancelled.", + "progress.stopRejected": "The backend rejected the {action} request. The task is still running; try again.", + "progress.stopDeliveryFailed": "The {action} request was not delivered. The task is still running; try again.", + "progress.stopUninterruptible": "The task has entered an uninterruptible install phase. Wait for it to finish.", + "progress.discardFailed": "Cancellation did not complete. The download remains paused; resume it or retry cancellation.", "progress.resume": "Resume", "progress.paused.title": "Download paused", "progress.finishing": "Download complete — installing, don't close", @@ -681,6 +689,10 @@ const FR: Record = { "progress.cancelPending": "Annulation…", "progress.cancelled": "Téléchargement annulé.", "progress.cannotCancel": "Cette étape ne peut pas être annulée.", + "progress.stopRejected": "Le moteur a refusé la demande « {action} ». La tâche continue ; réessayez.", + "progress.stopDeliveryFailed": "La demande « {action} » n’a pas été transmise. La tâche continue ; réessayez.", + "progress.stopUninterruptible": "La tâche a atteint une phase d’installation impossible à interrompre. Attendez la fin.", + "progress.discardFailed": "L’annulation n’a pas abouti. Le téléchargement reste en pause ; reprenez-le ou réessayez.", "progress.resume": "Reprendre", "progress.paused.title": "Téléchargement en pause", "progress.finishing": "Téléchargement terminé — installation, ne pas fermer", @@ -957,6 +969,10 @@ const ZH_TW: Record = { "progress.cancelPending": "正在取消…", "progress.cancelled": "下載已取消。", "progress.cannotCancel": "目前階段不能取消。", + "progress.stopRejected": "後端拒絕了{action}要求。工作仍在繼續,可重試。", + "progress.stopDeliveryFailed": "{action}要求未送達。工作仍在繼續,可重試。", + "progress.stopUninterruptible": "工作已進入無法中斷的安裝階段,請等待完成。", + "progress.discardFailed": "取消未完成。下載仍處於暫停狀態;你可以繼續下載或重試取消。", "progress.resume": "繼續", "progress.paused.title": "下載已暫停", "progress.finishing": "下載完成,正在安裝,請勿關閉", @@ -1243,6 +1259,10 @@ const DE: Record = { "progress.cancelPending": "Abbrechen…", "progress.cancelled": "Download abgebrochen.", "progress.cannotCancel": "Diese Phase kann nicht abgebrochen werden.", + "progress.stopRejected": "Das Backend hat die Anfrage „{action}“ abgelehnt. Der Vorgang läuft weiter; versuchen Sie es erneut.", + "progress.stopDeliveryFailed": "Die Anfrage „{action}“ wurde nicht übermittelt. Der Vorgang läuft weiter; versuchen Sie es erneut.", + "progress.stopUninterruptible": "Der Vorgang befindet sich in einer nicht unterbrechbaren Installationsphase. Warten Sie, bis sie abgeschlossen ist.", + "progress.discardFailed": "Der Abbruch wurde nicht abgeschlossen. Der Download bleibt pausiert; setzen Sie ihn fort oder versuchen Sie den Abbruch erneut.", "progress.resume": "Fortsetzen", "progress.paused.title": "Download pausiert", "progress.finishing": "Download abgeschlossen – wird installiert, nicht schließen", @@ -1529,6 +1549,10 @@ const KO: Record = { "progress.cancelPending": "취소 중…", "progress.cancelled": "다운로드가 취소되었습니다.", "progress.cannotCancel": "이 단계는 취소할 수 없습니다.", + "progress.stopRejected": "백엔드가 {action} 요청을 거부했습니다. 작업은 계속 진행 중이며 다시 시도할 수 있습니다.", + "progress.stopDeliveryFailed": "{action} 요청이 전달되지 않았습니다. 작업은 계속 진행 중이며 다시 시도할 수 있습니다.", + "progress.stopUninterruptible": "작업이 중단할 수 없는 설치 단계에 들어갔습니다. 완료될 때까지 기다려 주세요.", + "progress.discardFailed": "취소가 완료되지 않았습니다. 다운로드는 일시 중지 상태입니다. 계속하거나 취소를 다시 시도하세요.", "progress.resume": "계속", "progress.paused.title": "다운로드 일시 중지됨", "progress.finishing": "다운로드 완료 — 설치 중, 닫지 마세요", @@ -1807,6 +1831,10 @@ const JA: Record = { "progress.cancelPending": "キャンセル中…", "progress.cancelled": "ダウンロードをキャンセルしました。", "progress.cannotCancel": "この段階はキャンセルできません。", + "progress.stopRejected": "バックエンドが{action}要求を拒否しました。処理は続行中です。再試行できます。", + "progress.stopDeliveryFailed": "{action}要求を送信できませんでした。処理は続行中です。再試行できます。", + "progress.stopUninterruptible": "処理は中断できないインストール段階に入りました。完了までお待ちください。", + "progress.discardFailed": "キャンセルが完了しませんでした。ダウンロードは一時停止したままです。再開するか、キャンセルを再試行してください。", "progress.resume": "再開", "progress.paused.title": "ダウンロードを一時停止しました", "progress.finishing": "ダウンロード完了 — インストール中です。閉じないでください", @@ -2073,6 +2101,10 @@ const RU: Record = { "progress.cancelPending": "Отмена…", "progress.cancelled": "Загрузка отменена.", "progress.cannotCancel": "Этот этап нельзя отменить.", + "progress.stopRejected": "Серверная часть отклонила запрос «{action}». Операция продолжается; повторите попытку.", + "progress.stopDeliveryFailed": "Запрос «{action}» не был доставлен. Операция продолжается; повторите попытку.", + "progress.stopUninterruptible": "Операция перешла к непрерываемому этапу установки. Дождитесь завершения.", + "progress.discardFailed": "Отмена не завершена. Загрузка остаётся на паузе; продолжите её или повторите отмену.", "progress.resume": "Продолжить", "progress.paused.title": "Загрузка приостановлена", "progress.finishing": "Загрузка завершена — установка, не закрывайте", @@ -2339,6 +2371,10 @@ const AR: Record = { "progress.cancelPending": "جارٍ الإلغاء…", "progress.cancelled": "تم إلغاء التنزيل.", "progress.cannotCancel": "لا يمكن إلغاء هذه المرحلة.", + "progress.stopRejected": "رفضت الخدمة طلب {action}. لا تزال المهمة قيد التنفيذ؛ حاول مجددًا.", + "progress.stopDeliveryFailed": "لم يتم تسليم طلب {action}. لا تزال المهمة قيد التنفيذ؛ حاول مجددًا.", + "progress.stopUninterruptible": "دخلت المهمة مرحلة تثبيت لا يمكن مقاطعتها. انتظر حتى تكتمل.", + "progress.discardFailed": "لم يكتمل الإلغاء. لا يزال التنزيل متوقفًا مؤقتًا؛ تابعه أو أعد محاولة الإلغاء.", "progress.resume": "استئناف", "progress.paused.title": "تم إيقاف التنزيل مؤقتًا", "progress.finishing": "اكتمل التنزيل — جارٍ التثبيت، لا تغلق النافذة", @@ -2605,6 +2641,10 @@ const ES: Record = { "progress.cancelPending": "Cancelando…", "progress.cancelled": "Descarga cancelada.", "progress.cannotCancel": "Esta fase no se puede cancelar.", + "progress.stopRejected": "El motor rechazó la solicitud «{action}». La tarea sigue en curso; vuelve a intentarlo.", + "progress.stopDeliveryFailed": "La solicitud «{action}» no se entregó. La tarea sigue en curso; vuelve a intentarlo.", + "progress.stopUninterruptible": "La tarea ha entrado en una fase de instalación que no se puede interrumpir. Espera a que termine.", + "progress.discardFailed": "La cancelación no se completó. La descarga sigue en pausa; reanúdala o vuelve a cancelar.", "progress.resume": "Reanudar", "progress.paused.title": "Descarga en pausa", "progress.finishing": "Descarga completa — instalando, no cierres", @@ -2871,6 +2911,10 @@ const PT_BR: Record = { "progress.cancelPending": "Cancelando…", "progress.cancelled": "Download cancelado.", "progress.cannotCancel": "Esta fase não pode ser cancelada.", + "progress.stopRejected": "O mecanismo recusou a solicitação “{action}”. A tarefa continua em execução; tente novamente.", + "progress.stopDeliveryFailed": "A solicitação “{action}” não foi entregue. A tarefa continua em execução; tente novamente.", + "progress.stopUninterruptible": "A tarefa entrou em uma fase de instalação que não pode ser interrompida. Aguarde a conclusão.", + "progress.discardFailed": "O cancelamento não foi concluído. O download permanece pausado; retome-o ou tente cancelar novamente.", "progress.resume": "Retomar", "progress.paused.title": "Download pausado", "progress.finishing": "Download concluído — instalando, não feche", diff --git a/src/app/views/Home.test.tsx b/src/app/views/Home.test.tsx index c988105..338bbce 100644 --- a/src/app/views/Home.test.tsx +++ b/src/app/views/Home.test.tsx @@ -12,6 +12,7 @@ import type { MacInstallStatus, MacPerformReport, MacUpdateReport, + OperationSnapshot, UpdatePlan, } from "../../shared/types"; import { DEFAULT_SETTINGS } from "../../shared/types"; @@ -83,6 +84,15 @@ const REPORT_UPTODATE: MacUpdateReport = { const STATUS_MANAGED: MacInstallStatus = { installed: INSTALLED, status: "managed" }; const STATUS_NONE: MacInstallStatus = { installed: null, status: "none" }; +const ACTIVE_OPERATION: OperationSnapshot = { + id: "op-active", + kind: "update", + phase: "downloading", + progress: { downloaded: 10, total: 100, source: "mirror.example" }, + paused: false, + cancellable: true, + interruptible: true, +}; const PERFORM_OK: MacPerformReport = { upToDate: false, @@ -127,6 +137,7 @@ describe("MacHome state machine", () => { api.macPauseDownload.mockResolvedValue(true); api.macCancelDownload.mockResolvedValue(true); api.macDiscardDownload.mockResolvedValue(undefined); + api.getOperationSnapshot.mockResolvedValue(null); }); it("offers install when nothing is detected", async () => { @@ -245,7 +256,14 @@ describe("MacHome state machine", () => { // Bytes arrive → the pause button becomes actionable. await waitFor(() => expect(onProgress).toBeDefined()); act(() => { - onProgress?.({ payload: { downloaded: 512, total: 1024, source: "mirror.example" } }); + onProgress?.({ + payload: { + downloaded: 512, + total: 1024, + source: "mirror.example", + operationId: "op-active", + }, + }); }); // The progress bar exposes progressbar semantics to assistive tech. The @@ -260,6 +278,7 @@ describe("MacHome state machine", () => { await waitFor(() => expect(pause).toBeEnabled()); await user.click(pause); await waitFor(() => expect(api.macPauseDownload).toHaveBeenCalledTimes(1)); + expect(api.macPauseDownload).toHaveBeenCalledWith("op-active"); // The backend acknowledges the pause by failing the in-flight perform. act(() => rejectPerform?.(new Error("download cancelled"))); @@ -287,7 +306,11 @@ describe("MacHome state machine", () => { renderHome(); await user.click(await screen.findByRole("button", { name: /立即更新/ })); await waitFor(() => expect(onProgress).toBeDefined()); - act(() => onProgress?.({ payload: { downloaded: 10, total: 100, source: "s" } })); + act(() => + onProgress?.({ + payload: { downloaded: 10, total: 100, source: "s", operationId: "op-active" }, + }), + ); await user.click(await screen.findByRole("button", { name: /^暂停$/ })); act(() => rejectPerform?.(new Error("download cancelled"))); await screen.findByText("下载已暂停"); @@ -296,4 +319,101 @@ describe("MacHome state machine", () => { await waitFor(() => expect(api.macDiscardDownload).toHaveBeenCalledTimes(1)); expect(await screen.findByText("下载已取消。")).toBeInTheDocument(); }); + + it.each([ + { intent: "pause" as const, outcome: "false" as const }, + { intent: "pause" as const, outcome: "reject" as const }, + { intent: "cancel" as const, outcome: "false" as const }, + { intent: "cancel" as const, outcome: "reject" as const }, + ])( + "keeps the macOS progress flow recoverable when $intent returns $outcome", + async ({ intent, outcome }) => { + const user = userEvent.setup(); + api.getSettings.mockResolvedValue(settings({ askBefore: false })); + api.macPerformUpdate.mockImplementationOnce(() => new Promise(() => {})); + + let onProgress: ((event: { payload: DownloadProgress }) => void) | undefined; + listenMock.mockImplementation((event: string, cb: unknown) => { + if (event === "mac://download-progress") onProgress = cb as typeof onProgress; + return Promise.resolve(() => {}); + }); + + const stop = intent === "pause" ? api.macPauseDownload : api.macCancelDownload; + if (outcome === "false") { + stop.mockResolvedValue(false); + } else { + stop.mockRejectedValue(new Error("invoke bridge unavailable")); + } + + renderHome(); + await user.click(await screen.findByRole("button", { name: /立即更新/ })); + if (intent === "pause") { + await waitFor(() => expect(onProgress).toBeDefined()); + act(() => + onProgress?.({ + payload: { downloaded: 10, total: 100, source: "mirror.example" }, + }), + ); + } + + const action = intent === "pause" ? "暂停" : "取消"; + const button = await screen.findByRole("button", { name: action }); + await waitFor(() => expect(button).toBeEnabled()); + api.getOperationSnapshot.mockResolvedValue(ACTIVE_OPERATION); + await user.click(button); + + const expected = + outcome === "false" + ? `${action}请求被后端拒绝。任务仍在继续,可重试。` + : `${action}请求未送达。任务仍在继续,可重试。`; + expect(await screen.findByRole("alert")).toHaveTextContent(expected); + expect(screen.getByText("正在更新…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: action })).toBeEnabled(); + + // The same control becomes actionable again rather than staying pending. + await user.click(screen.getByRole("button", { name: action })); + await waitFor(() => expect(stop).toHaveBeenCalledTimes(2)); + }, + ); + + it("keeps the macOS paused screen and both recovery actions when discard rejects", async () => { + const user = userEvent.setup(); + api.getSettings.mockResolvedValue(settings({ askBefore: false })); + api.macDiscardDownload.mockRejectedValueOnce(new Error("cache locked")); + + let rejectPerform: ((cause: unknown) => void) | undefined; + let onProgress: ((event: { payload: DownloadProgress }) => void) | undefined; + listenMock.mockImplementation((event: string, cb: unknown) => { + if (event === "mac://download-progress") onProgress = cb as typeof onProgress; + return Promise.resolve(() => {}); + }); + api.macPerformUpdate.mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectPerform = reject)), + ); + + renderHome(); + await user.click(await screen.findByRole("button", { name: /立即更新/ })); + await waitFor(() => expect(onProgress).toBeDefined()); + act(() => + onProgress?.({ + payload: { downloaded: 10, total: 100, source: "s", operationId: "op-active" }, + }), + ); + await user.click(await screen.findByRole("button", { name: /^暂停$/ })); + act(() => rejectPerform?.(new Error("download cancelled"))); + await screen.findByText("下载已暂停"); + + await user.click(screen.getByRole("button", { name: "取消" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "取消未完成。下载仍处于暂停状态;你可以继续下载或重试取消。", + ); + expect(screen.getByText("下载已暂停")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "继续" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "取消" })).toBeEnabled(); + + // The failed discard is retryable; only the successful retry leaves pause. + await user.click(screen.getByRole("button", { name: "取消" })); + await waitFor(() => expect(api.macDiscardDownload).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("下载已取消。")).toBeInTheDocument(); + }); }); diff --git a/src/app/views/Home.tsx b/src/app/views/Home.tsx index 67420ad..a36f1a9 100644 --- a/src/app/views/Home.tsx +++ b/src/app/views/Home.tsx @@ -13,7 +13,12 @@ import type { MacUpdateReport, } from "../../shared/types"; import { DEFAULT_SETTINGS } from "../../shared/types"; -import { resolveFailure, userErrorMessage, type FailureSurface } from "../errorCopy"; +import { + contextualFailure, + resolveFailure, + userErrorMessage, + type FailureSurface, +} from "../errorCopy"; import { Icon, CodexGlyph } from "../icons"; import { useI18n, dirOf, type TKey } from "../i18n"; import { Ring, TopBar, ResultBanner, ErrorHero, FailureBanner, StatusBanner } from "../components"; @@ -76,6 +81,8 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { // A paused download: the progress screen stays up (not routed home) offering // 〔继续〕/〔取消〕. `dl` is the byte snapshot captured at the moment of pause. const [paused, setPaused] = useState(null); + const [pausedDiscardBusy, setPausedDiscardBusy] = useState(false); + const pausedDiscardBusyRef = useRef(false); const scopeRef = useRef(null); const confirmTitleId = useId(); const confirmBodyId = useId(); @@ -99,9 +106,9 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { resetStop, } = useDownloadProgress({ eventName: "mac://download-progress", - pauseDownload: () => managerApi.macPauseDownload(), - cancelDownload: () => managerApi.macCancelDownload(), - cannotCancelMessage: t("progress.cannotCancel"), + pauseDownload: (operationId) => managerApi.macPauseDownload(operationId), + cancelDownload: (operationId) => managerApi.macCancelDownload(operationId), + getOperationSnapshot: () => managerApi.getOperationSnapshot(), onError: setActionError, }); @@ -344,6 +351,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { // stopped instead of at 0. const resumeDownload = useCallback(() => { const kind = paused?.kind; + setActionError(null); setPaused(null); if (kind === "install") void runInstall(); else void runPerform(); @@ -353,15 +361,29 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { // cached partial and route home. (An in-flight cancel is handled by // requestDownloadStop instead.) const cancelPausedDownload = useCallback(async () => { - setPaused(null); + if (pausedDiscardBusyRef.current) return; + pausedDiscardBusyRef.current = true; + setActionError(null); + setPausedDiscardBusy(true); try { // Only claim "已取消" once the cached partial is actually gone — otherwise // a failed discard would leave a `.part` that the next update silently // resumes, contradicting the cancel. await managerApi.macDiscardDownload(); + setPaused(null); setNotice(t("progress.cancelled")); } catch (cause) { - setActionError(resolveFailure(cause, t)); + setActionError( + contextualFailure( + cause, + t, + t("progress.discardFailed"), + "paused_discard_failed", + ), + ); + } finally { + pausedDiscardBusyRef.current = false; + setPausedDiscardBusy(false); } }, [t]); @@ -553,7 +575,8 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { dlSpeed={dlSpeed} installing={paused ? paused.kind === "install" : busy === "install"} downloadStop={downloadStop} - downloadStopBusy={downloadStopBusy} + downloadStopBusy={downloadStopBusy || pausedDiscardBusy} + failure={actionError} onResume={resumeDownload} onPause={() => void requestDownloadStop("pause")} onCancel={() => { diff --git a/src/app/views/ProgressScreen.tsx b/src/app/views/ProgressScreen.tsx index 4ded0f7..38d098c 100644 --- a/src/app/views/ProgressScreen.tsx +++ b/src/app/views/ProgressScreen.tsx @@ -1,9 +1,10 @@ import type { RefObject } from "react"; import type { DownloadProgress } from "../../shared/types"; +import type { FailureSurface } from "../errorCopy"; import { Icon } from "../icons"; import { useI18n } from "../i18n"; -import { Ring, TopBar } from "../components"; +import { FailureBanner, Ring, TopBar } from "../components"; import { mib } from "../format"; export type DownloadStopIntent = "pause" | "cancel"; @@ -29,6 +30,7 @@ export function ProgressScreen({ installing, downloadStop, downloadStopBusy, + failure, onResume, onPause, onCancel, @@ -46,6 +48,7 @@ export function ProgressScreen({ installing: boolean; downloadStop: DownloadStopIntent | null; downloadStopBusy: boolean; + failure: FailureSurface | null; onResume: () => void; onPause: () => void; onCancel: () => void; @@ -62,12 +65,16 @@ export function ProgressScreen({ // mac, sideload/extract on Windows). Say so and drop the dead buttons rather // than leave them greyed for no visible reason. const finishing = !paused && Boolean(snap && snap.total > 0 && snap.downloaded >= snap.total); + const uninterruptible = failure?.code === "download_stop_uninterruptible"; // Pause only makes sense mid-transfer; cancel is the "abandon" out and works // through the preparing phase too (a backend abort checkpoint honors it), but // not once the install has begun. const canPause = - !paused && Boolean(dl && dl.total > 0 && dl.downloaded < dl.total) && !downloadStopBusy; - const canCancel = !paused && !finishing && !downloadStopBusy; + !paused && + !uninterruptible && + Boolean(dl && dl.total > 0 && dl.downloaded < dl.total) && + !downloadStopBusy; + const canCancel = !paused && !finishing && !uninterruptible && !downloadStopBusy; const phase = paused ? t("progress.paused.title") @@ -81,6 +88,7 @@ export function ProgressScreen({
+ {failure ? : null}
@@ -123,7 +131,7 @@ export function ProgressScreen({ ) : null}
{paused ? ( - @@ -136,7 +144,7 @@ export function ProgressScreen({
@@ -921,7 +925,9 @@ export function Settings({ ? t("settings.health.restoreConfirm.body") : healthConfirm.which === "settings" ? t("settings.health.resetConfirm.body.settings") - : t("settings.health.resetConfirm.body.provenance")} + : t("settings.health.resetConfirm.body.provenance", { + action: t("home.external.cta"), + })}

diff --git a/src/app/views/Uninstall.test.tsx b/src/app/views/Uninstall.test.tsx index 9803c1f..d6f8201 100644 --- a/src/app/views/Uninstall.test.tsx +++ b/src/app/views/Uninstall.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { managerApi } from "../../services/managerApi"; import { emptyOperationOutcome } from "../../shared/types"; -import { I18nProvider } from "../i18n"; +import { CATALOG, I18nProvider, type Lang } from "../i18n"; import { Uninstall } from "./Uninstall"; vi.mock("../../services/managerApi", () => ({ @@ -24,6 +24,7 @@ const winStatus = vi.mocked(managerApi.winStatus); const macUninstall = vi.mocked(managerApi.macUninstall); const winUninstall = vi.mocked(managerApi.winUninstall); const retryAncillary = vi.mocked(managerApi.retryAncillary); +const SAFETY_FLOW_LANGS = ["fr", "ar", "es", "zh-TW"] as const satisfies readonly Lang[]; function setPlatform(platform: string) { Object.defineProperty(navigator, "platform", { configurable: true, value: platform }); @@ -236,4 +237,134 @@ describe("Uninstall", () => { ), ); }); + + it("keeps both an unattempted action and the attempted action when retry still fails", async () => { + const user = userEvent.setup(); + setPlatform("MacIntel"); + macUninstall.mockResolvedValue({ + removed: true, + keptCodexHome: true, + message: "removed with two ancillary failures", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "absent", + installClass: "none", + provenance: { state: "failed", detail: "record failed" }, + cleanup: { state: "failed", detail: "cleanup failed" }, + recoveryActions: ["cleanup_metadata", "record_provenance"], + }), + }); + // Deliberately omit recoveryActions: the failed step status must still keep + // the attempted CTA, while the untouched cleanup action is merged back in. + retryAncillary.mockResolvedValue({ + message: "record still failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "disk full" }, + recoveryActions: [], + }), + }); + + renderUninstall(); + await screen.findByText("Data location"); + await user.click(screen.getByRole("button", { name: "Uninstall" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click( + screen.getByRole("button", { name: "Retry writing managed record only" }), + ); + + expect( + await screen.findByText(/some recovery steps are still incomplete/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry cleanup only (shortcuts / uninstall entry)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry writing managed record only" }), + ).toBeInTheDocument(); + }); + + it.each(SAFETY_FLOW_LANGS)( + "preserves unattempted recovery actions after one successful retry in %s", + async (lang) => { + const user = userEvent.setup(); + localStorage.setItem("cam.lang", lang); + setPlatform("MacIntel"); + macUninstall.mockResolvedValue({ + removed: true, + keptCodexHome: true, + message: "removed with recovery actions", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "absent", + installClass: "none", + cleanup: { state: "failed", detail: "ancillary cleanup failed" }, + recoveryActions: [ + "cleanup_metadata", + "clear_provenance", + "purge_user_data", + "record_provenance", + ], + warnings: ["ancillary cleanup failed"], + }), + }); + + renderUninstall(); + + await screen.findByText(CATALOG[lang]["uninstall.dataPath"]); + await user.click(screen.getByRole("button", { name: CATALOG[lang]["uninstall.confirm"] })); + await user.click(screen.getByRole("button", { name: CATALOG[lang]["uninstall.continue"] })); + + expect( + await screen.findByText(CATALOG[lang]["uninstall.partial.title"]), + ).toBeInTheDocument(); + expect( + screen.getByText(CATALOG[lang]["uninstall.partial.summary"], { selector: ".desc" }), + ).toBeInTheDocument(); + expect( + screen.getByText("removed with recovery actions", { selector: ".errdetails" }), + ).toBeInTheDocument(); + const recoveryKeys = [ + "uninstall.partial.retryCleanup", + "uninstall.partial.retryProvenance", + "uninstall.partial.retryPurge", + "uninstall.partial.retryRecord", + ] as const; + for (const key of recoveryKeys) { + expect(screen.getByRole("button", { name: CATALOG[lang][key] })).toBeInTheDocument(); + } + + await user.click( + screen.getByRole("button", { + name: CATALOG[lang]["uninstall.partial.retryProvenance"], + }), + ); + await waitFor(() => + expect(retryAncillary).toHaveBeenCalledWith( + expect.objectContaining({ actions: ["clear_provenance"], purgeUserData: false }), + ), + ); + expect( + await screen.findByText(CATALOG[lang]["uninstall.partial.retryPending"], { + selector: ".desc", + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: CATALOG[lang]["uninstall.partial.retryProvenance"], + }), + ).not.toBeInTheDocument(); + for (const key of [ + "uninstall.partial.retryCleanup", + "uninstall.partial.retryPurge", + "uninstall.partial.retryRecord", + ] as const) { + expect(screen.getByRole("button", { name: CATALOG[lang][key] })).toBeInTheDocument(); + } + expect(screen.getByText("cleanup ok", { selector: ".errdetails" })).toBeInTheDocument(); + expect(document.documentElement.dir).toBe(lang === "ar" ? "rtl" : "ltr"); + }, + ); }); diff --git a/src/app/views/Uninstall.tsx b/src/app/views/Uninstall.tsx index fe5dd63..a40c003 100644 --- a/src/app/views/Uninstall.tsx +++ b/src/app/views/Uninstall.tsx @@ -18,6 +18,45 @@ function hasTauriRuntime(): boolean { ); } +const PROVENANCE_RECOVERY_ACTIONS = new Set(["record_provenance", "clear_provenance"]); +const CLEANUP_RECOVERY_ACTIONS = new Set(["cleanup_metadata", "purge_user_data"]); + +/** Merge a scoped ancillary retry back into the original partial outcome. + * The backend reports only the actions attempted in this request; actions the + * user has not retried yet must remain visible, and a failed attempted action + * remains available even if a backend forgets to echo its recovery key. */ +function mergeAncillaryRetryOutcome( + previous: OperationOutcome, + attempted: string[], + retried: OperationOutcome, +): OperationOutcome { + const attemptedSet = new Set(attempted); + const unattempted = previous.recoveryActions.filter((action) => !attemptedSet.has(action)); + const failedAttempted = attempted.filter((action) => { + if (PROVENANCE_RECOVERY_ACTIONS.has(action)) return retried.provenance.state === "failed"; + if (CLEANUP_RECOVERY_ACTIONS.has(action)) return retried.cleanup.state === "failed"; + return false; + }); + const recoveryActions = [ + ...new Set([...unattempted, ...retried.recoveryActions, ...failedAttempted]), + ]; + const hasUnattemptedProvenance = unattempted.some((action) => + PROVENANCE_RECOVERY_ACTIONS.has(action), + ); + const hasUnattemptedCleanup = unattempted.some((action) => + CLEANUP_RECOVERY_ACTIONS.has(action), + ); + + return { + ...retried, + path: retried.path ?? previous.path, + provenance: hasUnattemptedProvenance ? previous.provenance : retried.provenance, + cleanup: hasUnattemptedCleanup ? previous.cleanup : retried.cleanup, + warnings: [...new Set([...previous.warnings, ...retried.warnings])], + recoveryActions, + }; +} + export function Uninstall({ onBack }: { onBack: () => void }) { const { t } = useI18n(); const platform = currentPlatform(); @@ -28,6 +67,7 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const [keepData, setKeepData] = useState(true); const [busy, setBusy] = useState(false); const [done, setDone] = useState(null); + const [doneDetail, setDoneDetail] = useState(null); const [error, setError] = useState(null); const [pathCopied, setPathCopied] = useState(false); // Install probe: Loading / Managed / External / None / Error — never treat a @@ -68,6 +108,7 @@ export function Uninstall({ onBack }: { onBack: () => void }) { setBusy(true); setError(null); setPartialOutcome(null); + setDoneDetail(null); try { // mac keeps ~/.codex via keepCodexHome; win purges via purgeUserData (the // inverse) — both surface the backend message as the source of truth. @@ -75,7 +116,8 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const r = await managerApi.winUninstall(true, !keepData); if (r.success && outcomeIsPartial(r.outcome)) { setPartialOutcome(r.outcome); - setDone(r.message); + setDone(t("uninstall.partial.summary")); + setDoneDetail(r.message); return; } if (!r.success) { @@ -87,7 +129,8 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const r = await managerApi.macUninstall(keepData); if (r.removed && outcomeIsPartial(r.outcome)) { setPartialOutcome(r.outcome); - setDone(r.message); + setDone(t("uninstall.partial.summary")); + setDoneDetail(r.message); return; } if (!r.removed) { @@ -113,12 +156,17 @@ export function Uninstall({ onBack }: { onBack: () => void }) { path: partialOutcome?.path ?? null, purgeUserData: actions.includes("purge_user_data"), }); - setDone(report.message); - if (outcomeIsPartial(report.outcome)) { - setPartialOutcome(report.outcome); + const mergedOutcome = partialOutcome + ? mergeAncillaryRetryOutcome(partialOutcome, actions, report.outcome) + : report.outcome; + if (mergedOutcome.recoveryActions.length > 0 || outcomeIsPartial(mergedOutcome)) { + setDone(t("uninstall.partial.retryPending")); + setPartialOutcome(mergedOutcome); } else { + setDone(t("uninstall.partial.retryDone")); setPartialOutcome(null); } + setDoneDetail(report.message); } catch (cause) { setError(userErrorMessage(cause, t)); } finally { @@ -162,6 +210,12 @@ export function Uninstall({ onBack }: { onBack: () => void }) {
{t("uninstall.heading")}
{done}
+ {doneDetail ? ( +
+ {t("home.error.details")} +
{doneDetail}
+
+ ) : null} {partialOutcome ? ( <> {t("uninstall.partial.title")} diff --git a/src/app/views/WinHome.test.tsx b/src/app/views/WinHome.test.tsx index 223ddfb..025f39b 100644 --- a/src/app/views/WinHome.test.tsx +++ b/src/app/views/WinHome.test.tsx @@ -9,6 +9,7 @@ import type { AppSettings, CapabilityCheck, InstalledWindowsCodex, + OperationCompletion, WinCapabilityReport, WindowsUpdatePlan, WinInstallStatus, @@ -27,9 +28,11 @@ vi.mock("../../services/managerApi", async (importOriginal) => { return { ...actual, managerApi: { + armDestructive: vi.fn(), getSettings: vi.fn(), setSettings: vi.fn(), getOperationSnapshot: vi.fn(() => Promise.resolve(null)), + getOperationCompletion: vi.fn(() => Promise.resolve(null)), winStatus: vi.fn(), winPlanUpdate: vi.fn(), winPerformUpdate: vi.fn(), @@ -155,9 +158,22 @@ function renderWinHome() { ); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("WinHome state machine", () => { beforeEach(() => { localStorage.setItem("cam.lang", "zh-CN"); + sessionStorage.clear(); + api.armDestructive.mockResolvedValue("win-op-1"); + api.getOperationCompletion.mockResolvedValue(null); api.getSettings.mockResolvedValue(settings()); api.winDefaultInstallRoot.mockResolvedValue(DEFAULT_SETTINGS.installRoot); api.winStatus.mockResolvedValue(STATUS_MANAGED); @@ -187,6 +203,7 @@ describe("WinHome state machine", () => { route: "msix-sideload", }, undefined, + "win-op-1", ), ); }); @@ -211,6 +228,417 @@ describe("WinHome state machine", () => { await waitFor(() => expect(api.winAdopt).toHaveBeenCalledTimes(1)); }); + it("keeps partial-install guidance aligned with the rendered recovery action", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "installed; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "开始管理" })).toBeInTheDocument(); + expect(screen.getByText("已安装 Codex", { selector: ".rb-title" })).toBeInTheDocument(); + expect( + screen.queryByText("installed; managed record failed", { selector: ".rb-detail" }), + ).not.toBeInTheDocument(); + const diagnostics = screen + .getByText("installed; managed record failed", { selector: ".errdetails" }) + .closest("details"); + expect(diagnostics).not.toHaveAttribute("open"); + }); + + it("keeps English backend prose collapsed in a non-English partial-install UI", async () => { + localStorage.setItem("cam.lang", "fr"); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate.mockResolvedValueOnce(report({ installed: null })).mockResolvedValue( + report({ + plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" }, + }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "installed; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /Installer Codex/ })); + + expect(screen.getByText("Codex installé", { selector: ".rb-title" })).toBeInTheDocument(); + expect( + screen.queryByText("installed; managed record failed", { selector: ".rb-detail" }), + ).not.toBeInTheDocument(); + const diagnostics = screen + .getByText("installed; managed record failed", { selector: ".errdetails" }) + .closest("details"); + expect(diagnostics).not.toHaveAttribute("open"); + }); + + it("does not let a pre-update managed snapshot clear a new partial-outcome guard", async () => { + api.getSettings.mockResolvedValue(settings({ askBefore: false })); + api.winStatus + .mockResolvedValueOnce(STATUS_MANAGED) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report()) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "2.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "updated; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /立即更新/ })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + }); + + it("requires re-detection instead of offering reinstall when a partial install is not found", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + installed: null, + message: "installed but not detected for managed record", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "unknown", + installClass: null, + provenance: { state: "failed", detail: "install not detected" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toHaveTextContent( + "请点「重新检查」重新检测;确认前请勿重复安装", + ); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "重新检查" })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "开始管理" })).toBeInTheDocument(); + }); + + it("keeps the partial-install recovery lock across a renderer reload", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.winPerformUpdate.mockReturnValue(new Promise(() => {})); + + const user = userEvent.setup(); + const firstRenderer = renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(1)); + + firstRenderer.unmount(); + renderWinHome(); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新检查" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + }); + + it("keeps reinstall blocked when invoke rejects after commit with an unknown outcome", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "outcome-unknown", + }); + api.winPerformUpdate.mockRejectedValue({ + code: "internal_error", + message: "install-root settings save failed after install", + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + }); + + it("releases the guard after a backend-proven pre-commit failure and allows retry", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + api.winPerformUpdate + .mockRejectedValueOnce({ code: "network_error", message: "download failed" }) + .mockResolvedValueOnce(PERFORM_OK); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + const retry = await screen.findByRole("button", { name: /安装 Codex/ }); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); + await user.click(retry); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(2)); + }); + + it("releases the guard after a backend-proven rollback and allows retry", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "rolled-back", + }); + api.winPerformUpdate + .mockRejectedValueOnce({ + code: "internal_error", + message: "portable health check failed; absent state restored", + }) + .mockResolvedValueOnce(PERFORM_OK); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + const retry = await screen.findByRole("button", { name: /安装 Codex/ }); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); + await user.click(retry); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(2)); + }); + + it("releases a reloaded guard after backend proves the install failed before commit", async () => { + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "failed-op" }), + ); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockImplementation((token) => + Promise.resolve( + token === "failed-op" + ? { + id: "failed-op", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + } + : null, + ), + ); + api.armDestructive.mockResolvedValue("retry-op"); + + const user = userEvent.setup(); + renderWinHome(); + const install = await screen.findByRole("button", { name: /安装 Codex/ }); + await user.click(install); + + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalled()); + }); + + it("does not let an old reconciliation clear a newer token", async () => { + const oldCompletion = deferred(); + const newCompletion = deferred(); + const oldStatusProbe = deferred(); + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "old-op" }), + ); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(oldStatusProbe.promise) + .mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockImplementation((token) => + token === "old-op" ? oldCompletion.promise : newCompletion.promise, + ); + + renderWinHome(); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("old-op")); + + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "new-op" }), + ); + await act(async () => { + oldCompletion.resolve({ + id: "old-op", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + await oldCompletion.promise; + }); + + // Clearing old-op adopts the replacement marker into React state. Keep its + // reconciliation pending while the old status probe and old finally settle: + // neither is allowed to release the newer generation's busy state. + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("new-op")); + await act(async () => { + oldStatusProbe.resolve({ installed: null, status: "none" }); + await oldStatusProbe.promise; + }); + + await waitFor(() => + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("new-op"), + ); + expect(screen.getByText("正在检查…", { selector: ".headline" })).toBeInTheDocument(); + + await act(async () => { + newCompletion.resolve(null); + await newCompletion.promise; + }); + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + }); + + it("keeps recovery busy until both status and plan probes settle", async () => { + const statusProbe = deferred(); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(statusProbe.promise); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "outcome-unknown", + }); + api.winPerformUpdate.mockRejectedValue({ + code: "internal_error", + message: "invoke returned after commit", + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.winPlanUpdate).toHaveBeenCalledTimes(2)); + + // The plan probe has completed, but the status probe still owns this + // recovery generation. Reinstall must remain unavailable. + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + + await act(async () => { + statusProbe.resolve({ installed: null, status: "none" }); + await statusProbe.promise; + }); + expect(await screen.findByRole("button", { name: "重新检查" })).toBeInTheDocument(); + }); + + it("does not let an old perform finally release a newer recovery generation", async () => { + const oldCompletion = deferred(); + const newCompletion = deferred(); + const oldStatusProbe = deferred(); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(oldStatusProbe.promise) + .mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.winPerformUpdate.mockRejectedValue({ code: "network_error", message: "late failure" }); + api.getOperationCompletion.mockImplementation((token) => + token === "win-op-1" ? oldCompletion.promise : newCompletion.promise, + ); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("win-op-1")); + + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "new-op" }), + ); + await act(async () => { + oldCompletion.resolve({ + id: "win-op-1", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + await oldCompletion.promise; + }); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("new-op")); + + await act(async () => { + oldStatusProbe.resolve({ installed: null, status: "none" }); + await oldStatusProbe.promise; + }); + // The old run has now unwound through its finally. Its generation no longer + // owns busy/resetStop, so the newer reconciliation remains visibly active. + expect(screen.getByText("正在检查…", { selector: ".headline" })).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("new-op"); + + await act(async () => { + newCompletion.resolve(null); + await newCompletion.promise; + }); + expect(await screen.findByRole("button", { name: "重新检查" })).toBeInTheDocument(); + }); + it("treats a stale expectation as a notice and re-checks", async () => { const user = userEvent.setup(); api.getSettings.mockResolvedValue(settings({ askBefore: false })); @@ -226,6 +654,7 @@ describe("WinHome state machine", () => { expect( await screen.findByText("已是最新", { selector: ".headline" }), ).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); }); it("warns when MSIX is planned but App Installer is missing, and flips to portable", async () => { diff --git a/src/app/views/WinHome.tsx b/src/app/views/WinHome.tsx index a2eb536..096d55b 100644 --- a/src/app/views/WinHome.tsx +++ b/src/app/views/WinHome.tsx @@ -12,7 +12,7 @@ import type { WinPerformReport, WinUpdateReport, } from "../../shared/types"; -import { DEFAULT_SETTINGS } from "../../shared/types"; +import { DEFAULT_SETTINGS, outcomeIsPartial } from "../../shared/types"; import { resolveFailure, userErrorMessage, type FailureSurface } from "../errorCopy"; import { Icon, CodexGlyph } from "../icons"; import { useI18n, dirOf, type TKey } from "../i18n"; @@ -32,6 +32,40 @@ import { useFocusRecheck, installIdentity } from "./useFocusRecheck"; import { useOperationReattach } from "./useOperationReattach"; type Kind = "loading" | "error" | "none" | "idle" | "update" | "external" | "uptodate"; +type Busy = "plan" | "perform" | "adopt" | "install" | "launch" | null; +type ProvenanceRecoveryState = "present" | "unknown"; +interface ProvenanceRecovery { + state: ProvenanceRecoveryState; + token: string | null; +} + +const WIN_PROVENANCE_RECOVERY_KEY = "cam.win.provenance-recovery"; + +function readStoredProvenanceRecovery(): ProvenanceRecovery | null { + try { + const value = window.sessionStorage.getItem(WIN_PROVENANCE_RECOVERY_KEY); + if (value === "present" || value === "unknown") { + return { state: value, token: null }; + } + const parsed = value ? (JSON.parse(value) as Partial) : null; + return parsed && + (parsed.state === "present" || parsed.state === "unknown") && + (typeof parsed.token === "string" || parsed.token === null) + ? { state: parsed.state, token: parsed.token } + : null; + } catch { + return null; + } +} + +function storeProvenanceRecovery(value: ProvenanceRecovery | null) { + try { + if (value) window.sessionStorage.setItem(WIN_PROVENANCE_RECOVERY_KEY, JSON.stringify(value)); + else window.sessionStorage.removeItem(WIN_PROVENANCE_RECOVERY_KEY); + } catch { + // The in-memory guard still protects this renderer when storage is unavailable. + } +} // Windows counterpart of MacHome — same design system + state machine, driven by // the win_* backend (codex-win-engine): MSIX sideload or portable fallback. @@ -45,12 +79,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const [updatedVer, setUpdatedVer] = useState<{ from: string; to: string } | null>(null); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [defaultInstallRoot, setDefaultInstallRoot] = useState(DEFAULT_SETTINGS.installRoot); - const [busy, setBusy] = useState<"plan" | "perform" | "adopt" | "install" | "launch" | null>( - null, - ); + const [busy, setBusy] = useState(null); const [checkError, setCheckError] = useState(null); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); + // A successful install whose managed record could not be written stays in a + // guarded recovery mode until adoption succeeds. Persist the marker for the + // lifetime of this app window so a renderer reload cannot expose reinstall. + const [provenanceRecovery, setProvenanceRecovery] = + useState(readStoredProvenanceRecovery); + const provenanceRecoveryPending = provenanceRecovery !== null; const [confirmOpen, setConfirmOpen] = useState(false); const [installDirOpen, setInstallDirOpen] = useState(false); const [installDirBusy, setInstallDirBusy] = useState(false); @@ -69,6 +107,39 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // Synchronous guard for launch double-clicks (state alone can miss a second // click before setBusy("launch") re-renders). const launchInFlightRef = useRef(false); + // A recovery token created by this renderer is reconciled by runPerform + // itself. Only a replacement renderer should run the mount-time recovery + // probe; otherwise setting the marker would immediately duplicate the + // command's own final status/plan probes. + const locallyStartedOperationRef = useRef(null); + // Every async owner of the main action/progress surface gets a monotonically + // increasing generation. A late completion may update neither busy nor + // transfer state after a newer operation has taken ownership. + const operationGenerationRef = useRef(0); + const busyRef = useRef(null); + const reattachGenerationRef = useRef(null); + const reattachEndedAsOwnerRef = useRef(false); + const beginOperation = useCallback((next: Exclude) => { + const generation = operationGenerationRef.current + 1; + operationGenerationRef.current = generation; + busyRef.current = next; + setBusy(next); + return generation; + }, []); + const ownsOperation = useCallback( + (generation: number) => operationGenerationRef.current === generation, + [], + ); + const setOwnedBusy = useCallback((generation: number, next: Busy) => { + if (operationGenerationRef.current !== generation) return false; + busyRef.current = next; + setBusy(next); + return true; + }, []); + const finishOperation = useCallback( + (generation: number) => setOwnedBusy(generation, null), + [setOwnedBusy], + ); const confirmTitleId = useId(); const confirmBodyId = useId(); const installDirTitleId = useId(); @@ -99,34 +170,122 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { onError: setActionError, }); - const check = useCallback(async () => { - setBusy("plan"); + const runCheck = useCallback(async (generation: number) => { + if (!ownsOperation(generation)) return false; setCheckError(null); setActionError(null); setNotice(null); try { - setReport(await managerApi.winPlanUpdate()); + const next = await managerApi.winPlanUpdate(); + if (!ownsOperation(generation)) return false; + setReport(next); return true; } catch (cause) { + if (!ownsOperation(generation)) return false; setReport(null); setCheckError(resolveFailure(cause, t)); return false; + } + }, [ownsOperation, t]); + + const check = useCallback(async () => { + if (busyRef.current !== null) return false; + const generation = beginOperation("plan"); + try { + return await runCheck(generation); } finally { - setBusy(null); + finishOperation(generation); } - }, [t]); + }, [beginOperation, finishOperation, runCheck]); - const refreshStatus = useCallback(async () => { + const refreshStatus = useCallback(async (generation?: number) => { + const canApply = () => generation === undefined || ownsOperation(generation); try { - setStatus(await managerApi.winStatus()); - setStatusFailed(false); + const next = await managerApi.winStatus(); + if (canApply()) { + setStatus(next); + setStatusFailed(false); + } + return next; } catch { - setStatusFailed(true); + if (canApply()) setStatusFailed(true); + return null; } finally { - setStatusLoaded(true); + if (canApply()) setStatusLoaded(true); } + }, [ownsOperation]); + + const clearProvenanceRecovery = useCallback((expectedToken: string | null) => { + // Storage is the cross-renderer authority. Re-read it at clear time so an + // old reconciliation can never remove a marker written by a newer run. + const stored = readStoredProvenanceRecovery(); + const storedMatches = stored?.token === expectedToken; + if (storedMatches) storeProvenanceRecovery(null); + if (locallyStartedOperationRef.current === expectedToken) { + locallyStartedOperationRef.current = null; + } + setProvenanceRecovery((current) => { + if (current?.token !== expectedToken) return current; + // If another renderer/run replaced storage while this probe was pending, + // adopt that newer marker into memory instead of clearing the guard. + return stored && !storedMatches ? stored : null; + }); }, []); + const reconcileProvenanceRecovery = useCallback( + async (token: string, ownerGeneration?: number) => { + const managesBusy = ownerGeneration === undefined; + const generation = ownerGeneration ?? beginOperation("plan"); + try { + const returnedCompletion = await managerApi.getOperationCompletion(token).catch(() => null); + const completion = returnedCompletion?.id === token ? returnedCompletion : null; + if (!ownsOperation(generation)) return completion; + if ( + completion?.state === "failed-before-commit" || + completion?.state === "rolled-back" + ) { + clearProvenanceRecovery(token); + } + // A succeeded or mutation-ambiguous rejected command still needs disk truth: + // managed clears the guard, external offers adoption, and unknown/none + // stays guarded so reinstall is never inferred from an invoke failure. + // Neither probe owns busy independently: reconciliation keeps the same + // generation until BOTH have settled. + const [nextStatus] = await Promise.all([ + refreshStatus(generation), + runCheck(generation), + ]); + if ( + ownsOperation(generation) && + nextStatus?.installed && + nextStatus.status === "managed" + ) { + clearProvenanceRecovery(token); + } + return completion; + } finally { + if (managesBusy) finishOperation(generation); + } + }, + [ + beginOperation, + clearProvenanceRecovery, + finishOperation, + ownsOperation, + refreshStatus, + runCheck, + ], + ); + + const refreshStatusAndPlan = useCallback(async () => { + const generation = beginOperation("plan"); + try { + return await Promise.all([refreshStatus(generation), runCheck(generation)]); + } finally { + finishOperation(generation); + } + }, [beginOperation, finishOperation, refreshStatus, runCheck]); + useEffect(() => { void (async () => { const s = await managerApi.getSettings().catch(() => DEFAULT_SETTINGS); @@ -161,10 +320,6 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { useEffect(() => { reportRef.current = report; }, [report]); - const busyRef = useRef(busy); - useEffect(() => { - busyRef.current = busy; - }, [busy]); const checkRef = useRef(check); useEffect(() => { checkRef.current = check; @@ -174,13 +329,45 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // by operation id, and poll until the backend lease ends. useOperationReattach({ startDlListen, - applySnapshotProgress, - resetStop, - setBusy: (next) => setBusy(next), - setPaused, + applySnapshotProgress: (progress) => { + const generation = reattachGenerationRef.current; + if (generation !== null && ownsOperation(generation)) { + applySnapshotProgress(progress); + } + }, + resetStop: () => { + const generation = reattachGenerationRef.current; + if (generation !== null && ownsOperation(generation)) resetStop(); + }, + setBusy: (next) => { + const generation = reattachGenerationRef.current; + if (next === null) { + reattachEndedAsOwnerRef.current = + generation !== null && finishOperation(generation); + reattachGenerationRef.current = null; + return; + } + if (generation === null) { + reattachGenerationRef.current = beginOperation(next); + } else { + setOwnedBusy(generation, next); + } + }, + setPaused: (next) => { + const generation = reattachGenerationRef.current; + if ( + (generation !== null && ownsOperation(generation)) || + (generation === null && reattachEndedAsOwnerRef.current) + ) { + setPaused(next); + } + }, onOperationEnded: () => { - void checkRef.current(); - void refreshStatus(); + if (!reattachEndedAsOwnerRef.current) return; + reattachEndedAsOwnerRef.current = false; + const token = readStoredProvenanceRecovery()?.token; + if (token) void reconcileProvenanceRecovery(token); + else void refreshStatusAndPlan(); }, isLocallyBusy: () => { const b = busyRef.current; @@ -188,6 +375,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { }, }); + useEffect(() => { + const token = provenanceRecovery?.token; + if (!token || locallyStartedOperationRef.current === token) return; + void (async () => { + const active = await managerApi.getOperationSnapshot().catch(() => null); + if (active?.id === token) return; + await reconcileProvenanceRecovery(token); + })(); + }, [provenanceRecovery?.token, reconcileProvenanceRecovery]); + // Window focus → silently re-detect the local install and re-check if the // install identity (version OR path) drifted out-of-band. Parity with the mac // home, which has had this since the atomic-snapshot rework; without it the @@ -230,18 +427,30 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { }, [settings.periodicCheck, settings.periodicCheckIntervalSeconds]); const adopt = useCallback(async () => { - setBusy("adopt"); + const generation = beginOperation("adopt"); + const recoveryToken = readStoredProvenanceRecovery()?.token ?? provenanceRecovery?.token ?? null; setCheckError(null); setActionError(null); setNotice(null); try { - setStatus(await managerApi.winAdopt()); + const next = await managerApi.winAdopt(); + if (ownsOperation(generation)) { + setStatus(next); + clearProvenanceRecovery(recoveryToken); + } } catch (cause) { - setActionError(resolveFailure(cause, t)); + if (ownsOperation(generation)) setActionError(resolveFailure(cause, t)); } finally { - setBusy(null); + finishOperation(generation); } - }, [t]); + }, [ + beginOperation, + clearProvenanceRecovery, + finishOperation, + ownsOperation, + provenanceRecovery?.token, + t, + ]); // The probe recommended MSIX, but this PC looks like it's missing the Store / // App Installer components — the MSIX can install yet fail to launch (the very @@ -264,7 +473,14 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // MSIX sideload or portable fallback — is decided by the backend plan). const runPerform = useCallback( async (mode: "perform" | "install", installRoot?: string) => { - setBusy(mode); + // React state may not have committed between two discrete clicks yet; + // the synchronous ref closes that double-start window. + if (busyRef.current !== null) return; + const generation = beginOperation(mode); + // The new owner starts from a clean transfer state. Any older finally is + // generation-guarded and therefore cannot erase progress emitted after + // this point. + resetStop(); setActionError(null); setNotice(null); setPaused(null); @@ -275,8 +491,15 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const fromVersion = mode === "perform" ? report?.installed?.version ?? status?.installed?.version ?? "" : ""; const toVersion = report?.plan?.latestVersion ?? ""; - const unlisten = await startDlListen(); + let unlisten = () => {}; + let operationToken: string | null = null; try { + const attachedUnlisten = await startDlListen(); + if (!ownsOperation(generation)) { + attachedUnlisten(); + return; + } + unlisten = attachedUnlisten; const expected = report?.plan ? { currentVersion: report.plan.currentVersion, @@ -285,26 +508,51 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { route: report.plan.route, } : undefined; - const result = await managerApi.winPerformUpdate(true, expected, installRoot); - setPerform(result); - setUpdatedVer( - mode === "perform" && fromVersion && toVersion - ? { from: fromVersion, to: toVersion } - : null, + operationToken = await managerApi.armDestructive("update"); + locallyStartedOperationRef.current = operationToken; + const armedRecovery: ProvenanceRecovery = { state: "unknown", token: operationToken }; + storeProvenanceRecovery(armedRecovery); + setProvenanceRecovery(armedRecovery); + const result = await managerApi.winPerformUpdate( + true, + expected, + installRoot, + operationToken, ); - setConfirmOpen(false); - setInstallDirOpen(false); // Partial success (app installed, provenance failed): keep success path // and surface a recovery notice — never treat as hard failure. - if ( + const needsProvenanceRecovery = result.success && - result.outcome?.recoveryActions?.includes("record_provenance") - ) { - setNotice(t("install.partial.note")); + result.outcome?.recoveryActions?.includes("record_provenance"); + if (ownsOperation(generation)) { + setPerform(result); + setUpdatedVer( + mode === "perform" && fromVersion && toVersion + ? { from: fromVersion, to: toVersion } + : null, + ); + setConfirmOpen(false); + setInstallDirOpen(false); + } + if (needsProvenanceRecovery && ownsOperation(generation)) { + // Guard the action area before either probe can settle; there must be + // no render where a completed-but-unrecorded install offers reinstall. + const recoveryState: ProvenanceRecoveryState = + result.outcome?.appState === "present" || result.installed ? "present" : "unknown"; + const recovery = { state: recoveryState, token: operationToken }; + storeProvenanceRecovery(recovery); + setProvenanceRecovery(recovery); + } else if (!needsProvenanceRecovery) { + clearProvenanceRecovery(operationToken); } - await refreshStatus(); - await check(); + await refreshStatus(generation); + await runCheck(generation); } catch (cause) { + const code = errorCode(cause); + const explicitlyPreMutation = code === "stale_expectation" || isDownloadCancelled(cause); + if (operationToken && explicitlyPreMutation) clearProvenanceRecovery(operationToken); + else if (operationToken) await reconcileProvenanceRecovery(operationToken, generation); + if (!ownsOperation(generation)) return; setConfirmOpen(false); setInstallDirOpen(false); const stop = downloadStopRef.current; @@ -314,9 +562,9 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { setPaused({ kind: mode, dl: dlRef.current, installRoot }); } else if (stop && isDownloadCancelled(cause)) { setNotice(t("progress.cancelled")); - } else if (errorCode(cause) === "stale_expectation") { - await refreshStatus(); - if (await check()) { + } else if (code === "stale_expectation") { + await refreshStatus(generation); + if (await runCheck(generation)) { setNotice(t("home.stale.rechecked")); } } else { @@ -324,11 +572,25 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } } finally { unlisten(); - setBusy(null); - resetStop(); + if (finishOperation(generation)) resetStop(); } }, - [status, report, refreshStatus, check, startDlListen, resetStop, dlRef, downloadStopRef, t], + [ + status, + report, + refreshStatus, + runCheck, + startDlListen, + resetStop, + dlRef, + downloadStopRef, + clearProvenanceRecovery, + reconcileProvenanceRecovery, + beginOperation, + finishOperation, + ownsOperation, + t, + ], ); // 〔继续〕from the paused state — re-run the same operation (same install @@ -363,20 +625,28 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { if (report?.plan?.route === "msix-sideload") { return false; } - setBusy("plan"); + const generation = beginOperation("plan"); setCheckError(null); setActionError(null); try { const next = await managerApi.winPlanUpdate(); + if (!ownsOperation(generation)) return null; setReport(next); return next.plan?.route === "portable-fallback"; } catch (cause) { - setCheckError(resolveFailure(cause, t)); + if (ownsOperation(generation)) setCheckError(resolveFailure(cause, t)); return null; } finally { - setBusy(null); + finishOperation(generation); } - }, [report?.plan?.route, settings.windowsInstallMode, t]); + }, [ + beginOperation, + finishOperation, + ownsOperation, + report?.plan?.route, + settings.windowsInstallMode, + t, + ]); const requestInstall = useCallback(async () => { const needsLocation = await freshInstallNeedsLocation(); @@ -390,6 +660,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { await runPerform("install"); }, [freshInstallNeedsLocation, runPerform]); + const recheckProvenanceRecovery = useCallback(async () => { + const token = provenanceRecovery?.token; + if (token) await reconcileProvenanceRecovery(token); + else await refreshStatusAndPlan(); + }, [ + provenanceRecovery?.token, + reconcileProvenanceRecovery, + refreshStatusAndPlan, + ]); + const installToCurrentRoot = useCallback(async () => { await runPerform("install", settings.installRoot); }, [runPerform, settings.installRoot]); @@ -424,6 +704,14 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { installed.version === status.installed.version, ); const isManaged = statusMatchesInstalled && status?.status === "managed"; + useEffect(() => { + // Token-bearing guards are released only by reconcile's fresh backend + // status. The currently rendered managed snapshot may predate an in-flight + // update and is not authoritative for that token. This effect exists only + // for legacy token-less markers written by older renderers. + if (!provenanceRecovery || provenanceRecovery.token !== null || !isManaged) return; + clearProvenanceRecovery(null); + }, [clearProvenanceRecovery, isManaged, provenanceRecovery]); const skippedCandidate = useMemo(() => winSkippedUpdateCandidate(plan), [plan]); const updateSuppressed = skippedUpdateMatches(settings.skippedCodexUpdate, skippedCandidate); const updateAvailable = Boolean(plan) && !plan?.upToDate && !updateSuppressed; @@ -467,6 +755,9 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const version = installed?.version || plan?.latestVersion || ""; const sourceLabel = t(`source.${settings.source}` as TKey); const installRootIsDefault = samePath(settings.installRoot, defaultInstallRoot); + const provenanceRecoveryAction = t( + kind === "external" ? "home.external.cta" : "home.recheck", + ); // A re-check (or the first auto-check) while an app is already known: the hero // morphs to the checking state so the status visibly reacts, then settles back. @@ -514,6 +805,7 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const adoptManualExisting = useCallback(async () => { if (!manualExistingCandidate) return; + const recoveryToken = readStoredProvenanceRecovery()?.token ?? provenanceRecovery?.token ?? null; setManualExistingBusy("adopt"); setManualExistingError(null); try { @@ -521,6 +813,7 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { setStatus(next); setStatusLoaded(true); setStatusFailed(false); + clearProvenanceRecovery(recoveryToken); setManualExistingOpen(false); setManualExistingCandidate(null); await check(); @@ -529,7 +822,13 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } finally { setManualExistingBusy(null); } - }, [check, manualExistingCandidate, t]); + }, [ + check, + clearProvenanceRecovery, + manualExistingCandidate, + provenanceRecovery?.token, + t, + ]); const skipCurrentUpdate = useCallback(async () => { if (!skippedCandidate) return; @@ -546,18 +845,20 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } }, [settings, skippedCandidate, t]); const onLaunch = () => { - if (busy !== null || launchInFlightRef.current) return; + if (busyRef.current !== null || launchInFlightRef.current) return; // Surface a failed open (PowerShell/AUMID or portable-exe error) via the // error banner like every other action, not an unhandled rejection. launchInFlightRef.current = true; setActionError(null); - setBusy("launch"); + const generation = beginOperation("launch"); void managerApi .winLaunch() - .catch((cause) => setActionError(resolveFailure(cause, t))) + .catch((cause) => { + if (ownsOperation(generation)) setActionError(resolveFailure(cause, t)); + }) .finally(() => { launchInFlightRef.current = false; - setBusy(null); + finishOperation(generation); }); }; const launching = busy === "launch"; @@ -589,12 +890,23 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const success = !rechecking && kind === "uptodate"; // A Windows install/update is "clean" only when it actually changed something // without a detour — not a stale-plan no-op (stage.upToDate) and not an - // MSIX→portable fallback. Non-clean successes keep the backend's explanation - // (message + notes) and stay pinned; only clean ones self-dismiss. + // MSIX→portable fallback. Non-clean successes stay pinned; only clean ones + // self-dismiss. A partial outcome's backend prose is diagnostic evidence, not + // localized safety guidance, so it lives behind the disclosure below. + const winPartial = Boolean(perform && outcomeIsPartial(perform.outcome)); const winClean = - Boolean(perform?.success) && !perform?.stage?.upToDate && !perform?.fallbackAttempted; + Boolean(perform?.success) && + !perform?.stage?.upToDate && + !perform?.fallbackAttempted && + !winPartial; const winResultDetail = - perform && !winClean ? perform.notes.filter(Boolean).join(" · ") || undefined : undefined; + perform && !winClean && !winPartial + ? perform.notes.filter(Boolean).join(" · ") || undefined + : undefined; + const winResultDiagnostics = + perform && winPartial + ? [perform.message, ...perform.notes].filter(Boolean).join("\n") || undefined + : undefined; // Char-split only LTR scripts — splitting cursive RTL (Arabic) breaks joining. const splitHeadline = !isShimmer && dirOf(lang) === "ltr"; useHomeMotion(scopeRef, scene, { splitHeadline, success }); @@ -641,25 +953,44 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { inert={confirmOpen || installDirOpen || manualExistingOpen ? true : undefined} > {perform ? ( - { - setPerform(null); - setUpdatedVer(null); - }} - /> + <> + { + setPerform(null); + setUpdatedVer(null); + }} + /> + {winResultDiagnostics ? ( +
+ {t("home.error.details")} +
{winResultDiagnostics}
+
+ ) : null} + + ) : null} + {provenanceRecoveryPending ? ( + + {t( + provenanceRecovery?.state === "unknown" && kind !== "external" + ? "install.partial.pending" + : "install.partial.note", + { action: provenanceRecoveryAction }, + )} + ) : null} {notice ? {notice} : null} {actionError ? : null} @@ -789,7 +1120,13 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) {
) : null} -
+
{/* While a check runs we keep a STABLE pair of buttons so nothing reflows under the hero. */} {rechecking ? ( @@ -801,7 +1138,17 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { ) : null} - {!rechecking && kind === "update" ? ( + {!rechecking && provenanceRecoveryPending && kind !== "external" ? ( + + ) : null} + {!rechecking && !provenanceRecoveryPending && kind === "update" ? ( <> ) : null} - {!rechecking && kind === "idle" ? ( + {!rechecking && !provenanceRecoveryPending && kind === "idle" ? ( <> {launchButton("primary")} ) : null} - {!rechecking && kind === "uptodate" ? ( + {!rechecking && !provenanceRecoveryPending && kind === "uptodate" ? ( <> {launchButton("primary")}