diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index e7679ea05..9cfdae5c0 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -773,7 +773,11 @@ function collectRustSourceRelativePaths(files) { } function extractNativeAppTauriInvokeCommands(source) { - return [...source.matchAll(/\btauriInvoke[\s\S]*?\(\s*([^,\n]+?)\s*,/g)] + return [ + ...source.matchAll( + /\btauriInvoke(?:<[^)]*>)?\(\s*([A-Z_][A-Z0-9_]*)\s*,/g, + ), + ] .map((match) => match[1].trim()) .filter(Boolean); } @@ -1264,6 +1268,17 @@ assertSameList( if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) { throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND'); } +for (const snippet of [ + 'function createNativeHostBridgeTimeoutError()', + 'async function invokeTauriHostBridgeWithTimeout', + 'Promise.race', + 'tauriInvoke>(HOST_BRIDGE_TAURI_COMMAND', + "message: 'host_bridge_timeout'", +]) { + if (!nativeAppHostBridgeSource.includes(snippet)) { + throw new Error(`H5 native app Tauri transport missing ${snippet}`); + } +} assertSameList( capability.windows ?? [], diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8fbdc8283..b3971cf8a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2466,6 +2466,13 @@ - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts packages/shared/src/contracts/hostBridge.test.ts`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-18 H5 Tauri HostBridge 请求超时 + +- 背景:共享 HostBridge request 已包含 `timeoutMs`,React Native WebView transport 会在 H5 侧释放超时请求,但 Tauri transport 如果直接等待 `core.invoke`,Rust command 卡住时 H5 也会一直等待,违背“每个请求必须有超时”的壳层约束。 +- 决策:`src/services/host-bridge/nativeAppHostBridge.ts` 的 Tauri transport 必须通过前端侧超时封装调用 `HOST_BRIDGE_TAURI_COMMAND`,与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义。桌面宿主迟到返回时不能改写 H5 已拒绝的请求结果;`apps/desktop-shell/scripts/check-config.mjs` 锁定 Tauri transport 的超时封装,避免后续退回裸 `invoke`。 +- 影响范围:`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 原生壳关键依赖版本收口 - 背景:Expo / React Native WebView / Tauri / Cargo 插件版本会直接影响 WebView 安全默认值、managed config 解析、production bundle、Tauri capability、插件初始化和 release 构建行为;如果只改依赖声明,壳行为可能绕过 HostBridge 门禁和现有验收口径静默漂移。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index dbc84499c..85b3e43b3 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -239,7 +239,7 @@ GameBridge 禁止: - HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape;`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。 - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 -- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 +- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 - 能力按 `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 版本让生产壳行为静默漂移。 @@ -264,7 +264,7 @@ GameBridge 禁止: - 现有业务入口只通过 HostBridge 调用登录、支付、分享、原生页跳转。 - 增加 H5 单测覆盖:支持、超时、不支持、错误回包、浏览器 fallback。 -当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。 +当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。两条 transport 都会按 `timeoutMs` 在 H5 侧释放请求,超时统一抛出 `timeout / host_bridge_timeout`。登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。 ### Phase 2:Expo 移动壳 MVP @@ -394,6 +394,8 @@ GameBridge 禁止: 2026-06-18 追加:H5 到 Tauri 的 command 名进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_TAURI_COMMAND='host_bridge_request'`,`nativeAppHostBridge` 只能通过该常量调用 Tauri 注入的 `core.invoke`;桌面壳配置检查会对齐共享常量、Tauri build manifest、Rust `generate_handler!` 和 H5 transport,禁止 H5 侧写死或调用其它 Tauri command。 +2026-06-18 追加:H5 到 Tauri 的 `core.invoke` 也必须经过 `nativeAppHostBridge` 的超时封装,不能裸等 Rust command Promise。Tauri transport 与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义;如果桌面宿主迟到返回,H5 侧已拒绝的请求结果不得被改写。`apps/desktop-shell/scripts/check-config.mjs` 会锁定 H5 Tauri transport 的超时封装,避免后续退回裸 `invoke`。 + 2026-06-18 追加:原生壳注入消息来源进入门禁。Expo 和 Tauri 注入给 H5 的 HostBridge response / event 都显式带 `origin: window.location.origin` 和 `source: window`;H5 `nativeAppHostBridge` listener 会忽略带非当前窗口 source 或非当前页面 origin 的 message。这样后续 AI sandbox iframe 即使能向父页面 `postMessage` 同形 envelope,也不能结算宿主请求或伪造宿主事件;GameBridge 继续走单独 allowlist。 2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。 diff --git a/src/services/host-bridge/nativeAppHostBridge.test.ts b/src/services/host-bridge/nativeAppHostBridge.test.ts index faabe5725..36b63a2a3 100644 --- a/src/services/host-bridge/nativeAppHostBridge.test.ts +++ b/src/services/host-bridge/nativeAppHostBridge.test.ts @@ -119,6 +119,110 @@ describe('nativeAppHostBridge', () => { }); }); + test('Tauri invoke 超时时按 HostBridge 超时错误拒绝请求', async () => { + vi.useFakeTimers(); + const invoke = vi.fn( + () => + new Promise(() => { + // 保持宿主命令挂起,让 H5 超时先返回。 + }), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + const pending = requestNativeAppHostBridge('share.open', undefined, { + timeoutMs: 10, + }); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'timeout', + message: 'host_bridge_timeout', + }); + await vi.advanceTimersByTimeAsync(11); + + await assertion; + expect(vi.getTimerCount()).toBe(0); + }); + + test('Tauri invoke 成功回包后清理超时计时器', async () => { + vi.useFakeTimers(); + const invoke = vi.fn(async (_command: string, args?: Record) => { + const request = (args as { request: HostBridgeRequest }).request; + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: true, + result: { + handled: true, + }, + } satisfies HostBridgeResponse; + }); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect( + requestNativeAppHostBridge('share.open', undefined, { + timeoutMs: 1000, + }), + ).resolves.toEqual({ + handled: true, + }); + + expect(vi.getTimerCount()).toBe(0); + }); + + test('Tauri invoke 超时后的迟到回包不改变已拒绝结果', async () => { + vi.useFakeTimers(); + let resolveInvoke: + | ((response: HostBridgeResponse<{ handled: boolean }>) => void) + | null = null; + const invoke = vi.fn( + () => + new Promise>((resolve) => { + resolveInvoke = resolve; + }), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + const pending = requestNativeAppHostBridge('share.open', undefined, { + timeoutMs: 10, + }); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'timeout', + message: 'host_bridge_timeout', + }); + await vi.advanceTimersByTimeAsync(11); + await assertion; + + const request = (invoke.mock.calls[0]?.[1] as { request: HostBridgeRequest }) + .request; + resolveInvoke?.({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: true, + result: { + handled: true, + }, + }); + await Promise.resolve(); + + await expect(pending).rejects.toMatchObject({ + name: 'timeout', + message: 'host_bridge_timeout', + }); + }); + test('没有原生宿主 bridge 时返回 null', async () => { await expect( requestNativeAppHostBridge('host.getRuntime'), diff --git a/src/services/host-bridge/nativeAppHostBridge.ts b/src/services/host-bridge/nativeAppHostBridge.ts index dfcd145d8..96d4031a4 100644 --- a/src/services/host-bridge/nativeAppHostBridge.ts +++ b/src/services/host-bridge/nativeAppHostBridge.ts @@ -32,6 +32,11 @@ type PendingNativeRequest = { timeoutId: ReturnType; }; +type NativeTauriInvoke = ( + command: string, + args?: Record, +) => Promise; + const pendingNativeRequests = new Map(); const nativeEventListeners = new Map void>>(); @@ -66,6 +71,13 @@ function createHostBridgeError(error: HostBridgeError) { return result; } +function createNativeHostBridgeTimeoutError() { + return createHostBridgeError({ + code: 'timeout', + message: 'host_bridge_timeout', + }); +} + function isHostBridgeResponse(value: unknown): value is HostBridgeResponse { if (!value || typeof value !== 'object') { return false; @@ -174,6 +186,32 @@ function ensureNativeBridgeListener() { nativeBridgeListenerInstalled = true; } +async function invokeTauriHostBridgeWithTimeout( + tauriInvoke: NativeTauriInvoke, + request: HostBridgeRequest, + timeoutMs: number, +) { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(createNativeHostBridgeTimeoutError()); + }, timeoutMs); + }); + + try { + return await Promise.race([ + tauriInvoke>(HOST_BRIDGE_TAURI_COMMAND, { + request, + }), + timeoutPromise, + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + export function canUseReactNativeHostBridge() { return ( typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage === @@ -233,9 +271,10 @@ export async function requestNativeAppHostBridge( const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke; if (typeof tauriInvoke === 'function') { - const response = await tauriInvoke>( - HOST_BRIDGE_TAURI_COMMAND, - { request }, + const response = await invokeTauriHostBridgeWithTimeout( + tauriInvoke, + request, + timeoutMs, ); if (!isHostBridgeResponse(response)) { throw new Error('host_bridge_invalid_response'); @@ -256,10 +295,7 @@ export async function requestNativeAppHostBridge( return await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { pendingNativeRequests.delete(request.id); - reject(createHostBridgeError({ - code: 'timeout', - message: 'host_bridge_timeout', - })); + reject(createNativeHostBridgeTimeoutError()); }, timeoutMs); pendingNativeRequests.set(request.id, {