diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00f604710..51656f104 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -30,6 +30,7 @@ - 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` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 由 `miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。 - 2026-06-19 宿主上下文 query 契约收口:`packages/shared/src/contracts/hostBridge.ts` 是宿主上下文 query 字段和值的唯一 TypeScript 来源;`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime parser 字段,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要保留的宿主字段。Expo 壳直接引用共享常量,Tauri Rust 和微信 CommonJS 镜像由 `npm run check:native-shells` / 单壳配置检查反查;微信请求头必须从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,不得另起常量。 +- 2026-06-19 H5 HostBridge 载荷边界收口:`src/services/host-bridge/hostBridge.ts` 作为 H5 facade 也必须直接导入 `HOST_BRIDGE_TEXT_MIME_TYPES`、`HOST_BRIDGE_DOCUMENT_MIME_TYPES`、`HOST_BRIDGE_IMAGE_MIME_TYPES` 和 `HOST_BRIDGE_AUDIO_MIME_TYPES`,只能从共享契约派生本地 Set 用于归一化,不得重新写 MIME 字面量清单;`npm run check:native-shells` 会拒绝 H5 facade 重新复制文本、图片或音频 MIME 边界。 - 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。 - 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。 - 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 20ebfa987..b6aa2b585 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -965,6 +965,52 @@ function assertH5HostBridgeEventSubscriptionGates() { } } +function assertH5HostBridgePayloadBoundaries() { + const h5HostBridgeSource = fs.readFileSync( + 'src/services/host-bridge/hostBridge.ts', + 'utf8', + ); + + for (const sharedBoundary of [ + 'HOST_BRIDGE_TEXT_MIME_TYPES', + 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', + 'HOST_BRIDGE_IMAGE_MIME_TYPES', + 'HOST_BRIDGE_AUDIO_MIME_TYPES', + ]) { + if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { + throw new Error( + `H5 HostBridge facade must import shared payload boundary ${sharedBoundary}`, + ); + } + if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) { + throw new Error( + `H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`, + ); + } + } + + for (const mimeLiteral of [ + "'text/plain'", + "'text/markdown'", + "'text/csv'", + "'application/json'", + "'image/png'", + "'image/jpeg'", + "'image/webp'", + "'audio/mpeg'", + "'audio/mp4'", + "'audio/wav'", + "'audio/ogg'", + "'audio/webm'", + ]) { + if (h5HostBridgeSource.includes(mimeLiteral)) { + throw new Error( + `H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`, + ); + } + } +} + function extractDocumentCapabilityList(source, marker) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { @@ -1613,6 +1659,9 @@ assertWechatMiniProgramCapabilityFlows(); console.log('[check:native-shells] h5-host-bridge-event-subscription-gates'); assertH5HostBridgeEventSubscriptionGates(); +console.log('[check:native-shells] h5-host-bridge-payload-boundaries'); +assertH5HostBridgePayloadBoundaries(); + console.log('[check:native-shells] production-shell-dev-scaffold-scan'); assertNoProductionShellDevScaffoldTerms(); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index bf6bf4b6d..23d3b9a98 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -13,13 +13,9 @@ import type { FileImportImageResult, FileImportTextResult, HapticsImpactPayload, - HostBridgeAudioMimeType, HostBridgeCapability, - HostBridgeDocumentMimeType, - HostBridgeImageMimeType, HostBridgeMethod, HostBridgeRuntimeResult, - HostBridgeTextMimeType, LocalNotificationPayload, NavigationCanGoBackEventPayload, NetworkStatusResult, @@ -29,9 +25,12 @@ import type { ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import { + HOST_BRIDGE_AUDIO_MIME_TYPES, HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_NATIVE_APP_QUERY, HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, + HOST_BRIDGE_TEXT_MIME_TYPES, HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, isHostBridgeCapability, normalizeHostBridgeBadgeCount, @@ -835,11 +834,7 @@ export async function exportHostAudioFile( } } -const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/webp', -]); +const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set(HOST_BRIDGE_IMAGE_MIME_TYPES); function normalizeHostImageImportResult( payload: FileImportImageResult | null | undefined, @@ -850,7 +845,7 @@ function normalizeHostImageImportResult( payload.action !== 'selected' && payload.action !== 'dropped' && payload.action !== 'captured' || - !HOST_BRIDGE_IMAGE_MIME_TYPES.has(payload.mimeType) || + !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(payload.mimeType) || typeof payload.fileName !== 'string' || !payload.fileName.trim() || typeof payload.base64Data !== 'string' || @@ -950,15 +945,8 @@ export async function scanHostQrCode() { } } -const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ - 'text/plain', - 'text/markdown', - 'text/csv', - 'application/json', -]); -const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set( - HOST_BRIDGE_DOCUMENT_MIME_TYPES, -); +const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set(HOST_BRIDGE_TEXT_MIME_TYPES); +const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES); function normalizeHostTextImportResult( payload: FileImportTextResult | null | undefined, @@ -967,7 +955,7 @@ function normalizeHostTextImportResult( !payload || typeof payload !== 'object' || payload.action !== 'selected' || - !HOST_BRIDGE_TEXT_MIME_TYPES.has(payload.mimeType) || + !HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(payload.mimeType) || typeof payload.fileName !== 'string' || !payload.fileName.trim() || typeof payload.content !== 'string' || @@ -1057,13 +1045,7 @@ export async function importHostDocumentFile() { } } -const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', -]); +const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set(HOST_BRIDGE_AUDIO_MIME_TYPES); function normalizeHostAudioImportResult( payload: FileImportAudioResult | null | undefined, @@ -1072,7 +1054,7 @@ function normalizeHostAudioImportResult( !payload || typeof payload !== 'object' || payload.action !== 'selected' || - !HOST_BRIDGE_AUDIO_MIME_TYPES.has(payload.mimeType) || + !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(payload.mimeType) || typeof payload.fileName !== 'string' || !payload.fileName.trim() || typeof payload.base64Data !== 'string' ||