接入移动端原生扫码能力

新增 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
@@ -22,6 +22,7 @@ import {
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
normalizeHostBridgeQrCodeValue,
normalizeHostBridgeRequestId,
} from './hostBridge';
@@ -87,6 +88,7 @@ describe('HostBridge shared contract helpers', () => {
expect(isHostBridgeCapability('file.importText')).toBe(true);
expect(isHostBridgeCapability('file.importImage')).toBe(true);
expect(isHostBridgeCapability('file.captureImage')).toBe(true);
expect(isHostBridgeCapability('scanner.scanQrCode')).toBe(true);
expect(isHostBridgeCapability('file.importAudio')).toBe(true);
expect(isHostBridgeCapability('file.exportAudio')).toBe(true);
expect(isHostBridgeCapability('file.imageDropped')).toBe(true);
@@ -140,6 +142,9 @@ describe('HostBridge shared contract helpers', () => {
expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain(
'file.captureImage',
);
expect(HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES).toContain(
'scanner.scanQrCode',
);
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain('app.setTitle');
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).toContain(
'file.imageDropped',
@@ -147,6 +152,9 @@ describe('HostBridge shared contract helpers', () => {
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain(
'file.captureImage',
);
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain(
'scanner.scanQrCode',
);
expect(HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES).not.toContain(
'haptics.impact',
);
@@ -170,6 +178,20 @@ describe('HostBridge shared contract helpers', () => {
expect(normalizeHostBridgeClipboardText(null)).toBeNull();
});
test('归一化宿主二维码扫码结果', () => {
expect(normalizeHostBridgeQrCodeValue(' https://example.com/a ')).toEqual({
value: 'https://example.com/a',
format: 'qr_code',
});
expect(normalizeHostBridgeQrCodeValue('')).toBeNull();
expect(normalizeHostBridgeQrCodeValue('bad\nvalue')).toBeNull();
expect(normalizeHostBridgeQrCodeValue(null)).toBeNull();
expect(normalizeHostBridgeQrCodeValue('a'.repeat(4100))).toEqual({
value: 'a'.repeat(4096),
format: 'qr_code',
});
});
test('归一化宿主本地通知内容', () => {
expect(
normalizeHostBridgeLocalNotification({
@@ -36,6 +36,7 @@ export const HOST_BRIDGE_METHODS = [
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -91,6 +92,7 @@ export const HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES: readonly HostBridgeCapab
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -461,6 +463,31 @@ export type FileImportImageResult = {
};
};
export type ScannerScanQrCodeResult = {
value: string;
format: 'qr_code';
};
export const HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH = 4096;
export function normalizeHostBridgeQrCodeValue(
rawValue: unknown,
): ScannerScanQrCodeResult | null {
if (typeof rawValue !== 'string') {
return null;
}
const value = rawValue.trim();
if (!value || hasHostBridgeControlCharacter(value)) {
return null;
}
return {
value: value.slice(0, HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH),
format: 'qr_code',
};
}
export type HostBridgeAudioMimeType =
| 'audio/mpeg'
| 'audio/mp4'