收口宿主用户交互超时
将宿主用户交互长操作超时提升到共享 HostBridge 契约 让 H5 HostBridge facade 复用共享用户交互超时 增加原生壳门禁拦截 H5 facade 手写交互超时 补充 HostBridge 测试、壳方案和共享决策记录
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user