接入移动端原生扫码能力

新增 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
@@ -27,6 +27,11 @@ import {
handleMobileHostBridgeMessage,
resetMobileHostBridgeForTest,
} from './bridge';
import {
cancelQrCodeScan,
completeQrCodeScan,
failQrCodeScan,
} from './scanner';
type NotificationPermissionStatus =
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
@@ -369,6 +374,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.captureImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('scanner.scanQrCode');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importAudio');
@@ -1477,6 +1485,37 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(cancelled).error.code).toBe('cancelled');
});
test('scanner.scanQrCode 等待扫码 overlay 返回真实结果', async () => {
const pendingResponse = send(request('scanner.scanQrCode'));
expect(completeQrCodeScan(' https://app.genarrative.world/w/PZ-1 ')).toBe(
true,
);
expect(expectOk(await pendingResponse).result).toEqual({
value: 'https://app.genarrative.world/w/PZ-1',
format: 'qr_code',
});
});
test('scanner.scanQrCode 取消和权限拒绝返回明确错误', async () => {
const cancelledResponse = send(request('scanner.scanQrCode'));
cancelQrCodeScan();
expect(expectFailed(await cancelledResponse).error).toEqual({
code: 'cancelled',
message: 'qr scan cancelled',
});
const deniedResponse = send(request('scanner.scanQrCode'));
failQrCodeScan('camera permission denied');
expect(expectFailed(await deniedResponse).error).toEqual({
code: 'host_error',
message: 'camera permission denied',
});
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
@@ -45,6 +45,7 @@ import {
ok,
unsupported,
} from './protocol';
import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import { buildMobileShellUrl } from '../shell/url';
@@ -279,6 +280,8 @@ export async function dispatchMobileHostBridgeRequest(
return ok(request, await importImageFile());
case 'file.captureImage':
return ok(request, await captureImageFile());
case 'scanner.scanQrCode':
return ok(request, await scanQrCode());
case 'file.importAudio':
return ok(request, await importAudioFile());
case 'file.exportAudio':
@@ -310,4 +313,5 @@ export async function dispatchMobileHostBridgeRequest(
export function resetMobileHostBridgeDispatchForTest() {
currentShareTarget = null;
navigation = null;
resetQrScannerForTest();
}
@@ -0,0 +1,111 @@
import {
type HostBridgeError,
type ScannerScanQrCodeResult,
normalizeHostBridgeQrCodeValue,
} from '../../../../packages/shared/src/contracts/hostBridge';
type QrScannerState = {
active: boolean;
requestKey: number;
};
type PendingQrScan = {
requestKey: number;
resolve: (result: ScannerScanQrCodeResult) => void;
reject: (error: HostBridgeError) => void;
};
const scannerListeners = new Set<(state: QrScannerState) => void>();
let pendingQrScan: PendingQrScan | null = null;
let nextQrScanRequestKey = 0;
function hostBridgeError(
code: HostBridgeError['code'],
message: string,
): HostBridgeError {
return { code, message };
}
function currentQrScannerState(): QrScannerState {
return {
active: Boolean(pendingQrScan),
requestKey: pendingQrScan?.requestKey ?? 0,
};
}
function emitQrScannerState() {
const state = currentQrScannerState();
for (const listener of scannerListeners) {
listener(state);
}
}
function clearPendingQrScan() {
pendingQrScan = null;
emitQrScannerState();
}
export function subscribeQrScannerState(
listener: (state: QrScannerState) => void,
) {
scannerListeners.add(listener);
listener(currentQrScannerState());
return () => {
scannerListeners.delete(listener);
};
}
export function scanQrCode(): Promise<ScannerScanQrCodeResult> {
if (pendingQrScan) {
return Promise.reject(
hostBridgeError('host_error', 'qr scanner already active'),
);
}
nextQrScanRequestKey += 1;
return new Promise((resolve, reject) => {
pendingQrScan = {
requestKey: nextQrScanRequestKey,
resolve,
reject,
};
emitQrScannerState();
});
}
export function completeQrCodeScan(rawValue: unknown) {
const result = normalizeHostBridgeQrCodeValue(rawValue);
if (!result || !pendingQrScan) {
return false;
}
pendingQrScan.resolve(result);
clearPendingQrScan();
return true;
}
export function cancelQrCodeScan() {
if (!pendingQrScan) {
return;
}
pendingQrScan.reject(hostBridgeError('cancelled', 'qr scan cancelled'));
clearPendingQrScan();
}
export function failQrCodeScan(message: string) {
if (!pendingQrScan) {
return;
}
pendingQrScan.reject(hostBridgeError('host_error', message));
clearPendingQrScan();
}
export function resetQrScannerForTest() {
pendingQrScan = null;
nextQrScanRequestKey = 0;
scannerListeners.clear();
}
@@ -0,0 +1,141 @@
import {
Camera,
CameraView,
type BarcodeScanningResult,
} from 'expo-camera';
import { useCallback, useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import {
cancelQrCodeScan,
completeQrCodeScan,
failQrCodeScan,
subscribeQrScannerState,
} from '../host-bridge/scanner';
export function QrScannerOverlay() {
const [isActive, setIsActive] = useState(false);
const [requestKey, setRequestKey] = useState(0);
const [hasPermission, setHasPermission] = useState(false);
const [scanCompleted, setScanCompleted] = useState(false);
useEffect(() => subscribeQrScannerState((state) => {
setIsActive(state.active);
setRequestKey(state.requestKey);
}), []);
useEffect(() => {
if (!isActive) {
setHasPermission(false);
setScanCompleted(false);
return;
}
let disposed = false;
setHasPermission(false);
setScanCompleted(false);
void Camera.requestCameraPermissionsAsync()
.then((permission) => {
if (disposed) {
return;
}
if (!permission.granted) {
failQrCodeScan('camera permission denied');
return;
}
setHasPermission(true);
})
.catch(() => {
if (!disposed) {
failQrCodeScan('camera permission unavailable');
}
});
return () => {
disposed = true;
};
}, [isActive, requestKey]);
const handleBarcodeScanned = useCallback(
(result: BarcodeScanningResult) => {
if (scanCompleted || result.type !== 'qr') {
return;
}
if (completeQrCodeScan(result.data)) {
setScanCompleted(true);
}
},
[scanCompleted],
);
if (!isActive) {
return null;
}
return (
<View style={styles.root}>
{hasPermission ? (
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanCompleted ? undefined : handleBarcodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
/>
) : null}
<View pointerEvents="none" style={styles.frame} />
<Pressable
accessibilityRole="button"
accessibilityLabel="关闭扫码"
onPress={cancelQrCodeScan}
style={styles.closeButton}
>
<Text style={styles.closeButtonText}></Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
root: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: '#0c0a09',
},
camera: {
flex: 1,
},
frame: {
position: 'absolute',
top: '22%',
right: 42,
bottom: '22%',
left: 42,
borderWidth: 2,
borderColor: '#fffdf9',
borderRadius: 8,
},
closeButton: {
position: 'absolute',
top: 20,
right: 20,
minWidth: 76,
minHeight: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
backgroundColor: '#fffdf9',
paddingHorizontal: 16,
},
closeButtonText: {
color: '#211a16',
fontSize: 15,
fontWeight: '700',
lineHeight: 20,
},
});
+2
View File
@@ -51,6 +51,7 @@ import {
shouldBlockMobileWebViewNavigationRequest,
} from './webViewPolicy';
import { parseMobileWebViewHistoryStateMessage } from './webViewHistory';
import { QrScannerOverlay } from './QrScannerOverlay';
function buildHostBridgeMessageScript(message: unknown) {
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
@@ -371,6 +372,7 @@ export default function ShellApp() {
</Pressable>
</View>
) : null}
<QrScannerOverlay />
</SafeAreaView>
</SafeAreaProvider>
);