From 9ae0a0c56c21862db2eb42f455ca06a6a720fcfc Mon Sep 17 00:00:00 2001 From: Shenwei Wang Date: Fri, 20 Mar 2026 11:30:17 +0800 Subject: [PATCH 1/2] fix: use tauri plugin dialog + fs to save a file --- .../components/DeviceVerificationSetup.tsx | 4 +-- src/app/components/Pdf-viewer/PdfViewer.tsx | 7 +++-- .../components/image-viewer/ImageViewer.tsx | 4 +-- src/app/components/message/FileHeader.tsx | 6 ++-- .../message/content/FileContent.tsx | 12 ++------ .../features/settings/devices/LocalBackup.tsx | 4 +-- src/app/utils/file-saver.ts | 28 +++++++++++++++++++ 7 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 src/app/utils/file-saver.ts diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1db..bc3a1118f8 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -13,9 +13,9 @@ import { color, Spinner, } from 'folds'; -import FileSaver from 'file-saver'; import to from 'await-to-js'; import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; +import { saveFile } from '../utils/file-saver'; import { PasswordInput } from './password-input'; import { ContainerColor } from '../styles/ContainerColor.css'; import { copyToClipboard } from '../utils/dom'; @@ -238,7 +238,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { const blob = new Blob([recoveryKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + saveFile(blob, 'recovery-key.txt'); }; const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 9c7fd9804c..fe38eaaf7b 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -21,7 +21,7 @@ import { config, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import FileSaver from 'file-saver'; +import { saveFile } from '../../utils/file-saver'; import * as css from './PdfViewer.css'; import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; @@ -77,8 +77,9 @@ export const PdfViewer = as<'div', PdfViewerProps>( } }, [docState, pageNo, zoom]); - const handleDownload = () => { - FileSaver.saveAs(src, name); + const handleDownload = async () => { + const blob = await fetch(src).then((r) => r.blob()); + await saveFile(blob, name); }; const handleJumpSubmit: FormEventHandler = (evt) => { diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b6dd..2f467ce2aa 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,8 +1,8 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ import React from 'react'; -import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; +import { saveFile } from '../../utils/file-saver'; import * as css from './ImageViewer.css'; import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; @@ -21,7 +21,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const handleDownload = async () => { const fileContent = await downloadMedia(src); - FileSaver.saveAs(fileContent, alt); + await saveFile(fileContent, alt); }; return ( diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec456..295ad8fa27 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -1,7 +1,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds'; import React, { ReactNode, useCallback } from 'react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import FileSaver from 'file-saver'; +import { saveFile } from '../../utils/file-saver'; import { mimeTypeToExt } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -33,9 +33,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); - const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, filename); - return fileURL; + await saveFile(fileContent, filename); }, [mx, url, useAuthentication, mimeType, encInfo, filename]) ); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 7e127f2a74..8d2dca9952 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -14,9 +14,9 @@ import { TooltipProvider, as, } from 'folds'; -import FileSaver from 'file-saver'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FocusTrap from 'focus-trap-react'; +import { saveFile } from '../../../utils/file-saver'; import { IFileInfo } from '../../../../types/matrix/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -261,9 +261,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); - const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, body); - return fileURL; + await saveFile(fileContent, body); }, [mx, url, useAuthentication, mimeType, encInfo, body]) ); @@ -275,11 +273,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil fill="Soft" radii="300" size="400" - onClick={() => - downloadState.status === AsyncStatus.Success - ? FileSaver.saveAs(downloadState.data, body) - : download() - } + onClick={() => download()} disabled={downloadState.status === AsyncStatus.Loading} before={ downloadState.status === AsyncStatus.Loading ? ( diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8fe1..78abd3ff17 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -1,6 +1,6 @@ import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds'; -import FileSaver from 'file-saver'; +import { saveFile } from '../../../utils/file-saver'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCardStyle } from '../styles.css'; @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'cinny-keys.txt'); + await saveFile(blob, 'cinny-keys.txt'); }, [mx] ) diff --git a/src/app/utils/file-saver.ts b/src/app/utils/file-saver.ts new file mode 100644 index 0000000000..75b9f7b6aa --- /dev/null +++ b/src/app/utils/file-saver.ts @@ -0,0 +1,28 @@ +import FileSaver from 'file-saver'; + +const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +export async function saveFile(blob: Blob, fileName: string): Promise { + if (!isTauri) { + FileSaver.saveAs(blob, fileName); + return; + } + + const { save } = await import('@tauri-apps/plugin-dialog'); + const { writeFile } = await import('@tauri-apps/plugin-fs'); + + const filePath = await save({ + defaultPath: fileName, + filters: [{ name: 'File', extensions: [getExt(fileName)] }], + }); + + if (filePath === null) return; + + const buffer = await blob.arrayBuffer(); + await writeFile(filePath, new Uint8Array(buffer)); +} + +function getExt(fileName: string): string { + const idx = fileName.lastIndexOf('.'); + return idx >= 0 ? fileName.slice(idx + 1) : '*'; +} From b907e256ac20027d0216cc78520dd856b5a2a161 Mon Sep 17 00:00:00 2001 From: Shenwei Wang Date: Mon, 23 Mar 2026 17:08:33 +0800 Subject: [PATCH 2/2] chore: add missing deps of tauri plugin dialog and fs --- package-lock.json | 57 ++++++++++++++++++++++++++++++++++++----------- package.json | 2 ++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7281a0d8a..287ef6cb48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", + "@tauri-apps/plugin-dialog": "2.6.0", + "@tauri-apps/plugin-fs": "2.4.5", "@vanilla-extract/css": "1.9.3", "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1", @@ -5541,6 +5543,34 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz", + "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6041,7 +6071,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "devOptional": true + "optional": true }, "node_modules/acorn": { "version": "8.14.0", @@ -6885,7 +6915,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "devOptional": true, + "optional": true, "engines": { "node": ">=10" } @@ -9498,7 +9528,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "devOptional": true, + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -9510,7 +9540,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -9522,7 +9552,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true + "optional": true }, "node_modules/fs.realpath": { "version": "1.0.0", @@ -10448,6 +10478,7 @@ "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "dev": true, "license": "ISC", + "optional": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -12284,7 +12315,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "devOptional": true, + "optional": true, "engines": { "node": ">=8" } @@ -12293,7 +12324,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "devOptional": true, + "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -12306,7 +12337,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -12318,13 +12349,13 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true + "optional": true }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "devOptional": true, + "optional": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -12465,7 +12496,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "devOptional": true, + "optional": true, "dependencies": { "abbrev": "1" }, @@ -17414,7 +17445,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "devOptional": true, + "optional": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -17431,7 +17462,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true + "optional": true }, "node_modules/temp-dir": { "version": "2.0.0", diff --git a/package.json b/package.json index 8e7b37b438..ee57fb65d7 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", + "@tauri-apps/plugin-dialog": "2.6.0", + "@tauri-apps/plugin-fs": "2.4.5", "@vanilla-extract/css": "1.9.3", "@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/vite-plugin": "3.7.1",