接入宿主网络状态队列轮询

新增宿主网络在线 Hook 并覆盖离线与恢复事件测试

平台外部生成队列概览在原生壳离线时暂停轮询

同步宿主壳网络状态消费方案与共享决策记录
This commit is contained in:
2026-06-18 07:31:41 +08:00
parent 20e4e88bd4
commit 621719077f
6 changed files with 181 additions and 6 deletions

View File

@@ -33,6 +33,7 @@
- 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capabilityExpo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。
- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capabilityExpo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`Tauri 壳通过主窗口 focus / blur 派发 `active` / `inactive`H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。
- 2026-06-18 原生壳网络状态:新增 `network.status``network.statusChanged` HostBridge capabilityExpo 壳通过 `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 capabilityTauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiBH5 统一使用 `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

View File

@@ -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()`:把当前公开作品分享目标同步给宿主。

View File

@@ -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,

View 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);
});
});

View 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;
}