接入移动端原生扫码能力

新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。

接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。

让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。

同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
This commit is contained in:
2026-06-19 05:13:21 +08:00
parent a471e69455
commit e4b9cbf09d
26 changed files with 758 additions and 28 deletions
@@ -16,6 +16,7 @@ type BarcodeDetectorConstructorLike = new (options?: {
export type PlatformProfileQrScannerModalProps = {
error: string | null;
mode?: 'camera' | 'resultOnly';
result: string | null;
onClose: () => void;
onError: (message: string) => void;
@@ -37,6 +38,7 @@ function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null
*/
export function PlatformProfileQrScannerModal({
error,
mode = 'camera',
result,
onClose,
onError,
@@ -47,7 +49,7 @@ export function PlatformProfileQrScannerModal({
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
if (!videoElement || mode === 'resultOnly') {
return;
}
@@ -141,7 +143,7 @@ export function PlatformProfileQrScannerModal({
clearScanTimer();
stopCamera();
};
}, [onError, onResult]);
}, [mode, onError, onResult]);
return (
<PlatformProfileModalShell
@@ -162,15 +164,17 @@ export function PlatformProfileQrScannerModal({
/>
</div>
<div className="space-y-3 px-5 py-5">
<div className="platform-qr-scanner-modal__viewport">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
muted
/>
<span className="platform-qr-scanner-modal__frame" />
</div>
{mode === 'camera' ? (
<div className="platform-qr-scanner-modal__viewport">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
muted
/>
<span className="platform-qr-scanner-modal__frame" />
</div>
) : null}
{result ? (
<PlatformStatusMessage
tone="success"
@@ -3175,6 +3175,40 @@ test('profile scan action opens camera scanner instead of recharge panel', async
expect(stopTrack).toHaveBeenCalledTimes(1);
});
test('profile scan action uses native QR scanner before browser camera', async () => {
const user = userEvent.setup();
const getUserMedia = vi.fn();
const scanHostQrCodeSpy = vi
.spyOn(hostBridgeServices, 'scanHostQrCode')
.mockResolvedValue({
value: 'https://app.genarrative.world/works/detail?work=PZ-1',
format: 'qr_code',
});
mockNarrowMobileLayout();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
renderProfileView();
const topbar = document.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
await user.click(
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
);
const qrScannerDialog = await screen.findByRole('dialog', { name: '扫码' });
expect(scanHostQrCodeSpy).toHaveBeenCalledTimes(1);
expect(qrScannerDialog.textContent).toContain(
'已识别:https://app.genarrative.world/works/detail?work=PZ-1',
);
expect(getUserMedia).not.toHaveBeenCalled();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('desktop account entry uses saved avatar image when available', async () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
+32 -1
View File
@@ -73,6 +73,7 @@ import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
scanHostQrCode,
} from '../../services/host-bridge/hostBridge';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import type { CustomWorldProfile } from '../../types';
@@ -2595,6 +2596,9 @@ export function RpgEntryHomeView({
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
const [qrScannerMode, setQrScannerMode] = useState<'camera' | 'resultOnly'>(
'camera',
);
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
@@ -3160,7 +3164,32 @@ export function RpgEntryHomeView({
setQrScannerError(null);
setQrScannerResult(null);
setIsQrScannerOpen(true);
setQrScannerMode('camera');
void scanHostQrCode()
.then((result) => {
if (result === null) {
return;
}
if (result) {
setQrScannerMode('resultOnly');
setQrScannerResult(result.value);
setIsQrScannerOpen(true);
return;
}
setQrScannerMode('camera');
setIsQrScannerOpen(true);
})
.catch((error: unknown) => {
setQrScannerMode('resultOnly');
setQrScannerResult(null);
setQrScannerError(
error instanceof Error ? error.message : '扫码失败,请稍后重试',
);
setIsQrScannerOpen(true);
});
};
const clearWorkSearch = () => {
setActiveWorkSearchKeyword('');
@@ -4873,9 +4902,11 @@ export function RpgEntryHomeView({
const qrScannerModal: ReactNode = isQrScannerOpen ? (
<PlatformProfileQrScannerModal
error={qrScannerError}
mode={qrScannerMode}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerMode('camera');
setQrScannerError(null);
setQrScannerResult(null);
}}
@@ -34,6 +34,7 @@ import {
requestWechatMiniProgramPhoneLogin,
resetHostRuntimeCacheForTest,
resolveHostRuntime,
scanHostQrCode,
setHostAppBadgeCount,
setHostAppTitle,
setHostShareTarget,
@@ -713,6 +714,11 @@ describe('hostBridge', () => {
? {
text: '作品号 PZ-1',
}
: request.method === 'scanner.scanQrCode'
? {
value: ' https://app.genarrative.world/works/detail?work=PZ-1 ',
format: 'qr_code',
}
: request.method === 'file.importImage'
? {
action: 'selected',
@@ -770,6 +776,7 @@ describe('hostBridge', () => {
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
@@ -848,6 +855,10 @@ describe('hostBridge', () => {
mimeType: 'image/jpeg',
bytes: 6,
});
await expect(scanHostQrCode()).resolves.toEqual({
value: 'https://app.genarrative.world/works/detail?work=PZ-1',
format: 'qr_code',
});
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
@@ -983,6 +994,12 @@ describe('hostBridge', () => {
timeoutMs: 30000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'scanner.scanQrCode',
timeoutMs: 60000,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
@@ -1072,6 +1089,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
@@ -1106,6 +1124,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
@@ -1599,6 +1618,66 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
});
test('原生 App 宿主取消扫码时不触发 H5 摄像头回退', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: false,
error: {
code: 'cancelled',
message: 'qr scan cancelled',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['scanner.scanQrCode']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(scanHostQrCode()).resolves.toBeNull();
});
test('原生 App 宿主返回非法扫码内容时回退为 false', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
value: 'bad\nvalue',
format: 'qr_code',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['scanner.scanQrCode']),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(scanHostQrCode()).resolves.toBe(false);
});
test('原生 App 宿主未声明拍摄图片能力时回退为 false', async () => {
window.history.replaceState(
null,
+28
View File
@@ -22,6 +22,7 @@ import type {
NavigationCanGoBackEventPayload,
NetworkStatusResult,
OpenExternalUrlPayload,
ScannerScanQrCodeResult,
SetBadgeCountPayload,
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
@@ -34,6 +35,7 @@ import {
normalizeHostBridgeExternalUrl,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
normalizeHostBridgeQrCodeValue,
} from '../../../packages/shared/src/contracts/hostBridge';
import type {
WechatMiniProgramPayParams,
@@ -107,6 +109,8 @@ export type HostFileImportTextResult = FileImportTextResult;
export type HostFileImportAudioResult = FileImportAudioResult;
export type HostScannerScanQrCodeResult = ScannerScanQrCodeResult;
export type HostClipboardWriteTextRequest = {
text: string;
};
@@ -901,6 +905,30 @@ export async function captureHostImageFile() {
}
}
export async function scanHostQrCode() {
if (!canUseNativeHostCapability('scanner.scanQrCode')) {
return false;
}
try {
const result =
await requestNativeAppHostBridge<ScannerScanQrCodeResult>(
'scanner.scanQrCode',
undefined,
{ timeoutMs: 60000 },
);
return normalizeHostBridgeQrCodeValue(result?.value) ?? false;
} catch (error) {
if (error instanceof Error && error.name === 'cancelled') {
return null;
}
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/plain',
'text/markdown',
@@ -12,7 +12,7 @@ import {
} from '../../../packages/shared/src/contracts/hostBridge';
const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000;
const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 30000;
const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 60000;
type NativeAppBridgeWindow = Window & {
ReactNativeWebView?: {