diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 52e348bc2..2633a1579 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2000,9 +2000,12 @@ if ( for (const snippet of [ 'open_desktop_host_bridge_external_url', 'external_url_unavailable_response', + 'log_desktop_navigation_failure', + 'desktop navigation failed for {label}: {error}', 'desktop_external_url_from_request', 'normalize_external_url', 'open_normalized_desktop_external_url(app, url)', + 'log_desktop_navigation_failure("external.open", &error.to_string())', 'desktop_external_url_request_accepts_only_safe_system_protocols', '"external URL cannot be opened"', 'external_url_unavailable_response_is_stable', @@ -2011,12 +2014,18 @@ for (const snippet of [ 'desktop_native_page_url_from_request', 'normalize_native_page_url', 'window.navigate(url)', + 'log_desktop_navigation_failure("native.navigate", &error.to_string())', + 'log_desktop_navigation_failure("native.window", "main window unavailable")', 'desktop_native_page_request_accepts_only_same_origin_h5_routes', + 'navigation_failures_are_logged_without_exposing_native_detail', '"native page unavailable"', 'native_page_unavailable_response_is_stable', 'reload_desktop_host_bridge_webview', 'webview_reload_unavailable_response', 'window.reload()', + 'log_desktop_navigation_failure("webview.reload", &error.to_string())', + 'log_desktop_navigation_failure("webview.window", "main window unavailable")', + 'webview_reload_failures_are_logged_without_exposing_native_detail', '"webview reload unavailable"', 'webview_reload_unavailable_response_is_stable', ]) { @@ -2024,6 +2033,12 @@ for (const snippet of [ throw new Error(`desktop shell navigation module is missing ${snippet}`); } } +if ( + desktopHostBridgeNavigationSource.includes('failed(request.id.clone(), "host_error", error.to_string())') || + desktopHostBridgeNavigationSource.includes('"main window not found"') +) { + throw new Error('desktop shell navigation module must hide native navigation errors'); +} if ( !desktopHostBridgeDispatchSource.includes( diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs index cb0ea4dcb..aeb5b1fcb 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs @@ -48,7 +48,10 @@ pub(crate) fn open_desktop_host_bridge_external_url( match open_normalized_desktop_external_url(app, url) { Ok(()) => ok(request.id.clone(), json!(true)), - Err(_) => external_url_unavailable_response(request), + Err(error) => { + log_desktop_navigation_failure("external.open", &error.to_string()); + external_url_unavailable_response(request) + } } } @@ -72,9 +75,15 @@ pub(crate) fn open_desktop_host_bridge_native_page( match app.get_webview_window("main") { Some(window) => match window.navigate(url) { Ok(()) => ok(request.id.clone(), json!(true)), - Err(_) => native_page_unavailable_response(request), + Err(error) => { + log_desktop_navigation_failure("native.navigate", &error.to_string()); + native_page_unavailable_response(request) + } }, - None => native_page_unavailable_response(request), + None => { + log_desktop_navigation_failure("native.window", "main window unavailable"); + native_page_unavailable_response(request) + } } } @@ -82,6 +91,14 @@ fn native_page_unavailable_response(request: &HostBridgeRequest) -> HostBridgeRe failed(request.id.clone(), "host_error", "native page unavailable") } +fn log_desktop_navigation_failure(label: &str, error: &str) -> bool { + if !error.is_empty() { + eprintln!("desktop navigation failed for {label}: {error}"); + } + + false +} + #[cfg(test)] mod tests { use super::*; @@ -182,6 +199,22 @@ mod tests { assert_eq!(error.code, "host_error"); assert_eq!(error.message, "native page unavailable"); } + + #[test] + fn navigation_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_navigation_failure( + "external.open", + "private external detail" + )); + assert!(!log_desktop_navigation_failure( + "native.navigate", + "private navigation detail" + )); + assert!(!log_desktop_navigation_failure( + "native.window", + "main window unavailable" + )); + } } pub(crate) fn reload_desktop_host_bridge_webview( @@ -191,9 +224,15 @@ pub(crate) fn reload_desktop_host_bridge_webview( match app.get_webview_window("main") { Some(window) => match window.reload() { Ok(()) => ok(request.id.clone(), json!(true)), - Err(_) => webview_reload_unavailable_response(request), + Err(error) => { + log_desktop_navigation_failure("webview.reload", &error.to_string()); + webview_reload_unavailable_response(request) + } }, - None => webview_reload_unavailable_response(request), + None => { + log_desktop_navigation_failure("webview.window", "main window unavailable"); + webview_reload_unavailable_response(request) + } } } @@ -219,4 +258,16 @@ mod reload_tests { assert_eq!(error.code, "host_error"); assert_eq!(error.message, "webview reload unavailable"); } + + #[test] + fn webview_reload_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_navigation_failure( + "webview.reload", + "private reload detail" + )); + assert!(!log_desktop_navigation_failure( + "webview.window", + "main window unavailable" + )); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b0e147419..200c7b04b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3136,6 +3136,12 @@ - 决策:`apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs` 和 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs` 必须在主窗口缺失、主题读取失败、角标设置失败和窗口标题设置失败时分别记录 `desktop appearance failed for ...`、`desktop app badge failed for ...` 或 `desktop window title failed for ...` 日志。HostBridge 对 H5 仍只返回稳定 `appearance unavailable`、`badge unavailable` 或 `window title unavailable`,不透传系统错误、窗口内部信息或平台细节。 - 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::appearance`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::badge`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::title`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 桌面壳导航系统异常必须可观测 + +- 背景:Tauri 桌面壳的外链打开、同源 H5 route 导航和 WebView reload 都直接影响原生壳内 H5 的完整流程;这些系统调用失败时,H5 只应得到稳定错误语义,但开发侧需要能区分外链打开失败、窗口导航失败、reload 失败和主窗口缺失。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs` 必须在外链打开失败、同源 H5 route 导航失败、WebView reload 失败和主窗口缺失时记录 `desktop navigation failed for ...` 日志。HostBridge 对 H5 仍只返回稳定 `external URL cannot be opened`、`native page unavailable` 或 `webview reload unavailable`,不透传系统错误、窗口内部信息或平台细节。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::navigation`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳门禁脚本必须自登记自扫描 - 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3681174d3..32164c08e 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -352,6 +352,8 @@ GameBridge 禁止: 2026-06-20 追加:桌面壳窗口状态小能力的系统异常必须可观测。Tauri 主窗口缺失、主题读取失败、角标设置失败或窗口标题设置失败时,桌面壳必须分别记录 `desktop appearance failed for ...`、`desktop app badge failed for ...` 或 `desktop window title failed for ...` 日志;HostBridge 回包仍只暴露稳定 `appearance unavailable`、`badge unavailable` 或 `window title unavailable` 语义,不透传系统错误、窗口内部信息或平台细节。 +2026-06-20 追加:桌面壳导航系统异常必须可观测。Tauri 外链打开失败、同源 H5 route 导航失败、WebView reload 失败或主窗口缺失时,桌面壳必须记录 `desktop navigation failed for ...` 日志;HostBridge 回包仍只暴露稳定 `external URL cannot be opened`、`native page unavailable` 或 `webview reload unavailable` 语义,不透传系统错误、窗口内部信息或平台细节。 + 2026-06-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录、其它身份边界变化或登录状态异常页点击重新尝试时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView;宿主未声明或刷新失败时再回退浏览器刷新,避免在移动壳内绕过受控容器刷新入口。 2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview` 的 `reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。