diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f20b636ea..0af3cf51a 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -929,6 +929,10 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedEvents = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EVENTS', +); const sharedDesktopCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', @@ -1046,6 +1050,7 @@ const desktopHostBridgePayloadLimits = { ), }; const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); +const desktopEvents = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_EVENTS'); const desktopHostBridgeProtocol = extractRustStringConst( rustHostSource, 'HOST_BRIDGE_PROTOCOL', @@ -1140,6 +1145,12 @@ assertSameList( ); assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); +assertSameList(desktopEvents, sharedEvents, 'desktop shell HostBridge event whitelist'); +for (const eventName of sharedEvents) { + if (!sharedCapabilities.includes(eventName)) { + throw new Error(`shared HostBridge event must also be a capability: ${eventName}`); + } +} const unknownHandledDesktopMethods = desktopHandledMethods.filter( (method) => !sharedMethods.includes(method), ); @@ -1486,6 +1497,7 @@ const requiredRustHostSnippets = [ '.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())', 'PageLoadEvent', 'host_bridge_event_script', + 'is_host_bridge_event_name', 'origin: window.location.origin', 'source: window', 'should_replay_desktop_webview_state_on_page_load', @@ -1557,6 +1569,7 @@ if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) { } for (const snippet of [ 'function createNativeHostBridgeTimeoutError()', + 'isHostBridgeEventName(candidate.event)', 'async function invokeTauriHostBridgeWithTimeout', 'Promise.race', 'tauriInvoke>(HOST_BRIDGE_TAURI_COMMAND', diff --git a/apps/desktop-shell/src-tauri/src/shell/events.rs b/apps/desktop-shell/src-tauri/src/shell/events.rs index 336642e26..149b5b1e1 100644 --- a/apps/desktop-shell/src-tauri/src/shell/events.rs +++ b/apps/desktop-shell/src-tauri/src/shell/events.rs @@ -1,10 +1,28 @@ use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; use serde_json::{json, Value}; +const HOST_BRIDGE_EVENTS: [&str; 4] = [ + "app.lifecycle", + "network.statusChanged", + "navigation.canGoBack", + "file.imageDropped", +]; + +fn is_host_bridge_event_name(event: &str) -> bool { + HOST_BRIDGE_EVENTS.contains(&event) +} + pub(crate) fn host_bridge_event_script( event: &str, payload: Value, ) -> Result { + if !is_host_bridge_event_name(event) { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown HostBridge event: {}", event), + ))); + } + let message = json!({ "bridge": HOST_BRIDGE_PROTOCOL, "version": HOST_BRIDGE_VERSION, @@ -45,4 +63,12 @@ mod tests { assert!(script.contains("\\\"state\\\":\\\"active\\\"")); assert!(script.contains("\\\"focused\\\":true")); } + + #[test] + fn host_bridge_event_script_rejects_unknown_events() { + let error = + host_bridge_event_script("unknown.event", json!({})).expect_err("unknown event"); + + assert!(error.to_string().contains("unknown HostBridge event")); + } } diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 638c3e472..4085e93c8 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -481,6 +481,10 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedEvents = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EVENTS', +); const sharedMobileBaseCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', @@ -571,6 +575,15 @@ if ( throw new Error('mobile shell must not redeclare HostBridge capability profiles'); } +const unknownSharedEvents = sharedEvents.filter( + (eventName) => !sharedCapabilities.includes(eventName), +); +if (unknownSharedEvents.length > 0) { + throw new Error( + `shared HostBridge events must also be capabilities: ${unknownSharedEvents.join(', ')}`, + ); +} + const unknownHandledMobileMethods = handledMobileMethods.filter( (method) => !sharedMethods.includes(method), ); @@ -878,6 +891,26 @@ for (const snippet of [ } } +for (const snippet of [ + 'type HostBridgeEventName', + '(event: HostBridgeEventName, payload: unknown)', +]) { + if (!shellAppSource.includes(snippet)) { + throw new Error(`mobile shell HostBridge event injection missing ${snippet}`); + } +} + +for (const eventName of sharedEvents) { + if ( + mobileCapabilitySet.has(eventName) && + !shellAppSource.includes(`'${eventName}'`) + ) { + throw new Error( + `mobile shell advertises HostBridge event ${eventName} but ShellApp does not inject it`, + ); + } +} + for (const snippet of [ 'MobileShellLoadFailureInput', 'normalizeMobileShellLoadFailure', diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index ed12dac67..d3fd7b353 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -16,6 +16,7 @@ import type { WebViewMessageEvent } from 'react-native-webview'; import { WebView } from 'react-native-webview'; import { + type HostBridgeEventName, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, } from '../../../../packages/shared/src/contracts/hostBridge'; @@ -98,16 +99,19 @@ export default function ShellApp() { const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); }, []); - const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => { - webViewRef.current?.injectJavaScript( - buildHostBridgeMessageScript({ - bridge: HOST_BRIDGE_PROTOCOL, - version: HOST_BRIDGE_VERSION, - event, - payload, - }), - ); - }, []); + const injectHostBridgeEvent = useCallback( + (event: HostBridgeEventName, payload: unknown) => { + webViewRef.current?.injectJavaScript( + buildHostBridgeMessageScript({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + event, + payload, + }), + ); + }, + [], + ); const injectLifecycleEvent = useCallback( (state: AppStateStatus) => { injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8b2170483..41bee8718 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2431,6 +2431,13 @@ - 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 HostBridge event 白名单跨壳门禁 + +- 背景:HostBridge request method 已有共享白名单和跨壳检查,但宿主注入给 H5 的 event 名如果仍是裸字符串,AI sandbox 或壳层新增事件时可能绕过契约,导致 H5 订阅到共享协议外事件,或 Tauri Rust 镜像与 TypeScript 契约漂移。 +- 决策:`HOST_BRIDGE_EVENTS` 以 `packages/shared/src/contracts/hostBridge.ts` 为唯一事件名来源,当前只包含 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`,且每个事件名必须同时是 HostBridge capability。Expo 移动壳事件注入函数必须使用共享 `HostBridgeEventName` 类型;Tauri 桌面壳 `shell/events.rs` 镜像同一事件清单并在脚本生成前拒绝未知事件;H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。 +- 影响范围:`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-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 a6daefbc6..ea68208a8 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -103,14 +103,22 @@ type HostBridgeResponse = { }; }; +type HostBridgeEventName = + | 'app.lifecycle' + | 'network.statusChanged' + | 'navigation.canGoBack' + | 'file.imageDropped'; + type HostBridgeEvent = { bridge: 'GenarrativeHostBridge'; version: 1; - event: string; + event: HostBridgeEventName; payload?: unknown; }; ``` +事件名同样是协议白名单,唯一来源为 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS`;Expo 壳注入函数使用 `HostBridgeEventName`,Tauri 壳在 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 transport 只分发白名单内事件。 + 首批 method: | method | 用途 | Expo 壳 | Tauri 壳 | @@ -419,6 +427,8 @@ 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-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。 2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 0f474d61f..ff9a43bcc 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -63,6 +63,8 @@ AI H5 sandbox - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 - `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` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 + +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` 只分发共享白名单内事件。 - `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。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` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 @@ -72,7 +74,7 @@ AI H5 sandbox 2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 -5. 每次新增或调整 native capability 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应 Expo / Tauri capability profile,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;排查单端问题时再单独运行 `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`。 +5. 每次新增或调整 native capability / HostBridge event 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应 Expo / Tauri capability profile 和事件白名单,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;排查单端问题时再单独运行 `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`。 ## 验收 @@ -81,7 +83,7 @@ AI H5 sandbox - 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 - 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 - 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 -- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。 +- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport 和已接入的 H5 直接调用链文件。 ## 后续 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 7d3162d1a..c40ba8de4 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -9,8 +9,10 @@ import { HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_TAURI_COMMAND, HOST_BRIDGE_CAPABILITIES, + HOST_BRIDGE_EVENTS, isHostBridgeMethod, isHostBridgeCapability, + isHostBridgeEventName, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, @@ -95,6 +97,23 @@ describe('HostBridge shared contract helpers', () => { expect(isHostBridgeCapability(null)).toBe(false); }); + test('识别 HostBridge 事件白名单', () => { + expect(HOST_BRIDGE_EVENTS).toEqual([ + 'app.lifecycle', + 'network.statusChanged', + 'navigation.canGoBack', + 'file.imageDropped', + ]); + for (const eventName of HOST_BRIDGE_EVENTS) { + expect(isHostBridgeEventName(eventName)).toBe(true); + expect(isHostBridgeCapability(eventName)).toBe(true); + expect(isHostBridgeMethod(eventName)).toBe(false); + } + expect(isHostBridgeEventName('host.getRuntime')).toBe(false); + expect(isHostBridgeEventName('unknown.event')).toBe(false); + expect(isHostBridgeEventName(null)).toBe(false); + }); + test('原生壳 capability profile 来自共享白名单', () => { const profiles = [ HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index abbd1dd36..866166b4f 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -195,10 +195,28 @@ export type HostBridgeResponse = { } ); +export const HOST_BRIDGE_EVENTS = [ + 'app.lifecycle', + 'network.statusChanged', + 'navigation.canGoBack', + 'file.imageDropped', +] as const; + +export type HostBridgeEventName = (typeof HOST_BRIDGE_EVENTS)[number]; + +export function isHostBridgeEventName( + value: unknown, +): value is HostBridgeEventName { + return ( + typeof value === 'string' && + HOST_BRIDGE_EVENTS.includes(value as HostBridgeEventName) + ); +} + export type HostBridgeEvent = { bridge: typeof HOST_BRIDGE_PROTOCOL; version: typeof HOST_BRIDGE_VERSION; - event: string; + event: HostBridgeEventName; payload?: Payload; }; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 56fd723be..e8eca8ef4 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -427,6 +427,7 @@ function assertNativeShellCapabilityPlan() { ); const sharedContractSource = fs.readFileSync(sharedHostBridgeContractPath, 'utf8'); const sharedMethods = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_METHODS'); + const sharedEvents = extractTsStringArray(sharedContractSource, 'HOST_BRIDGE_EVENTS'); const mobileCapabilities = extractTsStringArray( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', @@ -443,6 +444,22 @@ function assertNativeShellCapabilityPlan() { 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', ); const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource); + const desktopEventSource = fs.readFileSync( + 'apps/desktop-shell/src-tauri/src/shell/events.rs', + 'utf8', + ); + + assertSameList( + extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'), + sharedEvents, + 'desktop shell runtime event whitelist', + ); + + for (const eventName of sharedEvents) { + if (!sharedDesktopCapabilities.includes(eventName)) { + throw new Error(`shared HostBridge event must be in desktop capability profile: ${eventName}`); + } + } assertSameList( desktopCapabilities, diff --git a/src/services/host-bridge/nativeAppHostBridge.test.ts b/src/services/host-bridge/nativeAppHostBridge.test.ts index 3ff88c1db..eb3a92d0d 100644 --- a/src/services/host-bridge/nativeAppHostBridge.test.ts +++ b/src/services/host-bridge/nativeAppHostBridge.test.ts @@ -329,4 +329,29 @@ describe('nativeAppHostBridge', () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ canGoBack: true }); }); + + test('忽略未知宿主事件名', () => { + window.ReactNativeWebView = { + postMessage: vi.fn(), + }; + const listener = vi.fn(); + subscribeNativeAppHostBridgeEvent('navigation.canGoBack', listener); + + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + event: 'unknown.event', + payload: { + canGoBack: true, + }, + }), + origin: window.location.origin, + source: window, + }), + ); + + expect(listener).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/host-bridge/nativeAppHostBridge.ts b/src/services/host-bridge/nativeAppHostBridge.ts index 96d4031a4..91d247736 100644 --- a/src/services/host-bridge/nativeAppHostBridge.ts +++ b/src/services/host-bridge/nativeAppHostBridge.ts @@ -4,9 +4,11 @@ import { HOST_BRIDGE_VERSION, type HostBridgeError, type HostBridgeEvent, + type HostBridgeEventName, type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, + isHostBridgeEventName, } from '../../../packages/shared/src/contracts/hostBridge'; const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000; @@ -101,7 +103,7 @@ function isHostBridgeEvent(value: unknown): value is HostBridgeEvent { return ( candidate.bridge === HOST_BRIDGE_PROTOCOL && candidate.version === HOST_BRIDGE_VERSION && - typeof candidate.event === 'string' + isHostBridgeEventName(candidate.event) ); } @@ -230,7 +232,7 @@ export function canUseNativeAppHostBridge() { } export function subscribeNativeAppHostBridgeEvent( - eventName: string, + eventName: HostBridgeEventName, listener: (payload: Payload | undefined) => void, ) { ensureNativeBridgeListener();