diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 5ca3bab41..6e015df15 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1817,7 +1817,6 @@ for (const snippet of [ 'return normalizeHostBridgeExternalUrl(rawUrl)', 'navigator.canOpenURL(externalUrl)', 'navigator.openURL(externalUrl)', - 'catch {', 'return false;', 'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)', 'new URL(rawUrl, allowedOrigin).toString()', @@ -1828,9 +1827,11 @@ for (const snippet of [ } if ( - !navigationTestSource.includes('WebView 外链原生探测或打开失败时留在壳内') || + !navigationTestSource.includes('WebView 外链原生探测或打开失败时抛给壳层记录') || !navigationTestSource.includes('native canOpenURL failed') || - !navigationTestSource.includes('native openURL failed') + !navigationTestSource.includes('native openURL failed') || + !navigationTestSource.includes(").rejects.toThrow('native canOpenURL failed')") || + !navigationTestSource.includes(").rejects.toThrow('native openURL failed')") ) { throw new Error('mobile shell navigation tests must cover native external open failures'); } @@ -2272,9 +2273,8 @@ if ( !hostBridgeNavigationSource.includes( 'const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(', ) || - !hostBridgeNavigationSource.includes( - 'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)', - ) + !hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(') || + !hostBridgeNavigationSource.includes('externalUrlPayload.url') ) { throw new Error( 'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper', @@ -2491,6 +2491,8 @@ for (const snippet of [ 'mobile host event failed for network.statusChanged', 'external WebView navigation native failures stay outside the WebView', "expect(Linking.openURL).toHaveBeenCalledWith(", + "'mobile shell navigation failed for external_navigation.open'", + 'expect.any(Error)', ]) { if (!shellAppTestSource.includes(snippet)) { throw new Error(`mobile shell ShellApp HostBridge event test missing ${snippet}`); @@ -2505,25 +2507,31 @@ if ( } for (const scannerSnippet of [ + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', 'scanMobileHostBridgeQrCode(request: HostBridgeRequest)', 'ok(request, await scanQrCode())', 'normalizeHostBridgeQrCodeValue', + "hostBridgeError('timeout', 'qr scan timed out')", "hostBridgeError('cancelled', 'qr scan cancelled')", "hostBridgeError('host_error', 'qr scanner unavailable')", + 'clearTimeout(pendingQrScan.timeout)', ]) { if (!scannerSource.includes(scannerSnippet)) { throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`); } } for (const scannerTestSnippet of [ + 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', 'subscribeQrScannerState', 'scanQrCode', 'scanMobileHostBridgeQrCode', 'completeQrCodeScan', 'cancelQrCodeScan', 'failQrCodeScan', + 'times out and clears the pending scan with the shared scanner timeout', 'qr scanner already active', 'qr scanner unavailable', + 'qr scan timed out', 'qr scan cancelled', 'PZ-00000001', ]) { @@ -2797,9 +2805,13 @@ for (const snippet of [ 'openMobileHostBridgeExternalUrl', 'request: HostBridgeRequest', "import * as Linking from 'expo-linking'", - 'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)', + 'openMobileShellExternalNavigation(', + 'externalUrlPayload.url', + '} catch {', + 'opened = false;', '(request.payload as OpenExternalUrlPayload | undefined)?.url', 'normalizeHostBridgeExternalUrlPayload', + "message: 'external URL cannot be opened'", 'openMobileHostBridgeNativePage', '(request.payload as NavigateNativePagePayload | undefined)?.url', 'resolveMobileShellWebViewUrl', @@ -2812,7 +2824,10 @@ for (const snippet of [ } } -if (!hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(Linking, externalUrlPayload.url)')) { +if ( + !hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(') || + !hostBridgeNavigationSource.includes('externalUrlPayload.url') +) { throw new Error( 'mobile shell HostBridge external URL flow must use the shared external navigation helper', ); diff --git a/apps/mobile-shell/src/host-bridge/navigation.ts b/apps/mobile-shell/src/host-bridge/navigation.ts index cffc109d3..3533b7ee8 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.ts @@ -30,9 +30,16 @@ export async function openMobileHostBridgeExternalUrl( throw invalidRequest('url must use an allowed external protocol'); } - if ( - !(await openMobileShellExternalNavigation(Linking, externalUrlPayload.url)) - ) { + let opened = false; + try { + opened = await openMobileShellExternalNavigation( + Linking, + externalUrlPayload.url, + ); + } catch { + opened = false; + } + if (!opened) { throw { code: 'host_error', message: 'external URL cannot be opened', diff --git a/apps/mobile-shell/src/host-bridge/scanner.test.ts b/apps/mobile-shell/src/host-bridge/scanner.test.ts index fb41352eb..787c3a6df 100644 --- a/apps/mobile-shell/src/host-bridge/scanner.test.ts +++ b/apps/mobile-shell/src/host-bridge/scanner.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { + HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeRequest, @@ -124,6 +125,32 @@ describe('mobile QR scanner helpers', () => { }); }); + test('times out and clears the pending scan with the shared scanner timeout', async () => { + vi.useFakeTimers(); + const listener = vi.fn(); + subscribeQrScannerState(listener); + const pendingScan = scanQrCode(); + + vi.advanceTimersByTime(HOST_BRIDGE_SCANNER_TIMEOUT_MS); + + await expect(pendingScan).rejects.toEqual({ + code: 'timeout', + message: 'qr scan timed out', + }); + expect(listener).toHaveBeenLastCalledWith({ + active: false, + requestKey: 0, + }); + + const nextScan = scanQrCode(); + expect(completeQrCodeScan('PZ-after-timeout')).toBe(true); + await expect(nextScan).resolves.toEqual({ + value: 'PZ-after-timeout', + format: 'qr_code', + }); + vi.useRealTimers(); + }); + test('ignores completion, cancellation and failure without a pending scan', () => { expect(completeQrCodeScan('PZ-1')).toBe(false); expect(() => cancelQrCodeScan()).not.toThrow(); diff --git a/apps/mobile-shell/src/host-bridge/scanner.ts b/apps/mobile-shell/src/host-bridge/scanner.ts index 313bfb8e7..b80cbdfa6 100644 --- a/apps/mobile-shell/src/host-bridge/scanner.ts +++ b/apps/mobile-shell/src/host-bridge/scanner.ts @@ -1,4 +1,5 @@ import { + HOST_BRIDGE_SCANNER_TIMEOUT_MS, type HostBridgeError, type HostBridgeRequest, type ScannerScanQrCodeResult, @@ -15,6 +16,7 @@ type PendingQrScan = { requestKey: number; resolve: (result: ScannerScanQrCodeResult) => void; reject: (error: HostBridgeError) => void; + timeout: ReturnType; }; const scannerListeners = new Set<(state: QrScannerState) => void>(); @@ -44,6 +46,9 @@ function emitQrScannerState() { } function clearPendingQrScan() { + if (pendingQrScan) { + clearTimeout(pendingQrScan.timeout); + } pendingQrScan = null; emitQrScannerState(); } @@ -68,10 +73,18 @@ export function scanQrCode(): Promise { nextQrScanRequestKey += 1; return new Promise((resolve, reject) => { + const requestKey = nextQrScanRequestKey; pendingQrScan = { - requestKey: nextQrScanRequestKey, + requestKey, resolve, reject, + timeout: setTimeout(() => { + if (pendingQrScan?.requestKey !== requestKey) { + return; + } + pendingQrScan.reject(hostBridgeError('timeout', 'qr scan timed out')); + clearPendingQrScan(); + }, HOST_BRIDGE_SCANNER_TIMEOUT_MS), }; emitQrScannerState(); }); @@ -111,6 +124,9 @@ export function failQrCodeScan() { } export function resetQrScannerForTest() { + if (pendingQrScan) { + clearTimeout(pendingQrScan.timeout); + } pendingQrScan = null; nextQrScanRequestKey = 0; scannerListeners.clear(); diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 917394086..05d2369ac 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -604,10 +604,12 @@ describe('ShellApp HostBridge event injection', () => { 'https://outside.example/work/1', ); }); - expect(warnSpy).not.toHaveBeenCalledWith( - 'mobile shell navigation failed for external_navigation.open', - expect.any(Error), - ); + await waitFor(() => { + expect(warnSpy).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 3281cc607..81613cef4 100644 --- a/apps/mobile-shell/src/shell/navigation.test.ts +++ b/apps/mobile-shell/src/shell/navigation.test.ts @@ -101,7 +101,7 @@ describe('shouldOpenInMobileShellWebView', () => { expect(navigator.openURL).not.toHaveBeenCalled(); }); - test('WebView 外链原生探测或打开失败时留在壳内', async () => { + test('WebView 外链原生探测或打开失败时抛给壳层记录', async () => { const navigator: MobileShellExternalNavigator = { canOpenURL: vi.fn(async () => { throw new Error('native canOpenURL failed'); @@ -111,7 +111,7 @@ describe('shouldOpenInMobileShellWebView', () => { await expect( openMobileShellExternalNavigation(navigator, 'https://example.com/path'), - ).resolves.toBe(false); + ).rejects.toThrow('native canOpenURL failed'); expect(navigator.openURL).not.toHaveBeenCalled(); vi.mocked(navigator.canOpenURL).mockReset(); @@ -122,7 +122,7 @@ describe('shouldOpenInMobileShellWebView', () => { await expect( openMobileShellExternalNavigation(navigator, 'https://example.com/path'), - ).resolves.toBe(false); + ).rejects.toThrow('native openURL failed'); }); test('HostBridge 主动导航只解析同源网页目标', () => { diff --git a/apps/mobile-shell/src/shell/navigation.ts b/apps/mobile-shell/src/shell/navigation.ts index 3bc6cdac7..bc6f916c8 100644 --- a/apps/mobile-shell/src/shell/navigation.ts +++ b/apps/mobile-shell/src/shell/navigation.ts @@ -46,16 +46,12 @@ export async function openMobileShellExternalNavigation( return false; } - try { - if (!(await navigator.canOpenURL(externalUrl))) { - return false; - } - - await navigator.openURL(externalUrl); - return true; - } catch { + if (!(await navigator.canOpenURL(externalUrl))) { return false; } + + await navigator.openURL(externalUrl); + return true; } export function shouldAcceptMobileShellHostBridgeMessage( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a330c8d6e..a31f31934 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3064,3 +3064,17 @@ - 决策:桌面壳配置检查必须反查每个已声明 request capability 对应的真实模块委托,并拒绝由 `unsupported_method`、`unsupported_capability` 或 fallback-only case 支撑的声明能力;根级 `check:native-shells` 新增 H5 native app route flow 合约,锁定 `/child-motion-demo` 的 `navigateHostNativePage` 调用、浏览器 fallback、路由表和命名交互测试。 - 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档。 - 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 原生壳结构门禁清单必须自检唯一性 + +- 背景:`scripts/check-native-shells.mjs` 依赖显式期望清单锁定微信、移动和桌面壳文件结构;如果期望清单自身混入重复项,目录比对仍可能失去清晰错误定位。 +- 决策:根级原生壳结构门禁在比对真实目录前必须先检查所有期望清单和微信页面文件清单的唯一性,发现重复项直接失败。 +- 影响范围:`scripts/check-native-shells.mjs`。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 移动壳外链失败与扫码超时边界 + +- 背景:Expo 移动壳外链导航会离开带 HostBridge 的主 WebView,扫码能力也会打开原生相机 overlay;如果系统外链 API 异常被 helper 吞掉,或扫码 pending 没有共享超时清理,用户会看到点击无反应或后续扫码一直提示通道占用。 +- 决策:`openMobileShellExternalNavigation(...)` 只在非法 URL 或系统明确不能打开时返回 `false`,原生 `canOpenURL` / `openURL` 异常必须抛给 `ShellApp` 的 `logMobileShellNavigationFailure(...)` 记录;`scanner.scanQrCode` pending 状态必须使用共享 `HOST_BRIDGE_SCANNER_TIMEOUT_MS` 自动拒绝并清理,成功、取消、失败和测试 reset 都必须清理 timer。 +- 影响范围:`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/navigation.test.ts`、`apps/mobile-shell/src/shell/ShellApp.test.tsx`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/scanner.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/navigation.test.ts src/shell/ShellApp.test.tsx src/host-bridge/scanner.test.ts src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 589c5c23f..fe6d75a06 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -866,6 +866,20 @@ function assertSameList(actual, expected, label) { } } +function assertUniqueList(values, label) { + const seen = new Set(); + const duplicates = values.filter((value) => { + if (seen.has(value)) { + return true; + } + seen.add(value); + return false; + }); + if (duplicates.length > 0) { + throw new Error(`${label} must not contain duplicate entries: ${duplicates.join(', ')}`); + } +} + function assertSameSet(actual, expected, label) { const sortedActual = [...actual].sort(); const sortedExpected = [...expected].sort(); @@ -2186,6 +2200,24 @@ function assertWechatShareGridFailureBoundaries() { } function assertHostBridgeLayerLayout() { + for (const [label, values] of [ + ['wechat host bridge files expectation', expectedWechatHostBridgeFiles], + ['wechat shell files expectation', expectedWechatShellFiles], + ['wechat page routes expectation', Object.keys(expectedWechatPageFilesByRoute)], + ['mobile host bridge files expectation', expectedMobileHostBridgeFiles], + ['mobile shell src root entries expectation', expectedMobileSrcRootEntries], + ['mobile shell files expectation', expectedMobileShellFiles], + ['desktop host bridge Rust files expectation', expectedDesktopHostBridgeRustFiles], + ['desktop shell Rust files expectation', expectedDesktopShellRustFiles], + ]) { + assertUniqueList(values, label); + } + for (const [route, expectedFiles] of Object.entries( + expectedWechatPageFilesByRoute, + )) { + assertUniqueList(expectedFiles, `wechat ${route} page wrapper files expectation`); + } + assertSameList( readDirectoryFileList( 'miniprogram/host-bridge',