接入原生壳网络状态能力

新增 network.status 与 network.statusChanged HostBridge 能力

Expo 壳通过 expo-network 查询并订阅真实网络状态

Tauri 壳通过主站可达性查询和 WebView online/offline 事件同步网络状态

更新壳能力检查、测试和架构文档
This commit is contained in:
2026-06-18 02:35:48 +08:00
parent 346368f0e7
commit 586e46fa63
19 changed files with 551 additions and 5 deletions
@@ -9,6 +9,7 @@ import {
exportHostImageFile,
exportHostTextFile,
getHostAppearanceColorScheme,
getHostNetworkStatus,
getHostRuntime,
getNativeAppHostRuntime,
isWechatMiniProgramWebViewRuntime,
@@ -30,6 +31,7 @@ import {
setHostAppTitle,
setHostShareTarget,
subscribeHostAppLifecycle,
subscribeHostNetworkStatusChange,
subscribeHostRuntimeChange,
writeHostClipboardText,
} from './hostBridge';
@@ -217,6 +219,78 @@ describe('hostBridge', () => {
expect(listener).not.toHaveBeenCalled();
});
test('查询并订阅原生 App 网络状态', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string; method: string } })
.request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
isConnected: true,
isInternetReachable: false,
connectionType: 'WIFI',
nativeType: 'WIFI',
},
};
},
);
const listener = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['network.status', 'network.statusChanged']),
);
window.ReactNativeWebView = {
postMessage: vi.fn(),
};
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(getHostNetworkStatus()).resolves.toEqual({
isConnected: true,
isInternetReachable: false,
connectionType: 'wifi',
nativeType: 'WIFI',
});
const unsubscribe = subscribeHostNetworkStatusChange(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'network.statusChanged',
payload: {
isConnected: false,
isInternetReachable: false,
connectionType: 'NONE',
nativeType: 'NONE',
},
}),
}),
);
unsubscribe();
expect(listener).toHaveBeenCalledWith({
isConnected: false,
isInternetReachable: false,
connectionType: 'none',
nativeType: 'NONE',
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'network.status',
}),
});
});
test('从真实宿主 runtime 回读能力并通知订阅者', async () => {
const listener = vi.fn();
const unsubscribe = subscribeHostRuntimeChange(listener);
@@ -510,6 +584,13 @@ describe('hostBridge', () => {
}
: request.method === 'appearance.getColorScheme'
? { colorScheme: 'dark' }
: request.method === 'network.status'
? {
isConnected: true,
isInternetReachable: true,
connectionType: 'ethernet',
nativeType: 'online',
}
: true,
};
});
@@ -527,6 +608,7 @@ describe('hostBridge', () => {
'app.openExternalUrl',
'app.setTitle',
'app.setBadgeCount',
'network.status',
'share.open',
'file.exportImage',
]),
@@ -565,6 +647,12 @@ describe('hostBridge', () => {
true,
);
await expect(setHostAppBadgeCount({ count: 7 })).resolves.toBe(true);
await expect(getHostNetworkStatus()).resolves.toEqual({
isConnected: true,
isInternetReachable: true,
connectionType: 'ethernet',
nativeType: 'online',
});
await expect(
openHostShare({
title: '暖灯猫街',
@@ -645,6 +733,11 @@ describe('hostBridge', () => {
},
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'network.status',
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'share.open',
@@ -716,6 +809,7 @@ describe('hostBridge', () => {
await expect(setHostAppBadgeCount({ count: 1 })).resolves.toBe(false);
await expect(setHostAppBadgeCount({ count: -1 })).resolves.toBe(false);
await expect(setHostAppBadgeCount({ count: 1.5 })).resolves.toBe(false);
await expect(getHostNetworkStatus()).resolves.toBe(false);
await expect(
openHostShare({
title: '暖灯猫街',
+57
View File
@@ -9,6 +9,7 @@ import type {
HostBridgeCapability,
HostBridgeMethod,
HostBridgeRuntimeResult,
NetworkStatusResult,
OpenExternalUrlPayload,
SetBadgeCountPayload,
ShareOpenPayload,
@@ -17,6 +18,7 @@ import {
isHostBridgeCapability,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeColorScheme,
normalizeHostBridgeConnectionType,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeLifecycleState,
} from '../../../packages/shared/src/contracts/hostBridge';
@@ -104,6 +106,8 @@ export type HostAppearanceColorSchemeSnapshot = AppearanceColorSchemeResult;
export type HostAppLifecycleSnapshot = AppLifecycleEventPayload;
export type HostNetworkStatusSnapshot = NetworkStatusResult;
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null;
@@ -815,3 +819,56 @@ export function subscribeHostAppLifecycle(
},
);
}
function normalizeHostNetworkStatus(
payload: NetworkStatusResult | null | undefined,
): HostNetworkStatusSnapshot {
const connectionType = normalizeHostBridgeConnectionType(
payload?.connectionType ?? payload?.nativeType,
);
const isConnected =
typeof payload?.isConnected === 'boolean'
? payload.isConnected
: connectionType !== 'none' && connectionType !== 'unknown';
return {
isConnected,
isInternetReachable:
typeof payload?.isInternetReachable === 'boolean'
? payload.isInternetReachable
: null,
connectionType,
...(typeof payload?.nativeType === 'string'
? { nativeType: payload.nativeType }
: {}),
};
}
export async function getHostNetworkStatus() {
if (!canUseNativeHostCapability('network.status')) {
return false;
}
try {
return normalizeHostNetworkStatus(
await requestNativeAppHostBridge<NetworkStatusResult>('network.status'),
);
} catch {
return false;
}
}
export function subscribeHostNetworkStatusChange(
listener: (payload: HostNetworkStatusSnapshot) => void,
) {
if (!canUseNativeHostCapability('network.statusChanged')) {
return () => undefined;
}
return subscribeNativeAppHostBridgeEvent<NetworkStatusResult>(
'network.statusChanged',
(payload) => {
listener(normalizeHostNetworkStatus(payload));
},
);
}