From cf3b02829c76868e7f86e4ac713a4d7a07eccc10 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 14:12:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=94=81=E5=AE=9A=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AF=BC=E5=85=A5=E5=A4=B1=E8=B4=A5=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳文本文件选择与读取失败返回稳定错误 移动壳文档文件读取失败返回稳定错误 移动壳音频文件读取失败返回稳定错误 移动壳配置检查补充文件导入失败边界反查 --- apps/mobile-shell/scripts/check-config.mjs | 19 +++- .../src/host-bridge/files.test.ts | 86 +++++++++++++++++ apps/mobile-shell/src/host-bridge/files.ts | 92 +++++++++++++++---- 3 files changed, 175 insertions(+), 22 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 33434d121..f4b88056f 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -420,7 +420,7 @@ function assertMobileDocumentPickerBoundary( expectedTypeExpression, ) { const functionBody = extractFunctionBody(hostBridgeSource, functionName); - if (!functionBody.includes('DocumentPicker.getDocumentAsync({')) { + if (!functionBody.includes('pickMobileDocumentFile(')) { throw new Error(`mobile shell ${functionName} must use DocumentPicker`); } for (const requiredPickerOption of [ @@ -948,9 +948,9 @@ if ( } for (const [functionName, readCall] of [ - ['importTextFile', 'file.text()'], - ['importDocumentFile', 'file.base64()'], - ['importAudioFile', 'file.base64()'], + ['importTextFile', 'readMobileTextFile(file,'], + ['importDocumentFile', 'readMobileBase64File(file,'], + ['importAudioFile', 'readMobileBase64File(file,'], ]) { const functionBody = extractFunctionBody(hostBridgeSource, functionName); const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit('); @@ -1993,6 +1993,9 @@ for (const snippet of [ 'Sharing.shareAsync', 'assertMobileFileSharingAvailable', 'shareMobileFile', + 'pickMobileDocumentFile', + 'readMobileTextFile', + 'readMobileBase64File', 'DocumentPicker.getDocumentAsync', 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', @@ -2112,8 +2115,16 @@ for (const snippet of [ "code: 'cancelled'", 'maps native sharing availability failures to stable export errors', 'maps native sharing sheet failures to stable export errors', + 'maps native document picker failures to stable import errors', + 'maps native text file read failures to stable import errors', + 'maps native binary file read failures to stable import errors', + 'maps native audio file read failures to stable import errors', "message: 'file sharing unavailable'", "message: 'file sharing failed'", + "message: 'text file picker unavailable'", + "message: 'text file unavailable'", + "message: 'document file unavailable'", + "message: 'audio file unavailable'", 'rejects image import before picker launch when library permission is denied', 'rejects image import before picker launch when library permission request fails', 'maps native image library launch failures to stable import errors', diff --git a/apps/mobile-shell/src/host-bridge/files.test.ts b/apps/mobile-shell/src/host-bridge/files.test.ts index 3fa8245c1..1d6c8deda 100644 --- a/apps/mobile-shell/src/host-bridge/files.test.ts +++ b/apps/mobile-shell/src/host-bridge/files.test.ts @@ -29,6 +29,8 @@ const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toSt const fileTexts = vi.hoisted(() => new Map()); const fileBase64Data = vi.hoisted(() => new Map()); const fileSizes = vi.hoisted(() => new Map()); +const failingTextFiles = vi.hoisted(() => new Set()); +const failingBase64Files = vi.hoisted(() => new Set()); const writtenFiles = vi.hoisted( () => [] as { @@ -63,10 +65,16 @@ vi.mock('expo-file-system', () => ({ } text() { + if (failingTextFiles.has(this.uri)) { + return Promise.reject(new Error('native text read failed')); + } return Promise.resolve(fileTexts.get(this.uri) ?? ''); } base64() { + if (failingBase64Files.has(this.uri)) { + return Promise.reject(new Error('native base64 read failed')); + } return Promise.resolve(fileBase64Data.get(this.uri) ?? ''); } }, @@ -112,6 +120,8 @@ describe('mobile HostBridge file actions', () => { fileTexts.clear(); fileBase64Data.clear(); fileSizes.clear(); + failingTextFiles.clear(); + failingBase64Files.clear(); writtenFiles.length = 0; }); @@ -277,6 +287,82 @@ describe('mobile HostBridge file actions', () => { }); }); + test('maps native document picker failures to stable import errors', async () => { + documentPickerMock.mockRejectedValueOnce(new Error('native text picker failed')); + + await expect(importTextFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'text file picker unavailable', + }); + }); + + test('maps native text file read failures to stable import errors', async () => { + failingTextFiles.add('file:///picked/story.md'); + fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/story.md', + name: 'story.md', + mimeType: 'text/markdown', + size: Buffer.byteLength('故事'), + lastModified: 0, + }, + ], + }); + + await expect(importTextFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'text file unavailable', + }); + }); + + test('maps native binary file read failures to stable import errors', async () => { + failingBase64Files.add('file:///picked/brief.docx'); + fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/brief.docx', + name: 'brief.docx', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: Buffer.byteLength('PK\x03\x04docx'), + lastModified: 0, + }, + ], + }); + + await expect(importDocumentFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'document file unavailable', + }); + }); + + test('maps native audio file read failures to stable import errors', async () => { + failingBase64Files.add('file:///picked/voice.mp3'); + fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10')); + documentPickerMock.mockResolvedValueOnce({ + canceled: false, + assets: [ + { + uri: 'file:///picked/voice.mp3', + name: 'voice.mp3', + mimeType: 'audio/mpeg', + size: Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'), + lastModified: 0, + }, + ], + }); + + await expect(importAudioFile()).rejects.toMatchObject({ + code: 'host_error', + message: 'audio file unavailable', + }); + }); + test('imports and captures images only after native permissions are granted', async () => { libraryPermissionMock.mockResolvedValueOnce({ status: ImagePicker.PermissionStatus.GRANTED, diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index 791baf4d8..c0c5fd51f 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -51,6 +51,13 @@ import { } from './filePayloads'; import { invalidRequest, ok } from './protocol'; +type MobileDocumentPickerOptions = Parameters< + typeof DocumentPicker.getDocumentAsync +>[0]; +type MobileDocumentPickerResult = Awaited< + ReturnType +>; + async function assertMobileFileSharingAvailable() { let isSharingAvailable = false; try { @@ -145,11 +152,14 @@ export async function exportMobileHostBridgeTextFile( } export async function importTextFile(): Promise { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: ['text/*', 'application/json'], - }); + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }, + 'text file picker unavailable', + ); if (result.canceled) { throw { code: 'cancelled', @@ -177,7 +187,7 @@ export async function importTextFile(): Promise { HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, 'text exceeds file import size limit', ); - const content = await file.text(); + const content = await readMobileTextFile(file, 'text file unavailable'); const bytes = utf8ByteLength(content); if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) { throw invalidRequest('text exceeds file import size limit'); @@ -199,11 +209,14 @@ export async function importMobileHostBridgeTextFile( } export async function importDocumentFile(): Promise { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: MOBILE_DOCUMENT_PICKER_TYPES, - }); + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: MOBILE_DOCUMENT_PICKER_TYPES, + }, + 'document picker unavailable', + ); if (result.canceled) { throw { code: 'cancelled', @@ -231,7 +244,9 @@ export async function importDocumentFile(): Promise { HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, 'document exceeds file import size limit', ); - const base64Data = normalizedBase64Data(await file.base64()); + const base64Data = normalizedBase64Data( + await readMobileBase64File(file, 'document file unavailable'), + ); if (!base64Data) { throw invalidRequest('base64Data is required'); } @@ -467,11 +482,14 @@ export async function exportMobileHostBridgeAudioFile( } export async function importAudioFile(): Promise { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, - }); + const result = await pickMobileDocumentFile( + { + copyToCacheDirectory: true, + multiple: false, + type: MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, + }, + 'audio picker unavailable', + ); if (result.canceled) { throw { code: 'cancelled', @@ -499,7 +517,9 @@ export async function importAudioFile(): Promise { HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, 'audio exceeds file import size limit', ); - const base64Data = normalizedBase64Data(await file.base64()); + const base64Data = normalizedBase64Data( + await readMobileBase64File(file, 'audio file unavailable'), + ); if (!base64Data) { throw invalidRequest('base64Data is required'); } @@ -534,3 +554,39 @@ function isHostBridgeFileSharingError(error: unknown): error is HostBridgeError error.message === 'file sharing failed' ); } + +async function pickMobileDocumentFile( + options: MobileDocumentPickerOptions, + unavailableMessage: string, +): Promise { + try { + return await DocumentPicker.getDocumentAsync(options); + } catch { + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +} + +async function readMobileTextFile(file: File, unavailableMessage: string) { + try { + return await file.text(); + } catch { + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +} + +async function readMobileBase64File(file: File, unavailableMessage: string) { + try { + return await file.base64(); + } catch { + throw { + code: 'host_error', + message: unavailableMessage, + } satisfies HostBridgeError; + } +}