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