接入移动端原生扫码能力

新增 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
@@ -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?: {