接入原生壳生命周期事件
新增 app.lifecycle HostBridge 能力与 H5 订阅入口 Expo 壳通过 React Native AppState 注入真实前后台状态 Tauri 壳通过主窗口 focus 和 blur 注入真实激活状态 更新壳能力漂移检查、测试和架构文档
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -174,6 +174,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect.arrayContaining([
|
||||
'appearance.getColorScheme',
|
||||
'host.events',
|
||||
'app.lifecycle',
|
||||
'navigation.canGoBack',
|
||||
'app.setBadgeCount',
|
||||
]),
|
||||
|
||||
@@ -48,6 +48,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'host.getRuntime',
|
||||
'appearance.getColorScheme',
|
||||
'host.events',
|
||||
'app.lifecycle',
|
||||
'share.open',
|
||||
'share.setTarget',
|
||||
'navigation.openNativePage',
|
||||
|
||||
28
apps/mobile-shell/src/mobileShellLifecycle.test.ts
Normal file
28
apps/mobile-shell/src/mobileShellLifecycle.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
13
apps/mobile-shell/src/mobileShellLifecycle.ts
Normal file
13
apps/mobile-shell/src/mobileShellLifecycle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user