diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index a1bf7bc51..78afe9af2 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2402,6 +2402,7 @@ for (const snippet of [ "message: 'qr scanner unavailable'", "message: 'qr scan cancelled'", "test('logs and rejects the active scan when camera permission request fails'", + "test('ignores late camera permission after the scan is cancelled'", "'mobile QR scanner permission request failed'", "barcodeTypes: ['qr']", "type: 'qr'", diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx index 274c99437..f5ca3fcd1 100644 --- a/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import * as CameraModule from 'expo-camera'; import React from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -62,6 +62,23 @@ const cameraPermissionMock = vi.mocked( CameraModule.Camera.requestCameraPermissionsAsync, ); +function createDeferredPermission() { + let resolvePermission!: ( + permission: Awaited< + ReturnType + >, + ) => void; + const promise = new Promise< + Awaited> + >((resolve) => { + resolvePermission = resolve; + }); + return { + promise, + resolvePermission, + }; +} + describe('QrScannerOverlay', () => { afterEach(() => { resetQrScannerForTest(); @@ -156,4 +173,32 @@ describe('QrScannerOverlay', () => { message: 'qr scan cancelled', }); }); + + test('ignores late camera permission after the scan is cancelled', async () => { + const permission = createDeferredPermission(); + cameraPermissionMock.mockReturnValue(permission.promise); + const screen = render(); + + const scanPromise = scanQrCode(); + + await waitFor(() => { + expect(screen.getByText('关闭')).toBeTruthy(); + }); + + screen.getByText('关闭').click(); + + await expect(scanPromise).rejects.toMatchObject({ + code: 'cancelled', + message: 'qr scan cancelled', + }); + + await act(async () => { + permission.resolvePermission({ + granted: true, + } as Awaited>); + await permission.promise; + }); + + expect(overlayHarness.cameraViewProps.current).toBeNull(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9e97b0904..c0dd38901 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-20 移动扫码权限异步取消边界 + +- 背景:Expo 移动壳 `scanner.scanQrCode` 会打开真实相机权限请求和扫码 overlay;如果用户在系统权限 Promise 返回前关闭扫码,旧权限结果不能重新激活 CameraView,也不能完成已经取消的 HostBridge 请求。 +- 决策:`QrScannerOverlay` 必须在 active/requestKey 变化和组件清理时忽略迟到的权限结果;移动壳配置检查必须反查“取消后迟到权限不重新打开 CameraView”的测试用例。 +- 影响范围:`apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx`、`apps/mobile-shell/scripts/check-config.mjs`。 +- 验证方式:`npm run mobile-shell:test -- src/shell/QrScannerOverlay.test.tsx`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + ## 2026-06-20 桌面壳系统能力调用顺序门禁 - 背景:Tauri 桌面壳文件导出和本地通知已经在运行时代码中先校验 HostBridge payload,再打开系统保存对话框、读取通知权限或请求通知权限;如果后续重构把系统能力调用提前,非法请求会触达原生系统边界。