diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx index e64158f17..f67a37b30 100644 --- a/apps/mobile-shell/App.tsx +++ b/apps/mobile-shell/App.tsx @@ -24,7 +24,10 @@ import { shouldAcceptMobileShellHostBridgeMessage, shouldOpenInMobileShellWebView, } from './src/mobileShellNavigation'; -import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork'; +import { + getMobileNetworkStatus, + subscribeMobileNetworkStatus, +} from './src/mobileShellNetwork'; import { MOBILE_SHELL_HOST_VERSION } from './src/mobileShellRuntime'; import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea'; import { @@ -60,6 +63,28 @@ export default function App() { const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); }, []); + const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => { + webViewRef.current?.injectJavaScript( + buildHostBridgeMessageScript({ + bridge: 'GenarrativeHostBridge', + version: 1, + event, + payload, + }), + ); + }, []); + const injectLifecycleEvent = useCallback( + (state: AppStateStatus) => { + injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state)); + }, + [injectHostBridgeEvent], + ); + const injectNetworkStatusEvent = useCallback( + (payload: Awaited>) => { + injectHostBridgeEvent('network.statusChanged', payload); + }, + [injectHostBridgeEvent], + ); useEffect(() => { configureMobileHostBridgeNavigation({ @@ -118,38 +143,18 @@ export default function App() { }, [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, + injectLifecycleEvent, ); - sendLifecycleEvent(AppState.currentState); + injectLifecycleEvent(AppState.currentState); return () => subscription.remove(); - }, []); + }, [injectLifecycleEvent]); useEffect(() => { - return subscribeMobileNetworkStatus((payload) => { - webViewRef.current?.injectJavaScript( - buildHostBridgeMessageScript({ - bridge: 'GenarrativeHostBridge', - version: 1, - event: 'network.statusChanged', - payload, - }), - ); - }); - }, []); + return subscribeMobileNetworkStatus(injectNetworkStatusEvent); + }, [injectNetworkStatusEvent]); const handleMessage = (event: WebViewMessageEvent) => { if ( @@ -180,6 +185,22 @@ export default function App() { return false; }; + const handleWebViewLoad = (event: { nativeEvent: { url: string } }) => { + if ( + !shouldAcceptMobileShellHostBridgeMessage( + event.nativeEvent.url, + allowedWebOrigin, + ) + ) { + return; + } + + injectLifecycleEvent(AppState.currentState); + void getMobileNetworkStatus() + .then(injectNetworkStatusEvent) + .catch(() => undefined); + }; + return ( @@ -206,6 +227,7 @@ export default function App() { onMessage={handleMessage} onContentProcessDidTerminate={reloadCurrentWebView} onRenderProcessGone={reloadCurrentWebView} + onLoad={handleWebViewLoad} onShouldStartLoadWithRequest={handleShouldStartLoad} onNavigationStateChange={(event) => { setCanGoBack(event.canGoBack); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index f2dce2b31..eff1eff6a 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -702,7 +702,13 @@ for (const snippet of [ 'AppState.addEventListener', 'app.lifecycle', 'network.statusChanged', + 'getMobileNetworkStatus', 'subscribeMobileNetworkStatus', + 'injectHostBridgeEvent', + 'injectLifecycleEvent', + 'injectNetworkStatusEvent', + 'handleWebViewLoad', + 'onLoad={handleWebViewLoad}', 'navigation.canGoBack', 'buildHostBridgeMessageScript', 'origin: window.location.origin', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7607a4dd2..9ee614752 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -34,6 +34,7 @@ - 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。 - 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur 派发 `active` / `inactive`;H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 - 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 +- 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。 - 2026-06-18 外部生成队列轮询接入宿主网络状态:H5 新增 `useHostNetworkOnline()`,宿主未声明网络能力时按在线处理以保持浏览器和旧壳行为;宿主明确 `isConnected=false` 或 `isInternetReachable=false` 时,平台外部生成队列概览暂停 HTTP 轮询,恢复在线后重新刷新。该能力只减少离线请求,不改变外部生成队列、作品架、弹窗或后端任务状态事实。 - 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。 - 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png`、`image/jpeg`、`image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 9362718cd..1af819624 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -286,6 +286,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview` 的 `reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。 +2026-06-18 追加:移动壳 WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、`app.reloadWebView`、进程恢复 reload 和 H5 自身刷新后的新 JS 上下文。补发仍只使用 HostBridge event,不新增 capability,不向 `about:blank`、外域或错误页注入宿主状态。 + 2026-06-18 追加:移动壳根布局接入 `react-native-safe-area-context`,用 `SafeAreaProvider` 和四边 `SafeAreaView` 承接 iOS 刘海、底部 Home Indicator、Android 状态栏和横屏边缘安全区;WebView 仍加载同一 H5 主站,不改变 H5 路由、玩法 runtime 或 HostBridge capability。该能力属于宿主壳布局保护,不在 H5 内补额外占位 UI。 2026-06-18 追加:移动壳不锁定竖屏,Expo `orientation` 固定为 `default`,由 iOS / Android 设备方向和 H5 响应式布局共同承接竖屏、横屏与后续横屏玩法;壳层只负责安全区、WebView 容器和 HostBridge,不为某一类玩法硬编码方向。`npm run mobile-shell:typecheck` 与 `npm run mobile-shell:config` 会拒绝重新锁回 portrait / landscape。