diff --git a/package.json b/package.json index 8cabff7..c08cab0 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 4dc015b..c871825 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,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` @@ -109,6 +336,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(); @@ -209,6 +445,7 @@ pub fn run() { commands::log_frontend_error, ]) .setup(|app| { + build_main_window(app)?; #[cfg(target_os = "macos")] install_macos_menu(app)?; log::info!( @@ -226,7 +463,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. @@ -315,3 +572,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();