diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 03076fac1..f84369732 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -333,6 +333,17 @@ function extractStringArrayExport(source, exportName) { return entries; } +function extractRustStringArrayConst(source, constName) { + const match = source.match( + new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + function extractDesktopCapabilities(source) { const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); if (!match) { @@ -441,8 +452,14 @@ const sharedCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_CAPABILITIES', ); +const sharedMethods = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_METHODS', +); +const desktopMethods = extractRustStringArrayConst(main, 'HOST_BRIDGE_METHODS'); const desktopCapabilities = extractDesktopCapabilities(main); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; +assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); const unknownDesktopCapabilities = desktopCapabilities.filter( (capability) => !sharedCapabilities.includes(capability), ); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 7ea7d8410..abd603268 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -133,6 +133,17 @@ function extractStringConstExport(source, exportName) { return match[1]; } +function extractMobileBridgeHandledMethods(source) { + const match = source.match( + /async function handleRequest\(request: HostBridgeRequest\)[^{]*\{[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + ); + if (!match) { + throw new Error('unable to read mobile shell HostBridge handler methods'); + } + + return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]); +} + function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) { const dependencySections = [ 'dependencies', @@ -260,6 +271,11 @@ const sharedCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_CAPABILITIES', ); +const sharedMethods = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_METHODS', +); +const handledMobileMethods = extractMobileBridgeHandledMethods(bridgeSource); const mobileCapabilities = extractStringArrayExport( bridgeSource, 'MOBILE_HOST_CAPABILITIES', @@ -271,6 +287,15 @@ const iosMobileCapabilities = extractStringArrayExport( const mobileCapabilitySet = new Set(mobileCapabilities); const iosMobileCapabilitySet = new Set(iosMobileCapabilities); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; +const unknownHandledMobileMethods = handledMobileMethods.filter( + (method) => !sharedMethods.includes(method), +); +if (unknownHandledMobileMethods.length > 0) { + throw new Error( + `mobile shell handles unknown HostBridge methods: ${unknownHandledMobileMethods.join(', ')}`, + ); +} + const unknownMobileCapabilities = mobileCapabilities.filter( (capability) => !sharedCapabilities.includes(capability), ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f7aaa6c09..7e67b2db1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -69,6 +69,7 @@ - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 - 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 +- 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。 - 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 @@ -2400,6 +2401,13 @@ - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-18 HostBridge method 白名单跨壳门禁 + +- 背景:Expo 壳直接引用 TypeScript 共享契约,Tauri 壳必须在 Rust 中镜像 HostBridge method 白名单;如果只靠人工同步,后续新增 method 时容易出现 H5 已发送、Expo 已处理、Tauri 仍按未知 method 拒绝,或 Tauri 额外接受共享契约外 method 的漂移。 +- 决策:`HOST_BRIDGE_METHODS` 以 `packages/shared/src/contracts/hostBridge.ts` 为唯一协议来源。移动壳配置检查解析 `handleRequest` 的 method case,拒绝共享契约外 method;桌面壳配置检查解析 Rust `HOST_BRIDGE_METHODS`,要求与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再在 Expo / Tauri 中实现或明确返回 `unsupported_method`。 +- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 移动壳渠道 SDK 依赖收口 - 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index ebe4da1c5..7a6897a95 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -352,6 +352,8 @@ GameBridge 禁止: 2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 +2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。 + 2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。 2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。