补强原生壳事件注入链路

移动壳 ShellApp 补充生命周期网络和返回栈事件注入测试

移动壳网络状态重放失败改为显式日志记录

桌面壳生命周期和拖拽图片事件失败改为统一日志记录

两端配置门禁反查 HostBridge 事件注入和失败处理

宿主壳协议和共享决策记录同步事件失败规则
This commit is contained in:
2026-06-20 05:38:48 +08:00
parent 73ea93ccc8
commit 0cac82e982
10 changed files with 231 additions and 26 deletions
+5 -2
View File
@@ -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(
+3 -3
View File
@@ -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),
);
@@ -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),
);
}
});
}
@@ -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()))
));
@@ -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,
};
@@ -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())')
+170 -4
View File
@@ -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<string, unknown> | null };
const cameraViewProps = { current: null as Record<string, unknown> | null };
const injectedScripts = [] as string[];
const networkListeners = [] as Array<(state: Record<string, unknown>) => 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(<ShellApp />);
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(<ShellApp />);
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(<ShellApp />);
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(<ShellApp />);
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();
});
});
+7 -1
View File
@@ -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();
@@ -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`。
@@ -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 背景音乐和固定玩法 `<audio>` 背景音乐都必须按该状态暂停 / 恢复,宿主进入后台、inactive 或窗口失焦时暂停,回到 active 且 focused 后只在原运行态、音源和用户音量仍允许时恢复。
- `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`:原生 App 宿主的受控网络状态入口。Expo 移动壳通过 `expo-network` 查询并订阅真实系统网络状态;Tauri 桌面壳从 `WEB_APP_ORIGIN` 解析主站 host / port 后按共享契约 `HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS` 做短超时 TCP 可达性查询,并在主 WebView 内监听 `online` / `offline` 注入变化事件。H5 只依赖统一的 `isConnected`、`isInternetReachable` 和连接类型,不直接读取平台私有网络 API。平台外部生成队列概览通过 `useHostNetworkOnline()` 消费该状态,宿主未声明网络能力时保持原轮询行为,宿主明确离线或不可达时暂停轮询,恢复在线后重新刷新;该状态不替代后端队列事实或生成结果回读。
- `subscribeHostNavigationCanGoBack()` / `useHostNavigationCanGoBack()`:原生 App 宿主的受控返回栈状态入口。H5 只有在宿主同时声明 `host.events` 和 `navigation.canGoBack` 时才订阅该事件;Expo 移动壳和 Tauri 桌面壳注入的状态只表示当前 H5 文档路由栈或宿主已归一后的返回状态,不让 H5 读取任意原生 back-forward list。主 App 在原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,会先把当前条目替换为 `/` 返回锚点,再把当前路径连同已保留的宿主 query 推回 history;普通浏览器、小程序、runtime 路由、已有应用 history 或不支持返回栈事件的裁剪壳不触发该补齐。
声明 `host.events`、`app.lifecycle`、`network.statusChanged` 或 `navigation.canGoBack` 后,宿主壳必须把对应 WebView 事件脚本注册失败显式失败或记录日志;启动阶段无法安装网络 / 返回栈事件时不得继续以半可用状态运行,页面重放阶段不得静默吞掉失败。
声明 `host.events`、`app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 后,宿主壳必须把对应 WebView 事件脚本注册或发射失败显式失败或记录日志;启动阶段无法安装网络 / 返回栈事件时不得继续以半可用状态运行,页面重放、窗口生命周期事件和拖拽图片事件阶段不得静默吞掉失败。
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。