diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 3529b07be..cd894987e 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2083,6 +2083,17 @@ for (const snippet of [ 'Sharing.shareAsync', 'assertMobileFileSharingAvailable', 'shareMobileFile', + 'logMobileHostBridgeFileFailure', + 'mobile HostBridge file failed for', + "logMobileHostBridgeFileFailure('sharing.available', error)", + "logMobileHostBridgeFileFailure('sharing.open', error)", + "logMobileHostBridgeFileFailure('document_picker.open', error)", + "logMobileHostBridgeFileFailure('file.read_text', error)", + "logMobileHostBridgeFileFailure('file.read_base64', error)", + "logMobileHostBridgeFileFailure('image.library_permission', error)", + "logMobileHostBridgeFileFailure('image.library_open', error)", + "logMobileHostBridgeFileFailure('image.camera_permission', error)", + "logMobileHostBridgeFileFailure('image.camera_open', error)", 'pickMobileDocumentFile', 'readMobileTextFile', 'readMobileBase64File', @@ -2254,6 +2265,15 @@ for (const snippet of [ 'maps native image library launch failures to stable import errors', 'rejects image capture before camera launch when camera permission request fails', 'maps native camera launch failures to stable capture errors', + 'mobile HostBridge file failed for sharing.available', + 'mobile HostBridge file failed for sharing.open', + 'mobile HostBridge file failed for document_picker.open', + 'mobile HostBridge file failed for file.read_text', + 'mobile HostBridge file failed for file.read_base64', + 'mobile HostBridge file failed for image.library_permission', + 'mobile HostBridge file failed for image.library_open', + 'mobile HostBridge file failed for image.camera_permission', + 'mobile HostBridge file failed for image.camera_open', "message: 'photo library permission denied'", "message: 'photo library permission unavailable'", "message: 'photo library unavailable'", diff --git a/apps/mobile-shell/src/host-bridge/files.test.ts b/apps/mobile-shell/src/host-bridge/files.test.ts index a202f40db..fb52703d8 100644 --- a/apps/mobile-shell/src/host-bridge/files.test.ts +++ b/apps/mobile-shell/src/host-bridge/files.test.ts @@ -203,7 +203,9 @@ describe('mobile HostBridge file actions', () => { }); test('maps native sharing availability failures to stable export errors', async () => { - shareAvailableMock.mockRejectedValueOnce(new Error('expo sharing crashed')); + const error = new Error('expo sharing crashed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shareAvailableMock.mockRejectedValueOnce(error); await expect( exportTextFile({ @@ -218,10 +220,17 @@ describe('mobile HostBridge file actions', () => { expect(writtenFiles).toHaveLength(0); expect(shareAsyncMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for sharing.available', + error, + ); + warnSpy.mockRestore(); }); test('maps native sharing sheet failures to stable export errors', async () => { - shareAsyncMock.mockRejectedValueOnce(new Error('expo native share failed')); + const error = new Error('expo native share failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + shareAsyncMock.mockRejectedValueOnce(error); await expect( exportImageFile({ @@ -241,6 +250,11 @@ describe('mobile HostBridge file actions', () => { options: { encoding: 'base64' }, }, ]); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for sharing.open', + error, + ); + warnSpy.mockRestore(); }); test('imports text and document files without exposing local URIs', async () => { @@ -305,15 +319,23 @@ describe('mobile HostBridge file actions', () => { }); test('maps native document picker failures to stable import errors', async () => { - documentPickerMock.mockRejectedValueOnce(new Error('native text picker failed')); + const error = new Error('native text picker failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + documentPickerMock.mockRejectedValueOnce(error); await expect(importTextFile()).rejects.toMatchObject({ code: 'host_error', message: 'text file picker unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for document_picker.open', + error, + ); + warnSpy.mockRestore(); }); test('maps native text file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); failingTextFiles.add('file:///picked/story.md'); fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事')); documentPickerMock.mockResolvedValueOnce({ @@ -333,9 +355,15 @@ describe('mobile HostBridge file actions', () => { code: 'host_error', message: 'text file unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_text', + expect.any(Error), + ); + warnSpy.mockRestore(); }); test('maps native binary file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); failingBase64Files.add('file:///picked/brief.docx'); fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx')); documentPickerMock.mockResolvedValueOnce({ @@ -356,9 +384,15 @@ describe('mobile HostBridge file actions', () => { code: 'host_error', message: 'document file unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_base64', + expect.any(Error), + ); + warnSpy.mockRestore(); }); test('maps native audio file read failures to stable import errors', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); failingBase64Files.add('file:///picked/voice.mp3'); fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10')); documentPickerMock.mockResolvedValueOnce({ @@ -378,6 +412,11 @@ describe('mobile HostBridge file actions', () => { code: 'host_error', message: 'audio file unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for file.read_base64', + expect.any(Error), + ); + warnSpy.mockRestore(); }); test('imports and captures images only after native permissions are granted', async () => { @@ -448,53 +487,81 @@ describe('mobile HostBridge file actions', () => { }); test('rejects image import before picker launch when library permission request fails', async () => { - libraryPermissionMock.mockRejectedValueOnce(new Error('native permission failed')); + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + libraryPermissionMock.mockRejectedValueOnce(error); await expect(importImageFile()).rejects.toMatchObject({ code: 'host_error', message: 'photo library permission unavailable', }); expect(imageLibraryMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.library_permission', + error, + ); + warnSpy.mockRestore(); }); test('maps native image library launch failures to stable import errors', async () => { + const error = new Error('native image picker failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); libraryPermissionMock.mockResolvedValueOnce({ status: ImagePicker.PermissionStatus.GRANTED, granted: true, canAskAgain: true, expires: 'never', }); - imageLibraryMock.mockRejectedValueOnce(new Error('native image picker failed')); + imageLibraryMock.mockRejectedValueOnce(error); await expect(importImageFile()).rejects.toMatchObject({ code: 'host_error', message: 'photo library unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.library_open', + error, + ); + warnSpy.mockRestore(); }); test('rejects image capture before camera launch when camera permission request fails', async () => { - cameraPermissionMock.mockRejectedValueOnce(new Error('native permission failed')); + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cameraPermissionMock.mockRejectedValueOnce(error); await expect(captureImageFile()).rejects.toMatchObject({ code: 'host_error', message: 'camera permission unavailable', }); expect(cameraMock).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.camera_permission', + error, + ); + warnSpy.mockRestore(); }); test('maps native camera launch failures to stable capture errors', async () => { + const error = new Error('native camera failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); cameraPermissionMock.mockResolvedValueOnce({ status: ImagePicker.PermissionStatus.GRANTED, granted: true, canAskAgain: true, expires: 'never', }); - cameraMock.mockRejectedValueOnce(new Error('native camera failed')); + cameraMock.mockRejectedValueOnce(error); await expect(captureImageFile()).rejects.toMatchObject({ code: 'host_error', message: 'camera unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge file failed for image.camera_open', + error, + ); + warnSpy.mockRestore(); }); test('imports audio and exports binary files through controlled payloads', async () => { diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index c05252064..3b44ca19f 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -51,6 +51,10 @@ import { } from './filePayloads'; import { invalidRequest, ok } from './protocol'; +function logMobileHostBridgeFileFailure(label: string, error: unknown) { + console.warn(`mobile HostBridge file failed for ${label}`, error); +} + type MobileDocumentPickerOptions = Parameters< typeof DocumentPicker.getDocumentAsync >[0]; @@ -62,7 +66,8 @@ async function assertMobileFileSharingAvailable() { let isSharingAvailable = false; try { isSharingAvailable = await Sharing.isAvailableAsync(); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('sharing.available', error); throw { code: 'host_error', message: 'file sharing unavailable', @@ -87,7 +92,8 @@ async function shareMobileFile( ) { try { await Sharing.shareAsync(file.uri, options); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('sharing.open', error); throw { code: 'host_error', message: 'file sharing failed', @@ -334,7 +340,8 @@ export async function importImageFile(): Promise { let permission: ImagePicker.MediaLibraryPermissionResponse; try { permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('image.library_permission', error); throw { code: 'host_error', message: 'photo library permission unavailable', @@ -357,7 +364,8 @@ export async function importImageFile(): Promise { mediaTypes: ['images'], quality: 1, }); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('image.library_open', error); throw { code: 'host_error', message: 'photo library unavailable', @@ -381,7 +389,8 @@ export async function captureImageFile(): Promise { let permission: ImagePicker.CameraPermissionResponse; try { permission = await ImagePicker.requestCameraPermissionsAsync(); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('image.camera_permission', error); throw { code: 'host_error', message: 'camera permission unavailable', @@ -403,7 +412,8 @@ export async function captureImageFile(): Promise { mediaTypes: ['images'], quality: 1, }); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('image.camera_open', error); throw { code: 'host_error', message: 'camera unavailable', @@ -562,7 +572,8 @@ async function pickMobileDocumentFile( ): Promise { try { return await DocumentPicker.getDocumentAsync(options); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('document_picker.open', error); throw { code: 'host_error', message: unavailableMessage, @@ -573,7 +584,8 @@ async function pickMobileDocumentFile( async function readMobileTextFile(file: File, unavailableMessage: string) { try { return await file.text(); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('file.read_text', error); throw { code: 'host_error', message: unavailableMessage, @@ -584,7 +596,8 @@ async function readMobileTextFile(file: File, unavailableMessage: string) { async function readMobileBase64File(file: File, unavailableMessage: string) { try { return await file.base64(); - } catch { + } catch (error) { + logMobileHostBridgeFileFailure('file.read_base64', error); throw { code: 'host_error', message: unavailableMessage, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 08ecd86a6..8a1580211 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3100,6 +3100,12 @@ - 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 - 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::network shell::network`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 移动壳文件能力系统异常必须可观测 + +- 背景:Expo 移动壳已声明文本 / 文档 / 图片 / 音频导入导出、拍照和相册能力;这些能力会打开系统分享面板、DocumentPicker、相册、相机或读取缓存文件。如果原生 API reject 后只返回稳定 HostBridge 错误,H5 语义是安全的,但开发侧难以区分系统能力缺失、权限 API 异常、文件读取失败或分享面板失败。 +- 决策:`apps/mobile-shell/src/host-bridge/files.ts` 必须在 Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时记录 `mobile HostBridge file failed for ...` 日志;HostBridge 对 H5 仍只返回稳定 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不透传原生异常明细。移动壳配置检查反查日志 helper、关键 label 和对应单测。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/files.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳门禁脚本必须自登记自扫描 - 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 4d2fddecb..9acb9ddbd 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -318,6 +318,8 @@ GameBridge 禁止: 2026-06-19 追加:H5 创作 Agent 工作台在移动壳声明 `file.exportText` 时提供会话 Markdown 导出入口。H5 只把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装成 `text/markdown` 文本,先按共享 5 MiB 上限计算 UTF-8 byte,再通过 `exportHostTextFile()` 交给 Expo 系统分享 / 保存面板;宿主取消、缺能力或 unsupported 时不做浏览器下载回退,保持原生壳文件保存只走受控 HostBridge 能力。 +2026-06-20 追加:移动壳文件能力的原生系统异常必须可观测。Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时,`apps/mobile-shell/src/host-bridge/files.ts` 必须记录 `mobile HostBridge file failed for ...` 日志;HostBridge 回包仍只返回稳定的 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不把原生异常明细透传给 H5。移动壳配置检查反查这些日志 label 和对应单测,避免用户可见文件导入导出动作失败后只剩静默兜底。 + 2026-06-19 追加:视觉小说结果页素材选择弹窗在移动壳声明 `file.importImage` / `file.importAudio` 时优先打开宿主系统图片或音频选择器,把 HostBridge 返回的 base64 副本转换成浏览器 `File` 后继续调用 `uploadVisualNovelAsset`,不绕过平台素材上传、历史素材、AI 图片生成或草稿写回链路;用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入。普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 2026-06-19 追加:帮助与反馈页在移动壳声明 `file.captureImage` 时展示“拍摄凭证”入口,调用 Expo 相机拍摄后把 HostBridge 返回的图片副本转换成浏览器 `File`,继续复用反馈页最多 4 张、单张 1 MiB、总 4 MiB、图片 MIME、data URL 预览和提交 payload 校验。拍摄取消时停留在壳流程内,不触发浏览器文件输入;Tauri 桌面壳不声明拍摄能力,普通浏览器、小程序和未声明能力的裁剪壳不显示该入口。