From 933e690824f4ab2afb997d222af3a490eae8e00a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 14:00:40 +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=88=86=E4=BA=AB=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 | 6 + .../src/host-bridge/files.test.ts | 47 +++++- apps/mobile-shell/src/host-bridge/files.ts | 144 +++++++++++++----- 3 files changed, 152 insertions(+), 45 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index ab9b759da..edb01b1c4 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1991,6 +1991,8 @@ for (const snippet of [ 'Notifications.getPermissionsAsync', 'Notifications.requestPermissionsAsync', 'Sharing.shareAsync', + 'assertMobileFileSharingAvailable', + 'shareMobileFile', 'DocumentPicker.getDocumentAsync', 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', @@ -2108,6 +2110,10 @@ for (const snippet of [ 'exportAudioFile({', "code: 'unsupported_capability'", "code: 'cancelled'", + 'maps native sharing availability failures to stable export errors', + 'maps native sharing sheet failures to stable export errors', + "message: 'file sharing unavailable'", + "message: 'file sharing failed'", 'rejects image import before picker launch when library permission is denied', 'rejects image import before picker launch when library permission request fails', 'rejects image capture before camera launch when camera permission request fails', diff --git a/apps/mobile-shell/src/host-bridge/files.test.ts b/apps/mobile-shell/src/host-bridge/files.test.ts index 080c92fd8..ece35fb0f 100644 --- a/apps/mobile-shell/src/host-bridge/files.test.ts +++ b/apps/mobile-shell/src/host-bridge/files.test.ts @@ -153,9 +153,9 @@ describe('mobile HostBridge file actions', () => { }), () => exportImageFile({ - base64Data: PNG_BASE64, - fileName: '分享卡', - mimeType: 'image/png', + base64Data: PNG_BASE64, + fileName: '分享卡', + mimeType: 'image/png', }), () => exportAudioFile({ @@ -175,6 +175,47 @@ describe('mobile HostBridge file actions', () => { expect(shareAsyncMock).not.toHaveBeenCalled(); }); + test('maps native sharing availability failures to stable export errors', async () => { + shareAvailableMock.mockRejectedValueOnce(new Error('expo sharing crashed')); + + await expect( + exportTextFile({ + content: '泥巴AI', + fileName: '创作记录', + mimeType: 'text/plain', + }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'file sharing unavailable', + }); + + expect(writtenFiles).toHaveLength(0); + expect(shareAsyncMock).not.toHaveBeenCalled(); + }); + + test('maps native sharing sheet failures to stable export errors', async () => { + shareAsyncMock.mockRejectedValueOnce(new Error('expo native share failed')); + + await expect( + exportImageFile({ + base64Data: PNG_BASE64, + fileName: '分享卡', + mimeType: 'image/png', + }), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'file sharing failed', + }); + + expect(writtenFiles).toEqual([ + { + uri: 'file:///cache/分享卡.png', + content: PNG_BASE64, + options: { encoding: 'base64' }, + }, + ]); + }); + test('imports text and document files without exposing local URIs', async () => { fileTexts.set('file:///picked/story.md', '故事'); fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事')); diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index acfa93392..4697134a1 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -51,6 +51,43 @@ import { } from './filePayloads'; import { invalidRequest, ok } from './protocol'; +async function assertMobileFileSharingAvailable() { + let isSharingAvailable = false; + try { + isSharingAvailable = await Sharing.isAvailableAsync(); + } catch { + throw { + code: 'host_error', + message: 'file sharing unavailable', + } satisfies HostBridgeError; + } + + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } +} + +async function shareMobileFile( + file: File, + options: { + mimeType: string; + UTI: string; + dialogTitle: string; + }, +) { + try { + await Sharing.shareAsync(file.uri, options); + } catch { + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } +} + export async function exportTextFile( payload: unknown, ): Promise { @@ -65,13 +102,7 @@ export async function exportTextFile( throw invalidRequest('content exceeds file export size limit'); } - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } + await assertMobileFileSharingAvailable(); const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); const rawMimeType = exportPayload?.mimeType; @@ -82,13 +113,23 @@ export async function exportTextFile( if (!HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)) { throw invalidRequest('mimeType must be an allowed text type'); } - const file = new File(Paths.cache, fileName); - file.write(content); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: 'public.plain-text', - dialogTitle: fileName, - }); + try { + const file = new File(Paths.cache, fileName); + file.write(content); + await shareMobileFile(file, { + mimeType, + UTI: 'public.plain-text', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } return { action: 'saved', @@ -236,25 +277,29 @@ export async function exportImageFile( } ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType); - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } + await assertMobileFileSharingAvailable(); const fileName = normalizeExportedImageFileName( exportPayload?.fileName, mimeType as HostBridgeImageMimeType, ); - const file = new File(Paths.cache, fileName); - file.write(base64Data, { encoding: 'base64' }); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', - dialogTitle: fileName, - }); + try { + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await shareMobileFile(file, { + mimeType, + UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } return { action: 'saved', @@ -368,25 +413,29 @@ export async function exportAudioFile( } ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType); - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } + await assertMobileFileSharingAvailable(); const fileName = normalizeExportedAudioFileName( exportPayload?.fileName, mimeType as HostBridgeAudioMimeType, ); - const file = new File(Paths.cache, fileName); - file.write(base64Data, { encoding: 'base64' }); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: 'public.audio', - dialogTitle: fileName, - }); + try { + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await shareMobileFile(file, { + mimeType, + UTI: 'public.audio', + dialogTitle: fileName, + }); + } catch (error) { + if (isHostBridgeFileSharingError(error)) { + throw error; + } + throw { + code: 'host_error', + message: 'file sharing failed', + } satisfies HostBridgeError; + } return { action: 'saved', @@ -458,3 +507,14 @@ export async function importMobileHostBridgeAudioFile( ) { return ok(request, await importAudioFile()); } + +function isHostBridgeFileSharingError(error: unknown): error is HostBridgeError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'host_error' && + 'message' in error && + error.message === 'file sharing failed' + ); +}