From fb8c9c2880cc9072ffa150993eedc2699016e9eb Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 13:48:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=AE=BF=E4=B8=BB=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BA=A4=E4=BA=92=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将宿主用户交互长操作超时提升到共享 HostBridge 契约 让 H5 HostBridge facade 复用共享用户交互超时 增加原生壳门禁拦截 H5 facade 手写交互超时 补充 HostBridge 测试、壳方案和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 6 ++++ .../shared-memory/decision-log.md | 7 +++++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 ++- .../shared/src/contracts/hostBridge.test.ts | 2 ++ packages/shared/src/contracts/hostBridge.ts | 1 + scripts/check-native-shells.mjs | 6 ++++ src/services/host-bridge/hostBridge.test.ts | 29 ++++++++++--------- src/services/host-bridge/hostBridge.ts | 17 ++++++----- 8 files changed, 50 insertions(+), 22 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b0d943224..6c0f0164b 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1792,9 +1792,15 @@ if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) { if (!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,')) { throw new Error('H5 HostBridge facade must import shared runtime refresh timeout'); } +if (!h5HostBridgeSource.includes('HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,')) { + throw new Error('H5 HostBridge facade must import shared user interaction timeout'); +} if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) { throw new Error('H5 HostBridge facade must not redeclare runtime refresh timeout'); } +if (h5HostBridgeSource.includes('timeoutMs: 30000')) { + throw new Error('H5 HostBridge facade must not redeclare user interaction timeout'); +} for (const snippet of [ 'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS', 'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2cd969d96..e512b5d99 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2531,6 +2531,13 @@ - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、`scripts/check-native-shells.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 原生宿主用户交互超时单一来源 + +- 背景:文件导入 / 导出、图片选择 / 拍摄等 H5 HostBridge facade 请求需要等待系统面板或用户选择,不能使用普通短请求默认超时;如果每个调用点手写 `timeoutMs: 30000`,后续调整壳层交互超时时容易遗漏。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS`,作为 H5 facade 发起文件导入 / 导出、图片选择 / 拍摄等用户交互型 HostBridge 请求的长超时唯一来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得继续手写 `timeoutMs: 30000`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地字面量。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、`scripts/check-native-shells.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.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 61f62cfad..fbfbb6fcb 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -259,7 +259,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。 -- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,默认请求超时和最大请求超时以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS` 为唯一来源,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果,已完成响应缓存上限以共享契约 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 为唯一来源。 +- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,默认请求超时、最大请求超时和用户交互长操作超时以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS` / `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS` 为唯一来源,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果,已完成响应缓存上限以共享契约 `HOST_BRIDGE_RESPONSE_CACHE_MAX` 为唯一来源。 - HostBridge 的 capability profile、宿主上下文 query 字段和值、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,微信小程序壳和 Tauri 壳分别保留小程序 CommonJS / Rust 运行时代码镜像并由测试和配置门禁反查共享契约。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景,该回读请求的短超时以共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 为唯一来源。不能只凭 `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 profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、微信小程序页面路由、WebView source query、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段、微信壳 H5 / API HTTPS 域名格式、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。微信小程序壳不使用 Expo / Tauri 式统一 request dispatcher,但每个声明 capability 都必须在根级门禁中映射到真实流程文件、关键 `wx.*` 或页面工厂调用和对应测试清单。 @@ -453,6 +453,8 @@ GameBridge 禁止: 2026-06-19 追加:H5 原生宿主 runtime 回读短超时进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS=3000`,`src/services/host-bridge/hostBridge.ts` 在 `refreshNativeAppHostRuntime()` / `getNativeAppHostRuntime()` 请求 `host.getRuntime` 时必须使用该常量;根级原生壳门禁和桌面壳配置检查会拒绝 H5 facade 重新声明 `HOST_RUNTIME_REFRESH_TIMEOUT_MS`。 +2026-06-19 追加:H5 用户交互型宿主请求长超时进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS=30000`,文件导入 / 导出、图片选择 / 拍摄等需要等待系统面板或用户选择的 HostBridge facade 请求必须使用该常量;根级原生壳门禁和桌面壳配置检查会拒绝 H5 facade 重新写入 `timeoutMs: 30000`。 + 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-19 追加:H5 页面内应用导航会保留完整原生宿主上下文。`pushAppHistoryPath()` 和 `replaceAppHistoryPath()` 必须通过共享 `HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 补齐 `clientType`、`clientRuntime`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`,并用应用 history state 标记 H5 自己写入的导航条目。这样直达二级页补返回锚点、平台内页面切换和原生壳 runtime 能力刷新不会因为 H5 自己跳转而掉回普通浏览器运行态。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 39bb41fe2..9469e69e7 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -24,6 +24,7 @@ import { HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_TEXT_MIME_TYPES, + HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, isHostBridgeMethod, isHostBridgeCapability, isHostBridgeEventName, @@ -54,6 +55,7 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS).toBe(8000); expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000); expect(HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS).toBe(3000); + expect(HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS).toBe(30000); }); test('固定宿主侧响应回放缓存边界', () => { diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 4bc463be6..dbf89ab72 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -219,6 +219,7 @@ export type HostBridgeRequest = { export const HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS = 8000; export const HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS = 60000; export const HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS = 3000; +export const HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS = 30000; export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index f7f3ca41e..51ed08453 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -977,6 +977,7 @@ function assertH5HostBridgePayloadBoundaries() { 'HOST_BRIDGE_IMAGE_MIME_TYPES', 'HOST_BRIDGE_AUDIO_MIME_TYPES', 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', + 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', ]) { if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { throw new Error( @@ -1016,6 +1017,11 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', ); } + if (h5HostBridgeSource.includes('timeoutMs: 30000')) { + throw new Error( + 'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', + ); + } } function assertH5NativeAppTransportTimeoutBoundaries() { diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 9210648ed..3dd552b1c 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -3,7 +3,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge'; -import { HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS } from '../../../packages/shared/src/contracts/hostBridge'; +import { + HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, + HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, +} from '../../../packages/shared/src/contracts/hostBridge'; import { canUseHostShareGrid, canUseNativeHostCapability, @@ -1194,13 +1197,13 @@ describe('hostBridge', () => { base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }, - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importImage', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { @@ -1212,19 +1215,19 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importText', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importDocument', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importAudio', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { @@ -1427,7 +1430,7 @@ describe('hostBridge', () => { fileName: '作品记录.txt', content: 'content', }, - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1514,7 +1517,7 @@ describe('hostBridge', () => { base64Data: 'c2hhcmUtY2FyZA==', mimeType: 'image/png', }, - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1595,7 +1598,7 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importImage', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1641,7 +1644,7 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importText', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1689,7 +1692,7 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importDocument', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1735,7 +1738,7 @@ describe('hostBridge', () => { expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.importAudio', - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); @@ -1788,7 +1791,7 @@ describe('hostBridge', () => { base64Data: 'YXVkaW8=', mimeType: 'audio/wav', }, - timeoutMs: 30000, + timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, }), }); }); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index c05d4fda9..a15fd2f49 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -32,6 +32,7 @@ import { HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, HOST_BRIDGE_TEXT_MIME_TYPES, + HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, isHostBridgeCapability, normalizeHostBridgeBadgeCount, @@ -781,7 +782,7 @@ export async function exportHostTextFile( return await requestNativeAppHostBridge( 'file.exportText', params, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ); } catch (error) { if (isUnsupportedHostBridgeError(error)) { @@ -802,7 +803,7 @@ export async function exportHostImageFile( return await requestNativeAppHostBridge( 'file.exportImage', params, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ); } catch (error) { if (isUnsupportedHostBridgeError(error)) { @@ -823,7 +824,7 @@ export async function exportHostAudioFile( return await requestNativeAppHostBridge( 'file.exportAudio', params, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ); } catch (error) { if (isUnsupportedHostBridgeError(error)) { @@ -888,7 +889,7 @@ export async function importHostImageFile() { await requestNativeAppHostBridge( 'file.importImage', undefined, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), ); } catch (error) { @@ -909,7 +910,7 @@ export async function captureHostImageFile() { await requestNativeAppHostBridge( 'file.captureImage', undefined, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), ); } catch (error) { @@ -984,7 +985,7 @@ export async function importHostTextFile() { await requestNativeAppHostBridge( 'file.importText', undefined, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), ); } catch (error) { @@ -1033,7 +1034,7 @@ export async function importHostDocumentFile() { await requestNativeAppHostBridge( 'file.importDocument', undefined, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), ); } catch (error) { @@ -1084,7 +1085,7 @@ export async function importHostAudioFile() { await requestNativeAppHostBridge( 'file.importAudio', undefined, - { timeoutMs: 30000 }, + { timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS }, ), ); } catch (error) {