From ad883df30738860f5c6bbbc5f34ed216ceb1e222 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 01:09:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8E=9F=E7=94=9F=E5=A3=B3?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳配置检查校验声明能力来自共享 HostBridge 白名单 桌面壳配置检查校验 runtime 能力与 URL hostCapabilities 一致 文档补充新增 native capability 后必须运行双壳检查 共享决策记录补充壳能力防漂移约束 --- apps/desktop-shell/scripts/check-config.mjs | 85 ++++++++++++++++++- apps/mobile-shell/scripts/check-config.mjs | 78 +++++++++++++++++ .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 1 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 1 + 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 0d5d8fd8..fd1c7e78 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -10,6 +10,76 @@ const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8')); const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url); const buildScript = fs.readFileSync(buildScriptPath, 'utf8'); const iconPath = new URL('../src-tauri/icons/icon.png', import.meta.url); +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); +const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); +const main = fs.readFileSync(mainPath, 'utf8'); + +function extractStringArrayExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), + ); + if (!match) { + 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, + ]; + } + + return entries; +} + +function extractDesktopCapabilities(source) { + const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); + if (!match) { + throw new Error('unable to read desktop shell capabilities'); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function resolveHostCapabilitiesFromUrl(rawUrl) { + const url = new URL(rawUrl, 'https://app.genarrative.world/'); + return (url.searchParams.get('hostCapabilities') ?? '') + .split(',') + .map((capability) => capability.trim()) + .filter(Boolean); +} + +function assertSameList(actual, expected, label) { + if ( + actual.length !== expected.length || + actual.some((value, index) => value !== expected[index]) + ) { + throw new Error( + `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, + ); + } +} + +const sharedCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_CAPABILITIES', +); +const desktopCapabilities = extractDesktopCapabilities(main); +const unknownDesktopCapabilities = desktopCapabilities.filter( + (capability) => !sharedCapabilities.includes(capability), +); +if (unknownDesktopCapabilities.length > 0) { + throw new Error( + `desktop shell declares unknown HostBridge capabilities: ${unknownDesktopCapabilities.join(', ')}`, + ); +} if (config.build?.frontendDist !== '../../../dist') { throw new Error('desktop shell must package the root H5 dist'); @@ -34,7 +104,6 @@ const requiredUrlParts = [ 'hostShell=tauri_desktop', 'hostPlatform=unknown', 'bridgeVersion=1', - 'hostCapabilities=host.getRuntime,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText', ]; for (const part of requiredUrlParts) { @@ -46,6 +115,17 @@ for (const part of requiredUrlParts) { } } +assertSameList( + resolveHostCapabilitiesFromUrl(mainWindow.url), + desktopCapabilities, + 'desktop shell main window hostCapabilities', +); +assertSameList( + resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''), + desktopCapabilities, + 'desktop shell dev hostCapabilities', +); + const requiredPermissions = [ 'core:default', 'allow-host-bridge-request', @@ -86,9 +166,6 @@ if (icon.length < 10000) { throw new Error('desktop shell icon must use a real brand asset'); } -const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); -const main = fs.readFileSync(mainPath, 'utf8'); - for (const snippet of requiredMainSnippets) { if (!main.includes(snippet)) { throw new Error(`desktop shell Rust host bridge missing ${snippet}`); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2981400b..ad7573ee 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -8,11 +8,68 @@ const appPath = new URL('../App.tsx', import.meta.url); const appSource = fs.readFileSync(appPath, 'utf8'); const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); const packagePath = new URL('../package.json', import.meta.url); const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const iconPath = new URL('../assets/icon.png', import.meta.url); const icon = PNG.sync.read(fs.readFileSync(iconPath)); +function extractStringArrayExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), + ); + if (!match) { + 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, + ]; + } + + return entries; +} + +const sharedCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_CAPABILITIES', +); +const mobileCapabilities = extractStringArrayExport( + bridgeSource, + 'MOBILE_HOST_CAPABILITIES', +); +const mobileCapabilitySet = new Set(mobileCapabilities); +const unknownMobileCapabilities = mobileCapabilities.filter( + (capability) => !sharedCapabilities.includes(capability), +); +if (unknownMobileCapabilities.length > 0) { + throw new Error( + `mobile shell declares unknown HostBridge capabilities: ${unknownMobileCapabilities.join(', ')}`, + ); +} + +for (const capability of mobileCapabilities) { + const switchCase = `case '${capability}':`; + if ( + capability !== 'host.events' && + capability !== 'navigation.canGoBack' && + !bridgeSource.includes(switchCase) + ) { + throw new Error( + `mobile shell declares ${capability} but does not handle it in HostBridge`, + ); + } +} + if (appConfig.scheme !== 'genarrative') { throw new Error('mobile shell scheme must be genarrative'); } @@ -68,3 +125,24 @@ for (const snippet of [ throw new Error(`mobile shell HostBridge missing ${snippet}`); } } + +const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES"; +if (!appSource.includes(capabilityQuerySnippet)) { + throw new Error('mobile shell URL must use MOBILE_HOST_CAPABILITIES'); +} + +for (const capability of [ + 'host.getRuntime', + 'share.open', + 'share.setTarget', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.openExternalUrl', + 'clipboard.writeText', + 'file.exportText', + 'haptics.impact', +]) { + if (!mobileCapabilitySet.has(capability)) { + throw new Error(`mobile shell capabilities missing ${capability}`); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2bc8a9be..4eca5163 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -25,6 +25,7 @@ - 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。 +- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;新增能力必须先更新契约和真实壳实现,再通过这些检查。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 64984b27..6d766e5f 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -215,6 +215,7 @@ GameBridge 禁止: - 壳层只接受来自允许 origin / packaged asset 的消息。 - 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。 +- 壳能力声明必须通过 `npm run mobile-shell:typecheck` / `npm run desktop-shell:typecheck` 校验:声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities` 和壳实现不得漂移。 - 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 - Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。 - RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 711f5369..33381e1c 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -58,6 +58,7 @@ AI H5 sandbox 2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 +5. 每次新增或调整 native capability 后,必须运行 `npm run mobile-shell:typecheck` 和 `npm run desktop-shell:typecheck`,确保共享白名单、壳 runtime 回包和 URL `hostCapabilities` 没有漂移。 ## 验收