收口宿主运行态回读超时

将原生宿主 runtime 回读短超时提升到共享 HostBridge 契约

让 H5 HostBridge facade 复用共享 runtime 回读超时

增加原生壳门禁拦截本地 runtime 回读超时重声明

补充 runtime 回读测试、壳方案和共享决策记录
This commit is contained in:
2026-06-19 13:41:54 +08:00
parent ef2200f8ac
commit ca9581e534
8 changed files with 43 additions and 6 deletions
@@ -36,6 +36,11 @@ const nativeAppHostBridgePath = new URL(
import.meta.url,
);
const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8');
const h5HostBridgePath = new URL(
'../../../src/services/host-bridge/hostBridge.ts',
import.meta.url,
);
const h5HostBridgeSource = fs.readFileSync(h5HostBridgePath, 'utf8');
const appPath = new URL('../src-tauri/src/app.rs', import.meta.url);
const app = fs.readFileSync(appPath, 'utf8');
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
@@ -1784,6 +1789,12 @@ assertSameList(
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
}
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_RUNTIME_REFRESH_TIMEOUT_MS')) {
throw new Error('H5 HostBridge facade must not redeclare runtime refresh timeout');
}
for (const snippet of [
'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS',
'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS',
@@ -27,7 +27,7 @@
- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。
- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES``miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。
- 2026-06-19 宿主上下文 query 契约收口:`packages/shared/src/contracts/hostBridge.ts` 是宿主上下文 query 字段和值的唯一 TypeScript 来源;`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime parser 字段,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要保留的宿主字段。Expo 壳直接引用共享常量,Tauri Rust 和微信 CommonJS 镜像由 `npm run check:native-shells` / 单壳配置检查反查;微信请求头必须从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,不得另起常量。
- 2026-06-19 H5 HostBridge 载荷边界收口:`src/services/host-bridge/hostBridge.ts` 作为 H5 facade 也必须直接导入 `HOST_BRIDGE_TEXT_MIME_TYPES``HOST_BRIDGE_DOCUMENT_MIME_TYPES``HOST_BRIDGE_IMAGE_MIME_TYPES``HOST_BRIDGE_AUDIO_MIME_TYPES`,只能从共享契约派生本地 Set 用于归一化,不得重新写 MIME 字面量清单;`npm run check:native-shells` 会拒绝 H5 facade 重新复制文本、图片或音频 MIME 边界。
@@ -2524,6 +2524,13 @@
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``src/services/host-bridge/nativeAppHostBridge.ts``apps/desktop-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/services/host-bridge/nativeAppHostBridge.test.ts``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-19 原生宿主 runtime 回读短超时单一来源
- 背景:H5 主 App 进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主能力,但该回读只用于补齐能力缓存,不应该沿用普通宿主请求默认超时,也不应该在 H5 facade 里散落本地毫秒数。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS`,作为 `refreshNativeAppHostRuntime()` / `getNativeAppHostRuntime()` 请求 `host.getRuntime` 时的短超时唯一来源;`src/services/host-bridge/hostBridge.ts` 必须导入共享常量,不得本地声明 `HOST_RUNTIME_REFRESH_TIMEOUT_MS`。根级原生壳门禁和桌面壳配置检查会拒绝回退到本地 runtime 回读超时。
- 影响范围:`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 门禁和现有验收口径静默漂移。
@@ -261,7 +261,7 @@ GameBridge 禁止:
- 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` 为唯一来源。
- 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` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
- 能力按 `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.*` 或页面工厂调用和对应测试清单。
- 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` 的伪声明。
@@ -451,6 +451,8 @@ GameBridge 禁止:
2026-06-19 追加:原生壳请求超时边界进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS=8000``HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS=60000`H5 `nativeAppHostBridge` 的 React Native WebView transport 和 Tauri transport 都必须导入这两个常量做 `timeoutMs` 归一化,不得在 H5 transport 本地重声明默认 / 最大超时。`npm run check:native-shells` 和桌面壳配置检查会拒绝回退到本地超时边界。
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-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 自己跳转而掉回普通浏览器运行态。
@@ -22,6 +22,7 @@ import {
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
HOST_BRIDGE_RESPONSE_CACHE_MAX,
HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,
HOST_BRIDGE_TEXT_MIME_TYPES,
isHostBridgeMethod,
isHostBridgeCapability,
@@ -52,6 +53,7 @@ describe('HostBridge shared contract helpers', () => {
test('固定原生壳请求超时边界', () => {
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);
});
test('固定宿主侧响应回放缓存边界', () => {
@@ -218,6 +218,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_RESPONSE_CACHE_MAX = 128;
export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120;
+8 -1
View File
@@ -976,10 +976,11 @@ function assertH5HostBridgePayloadBoundaries() {
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
]) {
if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 HostBridge facade must import shared payload boundary ${sharedBoundary}`,
`H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`,
);
}
if (new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(h5HostBridgeSource)) {
@@ -1009,6 +1010,12 @@ function assertH5HostBridgePayloadBoundaries() {
);
}
}
if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
);
}
}
function assertH5NativeAppTransportTimeoutBoundaries() {
@@ -3,6 +3,7 @@
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 {
canUseHostShareGrid,
canUseNativeHostCapability,
@@ -647,6 +648,13 @@ describe('hostBridge', () => {
]);
expect(listener).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledTimes(1);
expect(
(
invoke.mock.calls[0]?.[1] as
| { request: { timeoutMs?: number } }
| undefined
)?.request.timeoutMs,
).toBe(HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS);
unsubscribe();
});
+2 -3
View File
@@ -29,6 +29,7 @@ import {
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
HOST_BRIDGE_IMAGE_MIME_TYPES,
HOST_BRIDGE_NATIVE_APP_QUERY,
HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY,
HOST_BRIDGE_TEXT_MIME_TYPES,
HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY,
@@ -148,8 +149,6 @@ export type HostNavigationCanGoBackSnapshot = NavigationCanGoBackEventPayload;
export type HostImageDropSnapshot = FileImportImageResult;
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null;
let nativeHostRuntimeRefreshPromise: Promise<HostBridgeRuntimeResult | null> | null =
null;
@@ -708,7 +707,7 @@ export async function getNativeAppHostRuntime() {
await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
'host.getRuntime',
undefined,
{ timeoutMs: HOST_RUNTIME_REFRESH_TIMEOUT_MS },
{ timeoutMs: HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS },
),
);
} catch (error) {