diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index c20ba0ce6..f9f581782 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1676,6 +1676,23 @@ assertSameList( 'desktop shell HostBridge capability profile', ); +for (const snippet of [ + 'fn runtime_capability_list_keeps_desktop_boundaries()', + 'let unique_capabilities = desktop_capabilities.iter().copied().collect::>();', + 'assert_eq!(unique_capabilities.len(), desktop_capabilities.len());', + '"notification.showLocal"', + '"auth.requestLogin"', + '"payment.request"', + '"scanner.scanQrCode"', + 'assert!(!desktop_capabilities.contains(&capability));', +]) { + if (!desktopHostBridgeCapabilitiesSource.includes(snippet)) { + throw new Error( + `desktop shell capability list boundary test is missing coverage: ${snippet}`, + ); + } +} + for (const capability of ['auth.requestLogin', 'payment.request']) { if (desktopCapabilities.includes(capability)) { throw new Error( diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs index 81ec74679..8ba0969b5 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs @@ -32,6 +32,7 @@ pub(crate) fn capabilities() -> Vec<&'static str> { mod tests { use super::*; use serde_json::Value; + use std::collections::HashSet; #[test] fn runtime_capability_list_stays_ordered() { @@ -67,4 +68,52 @@ mod tests { ); assert!(Value::from(capabilities()).as_array().is_some()); } + + #[test] + fn runtime_capability_list_keeps_desktop_boundaries() { + let desktop_capabilities = capabilities(); + let unique_capabilities = desktop_capabilities.iter().copied().collect::>(); + + assert_eq!(unique_capabilities.len(), desktop_capabilities.len()); + + for capability in [ + "host.getRuntime", + "appearance.getColorScheme", + "host.events", + "app.lifecycle", + "share.open", + "share.setTarget", + "navigation.openNativePage", + "navigation.canGoBack", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "network.statusChanged", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.importDocument", + "file.exportImage", + "file.importImage", + "file.importAudio", + "file.exportAudio", + "file.imageDropped", + "notification.showLocal", + ] { + assert!(desktop_capabilities.contains(&capability)); + } + + for capability in [ + "auth.requestLogin", + "payment.request", + "file.captureImage", + "scanner.scanQrCode", + "haptics.impact", + ] { + assert!(!desktop_capabilities.contains(&capability)); + } + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 59348830d..d6b31918f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -42,6 +42,7 @@ - 2026-06-20 移动壳文件桥接载荷边界:Expo `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.exportAudio` 的 DocumentPicker、ImagePicker、File、Sharing 系统交互、用户取消语义、缓存读写编排和 HostBridge 响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`;MIME、大小、base64、文件名清洗、图片 / 音频 bytes 匹配和 picker 结果到 HostBridge payload 的组装统一收口在 `apps/mobile-shell/src/host-bridge/filePayloads.ts`。移动壳单端配置检查和根级 `npm run check:native-shells` 会把 `filePayloads.ts` 纳入结构清单与 HostBridge 源码扫描,避免文件载荷边界重新散落到分发层或 shell 层。 - 2026-06-20 移动文件载荷单测边界:`apps/mobile-shell/src/host-bridge/filePayloads.test.ts` 直接覆盖移动壳文件载荷 helper 的 base64、UTF-8 byte、MIME / 扩展名归一、图片 / 音频 bytes 匹配、导出文件名补扩展、导入大小门禁和 ImagePicker payload 转换;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,防止后续只靠完整 HostBridge bridge 流程间接覆盖文件安全边界。 - 2026-06-20 移动本地通知单测边界:`apps/mobile-shell/src/host-bridge/notifications.test.ts` 直接覆盖 Expo `notification.showLocal` 的已授权 / iOS provisional 权限复用、alert-only 权限请求、权限拒绝失败、iOS 即时调度、Android 固定 channel、共享 payload 归一和结构化 `delivered_to_system` 成功响应;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免移动通知边界只靠完整 HostBridge bridge 流程间接覆盖。 +- 2026-06-20 桌面能力清单单测边界:Tauri `capabilities.rs` 必须用 Rust 单测同时覆盖桌面 runtime capability 清单顺序、无重复、真实桌面能力完整包含,并显式排除 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact` 等未接入能力;桌面单端配置检查会反查该测试边界,避免只靠方案文档或共享 profile 发现桌面壳能力伪声明。 - 2026-06-20 桌面本地通知契约镜像:Tauri `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH`、`HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH` 和 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 264980557..70184b98a 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -393,7 +393,7 @@ GameBridge 禁止: 当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur、窗口 resize 后的状态读取、托盘隐藏 / 恢复和页面加载重放注入统一状态,不开放 Tauri event 插件给前端。桌面壳只向 H5 派发共享契约里的 `active` / `inactive` / `background`,隐藏到托盘和系统最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只写入 `nativeState` 便于排障;H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点和后台状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `