diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index cf208e0d1..bf0dd9dd5 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2213,6 +2213,7 @@ const requiredRustHostSnippets = [ 'should_replay_desktop_webview_state_on_page_load', 'PageLoadEvent::Finished', 'replay_desktop_webview_state', + 'log_desktop_webview_state_replay_result', 'window.is_focused()', 'window.is_visible()', 'window.is_minimized()', @@ -2242,6 +2243,8 @@ const requiredRustHostSnippets = [ 'desktop_external_navigation_url', 'open_normalized_desktop_external_url', 'open_desktop_external_navigation', + 'desktop_new_window_external_url', + 'desktop_new_window_response', '.on_navigation(move |url|', '.on_new_window(move |url, _features|', '.on_page_load(|window, payload|', @@ -2673,6 +2676,11 @@ for (const appSetupSnippet of [ 'crate::host_bridge::host_bridge_request', 'desktop_main_window_config(app)?', 'WebviewWindowBuilder::from_config', + 'desktop_new_window_external_url(&url)', + 'desktop_new_window_response', + 'register_desktop_network_events(&window)?', + 'register_desktop_navigation_events(&window)?', + 'log_desktop_webview_state_replay_result(', ]) { if (!app.includes(appSetupSnippet)) { throw new Error(`desktop shell app.rs missing setup snippet ${appSetupSnippet}`); @@ -2683,6 +2691,9 @@ for (const blockedAppSetupSnippet of [ 'app.config().app.windows.get(0)', 'app.config().app.windows[0]', 'if let Some(config) = window_config', + 'let _ = register_desktop_network_events(&window)', + 'let _ = register_desktop_navigation_events(&window)', + 'let _ = emit_current_desktop_lifecycle_event(&window)', ]) { if (app.includes(blockedAppSetupSnippet)) { throw new Error( @@ -2691,6 +2702,18 @@ for (const blockedAppSetupSnippet of [ } } +for (const blockedLifecycleSnippet of [ + 'let _ = register_desktop_network_events(window)', + 'let _ = register_desktop_navigation_events(window)', + 'let _ = emit_current_desktop_lifecycle_event(window)', +]) { + if (rustHostSource.includes(blockedLifecycleSnippet)) { + throw new Error( + `desktop shell WebView state replay failures must be logged or propagated, not ${blockedLifecycleSnippet}`, + ); + } +} + for (const snippet of requiredRustHostSnippets) { if (!rustHostSource.includes(snippet)) { throw new Error(`desktop shell Rust host bridge missing ${snippet}`); diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs index 6618d0f60..d4a2fd553 100644 --- a/apps/desktop-shell/src-tauri/src/app.rs +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -9,7 +9,9 @@ use crate::shell::tray::{ }; use crate::shell::webview::{ desktop_window_config_with_runtime_platform, emit_current_desktop_lifecycle_event, - open_desktop_external_navigation, register_desktop_file_drop_events, + desktop_external_navigation_url, + log_desktop_webview_state_replay_result, open_desktop_external_navigation, + open_normalized_desktop_external_url, register_desktop_file_drop_events, register_desktop_lifecycle_events, register_desktop_navigation_events, register_desktop_network_events, replay_desktop_webview_state, should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, @@ -37,6 +39,14 @@ fn desktop_main_window_config( desktop_main_window_config_from_windows(&app.config().app.windows) } +fn desktop_new_window_external_url(url: &tauri::Url) -> Option { + desktop_external_navigation_url(url) +} + +fn desktop_new_window_response() -> NewWindowResponse { + NewWindowResponse::Deny +} + pub(crate) fn run() { tauri::Builder::default() .manage(DesktopShareState::default()) @@ -76,8 +86,13 @@ pub(crate) fn run() { } }) .on_new_window(move |url, _features| { - open_desktop_external_navigation(&new_window_app_handle, &url); - NewWindowResponse::Deny + if let Some(external_url) = desktop_new_window_external_url(&url) { + let _ = open_normalized_desktop_external_url( + &new_window_app_handle, + external_url, + ); + } + desktop_new_window_response::() }) .on_page_load(|window, payload| { if should_replay_desktop_webview_state_on_page_load(payload.event()) { @@ -88,9 +103,12 @@ pub(crate) fn run() { .build()?; register_desktop_window_close_events(&window, tray_registered); register_desktop_lifecycle_events(&window); - let _ = emit_current_desktop_lifecycle_event(&window); - let _ = register_desktop_network_events(&window); - let _ = register_desktop_navigation_events(&window); + log_desktop_webview_state_replay_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(&window), + ); + register_desktop_network_events(&window)?; + register_desktop_navigation_events(&window)?; register_desktop_file_drop_events(&window); register_desktop_deep_link_events(app)?; register_desktop_deep_link_schemes(app); @@ -143,4 +161,30 @@ mod tests { assert!(matches!(error, tauri::Error::WindowNotFound)); } + + #[test] + fn desktop_new_window_policy_denies_embedded_windows() { + assert!(matches!( + desktop_new_window_response::(), + NewWindowResponse::Deny + )); + } + + #[test] + fn desktop_new_window_policy_opens_only_safe_external_urls() { + let external_url = tauri::Url::parse("https://example.com/share") + .expect("external desktop new window url"); + assert_eq!( + desktop_new_window_external_url(&external_url), + Some("https://example.com/share".to_string()) + ); + + let same_origin_url = tauri::Url::parse("https://app.genarrative.world/works/detail") + .expect("same-origin desktop new window url"); + assert_eq!(desktop_new_window_external_url(&same_origin_url), None); + + let unsafe_url = + tauri::Url::parse("javascript:alert(1)").expect("unsafe desktop new window url"); + assert_eq!(desktop_new_window_external_url(&unsafe_url), None); + } } diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs index 71f5c5c0a..edfd12fec 100644 --- a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -97,10 +97,32 @@ pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEv event == PageLoadEvent::Finished } +pub(crate) fn log_desktop_webview_state_replay_result( + label: &'static str, + result: tauri::Result<()>, +) -> bool { + match result { + Ok(()) => true, + Err(error) => { + eprintln!("desktop webview state replay failed for {label}: {error}"); + false + } + } +} + pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { - let _ = emit_current_desktop_lifecycle_event(window); - let _ = register_desktop_network_events(window); - let _ = register_desktop_navigation_events(window); + log_desktop_webview_state_replay_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(window), + ); + log_desktop_webview_state_replay_result( + "network.statusChanged", + register_desktop_network_events(window), + ); + log_desktop_webview_state_replay_result( + "navigation.canGoBack", + register_desktop_navigation_events(window), + ); } #[cfg(test)] @@ -117,6 +139,15 @@ mod tests { )); } + #[test] + fn desktop_state_replay_result_reports_success_and_failure() { + assert!(log_desktop_webview_state_replay_result("network", Ok(()))); + assert!(!log_desktop_webview_state_replay_result( + "navigation", + Err(tauri::Error::AssetNotFound("navigation".to_string())) + )); + } + #[test] fn desktop_lifecycle_payload_maps_hidden_and_minimized_to_background() { assert_eq!( diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index 20711df8b..5e0e208e1 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -1,12 +1,14 @@ pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events; pub(crate) use crate::shell::lifecycle::{ - emit_current_desktop_lifecycle_event, register_desktop_lifecycle_events, - replay_desktop_webview_state, should_replay_desktop_webview_state_on_page_load, + emit_current_desktop_lifecycle_event, log_desktop_webview_state_replay_result, + register_desktop_lifecycle_events, replay_desktop_webview_state, + should_replay_desktop_webview_state_on_page_load, }; pub(crate) use crate::shell::navigation::{ - normalize_external_url, normalize_native_page_url, open_desktop_external_navigation, - open_normalized_desktop_external_url, register_desktop_navigation_events, - should_allow_desktop_webview_download, should_allow_desktop_webview_navigation, + desktop_external_navigation_url, normalize_external_url, normalize_native_page_url, + open_desktop_external_navigation, open_normalized_desktop_external_url, + register_desktop_navigation_events, should_allow_desktop_webview_download, + should_allow_desktop_webview_navigation, }; pub(crate) use crate::shell::network::{ register_desktop_network_events, resolve_desktop_network_status, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ff4bda074..746df54e6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2851,3 +2851,10 @@ - 决策:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs` 和 `apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 统一返回 `HostBridgeResponse`,各自承接 payload 校验、系统调用、成功响应和失败响应映射;`dispatch.rs` 只保留 method 委托。桌面壳配置检查会拒绝 `dispatch.rs` 重新对这两个 method 做 `match` 或包装 `ok(true)`。 - 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`docs/project-memory/shared-memory/decision-log.md`。 - 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面壳 HostBridge 事件注册失败不可静默 + +- 背景:Tauri 桌面壳已经声明 `host.events`、`app.lifecycle`、`network.statusChanged` 和 `navigation.canGoBack`,H5 会据此订阅生命周期、网络和返回栈事件;如果 WebView 事件脚本注册失败仍被静默忽略,H5 会误以为宿主能力可用。 +- 决策:桌面壳启动阶段安装 `network.statusChanged` 和 `navigation.canGoBack` 脚本失败时直接阻断启动;生命周期首发和页面加载重放阶段的 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 失败必须通过统一 helper 记录日志,不允许 `let _ = register_desktop_*` 或 `let _ = emit_current_*` 静默吞错。配置检查反查该错误处理路径。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 48f2ee219..44a25db93 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -58,6 +58,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发同名事件。桌面壳不会把 hidden、minimized 或 tray 扩成新的 `state`,而是读取 `is_visible()`、`is_minimized()`、`is_focused()` 后统一归一为 `active` / `inactive` / `background`,并只把 `hidden`、`minimized`、`focused`、`blurred` 放进 `nativeState` 用于排障。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `