From 4c4682069b362b381bd7afeebab81f4adda4a78a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 13:07:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3HostBridge=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expo壳按request id复用进行中请求并回放已完成响应 Tauri壳在唯一HostBridge命令外层增加重复请求等待与回放 补充两端测试、配置门禁、原生壳方案和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 8 ++ apps/desktop-shell/src-tauri/src/main.rs | 123 +++++++++++++++++- apps/mobile-shell/scripts/check-config.mjs | 5 + .../mobile-shell/src/mobileHostBridge.test.ts | 24 +++- apps/mobile-shell/src/mobileHostBridge.ts | 49 ++++++- .../shared-memory/decision-log.md | 8 ++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- 7 files changed, 209 insertions(+), 12 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index e1e1085e2..92bf5ed59 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -705,6 +705,14 @@ const requiredMainSnippets = [ 'DownloadEvent::Requested { .. } => false', '.on_download(|_webview, event| should_allow_desktop_webview_download(&event))', 'NewWindowResponse::Deny', + 'HostBridgeReplayState', + 'HostBridgeReplayReservation', + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + '.manage(HostBridgeReplayState::default())', + 'replay_state.reserve(&request.id)', + 'HostBridgeReplayState::wait_for_response', + 'execute_host_bridge_request(app, request).await', + 'replay_state.complete(slot, response)', ]; assertSameList( diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 326242778..43b76172f 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -1,10 +1,11 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::fs; use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; @@ -38,6 +39,7 @@ const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; +const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; const TRAY_MENU_SHOW: &str = "show-main-window"; const TRAY_MENU_RELOAD: &str = "reload-main-window"; @@ -63,13 +65,13 @@ struct HostBridgeRuntime { capabilities: Vec<&'static str>, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] struct HostBridgeError { code: &'static str, message: String, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct HostBridgeResponse { bridge: &'static str, @@ -87,6 +89,73 @@ struct DesktopShareState { target: Mutex>, } +#[derive(Debug, Default)] +struct HostBridgeReplayState { + cache: Mutex, +} + +#[derive(Debug, Default)] +struct HostBridgeReplayCache { + order: Vec, + slots: HashMap>, +} + +#[derive(Debug, Default)] +struct HostBridgeReplaySlot { + response: Mutex>, + ready: Condvar, +} + +#[derive(Debug)] +enum HostBridgeReplayReservation { + Execute(Arc), + Wait(Arc), +} + +impl HostBridgeReplayState { + fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation { + let mut cache = self.cache.lock().expect("host bridge replay cache lock"); + if let Some(slot) = cache.slots.get(request_id) { + return HostBridgeReplayReservation::Wait(slot.clone()); + } + + let slot = Arc::new(HostBridgeReplaySlot::default()); + cache.order.push(request_id.to_string()); + cache.slots.insert(request_id.to_string(), slot.clone()); + while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX { + if let Some(oldest_request_id) = cache.order.first().cloned() { + cache.order.remove(0); + cache.slots.remove(&oldest_request_id); + } + } + + HostBridgeReplayReservation::Execute(slot) + } + + fn complete( + &self, + slot: Arc, + response: HostBridgeResponse, + ) -> HostBridgeResponse { + let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + *stored_response = Some(response.clone()); + slot.ready.notify_all(); + response + } + + fn wait_for_response(slot: Arc) -> HostBridgeResponse { + let mut stored_response = slot.response.lock().expect("host bridge replay slot lock"); + while stored_response.is_none() { + stored_response = slot + .ready + .wait(stored_response) + .expect("host bridge replay slot wait"); + } + + stored_response.clone().expect("host bridge replay response") + } +} + fn desktop_platform() -> &'static str { if cfg!(target_os = "macos") { "macos" @@ -1247,8 +1316,7 @@ fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse } } -#[tauri::command] -async fn host_bridge_request( +async fn execute_host_bridge_request( app: tauri::AppHandle, request: HostBridgeRequest, ) -> HostBridgeResponse { @@ -1604,9 +1672,31 @@ async fn host_bridge_request( } } +#[tauri::command] +async fn host_bridge_request( + app: tauri::AppHandle, + replay_state: tauri::State<'_, HostBridgeReplayState>, + request: HostBridgeRequest, +) -> Result { + if let Some(response) = validate_request(&request) { + return Ok(response); + } + + let response = match replay_state.reserve(&request.id) { + HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), + HostBridgeReplayReservation::Execute(slot) => { + let response = execute_host_bridge_request(app, request).await; + replay_state.complete(slot, response) + } + }; + + Ok(response) +} + fn main() { tauri::Builder::default() .manage(DesktopShareState::default()) + .manage(HostBridgeReplayState::default()) .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { match resolve_desktop_single_instance_action() { DesktopSingleInstanceAction::ShowMainWindow => { @@ -1813,6 +1903,29 @@ mod tests { assert_eq!(response.error.expect("error").code, "invalid_request"); } + #[test] + fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { + let replay_state = HostBridgeReplayState::default(); + let mut side_effect_count = 0; + + let first_reservation = replay_state.reserve("request-1"); + let first_response = match first_reservation { + HostBridgeReplayReservation::Execute(slot) => { + side_effect_count += 1; + replay_state.complete(slot, ok("request-1".to_string(), json!(true))) + } + HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"), + }; + let second_response = match replay_state.reserve("request-1") { + HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"), + HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), + }; + + assert_eq!(side_effect_count, 1); + assert_eq!(second_response.ok, first_response.ok); + assert_eq!(second_response.result, first_response.result); + } + #[test] fn invalid_string_payload_is_rejected() { let mut invalid = request("clipboard.writeText"); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 2e606817f..dfeee5c73 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -649,6 +649,11 @@ for (const snippet of [ 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'base64Data', + 'HOST_BRIDGE_RESPONSE_CACHE_MAX', + 'completedHostBridgeResponses', + 'inFlightHostBridgeResponses', + 'resolveMobileHostBridgeResponse', + 'rememberHostBridgeResponse', ]) { if (!bridgeSource.includes(snippet)) { throw new Error(`mobile shell HostBridge missing ${snippet}`); diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index bf92384bc..0b4a3e341 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -44,6 +44,8 @@ const DENIED_NOTIFICATION_PERMISSION = { expires: 'never', } as NotificationPermissionStatus; +let requestSequence = 0; + vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), setStringAsync: vi.fn(), @@ -171,10 +173,11 @@ function request( method: HostBridgeMethod, payload?: unknown, ): HostBridgeRequest { + requestSequence += 1; return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, - id: 'request-1', + id: `request-${requestSequence}`, method, payload, }; @@ -216,6 +219,7 @@ function setPlatformOS(os: 'ios' | 'android') { } afterEach(() => { + requestSequence = 0; vi.mocked(Appearance.getColorScheme).mockReset(); vi.mocked(Appearance.getColorScheme).mockReturnValue('light'); vi.mocked(Haptics.impactAsync).mockReset(); @@ -465,6 +469,24 @@ describe('handleMobileHostBridgeMessage', () => { expect(Linking.openURL).not.toHaveBeenCalled(); }); + test('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => { + const duplicateRequest = request('share.open', { + title: '测试作品', + url: 'https://app.genarrative.world/works/detail?work=PZ-1', + }); + + const [firstResponse, secondResponse] = await Promise.all([ + send(duplicateRequest), + send(duplicateRequest), + ]); + const replayedResponse = await send(duplicateRequest); + + expectOk(firstResponse); + expect(secondResponse).toEqual(firstResponse); + expect(replayedResponse).toEqual(firstResponse); + expect(Share.share).toHaveBeenCalledTimes(1); + }); + test('network.status 返回 Expo Network 真实状态', async () => { vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ type: Network.NetworkStateType.CELLULAR, diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index e267f8aa4..ce00f2c25 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -59,6 +59,7 @@ const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; +const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ 'text/plain', 'text/markdown', @@ -132,6 +133,11 @@ export type MobileHostBridgeNavigation = { let currentShareTarget: unknown = null; let navigation: MobileHostBridgeNavigation | null = null; +const completedHostBridgeResponses = new Map(); +const inFlightHostBridgeResponses = new Map< + string, + Promise +>(); export function configureMobileHostBridgeNavigation( nextNavigation: MobileHostBridgeNavigation | null, @@ -984,6 +990,41 @@ function normalizeError(error: unknown): HostBridgeError { }; } +function rememberHostBridgeResponse(response: HostBridgeResponse) { + completedHostBridgeResponses.set(response.id, response); + if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) { + const oldestRequestId = completedHostBridgeResponses.keys().next().value; + if (oldestRequestId) { + completedHostBridgeResponses.delete(oldestRequestId); + } + } + + return response; +} + +async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) { + const completedResponse = completedHostBridgeResponses.get(request.id); + if (completedResponse) { + return completedResponse; + } + + const inFlightResponse = inFlightHostBridgeResponses.get(request.id); + if (inFlightResponse) { + return await inFlightResponse; + } + + const responsePromise = handleRequest(request) + .catch((error: unknown) => failure(request, normalizeError(error))) + .then(rememberHostBridgeResponse); + inFlightHostBridgeResponses.set(request.id, responsePromise); + + try { + return await responsePromise; + } finally { + inFlightHostBridgeResponses.delete(request.id); + } +} + export async function handleMobileHostBridgeMessage( rawMessage: string, sendResponse: (response: HostBridgeResponse) => void, @@ -999,14 +1040,12 @@ export async function handleMobileHostBridgeMessage( return; } - try { - sendResponse(await handleRequest(parsed)); - } catch (error) { - sendResponse(failure(parsed, normalizeError(error))); - } + sendResponse(await resolveMobileHostBridgeResponse(parsed)); } export function resetMobileHostBridgeForTest() { currentShareTarget = null; navigation = null; + completedHostBridgeResponses.clear(); + inFlightHostBridgeResponses.clear(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 263dddcd0..932829143 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -67,6 +67,7 @@ - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 +- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。 - 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 @@ -2384,6 +2385,13 @@ - 影响范围:根依赖、桌面壳依赖、桌面壳配置检查和 Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-18 HostBridge request id replay + +- 背景:H5 transport 会为每个 HostBridge 请求生成 id 并设置超时,但系统分享、外链打开、文件选择 / 保存、本地通知和窗口导航等宿主副作用如果收到重复 id,不应因为消息重试、快速双发或 WebView 事件抖动被执行两次。 +- 决策:Expo 移动壳缓存已完成响应,并让进行中的同 id 请求共用同一个执行 Promise;Tauri 桌面壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 让同 id 请求等待 / 回放首次结果。重复 id 只返回首次响应,不二次执行宿主能力。 +- 影响范围:`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 移动壳渠道 SDK 依赖收口 - 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index aa99e4128..3bfb49f0c 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -232,7 +232,7 @@ GameBridge 禁止: - HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。 - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 -- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。 +- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `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 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口和两端壳生产源码临时替身词扫描不得漂移。 - 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin` 和 `payment.request` 的伪声明。 @@ -348,6 +348,8 @@ GameBridge 禁止: 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-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。 + 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 仍保留本机开发源。 2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。