diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs
index 8a8b3c357..83e40a637 100644
--- a/scripts/check-native-shells.mjs
+++ b/scripts/check-native-shells.mjs
@@ -77,6 +77,7 @@ const h5HostBridgeRequiredCallChainFiles = [
'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
'src/components/platform-entry/PlatformProfilePrimitives.tsx',
+ 'src/components/platform-entry/PlatformProfileQrScannerModal.tsx',
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
@@ -430,6 +431,7 @@ const h5HostBridgeTests = [
'src/components/bark-battle-creation/BarkBattleResultView.test.tsx',
'src/components/common/CreativeAudioInputPanel.test.tsx',
'src/components/common/PublishShareModal.test.tsx',
+ 'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx',
'src/components/creation-agent/CreationAgentWorkspace.test.tsx',
'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts',
diff --git a/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx b/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx
index 12f6ecbf2..0a0373640 100644
--- a/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx
+++ b/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx
@@ -3,6 +3,7 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import { PlatformProfileQrScannerModal } from './PlatformProfileQrScannerModal';
type MockTrack = {
@@ -49,6 +50,7 @@ describe('PlatformProfileQrScannerModal', () => {
});
test('detects qr result and stops camera tracks', async () => {
+ vi.spyOn(hostBridgeServices, 'scanHostQrCode').mockResolvedValue(false);
const stop = vi.fn();
const stream = buildStream([{ stop }]);
const getUserMedia = vi.fn().mockResolvedValue(stream);
@@ -84,6 +86,7 @@ describe('PlatformProfileQrScannerModal', () => {
});
test('releases camera resource when modal unmounts before recognition', async () => {
+ vi.spyOn(hostBridgeServices, 'scanHostQrCode').mockResolvedValue(false);
const stop = vi.fn();
const stream = buildStream([{ stop }]);
const getUserMedia = vi.fn().mockResolvedValue(stream);
@@ -112,6 +115,59 @@ describe('PlatformProfileQrScannerModal', () => {
expect(stop).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: '扫码' })).toBeNull();
});
+
+ test('uses native QR scanner before browser camera', async () => {
+ const getUserMedia = vi.fn();
+ const onResult = vi.fn();
+ vi.spyOn(hostBridgeServices, 'scanHostQrCode').mockResolvedValue({
+ value: 'https://app.genarrative.world/works/detail?work=PZ-1',
+ format: 'qr_code',
+ });
+ installMediaDevices(getUserMedia);
+
+ render(
+ ,
+ );
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(onResult).toHaveBeenCalledWith(
+ 'https://app.genarrative.world/works/detail?work=PZ-1',
+ );
+ expect(getUserMedia).not.toHaveBeenCalled();
+ });
+
+ test('closes scanner without browser fallback when native scan is cancelled', async () => {
+ const getUserMedia = vi.fn();
+ const onClose = vi.fn();
+ vi.spyOn(hostBridgeServices, 'scanHostQrCode').mockResolvedValue(null);
+ installMediaDevices(getUserMedia);
+
+ render(
+ ,
+ );
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(getUserMedia).not.toHaveBeenCalled();
+ });
});
function buildStream(tracks: MockTrack[]): MockStream {
diff --git a/src/components/platform-entry/PlatformProfileQrScannerModal.tsx b/src/components/platform-entry/PlatformProfileQrScannerModal.tsx
index a36d813f7..a59c587ab 100644
--- a/src/components/platform-entry/PlatformProfileQrScannerModal.tsx
+++ b/src/components/platform-entry/PlatformProfileQrScannerModal.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
+import { scanHostQrCode } from '../../services/host-bridge/hostBridge';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
@@ -102,7 +103,7 @@ export function PlatformProfileQrScannerModal({
}
};
- const startCamera = async () => {
+ const startBrowserCamera = async () => {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
@@ -136,7 +137,28 @@ export function PlatformProfileQrScannerModal({
}
};
- void startCamera();
+ const startScanner = async () => {
+ try {
+ const nativeResult = await scanHostQrCode();
+ if (!isMounted) {
+ return;
+ }
+ if (nativeResult) {
+ onResult(nativeResult.value);
+ return;
+ }
+ if (nativeResult === null) {
+ onClose();
+ return;
+ }
+ } catch {
+ // 浏览器摄像头路径继续承接不支持或异常宿主。
+ }
+
+ await startBrowserCamera();
+ };
+
+ void startScanner();
return () => {
isMounted = false;