接入移动端原生扫码能力
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。 接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。 让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。 同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user