diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json index c7723e6f8..ae344f4b9 100644 --- a/apps/mobile-shell/app.json +++ b/apps/mobile-shell/app.json @@ -15,7 +15,7 @@ "expo-image-picker", { "photosPermission": "允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。", - "cameraPermission": false, + "cameraPermission": "允许 Genarrative 使用相机拍摄创作素材和参考图。", "microphonePermission": false } ], diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 8092cccd5..a2f264951 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -155,11 +155,11 @@ if (Array.isArray(imagePickerPlugin)) { if (typeof pluginOptions.photosPermission !== 'string') { throw new Error('mobile shell image picker photo permission text is missing'); } - if ( - pluginOptions.cameraPermission !== false || - pluginOptions.microphonePermission !== false - ) { - throw new Error('mobile shell image picker must not request camera or microphone'); + if (typeof pluginOptions.cameraPermission !== 'string') { + throw new Error('mobile shell image picker camera permission text is missing'); + } + if (pluginOptions.microphonePermission !== false) { + throw new Error('mobile shell image picker must not request microphone'); } } @@ -182,6 +182,7 @@ for (const snippet of [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.captureImage', 'file.importAudio', 'file.exportAudio', 'clipboard.readText', @@ -197,7 +198,9 @@ for (const snippet of [ 'DocumentPicker.getDocumentAsync', 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', + 'ImagePicker.launchCameraAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', + 'ImagePicker.requestCameraPermissionsAsync', 'File(asset.uri)', 'file.base64()', 'normalizeHostBridgeExportFileName', @@ -236,6 +239,7 @@ for (const capability of [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.captureImage', 'file.importAudio', 'file.exportAudio', 'haptics.impact', diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index 62e92a5a9..8beefe733 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -109,7 +109,9 @@ vi.mock('expo-image-picker', () => ({ DENIED: 'denied', GRANTED: 'granted', }, + launchCameraAsync: vi.fn(), launchImageLibraryAsync: vi.fn(), + requestCameraPermissionsAsync: vi.fn(), requestMediaLibraryPermissionsAsync: vi.fn(), })); @@ -230,6 +232,18 @@ afterEach(() => { canceled: true, assets: null, }); + vi.mocked(ImagePicker.launchCameraAsync).mockReset(); + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockReset(); + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockReset(); vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({ status: ImagePicker.PermissionStatus.GRANTED, @@ -292,6 +306,9 @@ describe('handleMobileHostBridgeMessage', () => { expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importImage'); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('file.captureImage'); expect( (okResponse.result as { capabilities: string[] }).capabilities, ).toContain('file.importAudio'); @@ -1046,6 +1063,71 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectFailed(oversized).error.code).toBe('invalid_request'); }); + test('file.captureImage 调起系统相机并返回受控图片数据', async () => { + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///private/mobile/camera.jpg', + width: 120, + height: 80, + type: 'image', + fileName: null, + fileSize: 6, + base64: 'Y2FtZXJh', + mimeType: 'image/jpeg', + }, + ], + }); + + const response = await send(request('file.captureImage')); + + expect(expectOk(response).result).toEqual({ + action: 'captured', + fileName: 'genarrative-import.jpg', + base64Data: 'Y2FtZXJh', + mimeType: 'image/jpeg', + bytes: 6, + }); + expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled(); + expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + }); + + test('file.captureImage 拒绝权限和取消拍摄', async () => { + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.DENIED, + granted: false, + canAskAgain: false, + expires: 'never', + }); + + const denied = await send(request('file.captureImage')); + + expect(expectFailed(denied).error.code).toBe('host_error'); + expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled(); + + vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({ + status: ImagePicker.PermissionStatus.GRANTED, + granted: true, + canAskAgain: true, + expires: 'never', + }); + vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({ + canceled: true, + assets: null, + }); + + const cancelled = await send(request('file.captureImage')); + + expect(expectFailed(cancelled).error.code).toBe('cancelled'); + }); + test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => { fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8='); vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({ diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index 8a4e1d1a3..d3faaf31f 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -105,6 +105,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'file.importText', 'file.exportImage', 'file.importImage', + 'file.captureImage', 'file.importAudio', 'file.exportAudio', 'haptics.impact', @@ -559,23 +560,10 @@ async function exportAudioFile( }; } -async function importImageFile(): Promise { - const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { - throw { - code: 'host_error', - message: 'photo library permission denied', - } satisfies HostBridgeError; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - allowsEditing: false, - allowsMultipleSelection: false, - base64: true, - exif: false, - mediaTypes: ['images'], - quality: 1, - }); +function imagePickerResultToImportPayload( + result: ImagePicker.ImagePickerResult, + action: FileImportImageResult['action'], +): FileImportImageResult { if (result.canceled) { throw { code: 'cancelled', @@ -610,7 +598,7 @@ async function importImageFile(): Promise { } return { - action: 'selected', + action, fileName: normalizeHostBridgeExportFileName( asset.fileName || fallbackImportedImageFileName(mimeType), ), @@ -620,6 +608,47 @@ async function importImageFile(): Promise { }; } +async function importImageFile(): Promise { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'photo library permission denied', + } satisfies HostBridgeError; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + + return imagePickerResultToImportPayload(result, 'selected'); +} + +async function captureImageFile(): Promise { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'camera permission denied', + } satisfies HostBridgeError; + } + + const result = await ImagePicker.launchCameraAsync({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + + return imagePickerResultToImportPayload(result, 'captured'); +} + async function importAudioFile(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, @@ -908,6 +937,8 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, await exportImageFile(request.payload)); case 'file.importImage': return ok(request, await importImageFile()); + case 'file.captureImage': + return ok(request, await captureImageFile()); case 'file.importAudio': return ok(request, await importAudioFile()); case 'file.exportAudio': diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ec6a36c1f..fbe10a4a3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -33,8 +33,9 @@ - 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur 派发 `active` / `inactive`;H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 - 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 - 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 H5 图片上传接入宿主导入:`CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` 时,主图上传和描述参考图上传优先调用 `importHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `` 路径,不新增玩法侧上传分叉。 +- 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-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `` 路径,不新增玩法侧上传分叉。 - 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。 - 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。 - 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `