diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f84369732..be2579fdd 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -312,7 +312,11 @@ function assertDesktopIconSet() { } } -function extractStringArrayExport(source, exportName) { +function extractStringArrayExport(source, exportName, seen = new Set()) { + if (seen.has(exportName)) { + throw new Error(`cyclic string array export ${exportName}`); + } + const match = source.match( new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), ); @@ -320,14 +324,15 @@ function extractStringArrayExport(source, exportName) { throw new Error(`unable to read ${exportName}`); } - const entries = [...match[1].matchAll(/'([^']+)'/g)].map( - (entry) => entry[1], - ); - if (match[1].includes('...HOST_BRIDGE_METHODS')) { - return [ - ...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'), - ...entries, - ]; + const nextSeen = new Set(seen); + nextSeen.add(exportName); + const entries = []; + for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { + if (entry[1]) { + entries.push(...extractStringArrayExport(source, entry[1], nextSeen)); + } else { + entries.push(entry[2]); + } } return entries; @@ -353,6 +358,23 @@ function extractDesktopCapabilities(source) { return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } +function extractDesktopHandledMethods(source) { + const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map( + (match) => match[1], + ); + if (matchBodies.length === 0) { + throw new Error('unable to read desktop shell HostBridge handler methods'); + } + + return [ + ...new Set( + matchBodies.flatMap((body) => + [...body.matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]), + ), + ), + ]; +} + function resolveHostCapabilitiesFromUrl(rawUrl) { const url = new URL(rawUrl, 'https://app.genarrative.world/'); return (url.searchParams.get('hostCapabilities') ?? '') @@ -458,8 +480,18 @@ const sharedMethods = extractStringArrayExport( ); const desktopMethods = extractRustStringArrayConst(main, 'HOST_BRIDGE_METHODS'); const desktopCapabilities = extractDesktopCapabilities(main); +const desktopHandledMethods = extractDesktopHandledMethods(main); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); +const unknownHandledDesktopMethods = desktopHandledMethods.filter( + (method) => !sharedMethods.includes(method), +); +if (unknownHandledDesktopMethods.length > 0) { + throw new Error( + `desktop shell handles unknown HostBridge methods: ${unknownHandledDesktopMethods.join(', ')}`, + ); +} + const unknownDesktopCapabilities = desktopCapabilities.filter( (capability) => !sharedCapabilities.includes(capability), ); @@ -477,6 +509,27 @@ for (const capability of sdkBackedCapabilities) { } } +const missingDesktopMethodHandlers = desktopCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + !desktopHandledMethods.includes(capability), +); +if (missingDesktopMethodHandlers.length > 0) { + throw new Error( + `desktop shell declares request capabilities without HostBridge handlers: ${missingDesktopMethodHandlers.join(', ')}`, + ); +} + +const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter( + (method) => + !desktopCapabilities.includes(method) && !sdkBackedCapabilities.includes(method), +); +if (undeclaredDesktopMethodHandlers.length > 0) { + throw new Error( + `desktop shell handles unadvertised HostBridge methods: ${undeclaredDesktopMethodHandlers.join(', ')}`, + ); +} + if (config.productName !== 'Genarrative') { throw new Error('desktop shell productName must be Genarrative'); } diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index abd603268..f67c0ab30 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -101,7 +101,11 @@ const blockedAndroidPermissions = [ 'android.permission.WRITE_EXTERNAL_STORAGE', ]; -function extractStringArrayExport(source, exportName) { +function extractStringArrayExport(source, exportName, seen = new Set()) { + if (seen.has(exportName)) { + throw new Error(`cyclic string array export ${exportName}`); + } + const match = source.match( new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), ); @@ -109,14 +113,15 @@ function extractStringArrayExport(source, exportName) { throw new Error(`unable to read ${exportName}`); } - const entries = [...match[1].matchAll(/'([^']+)'/g)].map( - (entry) => entry[1], - ); - if (match[1].includes('...HOST_BRIDGE_METHODS')) { - return [ - ...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'), - ...entries, - ]; + const nextSeen = new Set(seen); + nextSeen.add(exportName); + const entries = []; + for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) { + if (entry[1]) { + entries.push(...extractStringArrayExport(source, entry[1], nextSeen)); + } else { + entries.push(entry[2]); + } } return entries; @@ -325,18 +330,25 @@ for (const capability of sdkBackedCapabilities) { } } -for (const capability of iosMobileCapabilities) { - const switchCase = `case '${capability}':`; - if ( - capability !== 'host.events' && - capability !== 'app.lifecycle' && - capability !== 'navigation.canGoBack' && - !bridgeSource.includes(switchCase) - ) { - throw new Error( - `mobile shell declares ${capability} but does not handle it in HostBridge`, - ); - } +const missingMobileMethodHandlers = iosMobileCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + !handledMobileMethods.includes(capability), +); +if (missingMobileMethodHandlers.length > 0) { + throw new Error( + `mobile shell declares request capabilities without HostBridge handlers: ${missingMobileMethodHandlers.join(', ')}`, + ); +} + +const undeclaredMobileMethodHandlers = handledMobileMethods.filter( + (method) => + !iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method), +); +if (undeclaredMobileMethodHandlers.length > 0) { + throw new Error( + `mobile shell handles unadvertised HostBridge methods: ${undeclaredMobileMethodHandlers.join(', ')}`, + ); } if (appConfig.scheme !== 'genarrative') { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7e67b2db1..4806ebd31 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -70,6 +70,7 @@ - 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 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。 - 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 指向,避免后续换成非品牌或占位素材。 @@ -2408,6 +2409,13 @@ - 影响范围:`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 HostBridge capability / handler 关系门禁 + +- 背景:`HOST_BRIDGE_CAPABILITIES` 同时包含可请求 method 和事件类 capability。壳如果声明了 request method capability 但没有 handler,H5 会展示入口后收到 unsupported;壳如果处理了未声明 method,H5 又无法根据 capability 决定是否调用,容易形成隐藏能力或跨端漂移。 +- 决策:移动壳配置检查展开 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 并解析 `handleRequest` case;桌面壳配置检查解析 `capabilities()` 与 Rust request 分发 match。凡共享契约中属于 request method 的 capability,被壳声明后必须有对应 handler;handler 处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events`、`app.lifecycle`、`network.statusChanged`、`file.imageDropped`、`navigation.canGoBack` 等事件 capability 不要求 request handler。 +- 影响范围:`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 7a6897a95..a3c3d5278 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -354,6 +354,8 @@ GameBridge 禁止: 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 追加:HostBridge capability 与 request handler 关系进入门禁。共享契约中属于 request method 的 capability,如果被 Expo 或 Tauri 壳声明,就必须在对应壳的 HostBridge 分发中显式处理;反过来,壳分发中处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events`、`app.lifecycle`、`network.statusChanged`、`file.imageDropped`、`navigation.canGoBack` 等事件类 capability 不要求 request handler。 + 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 回归。