From 153cd8db9c7e678162252f0f721aa5325cbf1762 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 08:30:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=AE=BF=E4=B8=BB=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E8=AE=A2=E9=98=85=E9=97=A8=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5 HostBridge 事件订阅统一要求 host.events 与具体事件能力 补充事件通道和事件能力缺失的双向门控测试 更新宿主壳事件能力文档和共享决策记录 --- .../shared-memory/decision-log.md | 7 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- src/services/host-bridge/hostBridge.test.ts | 152 +++++++++++++++++- src/services/host-bridge/hostBridge.ts | 20 ++- 5 files changed, 171 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c5674bf07..a493bae2c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2451,6 +2451,13 @@ - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`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 mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 H5 HostBridge 事件订阅必须同时声明事件通道 + +- 背景:`navigation.canGoBack` 订阅已经同时要求 `host.events` 和具体事件 capability,但 `app.lifecycle`、`network.statusChanged` 与 `file.imageDropped` 一度只校验具体事件 capability;旧壳或裁剪壳如果缺少 `host.events`,H5 仍可能绑定到不存在或不受控的事件通道。 +- 决策:H5 所有 HostBridge 事件订阅入口统一使用“双能力门控”:必须同时声明 `host.events` 和对应事件 capability,才允许 `subscribeNativeAppHostBridgeEvent(...)` 绑定监听;缺任一能力时返回空取消函数。事件类 capability 继续不要求 request handler,`host.events` 只表示宿主会通过 HostBridge message 注入受控事件,不作为 request method。 +- 影响范围:`src/services/host-bridge/hostBridge.ts`、`src/services/host-bridge/hostBridge.test.ts`、宿主壳能力协议文档和 Expo / Tauri 宿主壳方案文档。 +- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 HostBridge capability / handler 关系门禁 - 背景:`HOST_BRIDGE_CAPABILITIES` 同时包含可请求 method 和事件类 capability。壳如果声明了 request method capability 但没有 handler,H5 会展示入口后收到 unsupported;壳如果处理了未声明 method,H5 又无法根据 capability 决定是否调用,容易形成隐藏能力或跨端漂移。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 58770c239..291c1a5d0 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -444,7 +444,7 @@ GameBridge 禁止: 2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。 -2026-06-19 追加:HostBridge event 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 是宿主注入事件名的唯一来源,当前只包含 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时是 capability。Expo 壳事件注入函数使用 `HostBridgeEventName`,Tauri 壳 `shell/events.rs` 镜像同一清单并在脚本生成前拒绝未知事件,H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。 +2026-06-19 追加:HostBridge event 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 是宿主注入事件名的唯一来源,当前只包含 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时是 capability。Expo 壳事件注入函数使用 `HostBridgeEventName`,Tauri 壳 `shell/events.rs` 镜像同一清单并在脚本生成前拒绝未知事件,H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。H5 `subscribeHostAppLifecycle()`、`subscribeHostNetworkStatusChange()`、`subscribeHostNavigationCanGoBack()` 和 `subscribeHostImageDrop()` 必须同时校验 `host.events` 与对应事件 capability,缺任一能力时不绑定事件监听。 2026-06-18 追加:HostBridge capability 与 request handler 关系进入门禁。共享契约中属于 request method 的 capability,如果被 Expo 或 Tauri 壳声明,就必须在对应壳的 HostBridge 分发中显式处理;反过来,壳分发中处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events`、`app.lifecycle`、`network.statusChanged`、`file.imageDropped`、`navigation.canGoBack` 等事件类 capability 不要求 request handler。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 76163ed26..ebff5b2d2 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -73,7 +73,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 -HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。 +HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。 - `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 4cfba3d68..4f88f679e 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -159,7 +159,7 @@ describe('hostBridge', () => { window.history.replaceState( null, '', - nativeAppPath(['app.lifecycle']), + nativeAppPath(['host.events', 'app.lifecycle']), ); window.ReactNativeWebView = { postMessage: vi.fn(), @@ -338,6 +338,152 @@ describe('hostBridge', () => { expect(listener).not.toHaveBeenCalled(); }); + test('事件类能力缺少宿主事件通道时不订阅原生事件', () => { + const lifecycleListener = vi.fn(); + const networkListener = vi.fn(); + const imageDropListener = vi.fn(); + + window.history.replaceState(null, '', nativeAppPath(['app.lifecycle'])); + const unsubscribeLifecycle = + subscribeHostAppLifecycle(lifecycleListener); + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: 'app.lifecycle', + payload: { + state: 'active', + focused: true, + }, + }), + origin: window.location.origin, + source: window, + }), + ); + unsubscribeLifecycle(); + + window.history.replaceState( + null, + '', + nativeAppPath(['network.statusChanged']), + ); + const unsubscribeNetwork = + subscribeHostNetworkStatusChange(networkListener); + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: 'network.statusChanged', + payload: { + isConnected: true, + isInternetReachable: true, + connectionType: 'wifi', + }, + }), + origin: window.location.origin, + source: window, + }), + ); + unsubscribeNetwork(); + + window.history.replaceState( + null, + '', + nativeAppPath(['file.imageDropped']), + ); + const unsubscribeImageDrop = + subscribeHostImageDrop(imageDropListener); + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: 'file.imageDropped', + payload: { + action: 'dropped', + fileName: '拖入图.webp', + base64Data: 'ZHJvcA==', + mimeType: 'image/webp', + bytes: 4, + }, + }), + origin: window.location.origin, + source: window, + }), + ); + unsubscribeImageDrop(); + + expect(lifecycleListener).not.toHaveBeenCalled(); + expect(networkListener).not.toHaveBeenCalled(); + expect(imageDropListener).not.toHaveBeenCalled(); + }); + + test('事件通道缺少具体事件能力时不订阅原生事件', () => { + const lifecycleListener = vi.fn(); + const networkListener = vi.fn(); + const imageDropListener = vi.fn(); + + window.history.replaceState(null, '', nativeAppPath(['host.events'])); + + const unsubscribeLifecycle = + subscribeHostAppLifecycle(lifecycleListener); + const unsubscribeNetwork = + subscribeHostNetworkStatusChange(networkListener); + const unsubscribeImageDrop = + subscribeHostImageDrop(imageDropListener); + + for (const event of [ + { + event: 'app.lifecycle', + payload: { + state: 'active', + focused: true, + }, + }, + { + event: 'network.statusChanged', + payload: { + isConnected: true, + isInternetReachable: true, + connectionType: 'wifi', + }, + }, + { + event: 'file.imageDropped', + payload: { + action: 'dropped', + fileName: '拖入图.webp', + base64Data: 'ZHJvcA==', + mimeType: 'image/webp', + bytes: 4, + }, + }, + ]) { + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: 'GenarrativeHostBridge', + version: 1, + event: event.event, + payload: event.payload, + }), + origin: window.location.origin, + source: window, + }), + ); + } + + unsubscribeLifecycle(); + unsubscribeNetwork(); + unsubscribeImageDrop(); + + expect(lifecycleListener).not.toHaveBeenCalled(); + expect(networkListener).not.toHaveBeenCalled(); + expect(imageDropListener).not.toHaveBeenCalled(); + }); + test('查询并订阅原生 App 网络状态', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { @@ -361,7 +507,7 @@ describe('hostBridge', () => { window.history.replaceState( null, '', - nativeAppPath(['network.status', 'network.statusChanged']), + nativeAppPath(['host.events', 'network.status', 'network.statusChanged']), ); window.ReactNativeWebView = { postMessage: vi.fn(), @@ -1325,7 +1471,7 @@ describe('hostBridge', () => { window.history.replaceState( null, '', - nativeAppPath(['file.importImage', 'file.imageDropped']), + nativeAppPath(['host.events', 'file.importImage', 'file.imageDropped']), ); window.__TAURI__ = { core: { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 16434ce7f..8e9dd064a 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -1149,7 +1149,7 @@ export async function getHostAppearanceColorScheme() { export function subscribeHostAppLifecycle( listener: (payload: HostAppLifecycleSnapshot) => void, ) { - if (!canUseNativeHostCapability('app.lifecycle')) { + if (!canUseNativeHostEventCapability('app.lifecycle')) { return () => undefined; } @@ -1211,7 +1211,7 @@ export async function getHostNetworkStatus() { export function subscribeHostNetworkStatusChange( listener: (payload: HostNetworkStatusSnapshot) => void, ) { - if (!canUseNativeHostCapability('network.statusChanged')) { + if (!canUseNativeHostEventCapability('network.statusChanged')) { return () => undefined; } @@ -1226,10 +1226,7 @@ export function subscribeHostNetworkStatusChange( export function subscribeHostNavigationCanGoBack( listener: (payload: HostNavigationCanGoBackSnapshot) => void, ) { - if ( - !canUseNativeHostCapability('host.events') || - !canUseNativeHostCapability('navigation.canGoBack') - ) { + if (!canUseNativeHostEventCapability('navigation.canGoBack')) { return () => undefined; } @@ -1246,7 +1243,7 @@ export function subscribeHostNavigationCanGoBack( export function subscribeHostImageDrop( listener: (payload: HostImageDropSnapshot) => void, ) { - if (!canUseNativeHostCapability('file.imageDropped')) { + if (!canUseNativeHostEventCapability('file.imageDropped')) { return () => undefined; } @@ -1260,3 +1257,12 @@ export function subscribeHostImageDrop( }, ); } + +function canUseNativeHostEventCapability( + capability: Exclude, +) { + return ( + canUseNativeHostCapability('host.events') && + canUseNativeHostCapability(capability) + ); +}