From ff1e340287764fec5e7802bae81c0d4e61a71af6 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 14 Aug 2026 16:14:26 +0200 Subject: [PATCH] fix: preserve media types in native downloads --- .../components/image-viewer/ImageViewer.tsx | 6 +- src/app/utils/download.test.ts | 58 +++++++++++++++++++ src/app/utils/download.ts | 23 +++++--- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 2fd2c286e..b01c53c66 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -181,7 +181,11 @@ export const ImageViewer = as<'div', ImageViewerProps>( showToast(`Failed to download file: ${message}`); return; } - await saveFileToDevice(fileContent, downloadFilename); + await saveFileToDevice( + fileContent, + downloadFilename, + galleryMimeType || fileContent.type || undefined + ); }; const menu = useMenuAnchor(); diff --git a/src/app/utils/download.test.ts b/src/app/utils/download.test.ts index 29934dcd0..9b71dee37 100644 --- a/src/app/utils/download.test.ts +++ b/src/app/utils/download.test.ts @@ -122,6 +122,24 @@ describe('saveFileToDevice', () => { expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'file.txt'); }); + it('uses authenticated media transport before saving a URL in the browser', async () => { + vi.mocked(isTauri).mockReturnValue(false); + const blob = new Blob(['data'], { type: 'image/png' }); + mocks.fetchMediaBlob.mockResolvedValue(blob); + + await expect( + saveFileToDevice( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo', + 'photo.png' + ) + ).resolves.toBe('saved'); + + expect(mocks.fetchMediaBlob).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo' + ); + expect(FileSaver.saveAs).toHaveBeenCalledWith(blob, 'photo.png'); + }); + it('uses authenticated media transport when saving a URL on Android', async () => { const blob = new Blob(['data'], { type: 'image/png' }); mocks.fetchMediaBlob.mockResolvedValue(blob); @@ -137,6 +155,26 @@ describe('saveFileToDevice', () => { 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo' ); }); + + it('uses authenticated media transport when saving a URL on desktop', async () => { + vi.mocked(osType).mockReturnValue('linux'); + mocks.fetchMediaBlob.mockResolvedValue(new Blob(['data'], { type: 'image/png' })); + + await expect( + saveFileToDevice( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo', + 'photo.png' + ) + ).resolves.toBe('saved'); + + expect(mocks.fetchMediaBlob).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo' + ); + expect(invoke).toHaveBeenCalledWith('save_download', { + filename: 'photo.png', + bytes: [100, 97, 116, 97], + }); + }); }); describe('downloadJsonFile', () => { @@ -270,6 +308,26 @@ describe('saveMediaToGallery', () => { expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); }); + it('uses authenticated media transport before saving a URL to iOS Photos', async () => { + vi.mocked(osType).mockReturnValue('ios'); + mocks.fetchMediaBlob.mockResolvedValue(new Blob(['data'], { type: 'image/png' })); + + await saveMediaToGallery( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo', + 'photo.png', + 'image/png' + ); + + expect(mocks.fetchMediaBlob).toHaveBeenCalledWith( + 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/photo' + ); + expect(invoke).toHaveBeenCalledWith('save_media_to_photos', { + filename: 'photo.png', + mimeType: 'image/png', + bytes: [100, 97, 116, 97], + }); + }); + it('shows a failure toast when the iOS Photos command rejects', async () => { vi.mocked(osType).mockReturnValue('ios'); vi.mocked(invoke).mockRejectedValue(new Error('photos unavailable')); diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index 864e6dc83..b647220ec 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -11,6 +11,9 @@ const BIDI_CONTROL_CHARS = /[\u202a-\u202e\u2066-\u2069]/g; const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const MAX_FILENAME_LENGTH = 255; +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + const nonEmptyString = (value: unknown): string | undefined => { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); @@ -99,8 +102,7 @@ export async function saveMediaToGallery( showToast('Saved to Gallery'); } catch (error) { if (uri) await AndroidFs.removeFile(uri).catch(() => undefined); - const message = error instanceof Error ? error.message : 'unknown error'; - showToast(`Failed to save to gallery: ${message}`); + showToast(`Failed to save to gallery: ${getErrorMessage(error)}`); } return; } @@ -116,8 +118,7 @@ export async function saveMediaToGallery( }); showToast('Saved to Photos'); } catch (error) { - const message = error instanceof Error ? error.message : 'unknown error'; - showToast(`Failed to save to photos: ${message}`); + showToast(`Failed to save to photos: ${getErrorMessage(error)}`); } } @@ -126,9 +127,16 @@ export async function saveFileToDevice( filename: string, mimeType?: string ): Promise<'saved' | 'cancelled' | 'failed'> { + let blob: Blob; + try { + blob = await resolveBlob(input); + } catch (error) { + showToast(`Failed to save file: ${getErrorMessage(error)}`); + return 'failed'; + } + if (isTauri()) { try { - const blob = await resolveBlob(input); const bytes = new Uint8Array(await blob.arrayBuffer()); if (osType() === 'android') { @@ -173,13 +181,12 @@ export async function saveFileToDevice( if (saved) showToast('File saved'); return saved ? 'saved' : 'cancelled'; } catch (error) { - const message = error instanceof Error ? error.message : 'unknown error'; - showToast(`Failed to save file: ${message}`); + showToast(`Failed to save file: ${getErrorMessage(error)}`); return 'failed'; } } - FileSaver.saveAs(input, filename); + FileSaver.saveAs(blob, filename); return 'saved'; }