补齐桌面壳后台生命周期
桌面壳生命周期按可见性、最小化和焦点统一归一 托盘隐藏前注入 background 并在恢复窗口时回放当前状态 H5 lifecycle 测试覆盖 background 暂停语义 宿主壳文档和原生壳门禁同步桌面 background 约定
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -43,7 +43,7 @@ AI H5 sandbox
|
||||
|
||||
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。
|
||||
- `getHostAppearanceColorScheme()`:原生 App 宿主的受控外观查询入口。H5 可通过 `appearance.getColorScheme` 读取宿主当前 `light` / `dark` / `unknown` 配色模式;Expo 移动壳通过 React Native `Appearance.getColorScheme()` 读取系统偏好,Tauri 桌面壳通过主窗口 `theme()` 读取窗口主题。该能力只读,不改变 H5 主题,也不覆盖用户或系统偏好。
|
||||
- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur 事件派发同名事件。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `<audio>` 背景音乐都必须按该状态暂停 / 恢复,宿主进入后台、inactive 或窗口失焦时暂停,回到 active 且 focused 后只在原运行态、音源和用户音量仍允许时恢复。
|
||||
- `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 桌面壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并在主 WebView 内监听 `online` / `offline` 注入变化事件。H5 只依赖统一的 `isConnected`、`isInternetReachable` 和连接类型,不直接读取平台私有网络 API。平台外部生成队列概览通过 `useHostNetworkOnline()` 消费该状态,宿主未声明网络能力时保持原轮询行为,宿主明确离线或不可达时暂停轮询,恢复在线后重新刷新;该状态不替代后端队列事实或生成结果回读。
|
||||
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
||||
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
||||
|
||||
@@ -66,3 +66,42 @@ test('host lifecycle active follows native app lifecycle events', () => {
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('host lifecycle active treats background as inactive even when focused is true', () => {
|
||||
let lifecycleListener:
|
||||
| Parameters<typeof subscribeHostAppLifecycle>[0]
|
||||
| null = null;
|
||||
subscribeHostAppLifecycleMock.mockImplementation((listener) => {
|
||||
lifecycleListener = listener;
|
||||
return () => undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHostLifecycleActive());
|
||||
|
||||
act(() => {
|
||||
lifecycleListener?.({
|
||||
state: 'background',
|
||||
focused: false,
|
||||
nativeState: 'hidden',
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
lifecycleListener?.({
|
||||
state: 'background',
|
||||
focused: true,
|
||||
nativeState: 'minimized',
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
lifecycleListener?.({
|
||||
state: 'active',
|
||||
focused: true,
|
||||
nativeState: 'focused',
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user