收口宿主用户交互超时

将宿主用户交互长操作超时提升到共享 HostBridge 契约

让 H5 HostBridge facade 复用共享用户交互超时

增加原生壳门禁拦截 H5 facade 手写交互超时

补充 HostBridge 测试、壳方案和共享决策记录
This commit is contained in:
2026-06-19 13:48:54 +08:00
parent ca9581e534
commit fb8c9c2880
8 changed files with 50 additions and 22 deletions
@@ -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',
@@ -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 门禁和现有验收口径静默漂移。
@@ -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 自己跳转而掉回普通浏览器运行态。
@@ -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('固定宿主侧响应回放缓存边界', () => {
@@ -219,6 +219,7 @@ export type HostBridgeRequest<Payload = unknown> = {
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;
+6
View File
@@ -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() {
+16 -13
View File
@@ -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,
}),
});
});
+9 -8
View File
@@ -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<FileExportTextResult>(
'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<FileExportImageResult>(
'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<FileExportAudioResult>(
'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<FileImportImageResult>(
'file.importImage',
undefined,
{ timeoutMs: 30000 },
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
} catch (error) {
@@ -909,7 +910,7 @@ export async function captureHostImageFile() {
await requestNativeAppHostBridge<FileImportImageResult>(
'file.captureImage',
undefined,
{ timeoutMs: 30000 },
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
} catch (error) {
@@ -984,7 +985,7 @@ export async function importHostTextFile() {
await requestNativeAppHostBridge<FileImportTextResult>(
'file.importText',
undefined,
{ timeoutMs: 30000 },
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
} catch (error) {
@@ -1033,7 +1034,7 @@ export async function importHostDocumentFile() {
await requestNativeAppHostBridge<FileImportDocumentResult>(
'file.importDocument',
undefined,
{ timeoutMs: 30000 },
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
} catch (error) {
@@ -1084,7 +1085,7 @@ export async function importHostAudioFile() {
await requestNativeAppHostBridge<FileImportAudioResult>(
'file.importAudio',
undefined,
{ timeoutMs: 30000 },
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
),
);
} catch (error) {