diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 5f1677332..350647b2b 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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( diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index 9e317470c..e638a9e4f 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -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); diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index 0ba43c570..4211c84d6 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -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", diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json index 005394c7d..05c3b5d4c 100644 --- a/apps/mobile-shell/app.json +++ b/apps/mobile-shell/app.json @@ -19,6 +19,12 @@ "enabled": false }, "plugins": [ + [ + "expo-camera", + { + "cameraPermission": "允许 Genarrative 使用相机扫描二维码。" + } + ], [ "expo-image-picker", { diff --git a/apps/mobile-shell/package.json b/apps/mobile-shell/package.json index 03070965a..8ef2d2177 100644 --- a/apps/mobile-shell/package.json +++ b/apps/mobile-shell/package.json @@ -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", diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index a9c5d9ff4..d55053dc1 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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('')) { + 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', diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs index d4da0f8c0..20bf939cf 100644 --- a/apps/mobile-shell/scripts/check-expo-config.mjs +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -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'); diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 9228cae39..02725dab7 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -27,6 +27,11 @@ import { handleMobileHostBridgeMessage, resetMobileHostBridgeForTest, } from './bridge'; +import { + cancelQrCodeScan, + completeQrCodeScan, + failQrCodeScan, +} from './scanner'; type NotificationPermissionStatus = Awaited>; @@ -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({ diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 5f954ffd9..c05f98578 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -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(); } diff --git a/apps/mobile-shell/src/host-bridge/scanner.ts b/apps/mobile-shell/src/host-bridge/scanner.ts new file mode 100644 index 000000000..458016953 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/scanner.ts @@ -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 { + 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(); +} diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx new file mode 100644 index 000000000..9afb0a3a5 --- /dev/null +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx @@ -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 ( + + {hasPermission ? ( + + ) : null} + + + 关闭 + + + ); +} + +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, + }, +}); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index d3fd7b353..f96e9cd99 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -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() { ) : null} + ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 177af6423..52bf61300 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -48,6 +48,7 @@ - 2026-06-18 桌面图片导入:新增 `file.importImage` 与 `file.imageDropped` HostBridge capability,Tauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `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` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `` 路径,不新增玩法侧上传分叉。 - 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 icon:Expo 移动壳启动页和 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 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 40f492899..ee6cc53f8 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -145,6 +145,7 @@ type HostBridgeEvent = { | `file.exportImage` | 导出当前 H5 已持有的图片文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.importImage` | 导入用户选择的图片文件 | 支持系统相册选择图片 | 支持系统选择图片 | | `file.captureImage` | 拍摄图片并导入当前 H5 流程 | 支持系统相机拍照 | 不声明 | +| `scanner.scanQrCode` | 扫描二维码并回传文本结果 | 支持 Expo Camera 扫码 | 不声明,返回 unsupported | | `file.importAudio` | 导入用户选择的音频文件 | 支持系统文档选择器 | 支持系统选择音频文件 | | `file.exportAudio` | 导出当前 H5 已持有的音频文件 | 支持系统分享 / 保存面板 | 支持系统保存对话框 | | `file.imageDropped` | 通知 H5 桌面拖入图片 | 不声明 | 支持主窗口拖拽图片事件 | @@ -250,7 +251,7 @@ GameBridge 禁止: - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 - 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 -- HostBridge 的 capability profile、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,Tauri 壳按 Rust 运行时代码镜像并由配置门禁反查共享契约。 +- HostBridge 的 capability profile、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,Tauri 壳按 Rust 运行时代码镜像并由配置门禁反查共享契约。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。 - 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须来自共享 HostBridge profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。 - Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。 @@ -286,9 +287,11 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `