接入原生壳生命周期事件

新增 app.lifecycle HostBridge 能力与 H5 订阅入口

Expo 壳通过 React Native AppState 注入真实前后台状态

Tauri 壳通过主窗口 focus 和 blur 注入真实激活状态

更新壳能力漂移检查、测试和架构文档
This commit is contained in:
2026-06-18 02:16:47 +08:00
parent 45eec17007
commit 346368f0e7
16 changed files with 299 additions and 6 deletions

View File

@@ -134,6 +134,7 @@ const requiredBuildCommands = ['host_bridge_request'];
const requiredMainSnippets = [
'tauri_plugin_clipboard_manager::init()',
'"appearance.getColorScheme"',
'"app.lifecycle"',
'"share.open"',
'"share.setTarget"',
'"navigation.openNativePage"',
@@ -149,6 +150,8 @@ const requiredMainSnippets = [
'set_title',
'set_badge_count',
'window.theme()',
'WindowEvent::Focused',
'host_bridge_event_script',
];
for (const permission of requiredPermissions) {

View File

@@ -7,6 +7,8 @@ use std::sync::Mutex;
use tauri::Manager;
use tauri::Theme;
use tauri::Url;
use tauri::WebviewWindow;
use tauri::WindowEvent;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -81,6 +83,7 @@ fn capabilities() -> Vec<&'static str> {
vec![
"host.getRuntime",
"appearance.getColorScheme",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
@@ -403,6 +406,55 @@ fn write_export_bytes_file(path: PathBuf, bytes: Vec<u8>) -> Result<usize, Strin
Ok(byte_count)
}
fn host_bridge_event_script(event: &str, payload: Value) -> Result<String, serde_json::Error> {
let message = json!({
"bridge": HOST_BRIDGE_PROTOCOL,
"version": HOST_BRIDGE_VERSION,
"event": event,
"payload": payload,
});
let data = serde_json::to_string(&message)?;
let data_literal = serde_json::to_string(&data)?;
Ok(format!(
"window.dispatchEvent(new MessageEvent('message', {{ data: {} }})); true;",
data_literal
))
}
fn emit_desktop_lifecycle_event(
window: &WebviewWindow,
state: &'static str,
focused: bool,
native_state: &'static str,
) -> tauri::Result<()> {
let script = host_bridge_event_script(
"app.lifecycle",
json!({
"state": state,
"focused": focused,
"nativeState": native_state,
}),
)
.map_err(tauri::Error::Json)?;
window.eval(script)
}
fn register_desktop_lifecycle_events(window: &WebviewWindow) {
let lifecycle_window = window.clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let (state, native_state) = if *focused {
("active", "focused")
} else {
("inactive", "blurred")
};
let _ = emit_desktop_lifecycle_event(&lifecycle_window, state, *focused, native_state);
}
});
}
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value
.get(field)
@@ -727,7 +779,10 @@ fn main() {
.setup(|app| {
let window_config = app.config().app.windows.get(0).cloned();
if let Some(config) = window_config {
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
let window =
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
register_desktop_lifecycle_events(&window);
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
}
Ok(())
})
@@ -763,6 +818,10 @@ mod tests {
.as_array()
.unwrap()
.contains(&json!("appearance.getColorScheme")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("app.lifecycle")));
assert!(result["capabilities"]
.as_array()
.unwrap()
@@ -833,6 +892,25 @@ mod tests {
assert_eq!(color_scheme_from_theme(Theme::Dark), "dark");
}
#[test]
fn host_bridge_event_script_dispatches_lifecycle_message() {
let script = host_bridge_event_script(
"app.lifecycle",
json!({
"state": "active",
"focused": true,
"nativeState": "focused",
}),
)
.expect("event script");
assert!(script.contains("MessageEvent('message'"));
assert!(script.contains("GenarrativeHostBridge"));
assert!(script.contains("app.lifecycle"));
assert!(script.contains("\\\"state\\\":\\\"active\\\""));
assert!(script.contains("\\\"focused\\\":true"));
}
#[test]
fn external_url_normalization_allows_only_safe_protocols() {
assert_eq!(

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"frontendDist": "../../../dist"
},
"app": {
@@ -14,7 +14,7 @@
{
"create": false,
"label": "main",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"title": "Genarrative",
"width": 1280,
"height": 820,

View File

@@ -1,6 +1,14 @@
import { StatusBar } from 'expo-status-bar';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BackHandler, Linking, Platform, StyleSheet, View } from 'react-native';
import {
AppState,
type AppStateStatus,
BackHandler,
Linking,
Platform,
StyleSheet,
View,
} from 'react-native';
import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview';
@@ -10,6 +18,7 @@ import {
resolveMobileHostCapabilities,
} from './src/mobileHostBridge';
import { buildMobileShellUrlFromDeepLink } from './src/mobileShellDeepLink';
import { lifecyclePayloadFromAppState } from './src/mobileShellLifecycle';
import {
resolveMobileShellExternalUrl,
shouldOpenInMobileShellWebView,
@@ -97,6 +106,27 @@ export default function App() {
return () => subscription.remove();
}, [canGoBack]);
useEffect(() => {
const sendLifecycleEvent = (state: AppStateStatus) => {
webViewRef.current?.injectJavaScript(
buildHostBridgeMessageScript({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'app.lifecycle',
payload: lifecyclePayloadFromAppState(state),
}),
);
};
const subscription = AppState.addEventListener(
'change',
sendLifecycleEvent,
);
sendLifecycleEvent(AppState.currentState);
return () => subscription.remove();
}, []);
const handleMessage = (event: WebViewMessageEvent) => {
void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => {
webViewRef.current?.injectJavaScript(

View File

@@ -75,6 +75,7 @@ for (const capability of iosMobileCapabilities) {
const switchCase = `case '${capability}':`;
if (
capability !== 'host.events' &&
capability !== 'app.lifecycle' &&
capability !== 'navigation.canGoBack' &&
!bridgeSource.includes(switchCase)
) {
@@ -116,6 +117,8 @@ for (const snippet of [
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'configureMobileHostBridgeNavigation',
'AppState.addEventListener',
'app.lifecycle',
'navigation.canGoBack',
'buildHostBridgeMessageScript',
]) {
@@ -156,6 +159,7 @@ for (const capability of [
'appearance.getColorScheme',
'share.open',
'share.setTarget',
'app.lifecycle',
'navigation.openNativePage',
'navigation.canGoBack',
'app.openExternalUrl',

View File

@@ -174,6 +174,7 @@ describe('handleMobileHostBridgeMessage', () => {
expect.arrayContaining([
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'navigation.canGoBack',
'app.setBadgeCount',
]),

View File

@@ -48,6 +48,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'share.open',
'share.setTarget',
'navigation.openNativePage',

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from 'vitest';
import { lifecyclePayloadFromAppState } from './mobileShellLifecycle';
describe('mobileShellLifecycle', () => {
test('把 React Native AppState 映射为统一 HostBridge 生命周期状态', () => {
expect(lifecyclePayloadFromAppState('active')).toEqual({
state: 'active',
focused: true,
nativeState: 'active',
});
expect(lifecyclePayloadFromAppState('background')).toEqual({
state: 'background',
focused: false,
nativeState: 'background',
});
expect(lifecyclePayloadFromAppState('inactive')).toEqual({
state: 'inactive',
focused: false,
nativeState: 'inactive',
});
expect(lifecyclePayloadFromAppState('unknown')).toEqual({
state: 'inactive',
focused: false,
nativeState: 'unknown',
});
});
});

View File

@@ -0,0 +1,13 @@
import type { AppStateStatus } from 'react-native';
export function lifecyclePayloadFromAppState(state: AppStateStatus) {
return {
state: state === 'active'
? 'active'
: state === 'background'
? 'background'
: 'inactive',
focused: state === 'active',
nativeState: state,
};
}