锁定移动壳外链导航失败响应
移动壳外链原生探测失败返回稳定不可打开结果 移动壳外链原生打开失败返回稳定不可打开结果 移动壳 HostBridge 外链异常返回稳定错误 移动壳配置检查补充外链原生失败边界反查
This commit is contained in:
@@ -159,6 +159,8 @@ const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
|
||||
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
|
||||
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
|
||||
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
|
||||
const navigationTestPath = new URL('../src/shell/navigation.test.ts', import.meta.url);
|
||||
const navigationTestSource = fs.readFileSync(navigationTestPath, 'utf8');
|
||||
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
|
||||
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
|
||||
const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url);
|
||||
@@ -1767,6 +1769,8 @@ for (const snippet of [
|
||||
'return normalizeHostBridgeExternalUrl(rawUrl)',
|
||||
'navigator.canOpenURL(externalUrl)',
|
||||
'navigator.openURL(externalUrl)',
|
||||
'catch {',
|
||||
'return false;',
|
||||
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
|
||||
'new URL(rawUrl, allowedOrigin).toString()',
|
||||
]) {
|
||||
@@ -1775,6 +1779,14 @@ for (const snippet of [
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!navigationTestSource.includes('WebView 外链原生探测或打开失败时留在壳内') ||
|
||||
!navigationTestSource.includes('native canOpenURL failed') ||
|
||||
!navigationTestSource.includes('native openURL failed')
|
||||
) {
|
||||
throw new Error('mobile shell navigation tests must cover native external open failures');
|
||||
}
|
||||
|
||||
if (
|
||||
navigationSource.includes('javascript:') ||
|
||||
navigationSource.includes('mailto:') ||
|
||||
@@ -2194,6 +2206,7 @@ for (const snippet of [
|
||||
'Linking.openURL',
|
||||
'javascript:alert(1)',
|
||||
'external URL cannot be opened',
|
||||
'converts native external open exceptions to stable host_error',
|
||||
'navigation.openNativePage unsupported in mobile shell',
|
||||
'app.reloadWebView unsupported in mobile shell',
|
||||
'hostCapabilities',
|
||||
@@ -2385,8 +2398,8 @@ for (const snippet of [
|
||||
'shellHarness.networkListeners[0]?.({',
|
||||
"type: 'genarrative.mobile.historyState'",
|
||||
'mobile host event failed for network.statusChanged',
|
||||
'external WebView navigation open failure is logged instead of hidden',
|
||||
'mobile shell navigation failed for external_navigation.open',
|
||||
'external WebView navigation native failures stay outside the WebView',
|
||||
"expect(Linking.openURL).toHaveBeenCalledWith(",
|
||||
]) {
|
||||
if (!shellAppTestSource.includes(snippet)) {
|
||||
throw new Error(`mobile shell ShellApp HostBridge event test missing ${snippet}`);
|
||||
|
||||
@@ -587,7 +587,31 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
});
|
||||
|
||||
test('原生异常对象不会透传非协议错误码', async () => {
|
||||
vi.mocked(Linking.canOpenURL).mockRejectedValue({
|
||||
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber)
|
||||
.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'native_badge_failure',
|
||||
message: 'native badge failed',
|
||||
nativeStackIOS: ['private native frame'],
|
||||
};
|
||||
});
|
||||
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const failedResponse = expectFailed(response);
|
||||
|
||||
expect(failedResponse.error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'mobile host bridge request failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('app.openExternalUrl 原生异常返回稳定 host_error', async () => {
|
||||
vi.mocked(Linking.canOpenURL).mockRejectedValueOnce({
|
||||
code: 'native_linking_failure',
|
||||
message: 'native linking failed',
|
||||
nativeStackIOS: ['private native frame'],
|
||||
@@ -603,7 +627,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
|
||||
expect(failedResponse.error).toEqual({
|
||||
code: 'host_error',
|
||||
message: 'mobile host bridge request failed',
|
||||
message: 'external URL cannot be opened',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,40 @@ describe('mobile HostBridge navigation helpers', () => {
|
||||
expect(Linking.openURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('converts native external open exceptions to stable host_error', async () => {
|
||||
vi.mocked(Linking.canOpenURL).mockRejectedValueOnce(
|
||||
new Error('native canOpenURL failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
openMobileHostBridgeExternalUrl(
|
||||
request('app.openExternalUrl', {
|
||||
url: 'https://example.com/path',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'external URL cannot be opened',
|
||||
});
|
||||
expect(Linking.openURL).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(Linking.canOpenURL).mockResolvedValueOnce(true);
|
||||
vi.mocked(Linking.openURL).mockRejectedValueOnce(
|
||||
new Error('native openURL failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
openMobileHostBridgeExternalUrl(
|
||||
request('app.openExternalUrl', {
|
||||
url: 'https://example.com/path',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'host_error',
|
||||
message: 'external URL cannot be opened',
|
||||
});
|
||||
});
|
||||
|
||||
test('opens same-origin native page targets in the WebView with host context', () => {
|
||||
const nav = navigation();
|
||||
|
||||
|
||||
@@ -450,17 +450,18 @@ describe('ShellApp HostBridge event injection', () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('external WebView navigation open failure is logged instead of hidden', async () => {
|
||||
test('external WebView navigation native failures stay outside the WebView', async () => {
|
||||
const ShellApp = await importShellApp();
|
||||
render(<ShellApp />);
|
||||
|
||||
const webViewProps = shellHarness.webViewProps.current as {
|
||||
onShouldStartLoadWithRequest?: (request: { url: string }) => boolean;
|
||||
};
|
||||
const navigationError = new Error('system browser unavailable');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const { Linking } = await import('react-native');
|
||||
vi.mocked(Linking.openURL).mockRejectedValueOnce(navigationError);
|
||||
vi.mocked(Linking.openURL).mockRejectedValueOnce(
|
||||
new Error('system browser unavailable'),
|
||||
);
|
||||
|
||||
expect(
|
||||
webViewProps.onShouldStartLoadWithRequest?.({
|
||||
@@ -469,11 +470,14 @@ describe('ShellApp HostBridge event injection', () => {
|
||||
).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'mobile shell navigation failed for external_navigation.open',
|
||||
navigationError,
|
||||
expect(Linking.openURL).toHaveBeenCalledWith(
|
||||
'https://outside.example/work/1',
|
||||
);
|
||||
});
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
'mobile shell navigation failed for external_navigation.open',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
type MobileShellExternalNavigator,
|
||||
openMobileShellExternalNavigation,
|
||||
resolveMobileShellExternalUrl,
|
||||
resolveMobileShellWebViewUrl,
|
||||
@@ -100,6 +101,30 @@ describe('shouldOpenInMobileShellWebView', () => {
|
||||
expect(navigator.openURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('WebView 外链原生探测或打开失败时留在壳内', async () => {
|
||||
const navigator: MobileShellExternalNavigator = {
|
||||
canOpenURL: vi.fn(async () => {
|
||||
throw new Error('native canOpenURL failed');
|
||||
}),
|
||||
openURL: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
|
||||
).resolves.toBe(false);
|
||||
expect(navigator.openURL).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(navigator.canOpenURL).mockReset();
|
||||
vi.mocked(navigator.canOpenURL).mockResolvedValue(true);
|
||||
vi.mocked(navigator.openURL).mockRejectedValueOnce(
|
||||
new Error('native openURL failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('HostBridge 主动导航只解析同源网页目标', () => {
|
||||
const allowedOrigin = 'https://app.genarrative.world';
|
||||
|
||||
|
||||
@@ -42,12 +42,20 @@ export async function openMobileShellExternalNavigation(
|
||||
rawUrl: string,
|
||||
) {
|
||||
const externalUrl = resolveMobileShellExternalUrl(rawUrl);
|
||||
if (!externalUrl || !(await navigator.canOpenURL(externalUrl))) {
|
||||
if (!externalUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await navigator.openURL(externalUrl);
|
||||
return true;
|
||||
try {
|
||||
if (!(await navigator.canOpenURL(externalUrl))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await navigator.openURL(externalUrl);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAcceptMobileShellHostBridgeMessage(
|
||||
|
||||
Reference in New Issue
Block a user