From 0cac82e9829d884951ae9b26f329371a4c83cdd4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 05:38:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E5=8E=9F=E7=94=9F=E5=A3=B3?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=B3=A8=E5=85=A5=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳 ShellApp 补充生命周期网络和返回栈事件注入测试 移动壳网络状态重放失败改为显式日志记录 桌面壳生命周期和拖拽图片事件失败改为统一日志记录 两端配置门禁反查 HostBridge 事件注入和失败处理 宿主壳协议和共享决策记录同步事件失败规则 --- apps/desktop-shell/scripts/check-config.mjs | 7 +- apps/desktop-shell/src-tauri/src/app.rs | 6 +- .../src-tauri/src/shell/file_drop.rs | 6 +- .../src-tauri/src/shell/lifecycle.rs | 21 ++- .../src-tauri/src/shell/webview.rs | 2 +- apps/mobile-shell/scripts/check-config.mjs | 16 ++ apps/mobile-shell/src/shell/ShellApp.test.tsx | 174 +++++++++++++++++- apps/mobile-shell/src/shell/ShellApp.tsx | 8 +- .../shared-memory/decision-log.md | 15 +- ...�前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- 10 files changed, 231 insertions(+), 26 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index bf0dd9dd5..d1904ded1 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2213,7 +2213,7 @@ const requiredRustHostSnippets = [ 'should_replay_desktop_webview_state_on_page_load', 'PageLoadEvent::Finished', 'replay_desktop_webview_state', - 'log_desktop_webview_state_replay_result', + 'log_desktop_host_event_result', 'window.is_focused()', 'window.is_visible()', 'window.is_minimized()', @@ -2228,6 +2228,7 @@ const requiredRustHostSnippets = [ "window.addEventListener('popstate'", '__genarrativeDesktopHistoryIndex', 'file.imageDropped', + 'emit_desktop_image_drop_event(&drop_window, paths, drop_position)', 'app.notification().builder()', 'desktop_entry_url_with_host_context', 'desktop_h5_url_with_host_context', @@ -2680,7 +2681,7 @@ for (const appSetupSnippet of [ 'desktop_new_window_response', 'register_desktop_network_events(&window)?', 'register_desktop_navigation_events(&window)?', - 'log_desktop_webview_state_replay_result(', + 'log_desktop_host_event_result(', ]) { if (!app.includes(appSetupSnippet)) { throw new Error(`desktop shell app.rs missing setup snippet ${appSetupSnippet}`); @@ -2706,6 +2707,8 @@ for (const blockedLifecycleSnippet of [ 'let _ = register_desktop_network_events(window)', 'let _ = register_desktop_navigation_events(window)', 'let _ = emit_current_desktop_lifecycle_event(window)', + 'let _ = emit_current_desktop_lifecycle_event(&lifecycle_window)', + 'let _ = emit_desktop_image_drop_event(&drop_window', ]) { if (rustHostSource.includes(blockedLifecycleSnippet)) { throw new Error( diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs index d4a2fd553..fcbcde11d 100644 --- a/apps/desktop-shell/src-tauri/src/app.rs +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -9,8 +9,8 @@ use crate::shell::tray::{ }; use crate::shell::webview::{ desktop_window_config_with_runtime_platform, emit_current_desktop_lifecycle_event, - desktop_external_navigation_url, - log_desktop_webview_state_replay_result, open_desktop_external_navigation, + desktop_external_navigation_url, log_desktop_host_event_result, + open_desktop_external_navigation, open_normalized_desktop_external_url, register_desktop_file_drop_events, register_desktop_lifecycle_events, register_desktop_navigation_events, register_desktop_network_events, replay_desktop_webview_state, @@ -103,7 +103,7 @@ pub(crate) fn run() { .build()?; register_desktop_window_close_events(&window, tray_registered); register_desktop_lifecycle_events(&window); - log_desktop_webview_state_replay_result( + log_desktop_host_event_result( "app.lifecycle", emit_current_desktop_lifecycle_event(&window), ); diff --git a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs index 164e7a73c..6a9cd01b7 100644 --- a/apps/desktop-shell/src-tauri/src/shell/file_drop.rs +++ b/apps/desktop-shell/src-tauri/src/shell/file_drop.rs @@ -1,5 +1,6 @@ use crate::host_bridge::files::{import_image_file_payload, import_image_mime_type}; use crate::shell::events::host_bridge_event_script; +use crate::shell::lifecycle::log_desktop_host_event_result; use serde_json::Value; use std::path::PathBuf; use tauri::{DragDropEvent, WebviewWindow, WindowEvent}; @@ -33,7 +34,10 @@ pub(crate) fn register_desktop_file_drop_events(window: &WebviewWindow) { window.on_window_event(move |event| { if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event { let drop_position = (position.x.round() as i32, position.y.round() as i32); - let _ = emit_desktop_image_drop_event(&drop_window, paths, drop_position); + log_desktop_host_event_result( + "file.imageDropped", + emit_desktop_image_drop_event(&drop_window, paths, drop_position), + ); } }); } diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs index edfd12fec..3beb216df 100644 --- a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -88,7 +88,10 @@ pub(crate) fn register_desktop_lifecycle_events(window: &WebviewWindow) { let lifecycle_window = window.clone(); window.on_window_event(move |event| { if matches!(event, WindowEvent::Focused(_) | WindowEvent::Resized(_)) { - let _ = emit_current_desktop_lifecycle_event(&lifecycle_window); + log_desktop_host_event_result( + "app.lifecycle", + emit_current_desktop_lifecycle_event(&lifecycle_window), + ); } }); } @@ -97,29 +100,29 @@ pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEv event == PageLoadEvent::Finished } -pub(crate) fn log_desktop_webview_state_replay_result( +pub(crate) fn log_desktop_host_event_result( label: &'static str, result: tauri::Result<()>, ) -> bool { match result { Ok(()) => true, Err(error) => { - eprintln!("desktop webview state replay failed for {label}: {error}"); + eprintln!("desktop host event failed for {label}: {error}"); false } } } pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { - log_desktop_webview_state_replay_result( + log_desktop_host_event_result( "app.lifecycle", emit_current_desktop_lifecycle_event(window), ); - log_desktop_webview_state_replay_result( + log_desktop_host_event_result( "network.statusChanged", register_desktop_network_events(window), ); - log_desktop_webview_state_replay_result( + log_desktop_host_event_result( "navigation.canGoBack", register_desktop_navigation_events(window), ); @@ -140,9 +143,9 @@ mod tests { } #[test] - fn desktop_state_replay_result_reports_success_and_failure() { - assert!(log_desktop_webview_state_replay_result("network", Ok(()))); - assert!(!log_desktop_webview_state_replay_result( + fn desktop_host_event_result_reports_success_and_failure() { + assert!(log_desktop_host_event_result("network", Ok(()))); + assert!(!log_desktop_host_event_result( "navigation", Err(tauri::Error::AssetNotFound("navigation".to_string())) )); diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index 5e0e208e1..6eaea895b 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -1,6 +1,6 @@ pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events; pub(crate) use crate::shell::lifecycle::{ - emit_current_desktop_lifecycle_event, log_desktop_webview_state_replay_result, + emit_current_desktop_lifecycle_event, log_desktop_host_event_result, register_desktop_lifecycle_events, replay_desktop_webview_state, should_replay_desktop_webview_state_on_page_load, }; diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index fa20a4b1e..d3a6fff51 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1135,6 +1135,8 @@ for (const snippet of [ 'injectHostBridgeEvent', 'injectLifecycleEvent', 'injectNetworkStatusEvent', + 'logMobileHostEventFailure', + "logMobileHostEventFailure('network.statusChanged', error)", 'handleWebViewLoad', 'onLoad={handleWebViewLoad}', 'navigation.canGoBack', @@ -1872,6 +1874,20 @@ for (const snippet of [ } } +for (const snippet of [ + "hostBridgeEvent('app.lifecycle')", + "hostBridgeEvent('network.statusChanged')", + "lastHostBridgeEvent('navigation.canGoBack')", + "shellHarness.appStateListeners[0]?.('background')", + 'shellHarness.networkListeners[0]?.({', + "type: 'genarrative.mobile.historyState'", + 'mobile host event failed for network.statusChanged', +]) { + if (!shellAppTestSource.includes(snippet)) { + throw new Error(`mobile shell ShellApp HostBridge event test missing ${snippet}`); + } +} + if ( !dispatchSource.includes('scanMobileHostBridgeQrCode(request)') || dispatchSource.includes('ok(request, await scanQrCode())') diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index b48d64b7a..5b0f40abc 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { render, waitFor } from '@testing-library/react'; +import * as Network from 'expo-network'; import React from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -11,17 +12,23 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; const shellHarness = vi.hoisted(() => { + const appStateListeners = [] as Array<(state: string) => void>; const webViewProps = { current: null as Record | null }; const cameraViewProps = { current: null as Record | null }; const injectedScripts = [] as string[]; + const networkListeners = [] as Array<(state: Record) => void>; return { + appStateListeners, cameraViewProps, injectedScripts, + networkListeners, reset() { + appStateListeners.length = 0; webViewProps.current = null; cameraViewProps.current = null; injectedScripts.length = 0; + networkListeners.length = 0; }, webViewProps, }; @@ -95,9 +102,12 @@ vi.mock('expo-linking', () => ({ })); vi.mock('expo-network', () => ({ - addNetworkStateListener: vi.fn(() => ({ - remove: vi.fn(), - })), + addNetworkStateListener: vi.fn((listener) => { + shellHarness.networkListeners.push(listener); + return { + remove: vi.fn(), + }; + }), getNetworkStateAsync: vi.fn(async () => ({ isConnected: true, isInternetReachable: true, @@ -158,7 +168,10 @@ vi.mock('react-native-webview', () => ({ vi.mock('react-native', () => ({ AppState: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), + addEventListener: vi.fn((_event, listener) => { + shellHarness.appStateListeners.push(listener); + return { remove: vi.fn() }; + }), currentState: 'active', }, BackHandler: { @@ -217,6 +230,30 @@ function extractInjectedHostBridgeMessage(script: string) { return JSON.parse(JSON.parse(encodedMessage)) as unknown; } +function hostBridgeMessages() { + return shellHarness.injectedScripts.map(extractInjectedHostBridgeMessage); +} + +function hostBridgeEvent(eventName: string) { + return hostBridgeMessages().find( + (message) => + typeof message === 'object' && + message !== null && + (message as { event?: unknown }).event === eventName, + ) as { event: string; payload?: unknown } | undefined; +} + +function lastHostBridgeEvent(eventName: string) { + return hostBridgeMessages() + .filter( + (message) => + typeof message === 'object' && + message !== null && + (message as { event?: unknown }).event === eventName, + ) + .at(-1) as { event: string; payload?: unknown } | undefined; +} + afterEach(() => { shellHarness.reset(); vi.resetModules(); @@ -284,3 +321,132 @@ describe('ShellApp QR scanner HostBridge flow', () => { }); }); }); + +describe('ShellApp HostBridge event injection', () => { + test('AppState changes inject app.lifecycle events into WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.appStateListeners.length).toBeGreaterThan(0); + }); + + shellHarness.injectedScripts.length = 0; + shellHarness.appStateListeners[0]?.('background'); + + const event = hostBridgeEvent('app.lifecycle'); + expect(event).toMatchObject({ + event: 'app.lifecycle', + payload: { + state: 'background', + focused: false, + nativeState: 'background', + }, + }); + }); + + test('native network listener injects network.statusChanged events into WebView', async () => { + const ShellApp = await importShellApp(); + render(); + + await waitFor(() => { + expect(shellHarness.networkListeners.length).toBeGreaterThan(0); + }); + + shellHarness.injectedScripts.length = 0; + shellHarness.networkListeners[0]?.({ + isConnected: false, + isInternetReachable: false, + type: 'NONE', + }); + + const event = hostBridgeEvent('network.statusChanged'); + expect(event).toMatchObject({ + event: 'network.statusChanged', + payload: { + isConnected: false, + isInternetReachable: false, + connectionType: 'none', + nativeType: 'NONE', + }, + }); + }); + + test('native and H5 navigation state inject combined navigation.canGoBack events', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onMessage?: (event: { + nativeEvent: { + data: string; + url: string; + }; + }) => void; + onNavigationStateChange?: (event: { canGoBack: boolean }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + + shellHarness.injectedScripts.length = 0; + webViewProps.onNavigationStateChange?.({ canGoBack: true }); + expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({ + event: 'navigation.canGoBack', + payload: { + canGoBack: true, + }, + }); + + shellHarness.injectedScripts.length = 0; + webViewProps.onNavigationStateChange?.({ canGoBack: false }); + webViewProps.onMessage?.({ + nativeEvent: { + data: JSON.stringify({ + type: 'genarrative.mobile.historyState', + canGoBack: true, + }), + url: webViewUrl, + }, + }); + + expect(lastHostBridgeEvent('navigation.canGoBack')).toMatchObject({ + event: 'navigation.canGoBack', + payload: { + canGoBack: true, + }, + }); + }); + + test('load-time network event replay failure is logged instead of hidden', async () => { + const ShellApp = await importShellApp(); + render(); + + const webViewProps = shellHarness.webViewProps.current as { + onLoad?: (event: { + nativeEvent: { + url: string; + }; + }) => void; + source?: { uri?: string }; + }; + const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const networkError = new Error('network replay failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Network.getNetworkStateAsync).mockRejectedValueOnce(networkError); + + webViewProps.onLoad?.({ + nativeEvent: { + url: webViewUrl, + }, + }); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile host event failed for network.statusChanged', + networkError, + ); + }); + + warnSpy.mockRestore(); + }); +}); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 554345ff1..0b25ce63a 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -73,6 +73,10 @@ function buildHostBridgeEventScript(event: HostBridgeEventName, payload: unknown }); } +function logMobileHostEventFailure(label: HostBridgeEventName, error: unknown) { + console.warn(`mobile host event failed for ${label}`, error); +} + type MobileWebViewLoadErrorEvent = { nativeEvent: { url: string; @@ -290,7 +294,9 @@ export default function ShellApp() { injectLifecycleEvent(AppState.currentState); void getMobileNetworkStatus() .then(injectNetworkStatusEvent) - .catch(() => undefined); + .catch((error: unknown) => { + logMobileHostEventFailure('network.statusChanged', error); + }); }; const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { resetNavigationCanGoBack(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 746df54e6..4be835a5f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2852,9 +2852,16 @@ - 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`docs/project-memory/shared-memory/decision-log.md`。 - 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 -## 2026-06-20 桌面壳 HostBridge 事件注册失败不可静默 +## 2026-06-20 桌面壳 HostBridge 事件失败不可静默 -- 背景:Tauri 桌面壳已经声明 `host.events`、`app.lifecycle`、`network.statusChanged` 和 `navigation.canGoBack`,H5 会据此订阅生命周期、网络和返回栈事件;如果 WebView 事件脚本注册失败仍被静默忽略,H5 会误以为宿主能力可用。 -- 决策:桌面壳启动阶段安装 `network.statusChanged` 和 `navigation.canGoBack` 脚本失败时直接阻断启动;生命周期首发和页面加载重放阶段的 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 失败必须通过统一 helper 记录日志,不允许 `let _ = register_desktop_*` 或 `let _ = emit_current_*` 静默吞错。配置检查反查该错误处理路径。 -- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 背景:Tauri 桌面壳已经声明 `host.events`、`app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`,H5 会据此订阅生命周期、网络、返回栈和拖拽图片事件;如果 WebView 事件脚本注册或发射失败仍被静默忽略,H5 会误以为宿主能力可用。 +- 决策:桌面壳启动阶段安装 `network.statusChanged` 和 `navigation.canGoBack` 脚本失败时直接阻断启动;生命周期首发、页面加载重放、窗口生命周期事件和拖拽图片事件阶段的 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack`、`file.imageDropped` 失败必须通过统一 helper 记录日志,不允许 `let _ = register_desktop_*`、`let _ = emit_current_*` 或 `let _ = emit_desktop_image_drop_event` 静默吞错。配置检查反查该错误处理路径。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 - 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳 ShellApp HostBridge 事件注入必须可执行覆盖 + +- 背景:Expo 移动壳声明 `host.events`、`app.lifecycle`、`network.statusChanged` 和 `navigation.canGoBack`,但 ShellApp 真实 AppState、Network 和 WebView 返回栈注入链路需要和扫码链路一样有可执行测试覆盖,不能只靠字符串门禁。 +- 决策:`apps/mobile-shell/src/shell/ShellApp.test.tsx` 必须覆盖 AppState 到 `app.lifecycle`、Expo Network listener 到 `network.statusChanged`、WebView native / H5 history 合成到 `navigation.canGoBack` 的真实注入脚本;页面 load 后网络状态重放失败必须记录日志,不允许静默 `.catch(() => undefined)`。移动壳配置检查反查这些测试片段和失败日志 helper。 +- 影响范围:`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/shell/ShellApp.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 44a25db93..49f7869be 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -58,7 +58,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发同名事件。桌面壳不会把 hidden、minimized 或 tray 扩成新的 `state`,而是读取 `is_visible()`、`is_minimized()`、`is_focused()` 后统一归一为 `active` / `inactive` / `background`,并只把 `hidden`、`minimized`、`focused`、`blurred` 放进 `nativeState` 用于排障。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `