diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 2024728bb..76fad6a59 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -645,7 +645,7 @@ function extractStringArrayExport(source, exportName, seen = new Set()) { } const match = source.match( - new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`), ); if (!match) { throw new Error(`unable to read ${exportName}`); @@ -690,14 +690,14 @@ function extractRustStringConst(source, constName) { function extractRustNumberConst(source, constName) { const match = source.match( new RegExp( - `(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|usize|i64|i32)\\s*=\\s*(\\d+);`, + `(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|u64|usize|i64|i32)\\s*=\\s*([^;]+);`, ), ); if (!match) { throw new Error(`unable to read Rust const ${constName}`); } - return Number(match[1]); + return evaluateNumberExpression(match[1]); } function extractTsStringConst(source, constName) { @@ -713,13 +713,54 @@ function extractTsStringConst(source, constName) { function extractTsNumberConst(source, constName) { const match = source.match( - new RegExp(`export const ${constName}\\s*=\\s*(\\d+);`), + new RegExp(`export const ${constName}\\s*=\\s*([^;]+);`), ); if (!match) { throw new Error(`unable to read TypeScript const ${constName}`); } - return Number(match[1]); + return evaluateNumberExpression(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 extractRustStringMatchArms(source, functionName) { + const match = source.match( + new RegExp( + `(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{[\\s\\S]*?match [^\\{]*\\{([\\s\\S]*?)\\n \\}`, + ), + ); + if (!match) { + throw new Error(`unable to read Rust match function ${functionName}`); + } + + return [...match[1].matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]); +} + +function extractRustSomeStringValues(source, functionName) { + const match = source.match( + new RegExp(`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{([\\s\\S]*?)\\n\\}`), + ); + if (!match) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + return [...match[1].matchAll(/=>\s*Some\("([^"]+)"\)/g)].map( + (entry) => entry[1], + ); } function extractDesktopCapabilities(source) { @@ -891,6 +932,106 @@ const sharedHostBridgeVersion = extractTsNumberConst( sharedContractSource, 'HOST_BRIDGE_VERSION', ); +const sharedHostBridgePayloadLimits = { + HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + ), + HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_BADGE_COUNT_MAX', + ), + HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH', + ), + HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_FILE_NAME_MAX_LENGTH', + ), + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', + ), +}; +const desktopHostBridgePayloadLimits = { + HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + ), + HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst( + rustHostSource, + 'BADGE_COUNT_MAX', + ), + HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'CLIPBOARD_TEXT_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'LOCAL_NOTIFICATION_TITLE_MAX_LENGTH', + ), + HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'LOCAL_NOTIFICATION_BODY_MAX_LENGTH', + ), + HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractRustNumberConst( + rustHostSource, + 'EXPORT_FILE_NAME_MAX_LENGTH', + ), + HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_TEXT_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_IMAGE_MAX_BYTES', + ), + HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'EXPORT_AUDIO_MAX_BYTES', + ), + HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractRustNumberConst( + rustHostSource, + 'IMPORT_AUDIO_MAX_BYTES', + ), +}; const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); const desktopHostBridgeProtocol = extractRustStringConst( rustHostSource, @@ -919,6 +1060,62 @@ if (desktopHostBridgeVersion !== sharedHostBridgeVersion) { ); } +for (const [limitName, sharedLimit] of Object.entries( + sharedHostBridgePayloadLimits, +)) { + const desktopLimit = desktopHostBridgePayloadLimits[limitName]; + if (desktopLimit !== sharedLimit) { + throw new Error( + `desktop shell ${limitName} drifted: expected ${sharedLimit} but got ${desktopLimit}`, + ); + } +} + +if ( + extractRustStringConst(rustHostSource, 'EXPORT_FILE_NAME_FALLBACK') !== + extractTsStringConst(sharedContractSource, 'HOST_BRIDGE_FILE_NAME_FALLBACK') +) { + throw new Error('desktop shell file name fallback drifted from shared HostBridge contract'); +} + +const sharedTextMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_TEXT_MIME_TYPES', +); +const sharedImageMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_IMAGE_MIME_TYPES', +); +const sharedAudioMimeTypes = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_AUDIO_MIME_TYPES', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_text_mime_type'), + sharedTextMimeTypes, + 'desktop shell text MIME types', +); +assertSameList( + extractRustStringMatchArms(rustHostSource, 'export_image_extension'), + sharedImageMimeTypes, + 'desktop shell export image MIME types', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_image_mime_type'), + sharedImageMimeTypes, + 'desktop shell import image MIME types', +); +assertSameList( + extractRustStringMatchArms(rustHostSource, 'export_audio_extension'), + sharedAudioMimeTypes, + 'desktop shell export audio MIME types', +); +assertSameList( + extractRustSomeStringValues(rustHostSource, 'import_audio_mime_type'), + sharedAudioMimeTypes, + 'desktop shell import audio MIME types', +); + assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); const unknownHandledDesktopMethods = desktopHandledMethods.filter( (method) => !sharedMethods.includes(method), diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index b4d0b35f5..2dc3694e3 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -140,7 +140,7 @@ function extractStringArrayExport(source, exportName, seen = new Set()) { } const match = source.match( - new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), + new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`), ); if (!match) { throw new Error(`unable to read ${exportName}`); @@ -182,6 +182,52 @@ 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 \}/, @@ -487,6 +533,58 @@ 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, @@ -499,6 +597,33 @@ 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) { + throw new Error( + `mobile shell ${limitName} drifted: expected ${sharedLimit} but got ${mobileLimit}`, + ); + } +} + +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 unknownHandledMobileMethods = handledMobileMethods.filter( (method) => !sharedMethods.includes(method), ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3e545c689..4909fc743 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2498,6 +2498,13 @@ - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 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`、`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`。 + ## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入 - 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 5240926a2..87cca0c9f 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -241,8 +241,9 @@ 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 壳可以按宿主语言镜像实现,但必须由配置门禁反查共享契约。 - 能力按 `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 三端生产源码临时替身词扫描不得漂移。 +- 壳能力声明与三端壳验收必须通过 `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 版本让生产壳行为静默漂移。 - 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin` 和 `payment.request` 的伪声明。 - 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 @@ -453,6 +454,8 @@ 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。 + ### Phase 4:宿主能力扩展 - 移动端接入系统分享、推送、原生登录和渠道支付。 diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index b4f657e4b..1adb65a80 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -329,6 +329,13 @@ export type HostBridgeTextMimeType = | 'text/csv' | 'application/json'; +export const HOST_BRIDGE_TEXT_MIME_TYPES = [ + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', +] as const satisfies readonly HostBridgeTextMimeType[]; + export type FileImportTextResult = { action: 'selected'; fileName: string; @@ -354,6 +361,12 @@ export type HostBridgeImageMimeType = | 'image/jpeg' | 'image/webp'; +export const HOST_BRIDGE_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', +] as const satisfies readonly HostBridgeImageMimeType[]; + export type FileImportImageResult = { action: 'selected' | 'dropped' | 'captured'; fileName: string; @@ -373,6 +386,14 @@ export type HostBridgeAudioMimeType = | 'audio/ogg' | 'audio/webm'; +export const HOST_BRIDGE_AUDIO_MIME_TYPES = [ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +] as const satisfies readonly HostBridgeAudioMimeType[]; + export type FileImportAudioResult = { action: 'selected'; fileName: string; @@ -393,6 +414,13 @@ export type FileExportAudioResult = { bytes: number; }; +export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; +export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; + export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [ 'light', 'medium', @@ -486,8 +514,8 @@ export type ShareOpenPayload = { url?: string; }; -const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; -const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; +export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; +export const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; function isHostBridgeInvalidFileNameCharacter(value: string) { if (hasHostBridgeControlCharacter(value)) {