diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index dbfc094f2..4bee3a696 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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}`); diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 07d728313..749cc236f 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -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', }); }); diff --git a/apps/mobile-shell/src/host-bridge/navigation.test.ts b/apps/mobile-shell/src/host-bridge/navigation.test.ts index 2d53eda45..adbac1cbf 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.test.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.test.ts @@ -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(); diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 336db22c4..158157122 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -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(); 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(); }); diff --git a/apps/mobile-shell/src/shell/navigation.test.ts b/apps/mobile-shell/src/shell/navigation.test.ts index c55222f0a..3281cc607 100644 --- a/apps/mobile-shell/src/shell/navigation.test.ts +++ b/apps/mobile-shell/src/shell/navigation.test.ts @@ -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'; diff --git a/apps/mobile-shell/src/shell/navigation.ts b/apps/mobile-shell/src/shell/navigation.ts index 8b291f70d..3bc6cdac7 100644 --- a/apps/mobile-shell/src/shell/navigation.ts +++ b/apps/mobile-shell/src/shell/navigation.ts @@ -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(