接入移动壳受控路由导航

移动 HostBridge 声明 navigation.openNativePage 并限制为同源 H5 route

Expo WebView 将受控导航请求切换为新的 WebView URL

补充移动壳 HostBridge 与导航解析测试和配置守卫

更新宿主壳方案、统一协议和团队共享决策记录
This commit is contained in:
2026-06-17 22:15:31 +08:00
parent 02a475d652
commit 080ebaedfd
9 changed files with 249 additions and 6 deletions

View File

@@ -0,0 +1,151 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
configureMobileHostBridgeNavigation,
handleMobileHostBridgeMessage,
} from './mobileHostBridge';
vi.mock('expo-clipboard', () => ({
setStringAsync: vi.fn(),
}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: {
Heavy: 'heavy',
Light: 'light',
Medium: 'medium',
},
impactAsync: vi.fn(),
}));
vi.mock('expo-linking', () => ({
openURL: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
Share: {
share: vi.fn(),
},
}));
function request(
method: HostBridgeMethod,
payload?: unknown,
): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'request-1',
method,
payload,
};
}
async function send(requestValue: HostBridgeRequest) {
const responses: HostBridgeResponse[] = [];
await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) =>
responses.push(response),
);
const response = responses[0];
if (!response) {
throw new Error('host bridge response missing');
}
return response;
}
function expectOk(response: HostBridgeResponse) {
if (!response.ok) {
throw new Error('expected ok host bridge response');
}
return response;
}
function expectFailed(response: HostBridgeResponse) {
if (response.ok) {
throw new Error('expected failed host bridge response');
}
return response;
}
afterEach(() => {
configureMobileHostBridgeNavigation(null);
});
describe('handleMobileHostBridgeMessage', () => {
test('runtime 能力清单声明移动壳支持受控 WebView 导航', async () => {
const response = await send(request('host.getRuntime'));
const okResponse = expectOk(response);
expect(okResponse.result).toMatchObject({
shell: 'expo_mobile',
platform: 'ios',
});
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('navigation.openNativePage');
});
test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => {
const openWebViewUrl = vi.fn();
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl,
});
const response = await send(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
);
expectOk(response);
expect(openWebViewUrl).toHaveBeenCalledWith(
'https://app.genarrative.world/works/detail?work=PZ-1',
);
});
test('navigation.openNativePage 拒绝外域目标', async () => {
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl: vi.fn(),
});
const response = await send(
request('navigation.openNativePage', {
url: 'https://example.com/works/detail?work=PZ-1',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('invalid_request');
});
test('未配置 WebView 导航器时明确返回 unsupported', async () => {
const response = await send(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('unsupported_method');
});
});

View File

@@ -13,20 +13,35 @@ import {
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
type NavigateNativePagePayload,
type OpenExternalUrlPayload,
type ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'app.openExternalUrl',
'clipboard.writeText',
'haptics.impact',
];
export type MobileHostBridgeNavigation = {
allowedOrigin: string;
openWebViewUrl: (url: string) => void;
};
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
export function configureMobileHostBridgeNavigation(
nextNavigation: MobileHostBridgeNavigation | null,
) {
navigation = nextNavigation;
}
function unsupported(method: HostBridgeMethod): HostBridgeError {
return {
@@ -141,6 +156,28 @@ async function openShare(payload: unknown) {
return true;
}
function openNativePage(payload: unknown) {
if (!navigation) {
throw unsupported('navigation.openNativePage');
}
const url = (payload as NavigateNativePagePayload | undefined)?.url;
if (typeof url !== 'string') {
throw invalidRequest('url is required');
}
const webViewUrl = resolveMobileShellWebViewUrl(
url,
navigation.allowedOrigin,
);
if (!webViewUrl) {
throw invalidRequest('url must be an allowed same-origin web path');
}
navigation.openWebViewUrl(webViewUrl);
return true;
}
async function handleRequest(request: HostBridgeRequest) {
switch (request.method) {
case 'host.getRuntime':
@@ -165,9 +202,10 @@ async function handleRequest(request: HostBridgeRequest) {
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
case 'navigation.openNativePage':
return ok(request, openNativePage(request.payload));
case 'auth.requestLogin':
case 'payment.request':
case 'navigation.openNativePage':
return failure(request, unsupported(request.method));
default:
return failure(request, unsupported(request.method));

View File

@@ -1,6 +1,9 @@
import { describe, expect, test } from 'vitest';
import { shouldOpenInMobileShellWebView } from './mobileShellNavigation';
import {
resolveMobileShellWebViewUrl,
shouldOpenInMobileShellWebView,
} from './mobileShellNavigation';
describe('shouldOpenInMobileShellWebView', () => {
test('只允许主站同源页面留在移动壳 WebView 内', () => {
@@ -33,4 +36,24 @@ describe('shouldOpenInMobileShellWebView', () => {
false,
);
});
test('HostBridge 主动导航只解析同源网页目标', () => {
const allowedOrigin = 'https://app.genarrative.world';
expect(
resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin),
).toBe('https://app.genarrative.world/works/detail?work=PZ-1');
expect(
resolveMobileShellWebViewUrl(
'https://app.genarrative.world/creation/puzzle#draft',
allowedOrigin,
),
).toBe('https://app.genarrative.world/creation/puzzle#draft');
expect(
resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin),
).toBeNull();
expect(
resolveMobileShellWebViewUrl('about:blank', allowedOrigin),
).toBeNull();
});
});

View File

@@ -25,3 +25,21 @@ export function shouldOpenInMobileShellWebView(
return false;
}
}
export function resolveMobileShellWebViewUrl(
rawUrl: string,
allowedOrigin: string,
) {
if (
rawUrl === 'about:blank' ||
!shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)
) {
return null;
}
try {
return new URL(rawUrl, allowedOrigin).toString();
} catch {
return null;
}
}