接入移动端原生扫码能力

新增 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
+8 -3
View File
@@ -1080,7 +1080,11 @@ const desktopCapabilities = extractDesktopCapabilities(
const desktopHandledMethods = extractDesktopHandledMethods(
desktopHostBridgeDispatchSource,
);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
const hostBridgeAcceptedUnsupportedMethods = [
'auth.requestLogin',
'payment.request',
'scanner.scanQrCode',
];
if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) {
throw new Error(
`desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`,
@@ -1200,7 +1204,7 @@ assertSameList(
'desktop shell HostBridge capability profile',
);
for (const capability of sdkBackedCapabilities) {
for (const capability of ['auth.requestLogin', 'payment.request']) {
if (desktopCapabilities.includes(capability)) {
throw new Error(
`desktop shell must not declare ${capability} until a real SDK/channel flow is implemented`,
@@ -1221,7 +1225,8 @@ if (missingDesktopMethodHandlers.length > 0) {
const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter(
(method) =>
!desktopCapabilities.includes(method) && !sdkBackedCapabilities.includes(method),
!desktopCapabilities.includes(method) &&
!hostBridgeAcceptedUnsupportedMethods.includes(method),
);
if (undeclaredDesktopMethodHandlers.length > 0) {
throw new Error(
@@ -636,7 +636,7 @@ mod tests {
#[test]
fn unsupported_method_is_explicit() {
for method in ["auth.requestLogin", "payment.request"] {
for method in ["auth.requestLogin", "payment.request", "scanner.scanQrCode"] {
let response = resolve_host_bridge_request(request(method));
assert!(!response.ok);
@@ -5,7 +5,7 @@ use std::sync::{Arc, Condvar, Mutex};
pub(crate) const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
pub(crate) const HOST_BRIDGE_VERSION: u8 = 1;
pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [
pub(crate) const HOST_BRIDGE_METHODS: [&str; 24] = [
"host.getRuntime",
"appearance.getColorScheme",
"auth.requestLogin",
@@ -25,6 +25,7 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 23] = [
"file.exportImage",
"file.importImage",
"file.captureImage",
"scanner.scanQrCode",
"file.importAudio",
"file.exportAudio",
"haptics.impact",
+6
View File
@@ -19,6 +19,12 @@
"enabled": false
},
"plugins": [
[
"expo-camera",
{
"cameraPermission": "允许 Genarrative 使用相机扫描二维码。"
}
],
[
"expo-image-picker",
{
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "^56.0.12",
"expo-camera": "56.0.8",
"expo-clipboard": "^56.0.4",
"expo-document-picker": "^56.0.4",
"expo-file-system": "^56.0.8",
@@ -8,6 +8,8 @@ const appPath = new URL('../App.tsx', import.meta.url);
const appSource = fs.readFileSync(appPath, 'utf8');
const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
@@ -421,6 +423,7 @@ for (const [scriptName, expected] of Object.entries({
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '^56.0.15',
expo: '^56.0.12',
'expo-camera': '56.0.8',
'expo-clipboard': '^56.0.4',
'expo-document-picker': '^56.0.4',
'expo-file-system': '^56.0.8',
@@ -455,6 +458,7 @@ for (const [dependency, expected] of Object.entries({
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '56.0.15',
expo: '56.0.12',
'expo-camera': '56.0.8',
'expo-clipboard': '56.0.4',
'expo-document-picker': '56.0.4',
'expo-file-system': '56.0.8',
@@ -551,6 +555,7 @@ const sharedPayloadBoundaryImports = [
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'normalizeHostBridgeQrCodeValue',
'HOST_BRIDGE_TEXT_MIME_TYPES',
];
for (const boundaryImport of sharedPayloadBoundaryImports) {
@@ -574,6 +579,7 @@ const forbiddenLocalPayloadBoundaryDeclarations = [
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_QR_CODE_VALUE_MAX_LENGTH',
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
@@ -1119,6 +1125,7 @@ if (shellAppSource.includes('127.0.0.1:3000')) {
}
for (const dependency of [
'expo-camera',
'expo-file-system',
'expo-document-picker',
'expo-image-picker',
@@ -1152,6 +1159,20 @@ if (Array.isArray(imagePickerPlugin)) {
}
}
const cameraPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-camera' : plugin === 'expo-camera',
);
if (!cameraPlugin) {
throw new Error('mobile shell camera plugin is missing');
}
if (Array.isArray(cameraPlugin)) {
const pluginOptions = cameraPlugin[1] ?? {};
if (typeof pluginOptions.cameraPermission !== 'string') {
throw new Error('mobile shell camera permission text is missing');
}
}
const notificationsPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications',
);
@@ -1210,6 +1231,7 @@ for (const snippet of [
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
@@ -1228,6 +1250,12 @@ for (const snippet of [
'ImagePicker.launchCameraAsync',
'ImagePicker.requestMediaLibraryPermissionsAsync',
'ImagePicker.requestCameraPermissionsAsync',
'scanQrCode',
'normalizeHostBridgeQrCodeValue',
'completeQrCodeScan',
'cancelQrCodeScan',
'failQrCodeScan',
'subscribeQrScannerState',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
"'audio/*'",
'File(asset.uri)',
@@ -1257,6 +1285,27 @@ for (const snippet of [
}
}
for (const snippet of [
'CameraView',
'Camera.requestCameraPermissionsAsync',
'type BarcodeScanningResult',
'onBarcodeScanned',
'barcodeScannerSettings',
"barcodeTypes: ['qr']",
'completeQrCodeScan(result.data)',
'failQrCodeScan',
'cancelQrCodeScan',
'subscribeQrScannerState',
]) {
if (!qrScannerOverlaySource.includes(snippet)) {
throw new Error(`mobile shell QR scanner overlay missing ${snippet}`);
}
}
if (!shellAppSource.includes('<QrScannerOverlay />')) {
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
}
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
if (shellAppSource.includes(capabilityQuerySnippet)) {
throw new Error('mobile shell URL must resolve platform-aware capabilities');
@@ -1323,6 +1372,7 @@ for (const capability of [
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -169,7 +169,11 @@ assertEqual(
'resize',
'Android software keyboard layout mode',
);
assertSameList(expoConfig.android?.permissions ?? [], [], 'Android explicit permissions');
assertSameList(
expoConfig.android?.permissions ?? [],
['android.permission.CAMERA'],
'Android explicit permissions',
);
assertIncludes(
expoConfig.android?.blockedPermissions,
'android.permission.MANAGE_EXTERNAL_STORAGE',
@@ -262,6 +266,16 @@ assertEqual(
'image picker microphone permission',
);
const cameraPlugin = findPlugin('expo-camera');
if (!Array.isArray(cameraPlugin)) {
throw new Error('Expo config camera plugin is missing options');
}
assertEqual(
typeof cameraPlugin[1]?.cameraPermission,
'string',
'camera permission text type',
);
const notificationsPlugin = findPlugin('expo-notifications');
if (!Array.isArray(notificationsPlugin)) {
throw new Error('Expo config notifications plugin is missing options');
@@ -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>
);
@@ -48,6 +48,7 @@
- 2026-06-18 桌面图片导入:新增 `file.importImage``file.imageDropped` HostBridge capabilityTauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。
- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`
- 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI,也不请求麦克风权限;Tauri 壳不声明该能力,不伪造桌面拍摄。
- 2026-06-19 移动二维码扫描:Expo 壳新增 `scanner.scanQrCode` HostBridge capability,通过 `expo-camera` 请求相机权限并打开真实扫码 overlay,成功只返回共享契约清洗后的二维码文本和 `qr_code` 格式,空值、控制字符和超长文本按 `normalizeHostBridgeQrCodeValue` 处理;用户关闭扫码返回 `cancelled`,H5 个人中心扫码入口不再连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中返回 `unsupported_method`,不声明 capability、不伪造桌面扫码;宿主缺能力或非法结果时 H5 继续走原浏览器扫码 fallback。
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
- 2026-06-18 移动壳安全区:Expo 壳根布局使用 `react-native-safe-area-context``SafeAreaProvider` 与四边 `SafeAreaView` 保护 WebView,避免 H5 主站内容贴进 iOS 刘海、底部 Home Indicator、Android 状态栏或横屏边缘;该能力属于宿主壳布局保护,不新增 H5 占位 UI,不改变玩法 runtime 或 HostBridge capability。
- 2026-06-18 移动壳方向策略:Expo 壳 `orientation` 固定为 `default`,不锁竖屏或横屏;后续固定玩法和 AI H5 sandbox 的方向需求由设备方向、H5 响应式布局和玩法自身画布适配承接,壳层只负责安全区、WebView 容器和 HostBridge。移动壳配置检查和 Expo public config smoke 会拒绝重新锁定 portrait / landscape。
@@ -97,7 +98,7 @@
- 2026-06-18 移动壳启动页与 adaptive iconExpo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。
- 2026-06-18 桌面壳 bundle 图标集:Tauri 桌面壳从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生 `32x32.png``128x128.png``128x128@2x.png``icon.ico``icon.icns`,并在 `bundle.icon` 中同时声明这些平台图标。检查脚本会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌 / 占位素材。
- 2026-06-18 移动壳网络安全元数据:Expo 移动壳默认包配置显式禁用 Android 明文流量 `usesCleartextTraffic=false`iOS ATS 禁用任意加载 `NSAllowsArbitraryLoads=false`,并设置 `ITSAppUsesNonExemptEncryption=false` 作为当前未接入自定义加密能力的出口合规声明;本地 Vite 联调只通过 development build 显式环境变量进入,不把任意明文流量开关带进默认包配置。
- 2026-06-18 移动壳麦克风权限禁用:Expo 移动壳的 `expo-image-picker` 插件必须保持 `microphonePermission=false`Android 包配置必须通过 `android.blockedPermissions` 显式移除 `android.permission.RECORD_AUDIO`且不得在 `android.permissions` 中重新声明;移动壳现阶段没有录音、后台音频采集或远程语音 SDK,音频导入只走系统文档选择器。配置检查会拒绝麦克风权限拦截缺失被反向加入。
- 2026-06-18 移动壳麦克风权限禁用:Expo 移动壳的 `expo-image-picker` 插件必须保持 `microphonePermission=false`Android 包配置必须通过 `android.blockedPermissions` 显式移除 `android.permission.RECORD_AUDIO``app.json` 不手写 `android.permissions`,最终 Expo public config 只允许 `expo-camera``scanner.scanQrCode` 带入 `android.permission.CAMERA`;移动壳现阶段没有录音、后台音频采集或远程语音 SDK,音频导入只走系统文档选择器。配置检查会拒绝麦克风权限拦截缺失被反向加入或相机之外的显式权限漂移
- 2026-06-18 移动壳 Android 自动备份关闭:Expo 移动壳必须保持 `android.allowBackup=false`,避免 WebView cookie、localStorage、缓存文件和宿主文件导入导出中间态进入 Google Drive 自动备份 / 恢复链路;正式业务事实仍以后端账号、作品、钱包和草稿状态为准。配置检查会拒绝恢复 Android 默认允许备份的包配置。
- 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。
- 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `<a download>` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText``file.exportImage``file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。
File diff suppressed because one or more lines are too long
@@ -63,6 +63,7 @@ AI H5 sandbox
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
+121
View File
@@ -14,6 +14,7 @@
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"expo": "^56.0.12",
"expo-camera": "56.0.8",
"expo-clipboard": "^56.0.4",
"expo-document-picker": "^56.0.4",
"expo-file-system": "^56.0.8",
@@ -4064,6 +4065,12 @@
"@types/chai": "<5.2.0"
}
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4975,6 +4982,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/barcode-detector": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
"integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
"license": "MIT",
"dependencies": {
"zxing-wasm": "3.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6329,6 +6345,26 @@
"react-native": "*"
}
},
"node_modules/expo-camera": {
"version": "56.0.8",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz",
"integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "^3.0.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-clipboard": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz",
@@ -10913,6 +10949,18 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -13335,6 +13383,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zxing-wasm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
"integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.7.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
},
"dependencies": {
@@ -15747,6 +15823,11 @@
"dev": true,
"requires": {}
},
"@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="
},
"@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -16382,6 +16463,14 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"barcode-detector": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
"integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
"requires": {
"zxing-wasm": "3.1.0"
}
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -17312,6 +17401,14 @@
"expo-constants": "~56.0.18"
}
},
"expo-camera": {
"version": "56.0.8",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz",
"integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==",
"requires": {
"barcode-detector": "^3.0.0"
}
},
"expo-clipboard": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz",
@@ -20384,6 +20481,11 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="
},
"tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -21661,6 +21763,25 @@
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
},
"zxing-wasm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
"integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
"requires": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.7.0"
},
"dependencies": {
"type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"requires": {
"tagged-tag": "^1.0.0"
}
}
}
}
}
}
+1
View File
@@ -86,6 +86,7 @@
"cannon-es": "^0.20.0",
"dotenv": "^17.2.3",
"expo": "^56.0.12",
"expo-camera": "56.0.8",
"expo-clipboard": "^56.0.4",
"expo-document-picker": "^56.0.4",
"expo-file-system": "^56.0.8",
@@ -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'
+5 -1
View File
@@ -43,6 +43,7 @@ const h5HostBridgeScannedFacadeImports = new Set([
'requestHostHapticsImpact',
'requestHostLogin',
'requestHostPayment',
'scanHostQrCode',
'setHostAppBadgeCount',
'setHostAppTitle',
'setHostShareTarget',
@@ -120,9 +121,11 @@ const expectedMobileHostBridgeFiles = [
'dispatch.ts',
'files.ts',
'protocol.ts',
'scanner.ts',
'share.ts',
];
const expectedMobileShellFiles = [
'QrScannerOverlay.tsx',
'ShellApp.tsx',
'deepLink.test.ts',
'deepLink.ts',
@@ -718,8 +721,9 @@ function assertNativeShellCapabilityPlan() {
...sharedMethods.slice(8, 12),
'network.statusChanged',
...sharedMethods.slice(12, 21),
sharedMethods[21],
'file.imageDropped',
...sharedMethods.slice(21),
...sharedMethods.slice(22),
],
'native shell documented method table',
);
@@ -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';
+32 -1
View File
@@ -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,
+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',

Some files were not shown because too many files have changed in this diff Show More