From ca9581e534c52f02434896d1bd71ee7e8e9a8c06 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 13:41:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=AE=BF=E4=B8=BB=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81=E5=9B=9E=E8=AF=BB=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原生宿主 runtime 回读短超时提升到共享 HostBridge 契约 让 H5 HostBridge facade 复用共享 runtime 回读超时 增加原生壳门禁拦截本地 runtime 回读超时重声明 补充 runtime 回读测试、壳方案和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 11 +++++++++++ docs/project-memory/shared-memory/decision-log.md | 9 ++++++++- ...架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +++- packages/shared/src/contracts/hostBridge.test.ts | 2 ++ packages/shared/src/contracts/hostBridge.ts | 1 + scripts/check-native-shells.mjs | 9 ++++++++- src/services/host-bridge/hostBridge.test.ts | 8 ++++++++ src/services/host-bridge/hostBridge.ts | 5 ++--- 8 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index c688bcc3b..b0d943224 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5e3518974..2cd969d96 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 门禁和现有验收口径静默漂移。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0d686af50..61f62cfad 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -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 自己跳转而掉回普通浏览器运行态。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 49e631d32..39bb41fe2 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -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('固定宿主侧响应回放缓存边界', () => { diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 33abdf322..4bc463be6 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -218,6 +218,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_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 942f9a006..f7f3ca41e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -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() { diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 7d3fd3cc7..9210648ed 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -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(); }); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 23d3b9a98..c05d4fda9 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -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 | null = null; @@ -708,7 +707,7 @@ export async function getNativeAppHostRuntime() { await requestNativeAppHostBridge( 'host.getRuntime', undefined, - { timeoutMs: HOST_RUNTIME_REFRESH_TIMEOUT_MS }, + { timeoutMs: HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS }, ), ); } catch (error) {