From bb85986d1fdfa2b4633ff1be6d72f331ecadee0c Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 00:36:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7=E8=BE=B9=E7=95=8C=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳文件能力直接导入共享 HostBridge MIME 和大小边界 移动壳配置检查禁止重新本地声明文件载荷边界 更新原生壳方案和共享决策记录 --- apps/mobile-shell/scripts/check-config.mjs | 157 +++++------------- apps/mobile-shell/src/host-bridge/files.ts | 76 ++++----- .../shared-memory/decision-log.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- 4 files changed, 74 insertions(+), 165 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2dc3694e3..0e82faf96 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -182,52 +182,6 @@ function extractNumberConstExport(source, exportName) { return Number(match[1]); } -function evaluateNumberExpression(expression) { - const tokens = expression - .split('*') - .map((token) => token.trim()) - .filter(Boolean); - if ( - tokens.length === 0 || - tokens.some((token) => !/^\d+$/.test(token)) - ) { - throw new Error(`unsupported numeric expression ${expression}`); - } - - return tokens.reduce((value, token) => value * Number(token), 1); -} - -function extractNumberExpressionConstExport(source, exportName) { - const match = source.match( - new RegExp(`export const ${exportName}\\s*=\\s*([^;]+);`), - ); - if (!match) { - throw new Error(`unable to read ${exportName}`); - } - - return evaluateNumberExpression(match[1]); -} - -function extractLocalNumberConst(source, constName) { - const match = source.match(new RegExp(`const ${constName}\\s*=\\s*([^;]+);`)); - if (!match) { - throw new Error(`unable to read local const ${constName}`); - } - - return evaluateNumberExpression(match[1]); -} - -function extractStringSetConst(source, constName) { - const match = source.match( - new RegExp(`const ${constName}[^=]*= new Set[^\\[]*\\[([\\s\\S]*?)\\]\\);`), - ); - if (!match) { - throw new Error(`unable to read string Set ${constName}`); - } - - return [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]); -} - function extractMobileBridgeHandledMethods(source) { const match = source.match( /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, @@ -533,58 +487,6 @@ const sharedHostBridgeVersion = extractNumberConstExport( sharedContractSource, 'HOST_BRIDGE_VERSION', ); -const sharedHostBridgePayloadLimits = { - HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', - ), - HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', - ), - HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport( - sharedContractSource, - 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', - ), -}; -const mobileHostBridgePayloadLimits = { - HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'EXPORT_TEXT_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'IMPORT_TEXT_MAX_BYTES', - ), - HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'EXPORT_IMAGE_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'IMPORT_IMAGE_MAX_BYTES', - ), - HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'EXPORT_AUDIO_MAX_BYTES', - ), - HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractLocalNumberConst( - hostBridgeSource, - 'IMPORT_AUDIO_MAX_BYTES', - ), -}; const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); const mobileCapabilities = extractStringArrayExport( hostBridgeSource, @@ -597,32 +499,49 @@ const iosMobileCapabilities = extractStringArrayExport( const mobileCapabilitySet = new Set(mobileCapabilities); const iosMobileCapabilitySet = new Set(iosMobileCapabilities); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; -for (const [limitName, sharedLimit] of Object.entries( - sharedHostBridgePayloadLimits, -)) { - const mobileLimit = mobileHostBridgePayloadLimits[limitName]; - if (mobileLimit !== sharedLimit) { +const sharedPayloadBoundaryImports = [ + 'HOST_BRIDGE_AUDIO_MIME_TYPES', + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMAGE_MIME_TYPES', + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_TEXT_MIME_TYPES', +]; +for (const boundaryImport of sharedPayloadBoundaryImports) { + if (!hostBridgeSource.includes(boundaryImport)) { throw new Error( - `mobile shell ${limitName} drifted: expected ${sharedLimit} but got ${mobileLimit}`, + `mobile shell must import shared HostBridge payload boundary ${boundaryImport}`, ); } } -assertSameList( - extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'), - extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'), - 'mobile shell text MIME types', -); -assertSameList( - extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'), - extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'), - 'mobile shell image MIME types', -); -assertSameList( - extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'), - extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'), - 'mobile shell audio MIME types', -); +const forbiddenLocalPayloadBoundaryDeclarations = [ + 'EXPORT_TEXT_MAX_BYTES', + 'IMPORT_TEXT_MAX_BYTES', + 'EXPORT_IMAGE_MAX_BYTES', + 'IMPORT_IMAGE_MAX_BYTES', + 'EXPORT_AUDIO_MAX_BYTES', + 'IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_TEXT_MIME_TYPES', + 'HOST_BRIDGE_IMAGE_MIME_TYPES', + 'HOST_BRIDGE_AUDIO_MIME_TYPES', +]; +for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) { + if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) { + throw new Error( + `mobile shell must not redeclare HostBridge payload boundary ${localBoundary}`, + ); + } +} const unknownHandledMobileMethods = handledMobileMethods.filter( (method) => !sharedMethods.includes(method), diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index ae65ff5f6..e2bf626e8 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -17,41 +17,31 @@ import { type HostBridgeError, type HostBridgeImageMimeType, type HostBridgeTextMimeType, + HOST_BRIDGE_AUDIO_MIME_TYPES, + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES, + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES, + HOST_BRIDGE_IMAGE_MIME_TYPES, + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + HOST_BRIDGE_TEXT_MIME_TYPES, normalizeHostBridgeExportFileName, } from '../../../../packages/shared/src/contracts/hostBridge'; import { invalidRequest } from './protocol'; -const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; -const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; -const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; -const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; -const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; -const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; -const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ - 'text/plain', - 'text/markdown', - 'text/csv', - 'application/json', -]); -const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/webp', -]); -const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', -]); +const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set( + HOST_BRIDGE_TEXT_MIME_TYPES, +); +const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( + HOST_BRIDGE_IMAGE_MIME_TYPES, +); +const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set( + HOST_BRIDGE_AUDIO_MIME_TYPES, +); export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ 'audio/*', - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', + ...HOST_BRIDGE_AUDIO_MIME_TYPES, ]; const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; @@ -247,7 +237,7 @@ export async function exportTextFile( } const bytes = utf8ByteLength(content); - if (bytes > EXPORT_TEXT_MAX_BYTES) { + if (bytes > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES) { throw invalidRequest('content exceeds file export size limit'); } @@ -282,7 +272,7 @@ function normalizeImportedTextMimeType( ): HostBridgeTextMimeType | null { if (typeof value === 'string') { const mimeType = value.toLowerCase(); - if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) { + if (HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) { return mimeType as HostBridgeTextMimeType; } } @@ -331,7 +321,7 @@ export async function importTextFile(): Promise { } if ( typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES) + (asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) ) { throw invalidRequest('text exceeds file import size limit'); } @@ -339,7 +329,7 @@ export async function importTextFile(): Promise { const file = new File(asset.uri); const content = await file.text(); const bytes = utf8ByteLength(content); - if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) { + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) { throw invalidRequest('text exceeds file import size limit'); } @@ -359,7 +349,7 @@ export async function exportImageFile( const mimeType = exportPayload?.mimeType; if ( typeof mimeType !== 'string' || - !HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) + !HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType) ) { throw invalidRequest('mimeType must be an allowed image type'); } @@ -369,7 +359,7 @@ export async function exportImageFile( throw invalidRequest('base64Data is required'); } const bytes = base64DecodedByteLength(base64Data); - if (bytes > EXPORT_IMAGE_MAX_BYTES) { + if (bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file export size limit'); } ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType); @@ -409,7 +399,7 @@ function normalizeImportedImageMimeType( } const mimeType = value.toLowerCase(); - return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) + return HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType as HostBridgeImageMimeType) ? (mimeType as HostBridgeImageMimeType) : null; } @@ -451,13 +441,13 @@ function imagePickerResultToImportPayload( } const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) { + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { throw invalidRequest('image exceeds file import size limit'); } ensureImageBytesMatchMimeType(base64Data, mimeType); if ( typeof asset.fileSize === 'number' && - asset.fileSize > IMPORT_IMAGE_MAX_BYTES + asset.fileSize > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES ) { throw invalidRequest('image exceeds file import size limit'); } @@ -547,7 +537,7 @@ function normalizeImportedAudioMimeType( ): HostBridgeAudioMimeType | null { if (typeof value === 'string') { const mimeType = value.toLowerCase(); - if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) { + if (HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType)) { return mimeType as HostBridgeAudioMimeType; } } @@ -579,7 +569,7 @@ export async function exportAudioFile( const mimeType = exportPayload?.mimeType; if ( typeof mimeType !== 'string' || - !HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType) + !HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType as HostBridgeAudioMimeType) ) { throw invalidRequest('mimeType must be an allowed audio type'); } @@ -589,7 +579,7 @@ export async function exportAudioFile( throw invalidRequest('base64Data is required'); } const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) { + if (bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES) { throw invalidRequest('audio exceeds file export size limit'); } ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType); @@ -648,7 +638,7 @@ export async function importAudioFile(): Promise { } if ( typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES) + (asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) ) { throw invalidRequest('audio exceeds file import size limit'); } @@ -659,7 +649,7 @@ export async function importAudioFile(): Promise { throw invalidRequest('base64Data is required'); } const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) { + if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES) { throw invalidRequest('audio exceeds file import size limit'); } ensureAudioBytesMatchMimeType(base64Data, mimeType); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4909fc743..4b1cb8048 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2501,7 +2501,7 @@ ## 2026-06-19 HostBridge 载荷边界单一来源 - 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 -- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳与 Tauri 桌面壳可以按各自宿主语言镜像实现,但 `apps/mobile-shell/scripts/check-config.mjs` 与 `apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 87cca0c9f..070a17a17 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -241,7 +241,7 @@ GameBridge 禁止: - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 - 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 -- HostBridge 的文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 与 Tauri 壳可以按宿主语言镜像实现,但必须由配置门禁反查共享契约。 +- HostBridge 的文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享契约常量,Tauri 壳按 Rust 运行时代码镜像并由配置门禁反查共享契约。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。 - 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。 - Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。 @@ -454,7 +454,7 @@ GameBridge 禁止: 2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`files.ts`、`share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 -2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳与 Tauri 桌面壳的配置检查会反查各自实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。 +2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳必须直接导入这些共享常量,不再本地重声明文件大小或 MIME 清单,Tauri 桌面壳的配置检查会反查 Rust 镜像实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。 ### Phase 4:宿主能力扩展