收紧移动壳外链与扫码边界

移动壳外链原生异常改由壳层记录日志

移动扫码请求使用共享超时自动清理 pending 状态

原生壳结构门禁补充期望清单唯一性自检

项目记忆同步移动壳边界验收约定
This commit is contained in:
2026-06-20 16:37:06 +08:00
parent 3661ca69ee
commit 2f16a43000
9 changed files with 136 additions and 27 deletions
+23 -8
View File
@@ -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',
);
@@ -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',
@@ -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();
+17 -1
View File
@@ -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<typeof setTimeout>;
};
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<ScannerScanQrCodeResult> {
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();
@@ -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();
});
@@ -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 主动导航只解析同源网页目标', () => {
+4 -8
View File
@@ -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(
@@ -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`
+32
View File
@@ -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',