接入原生壳网络状态能力

新增 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

View File

@@ -23,6 +23,7 @@ import {
resolveMobileShellExternalUrl,
shouldOpenInMobileShellWebView,
} from './src/mobileShellNavigation';
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
import { buildMobileShellUrl } from './src/mobileShellUrl';
const defaultWebUrl = 'http://127.0.0.1:3000/';
@@ -127,6 +128,19 @@ export default function App() {
return () => subscription.remove();
}, []);
useEffect(() => {
return subscribeMobileNetworkStatus((payload) => {
webViewRef.current?.injectJavaScript(
buildHostBridgeMessageScript({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'network.statusChanged',
payload,
}),
);
});
}, []);
const handleMessage = (event: WebViewMessageEvent) => {
void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => {
webViewRef.current?.injectJavaScript(

View File

@@ -18,6 +18,7 @@
"expo-file-system": "^56.0.8",
"expo-haptics": "^56.0.3",
"expo-linking": "^56.0.14",
"expo-network": "^56.0.5",
"expo-sharing": "^56.0.18",
"expo-status-bar": "^56.0.4",
"react": "^19.0.0",

View File

@@ -119,6 +119,8 @@ for (const snippet of [
'configureMobileHostBridgeNavigation',
'AppState.addEventListener',
'app.lifecycle',
'network.statusChanged',
'subscribeMobileNetworkStatus',
'navigation.canGoBack',
'buildHostBridgeMessageScript',
]) {
@@ -127,7 +129,7 @@ for (const snippet of [
}
}
for (const dependency of ['expo-file-system', 'expo-sharing']) {
for (const dependency of ['expo-file-system', 'expo-network', 'expo-sharing']) {
if (!packageConfig.dependencies?.[dependency]) {
throw new Error(`mobile shell package missing ${dependency}`);
}
@@ -136,6 +138,8 @@ for (const dependency of ['expo-file-system', 'expo-sharing']) {
for (const snippet of [
'file.exportText',
'file.exportImage',
'network.status',
'getMobileNetworkStatus',
'Sharing.shareAsync',
'normalizeHostBridgeExportFileName',
'base64Data',
@@ -160,6 +164,8 @@ for (const capability of [
'share.open',
'share.setTarget',
'app.lifecycle',
'network.status',
'network.statusChanged',
'navigation.openNativePage',
'navigation.canGoBack',
'app.openExternalUrl',

View File

@@ -1,5 +1,6 @@
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Network from 'expo-network';
import * as Sharing from 'expo-sharing';
import {
Appearance,
@@ -69,6 +70,20 @@ vi.mock('expo-linking', () => ({
openURL: vi.fn(),
}));
vi.mock('expo-network', () => ({
getNetworkStateAsync: vi.fn(async () => ({
type: 'WIFI',
isConnected: true,
isInternetReachable: true,
})),
NetworkStateType: {
CELLULAR: 'CELLULAR',
ETHERNET: 'ETHERNET',
NONE: 'NONE',
WIFI: 'WIFI',
},
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(),
@@ -142,6 +157,12 @@ afterEach(() => {
vi.mocked(Appearance.getColorScheme).mockReturnValue('light');
vi.mocked(Haptics.impactAsync).mockReset();
vi.mocked(Linking.openURL).mockReset();
vi.mocked(Network.getNetworkStateAsync).mockReset();
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.WIFI,
isConnected: true,
isInternetReachable: true,
});
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
setPlatformOS('ios');
vi.mocked(Sharing.isAvailableAsync).mockReset();
@@ -175,6 +196,8 @@ describe('handleMobileHostBridgeMessage', () => {
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'network.status',
'network.statusChanged',
'navigation.canGoBack',
'app.setBadgeCount',
]),
@@ -288,6 +311,23 @@ describe('handleMobileHostBridgeMessage', () => {
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('network.status 返回 Expo Network 真实状态', async () => {
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.CELLULAR,
isConnected: true,
isInternetReachable: false,
});
const response = await send(request('network.status'));
expect(expectOk(response).result).toEqual({
isConnected: true,
isInternetReachable: false,
connectionType: 'cellular',
nativeType: 'CELLULAR',
});
});
test('haptics.impact 调起 Expo 触觉反馈', async () => {
const response = await send(
request('haptics.impact', {

View File

@@ -34,6 +34,7 @@ import {
type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
import { getMobileNetworkStatus } from './mobileShellNetwork';
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
@@ -54,6 +55,8 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'navigation.openNativePage',
'navigation.canGoBack',
'app.openExternalUrl',
'network.status',
'network.statusChanged',
'clipboard.writeText',
'file.exportText',
'file.exportImage',
@@ -437,6 +440,8 @@ async function handleRequest(request: HostBridgeRequest) {
return ok(request, getColorScheme());
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'network.status':
return ok(request, await getMobileNetworkStatus());
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'file.exportText':

View File

@@ -0,0 +1,84 @@
import { describe, expect, test, vi } from 'vitest';
import {
getMobileNetworkStatus,
normalizeMobileNetworkStatus,
subscribeMobileNetworkStatus,
} from './mobileShellNetwork';
vi.mock('expo-network', () => ({
addNetworkStateListener: vi.fn(),
getNetworkStateAsync: vi.fn(),
NetworkStateType: {
CELLULAR: 'CELLULAR',
ETHERNET: 'ETHERNET',
NONE: 'NONE',
WIFI: 'WIFI',
},
}));
describe('mobileShellNetwork', async () => {
const Network = await import('expo-network');
test('归一化 Expo Network 状态', () => {
expect(
normalizeMobileNetworkStatus({
type: Network.NetworkStateType.WIFI,
isConnected: true,
isInternetReachable: true,
}),
).toEqual({
isConnected: true,
isInternetReachable: true,
connectionType: 'wifi',
nativeType: 'WIFI',
});
expect(
normalizeMobileNetworkStatus({
type: Network.NetworkStateType.NONE,
}),
).toEqual({
isConnected: false,
isInternetReachable: null,
connectionType: 'none',
nativeType: 'NONE',
});
});
test('查询和订阅真实 Expo Network API', async () => {
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.CELLULAR,
isConnected: true,
isInternetReachable: false,
});
const remove = vi.fn();
vi.mocked(Network.addNetworkStateListener).mockReturnValue({ remove });
const listener = vi.fn();
await expect(getMobileNetworkStatus()).resolves.toEqual({
isConnected: true,
isInternetReachable: false,
connectionType: 'cellular',
nativeType: 'CELLULAR',
});
const unsubscribe = subscribeMobileNetworkStatus(listener);
const networkListener = vi.mocked(Network.addNetworkStateListener).mock
.calls[0]?.[0];
networkListener?.({
type: Network.NetworkStateType.ETHERNET,
isConnected: true,
isInternetReachable: true,
});
unsubscribe();
expect(listener).toHaveBeenCalledWith({
isConnected: true,
isInternetReachable: true,
connectionType: 'ethernet',
nativeType: 'ETHERNET',
});
expect(remove).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,40 @@
import * as Network from 'expo-network';
import {
type NetworkStatusResult,
normalizeHostBridgeConnectionType,
} from '../../../packages/shared/src/contracts/hostBridge';
export function normalizeMobileNetworkStatus(
state: Network.NetworkState,
): NetworkStatusResult {
const nativeType = state.type;
const connectionType = normalizeHostBridgeConnectionType(nativeType);
return {
isConnected:
typeof state.isConnected === 'boolean'
? state.isConnected
: connectionType !== 'none' && connectionType !== 'unknown',
isInternetReachable:
typeof state.isInternetReachable === 'boolean'
? state.isInternetReachable
: null,
connectionType,
...(nativeType ? { nativeType } : {}),
};
}
export async function getMobileNetworkStatus() {
return normalizeMobileNetworkStatus(await Network.getNetworkStateAsync());
}
export function subscribeMobileNetworkStatus(
listener: (status: NetworkStatusResult) => void,
) {
const subscription = Network.addNetworkStateListener((state) => {
listener(normalizeMobileNetworkStatus(state));
});
return () => subscription.remove();
}