From a471e69455a634218851b9a835372f77d38237d4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 04:25:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面壳生命周期按可见性、最小化和焦点统一归一 托盘隐藏前注入 background 并在恢复窗口时回放当前状态 H5 lifecycle 测试覆盖 background 暂停语义 宿主壳文档和原生壳门禁同步桌面 background 约定 --- apps/desktop-shell/scripts/check-config.mjs | 8 ++ apps/desktop-shell/src-tauri/src/app.rs | 4 +- .../src-tauri/src/shell/lifecycle.rs | 118 +++++++++++++++--- .../desktop-shell/src-tauri/src/shell/tray.rs | 4 + .../src-tauri/src/shell/webview.rs | 4 +- .../shared-memory/decision-log.md | 2 +- ...€‘ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 6 +- ...�前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- src/hooks/useHostLifecycleActive.test.tsx | 39 ++++++ 9 files changed, 163 insertions(+), 24 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index cc5a338bc..5f1677332 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1517,6 +1517,12 @@ const requiredRustHostSnippets = [ 'normalize_clipboard_text', 'window.theme()', 'WindowEvent::Focused', + 'WindowEvent::Resized', + 'resolve_desktop_lifecycle_payload', + 'emit_current_desktop_lifecycle_event', + '"background"', + '"hidden"', + '"minimized"', 'WindowEvent::DragDrop', 'first_valid_desktop_image_drop_payload', '.filter(|path| path.is_file() && import_image_mime_type(path).is_some())', @@ -1530,6 +1536,8 @@ const requiredRustHostSnippets = [ 'PageLoadEvent::Finished', 'replay_desktop_webview_state', 'window.is_focused()', + 'window.is_visible()', + 'window.is_minimized()', 'resolve_desktop_network_status', 'network.statusChanged', 'register_desktop_navigation_events', diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs index 3271ccccd..3344da995 100644 --- a/apps/desktop-shell/src-tauri/src/app.rs +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -8,7 +8,7 @@ use crate::shell::tray::{ resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction, }; use crate::shell::webview::{ - desktop_window_config_with_runtime_platform, emit_desktop_lifecycle_event, + desktop_window_config_with_runtime_platform, emit_current_desktop_lifecycle_event, open_desktop_external_navigation, register_desktop_file_drop_events, register_desktop_lifecycle_events, register_desktop_navigation_events, register_desktop_network_events, replay_desktop_webview_state, @@ -71,7 +71,7 @@ pub(crate) fn run() { .build()?; register_desktop_window_close_events(&window, tray_registered); register_desktop_lifecycle_events(&window); - let _ = emit_desktop_lifecycle_event(&window, "active", true, "created"); + let _ = emit_current_desktop_lifecycle_event(&window); let _ = register_desktop_network_events(&window); let _ = register_desktop_navigation_events(&window); register_desktop_file_drop_events(&window); diff --git a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs index 6b230dc97..71f5c5c0a 100644 --- a/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs +++ b/apps/desktop-shell/src-tauri/src/shell/lifecycle.rs @@ -5,6 +5,49 @@ use serde_json::json; use tauri::webview::PageLoadEvent; use tauri::{WebviewWindow, WindowEvent}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DesktopLifecyclePayload { + state: &'static str, + focused: bool, + native_state: &'static str, +} + +pub(crate) fn resolve_desktop_lifecycle_payload( + focused: bool, + visible: bool, + minimized: bool, +) -> DesktopLifecyclePayload { + if !visible { + return DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "hidden", + }; + } + + if minimized { + return DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "minimized", + }; + } + + if focused { + DesktopLifecyclePayload { + state: "active", + focused: true, + native_state: "focused", + } + } else { + DesktopLifecyclePayload { + state: "inactive", + focused: false, + native_state: "blurred", + } + } +} + pub(crate) fn emit_desktop_lifecycle_event( window: &WebviewWindow, state: &'static str, @@ -24,16 +67,28 @@ pub(crate) fn emit_desktop_lifecycle_event( window.eval(script) } +pub(crate) fn emit_desktop_lifecycle_payload( + window: &WebviewWindow, + payload: DesktopLifecyclePayload, +) -> tauri::Result<()> { + emit_desktop_lifecycle_event(window, payload.state, payload.focused, payload.native_state) +} + +pub(crate) fn emit_current_desktop_lifecycle_event(window: &WebviewWindow) -> tauri::Result<()> { + let visible = window.is_visible().unwrap_or(true); + let minimized = window.is_minimized().unwrap_or(false); + let focused = window.is_focused().unwrap_or(visible && !minimized); + emit_desktop_lifecycle_payload( + window, + resolve_desktop_lifecycle_payload(focused, visible, minimized), + ) +} + pub(crate) 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); + if matches!(event, WindowEvent::Focused(_) | WindowEvent::Resized(_)) { + let _ = emit_current_desktop_lifecycle_event(&lifecycle_window); } }); } @@ -43,14 +98,7 @@ pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEv } pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) { - let focused = window.is_focused().unwrap_or(true); - let (state, native_state) = if focused { - ("active", "focused") - } else { - ("inactive", "blurred") - }; - - let _ = emit_desktop_lifecycle_event(window, state, focused, native_state); + let _ = emit_current_desktop_lifecycle_event(window); let _ = register_desktop_network_events(window); let _ = register_desktop_navigation_events(window); } @@ -68,4 +116,44 @@ mod tests { PageLoadEvent::Started )); } + + #[test] + fn desktop_lifecycle_payload_maps_hidden_and_minimized_to_background() { + assert_eq!( + resolve_desktop_lifecycle_payload(true, false, false), + DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "hidden", + } + ); + assert_eq!( + resolve_desktop_lifecycle_payload(true, true, true), + DesktopLifecyclePayload { + state: "background", + focused: false, + native_state: "minimized", + } + ); + } + + #[test] + fn desktop_lifecycle_payload_maps_visible_focus_state() { + assert_eq!( + resolve_desktop_lifecycle_payload(true, true, false), + DesktopLifecyclePayload { + state: "active", + focused: true, + native_state: "focused", + } + ); + assert_eq!( + resolve_desktop_lifecycle_payload(false, true, false), + DesktopLifecyclePayload { + state: "inactive", + focused: false, + native_state: "blurred", + } + ); + } } diff --git a/apps/desktop-shell/src-tauri/src/shell/tray.rs b/apps/desktop-shell/src-tauri/src/shell/tray.rs index 1ccec2f4d..5d4db09b3 100644 --- a/apps/desktop-shell/src-tauri/src/shell/tray.rs +++ b/apps/desktop-shell/src-tauri/src/shell/tray.rs @@ -1,3 +1,4 @@ +use crate::shell::lifecycle::{emit_current_desktop_lifecycle_event, emit_desktop_lifecycle_event}; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{Manager, WebviewWindow, WindowEvent}; @@ -63,6 +64,7 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { window.show()?; window.unminimize()?; window.set_focus()?; + emit_current_desktop_lifecycle_event(&window)?; } Ok(()) @@ -132,6 +134,8 @@ pub(crate) fn register_desktop_window_close_events(window: &WebviewWindow, tray_ == DesktopWindowCloseAction::HideToTray { api.prevent_close(); + // 中文注释:隐藏 WebView 前先通知 H5 暂停游戏循环和音频,避免隐藏后脚本执行被平台挂起。 + let _ = emit_desktop_lifecycle_event(&close_window, "background", false, "hidden"); let _ = close_window.hide(); } } diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index e9b33dde3..025f4b011 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -1,7 +1,7 @@ pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events; pub(crate) use crate::shell::lifecycle::{ - emit_desktop_lifecycle_event, register_desktop_lifecycle_events, replay_desktop_webview_state, - should_replay_desktop_webview_state_on_page_load, + emit_current_desktop_lifecycle_event, register_desktop_lifecycle_events, + replay_desktop_webview_state, should_replay_desktop_webview_state_on_page_load, }; pub(crate) use crate::shell::navigation::{ normalize_external_url, normalize_native_page_url, open_desktop_external_navigation, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c2bc94ee5..177af6423 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -38,7 +38,7 @@ - 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capability,H5 只传 `0-99999` 整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。 - 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。 - 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`;两端都声明 `host.events` 表示事件通过 HostBridge message 注入,但不把它作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 +- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发统一状态;桌面隐藏到托盘或最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只进入 `nativeState` 便于排障,不扩展共享 `state`。两端都声明 `host.events` 表示事件通过 HostBridge message 注入,但不把它作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。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 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle` 并重新安装 `network.statusChanged` 监听脚本,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示该真实事件通道可用,但仍不开放 Tauri event 插件或额外 command。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 346d782d9..40f492899 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -363,7 +363,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `