diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 4020bbe1c..7c0427abb 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1666,9 +1666,17 @@ for (const expectedShareSnippet of [ 'url.origin() != base_url.origin()', 'DesktopSharePayload::Invalid', '"share target is invalid"', + 'log_desktop_share_failure', + 'desktop share failed for {label}: {error}', + 'share_unavailable_response', + '"share unavailable"', 'set_desktop_host_bridge_share_target', 'share_text_from_value(target)', + 'log_desktop_share_failure("target.lock", "share target lock poisoned")', + 'log_desktop_share_failure("target.store", "share target lock poisoned")', 'share_target_payload_must_be_valid_before_cache', + 'share_unavailable_response_is_stable', + 'share_failures_are_logged_without_exposing_native_detail', 'open_desktop_host_bridge_share', 'write_desktop_clipboard_text(app, &share_text)', '"copied_to_clipboard"', @@ -1680,6 +1688,13 @@ for (const expectedShareSnippet of [ } } +if ( + desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", "share target lock poisoned")') || + desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", error.to_string())') +) { + throw new Error('desktop shell share module must hide native share errors'); +} + if ( !desktopHostBridgeDispatchSource.includes( 'set_desktop_host_bridge_share_target(&app, &request)', diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs index af0283016..1f2f2abc7 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs @@ -28,6 +28,18 @@ fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { .filter(|text| !text.is_empty()) } +fn log_desktop_share_failure(label: &str, error: &str) -> bool { + if !error.is_empty() { + eprintln!("desktop share failed for {label}: {error}"); + } + + false +} + +fn share_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse { + failed(request.id.clone(), "host_error", "share unavailable") +} + fn share_target_payload(value: &Value) -> &Value { value.get("target").unwrap_or(value) } @@ -112,11 +124,8 @@ pub(crate) fn share_text_from_request( .target .lock() .map_err(|_| { - failed( - request.id.clone(), - "host_error", - "share target lock poisoned", - ) + log_desktop_share_failure("target.lock", "share target lock poisoned"); + share_unavailable_response(request) })? .clone(); @@ -170,11 +179,10 @@ pub(crate) fn set_desktop_host_bridge_share_target( *current_target = Some(target.clone()); ok(request.id.clone(), json!(true)) } - Err(_) => failed( - request.id.clone(), - "host_error", - "share target lock poisoned", - ), + Err(_) => { + log_desktop_share_failure("target.store", "share target lock poisoned"); + share_unavailable_response(request) + } }; response } @@ -357,4 +365,26 @@ mod tests { assert_eq!(error.code, "invalid_request"); assert_eq!(error.message, "share target is required"); } + + #[test] + fn share_unavailable_response_is_stable() { + let response = share_unavailable_response(&request("share.open")); + + assert!(!response.ok); + let error = response.error.expect("share error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "share unavailable"); + } + + #[test] + fn share_failures_are_logged_without_exposing_native_detail() { + assert!(!log_desktop_share_failure( + "target.lock", + "share target lock poisoned" + )); + assert!(!log_desktop_share_failure( + "target.store", + "share target lock poisoned" + )); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c8c398bb..06fe24ce1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3154,6 +3154,12 @@ - 决策:`apps/mobile-shell/src/host-bridge/share.ts` 必须在 `Share.share(...)` reject 时记录 `mobile share failed for open.share` 日志。HostBridge 对 H5 仍只返回稳定 `share unavailable`,不透传原生分享面板异常明细。 - 验证方式:`npm run mobile-shell:test -- --run src/host-bridge/share.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 桌面壳分享缓存内部异常不得透传 + +- 背景:桌面壳 `share.setTarget` 和 `share.open` 会读写 Rust 侧分享目标缓存;如果缓存锁异常直接返回 `share target lock poisoned`,H5 会看到 Rust 内部同步原语细节,且开发侧没有统一日志标签定位读缓存还是写缓存失败。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/share.rs` 必须在分享目标缓存读写失败时分别记录 `desktop share failed for target.lock` 或 `desktop share failed for target.store` 日志。HostBridge 对 H5 仍只返回稳定 `share unavailable`,不透传锁状态、内部缓存状态或 Rust 同步原语细节。 +- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::share`、`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 31549ba51..ed6217cdf 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -358,6 +358,8 @@ GameBridge 禁止: 2026-06-20 追加:桌面壳网络状态查询系统异常必须可观测。Tauri 桌面壳后台解析网络状态任务失败时必须记录 `desktop network failed for status.resolve` 日志;HostBridge 回包仍只暴露稳定 `network status unavailable` 语义,不透传 resolver、线程或系统错误细节。 +2026-06-20 追加:桌面壳分享缓存内部异常必须可观测。Tauri 桌面壳读写 `share.setTarget` 缓存失败时必须记录 `desktop share failed for ...` 日志;HostBridge 回包仍只暴露稳定 `share unavailable` 语义,不透传锁状态、内部缓存状态或 Rust 同步原语细节。 + 2026-06-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录、其它身份边界变化或登录状态异常页点击重新尝试时,`AuthGate` 会优先请求 Expo 壳刷新当前 WebView;宿主未声明或刷新失败时再回退浏览器刷新,避免在移动壳内绕过受控容器刷新入口。 2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview` 的 `reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。