接入宿主网络状态队列轮询
新增宿主网络在线 Hook 并覆盖离线与恢复事件测试 平台外部生成队列概览在原生壳离线时暂停轮询 同步宿主壳网络状态消费方案与共享决策记录
This commit is contained in:
@@ -33,6 +33,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 外部生成队列轮询接入宿主网络状态: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`。
|
||||
- 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI,也不请求麦克风权限;Tauri 壳不声明该能力,不伪造桌面拍摄。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -42,7 +42,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 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur 事件派发同名事件;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。
|
||||
- `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 二维码。
|
||||
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
||||
|
||||
@@ -114,6 +114,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { useHostNetworkOnline } from '../../hooks/useHostNetworkOnline';
|
||||
import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
@@ -4806,14 +4807,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
(selectionStage === 'platform' &&
|
||||
platformBootstrap.platformTab === 'profile' &&
|
||||
platformBootstrap.canReadProtectedData);
|
||||
const isHostNetworkOnline = useHostNetworkOnline();
|
||||
const externalGenerationQueueOwnerKey =
|
||||
shouldShowExternalGenerationQueueStatus
|
||||
? (authUi?.user?.id ?? 'anonymous')
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowExternalGenerationQueueStatus) {
|
||||
setExternalGenerationQueueOverview(null);
|
||||
setExternalGenerationQueueOverview(null);
|
||||
}, [externalGenerationQueueOwnerKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldShowExternalGenerationQueueStatus ||
|
||||
!isHostNetworkOnline
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExternalGenerationQueueOverview(null);
|
||||
|
||||
let disposed = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
@@ -4841,7 +4852,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
controller?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [authUi?.user?.id, shouldShowExternalGenerationQueueStatus]);
|
||||
}, [
|
||||
externalGenerationQueueOwnerKey,
|
||||
isHostNetworkOnline,
|
||||
shouldShowExternalGenerationQueueStatus,
|
||||
]);
|
||||
const activeExternalGenerationJobState = useMemo(() => {
|
||||
const candidates = [
|
||||
puzzleOperation?.queueState ?? null,
|
||||
|
||||
112
src/hooks/useHostNetworkOnline.test.tsx
Normal file
112
src/hooks/useHostNetworkOnline.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getHostNetworkStatus,
|
||||
subscribeHostNetworkStatusChange,
|
||||
} from '../services/host-bridge/hostBridge';
|
||||
import {
|
||||
resolveHostNetworkOnline,
|
||||
useHostNetworkOnline,
|
||||
} from './useHostNetworkOnline';
|
||||
|
||||
vi.mock('../services/host-bridge/hostBridge', () => ({
|
||||
getHostNetworkStatus: vi.fn(async () => false),
|
||||
subscribeHostNetworkStatusChange: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
const getHostNetworkStatusMock = vi.mocked(getHostNetworkStatus);
|
||||
const subscribeHostNetworkStatusChangeMock = vi.mocked(
|
||||
subscribeHostNetworkStatusChange,
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('resolveHostNetworkOnline', () => {
|
||||
test('宿主没有网络能力时按在线处理', () => {
|
||||
expect(resolveHostNetworkOnline(false)).toBe(true);
|
||||
expect(resolveHostNetworkOnline(null)).toBe(true);
|
||||
});
|
||||
|
||||
test('宿主明确离线或不可达时按离线处理', () => {
|
||||
expect(
|
||||
resolveHostNetworkOnline({
|
||||
isConnected: false,
|
||||
isInternetReachable: null,
|
||||
connectionType: 'none',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveHostNetworkOnline({
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
connectionType: 'wifi',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHostNetworkOnline', () => {
|
||||
test('默认保持在线并订阅宿主网络变化', async () => {
|
||||
const { result } = renderHook(() => useHostNetworkOnline());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(getHostNetworkStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(subscribeHostNetworkStatusChangeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('宿主初始离线时切到离线状态', async () => {
|
||||
getHostNetworkStatusMock.mockResolvedValueOnce({
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
connectionType: 'cellular',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHostNetworkOnline());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('宿主网络事件恢复在线并在卸载时取消订阅', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
let networkListener:
|
||||
| Parameters<typeof subscribeHostNetworkStatusChange>[0]
|
||||
| null = null;
|
||||
subscribeHostNetworkStatusChangeMock.mockImplementation((listener) => {
|
||||
networkListener = listener;
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const { result, unmount } = renderHook(() => useHostNetworkOnline());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
networkListener?.({
|
||||
isConnected: false,
|
||||
isInternetReachable: false,
|
||||
connectionType: 'none',
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
networkListener?.({
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
connectionType: 'wifi',
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
43
src/hooks/useHostNetworkOnline.ts
Normal file
43
src/hooks/useHostNetworkOnline.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
getHostNetworkStatus,
|
||||
type HostNetworkStatusSnapshot,
|
||||
subscribeHostNetworkStatusChange,
|
||||
} from '../services/host-bridge/hostBridge';
|
||||
|
||||
export function resolveHostNetworkOnline(
|
||||
status: HostNetworkStatusSnapshot | false | null | undefined,
|
||||
) {
|
||||
if (!status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return status.isConnected && status.isInternetReachable !== false;
|
||||
}
|
||||
|
||||
export function useHostNetworkOnline() {
|
||||
const [isHostNetworkOnline, setIsHostNetworkOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false;
|
||||
|
||||
const applyNetworkStatus = (
|
||||
status: HostNetworkStatusSnapshot | false | null | undefined,
|
||||
) => {
|
||||
if (!isDisposed) {
|
||||
setIsHostNetworkOnline(resolveHostNetworkOnline(status));
|
||||
}
|
||||
};
|
||||
|
||||
void getHostNetworkStatus().then(applyNetworkStatus);
|
||||
const unsubscribe = subscribeHostNetworkStatusChange(applyNetworkStatus);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isHostNetworkOnline;
|
||||
}
|
||||
Reference in New Issue
Block a user