From 05ca80ec9022af5358fdb890e5f01f38e9e51c7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:44:19 +0000 Subject: [PATCH 1/7] chore(deps): Bump @vercel/analytics from 1.6.1 to 2.0.1 Bumps [@vercel/analytics](https://github.com/vercel/analytics/tree/HEAD/packages/web) from 1.6.1 to 2.0.1. - [Release notes](https://github.com/vercel/analytics/releases) - [Commits](https://github.com/vercel/analytics/commits/v2.0.1/packages/web) --- updated-dependencies: - dependency-name: "@vercel/analytics" dependency-version: 2.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- pnpm-lock.yaml | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 548785f0..04714e4a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,7 +27,7 @@ "@radix-ui/react-tabs": "^1.1.21", "@space-man/react-theme-animation": "^1.1.1", "@tabler/icons-react": "^3.46.0", - "@vercel/analytics": "^1.6.1", + "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^1.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3979901c..850b834c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,8 +454,8 @@ importers: specifier: ^3.46.0 version: 3.46.0(react@19.2.8) '@vercel/analytics': - specifier: ^1.6.1 - version: 1.6.1(next@16.3.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.68.0))(vue@3.5.41(typescript@5.9.3)) + specifier: ^2.0.1 + version: 2.0.1(next@16.3.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.68.0))(vue@3.5.41(typescript@5.9.3)) '@vercel/speed-insights': specifier: ^1.3.1 version: 1.3.1(next@16.3.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.68.0))(vue@3.5.41(typescript@5.9.3)) @@ -3754,12 +3754,13 @@ packages: cpu: [x64] os: [win32] - '@vercel/analytics@1.6.1': - resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: '@remix-run/react': ^2 '@sveltejs/kit': ^1 || ^2 next: '>= 13' + nuxt: '>= 3' react: ^18 || ^19 || ^19.0.0-rc svelte: '>= 4' vue: ^3 @@ -3771,6 +3772,8 @@ packages: optional: true next: optional: true + nuxt: + optional: true react: optional: true svelte: @@ -11430,7 +11433,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@1.6.1(next@16.3.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.68.0))(vue@3.5.41(typescript@5.9.3))': + '@vercel/analytics@2.0.1(next@16.3.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.68.0))(vue@3.5.41(typescript@5.9.3))': optionalDependencies: next: 16.3.3(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 From 1bd0b57d8109a8751e36790a2ffdc4d65a039c0a Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 7 Sep 2026 23:15:26 +0530 Subject: [PATCH 2/7] fix(favicon-generator): make downloads work in the desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tauri webview registers no download handler, so wry answers every `` navigation with WKNavigationActionPolicy::Cancel — the anchor click file-saver performs is silently dropped and no file is ever written. Both "Download favicon.ico" and "Download all (.zip)" (and the per-size PNG links) did nothing in the shipped app. Add `saveFile()`, which on desktop asks for a path with the native Save dialog and writes the bytes with plugin-fs, and falls back to the anchor download on web. The dialog grants the picked path into the fs scope, the same pattern collection-files.ts already relies on; `fs:allow-write-file` is added to the capability set so binary writes are permitted. Also fixes a dead guard in downloadIco: `icon!.bytes` threw before the `images.some((i) => !i.png)` check could run. buildIcoBytes() now returns null when a size is still missing. Failures surface as toasts instead of unhandled rejections, and the previously hardcoded "Failed to load image" string goes through next-intl with translations for all 27 locales. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- apps/desktop-ui/messages/af.json | 4 +- apps/desktop-ui/messages/ar.json | 4 +- apps/desktop-ui/messages/ca.json | 4 +- apps/desktop-ui/messages/cs.json | 4 +- apps/desktop-ui/messages/da.json | 4 +- apps/desktop-ui/messages/de.json | 4 +- apps/desktop-ui/messages/el.json | 4 +- apps/desktop-ui/messages/en.json | 4 +- apps/desktop-ui/messages/es.json | 4 +- apps/desktop-ui/messages/fa.json | 4 +- apps/desktop-ui/messages/fr.json | 4 +- apps/desktop-ui/messages/id.json | 4 +- apps/desktop-ui/messages/it.json | 4 +- apps/desktop-ui/messages/ja.json | 4 +- apps/desktop-ui/messages/ko.json | 4 +- apps/desktop-ui/messages/ms.json | 4 +- apps/desktop-ui/messages/nb.json | 4 +- apps/desktop-ui/messages/nl.json | 4 +- apps/desktop-ui/messages/pl.json | 4 +- apps/desktop-ui/messages/pt-BR.json | 4 +- apps/desktop-ui/messages/pt.json | 4 +- apps/desktop-ui/messages/ru.json | 4 +- apps/desktop-ui/messages/sv.json | 4 +- apps/desktop-ui/messages/tr.json | 4 +- apps/desktop-ui/messages/uk.json | 4 +- apps/desktop-ui/messages/vi.json | 4 +- apps/desktop-ui/messages/zh.json | 4 +- .../favicon-generator-layout.tsx | 70 +++++++++++-------- apps/desktop-ui/src/lib/desktop/save-file.ts | 38 ++++++++++ .../src-tauri/capabilities/default.json | 5 +- 30 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 apps/desktop-ui/src/lib/desktop/save-file.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index aa61f875..a61dc012 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -4890,7 +4890,9 @@ "copy": "Kopieer", "headSnippet": "HTML head-snipper", "downloadIco": "Laai favicon.ico af", - "downloadZip": "Laai alles af (.zip)" + "downloadZip": "Laai alles af (.zip)", + "loadError": "Kon nie die prent laai nie", + "saveError": "Kon nie die lêer stoor nie" }, "JsonDiff": { "title": "JSON Diff", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 4e1ac6d2..5970e15c 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -4890,7 +4890,9 @@ "copy": "نسخ", "headSnippet": "مقتطف HTML head", "downloadIco": "تنزيل favicon.ico", - "downloadZip": "تنزيل الكل (.zip)" + "downloadZip": "تنزيل الكل (.zip)", + "loadError": "تعذّر تحميل الصورة", + "saveError": "تعذّر حفظ الملف" }, "JsonDiff": { "title": "فرق JSON", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 0bbbc2fb..7a970f83 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -4890,7 +4890,9 @@ "copy": "Copia", "headSnippet": "Fragment del head HTML", "downloadIco": "Descarrega favicon.ico", - "downloadZip": "Descarrega-ho tot (.zip)" + "downloadZip": "Descarrega-ho tot (.zip)", + "loadError": "No s'ha pogut carregar la imatge", + "saveError": "No s'ha pogut desar el fitxer" }, "JsonDiff": { "title": "Diff de JSON", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index ae84c5af..27847daf 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -4890,7 +4890,9 @@ "copy": "Kopírovat", "headSnippet": "Úryvek HTML head", "downloadIco": "Stáhnout favicon.ico", - "downloadZip": "Stáhnout vše (.zip)" + "downloadZip": "Stáhnout vše (.zip)", + "loadError": "Obrázek se nepodařilo načíst", + "saveError": "Soubor se nepodařilo uložit" }, "JsonDiff": { "title": "Porovnání JSON", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c348e15d..52436954 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -4890,7 +4890,9 @@ "copy": "Kopiér", "headSnippet": "HTML head-uddrag", "downloadIco": "Download favicon.ico", - "downloadZip": "Download alle (.zip)" + "downloadZip": "Download alle (.zip)", + "loadError": "Billedet kunne ikke indlæses", + "saveError": "Filen kunne ikke gemmes" }, "JsonDiff": { "title": "JSON-diff", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index b3af3829..347a786f 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -4890,7 +4890,9 @@ "copy": "Kopieren", "headSnippet": "HTML-head-Snippet", "downloadIco": "favicon.ico herunterladen", - "downloadZip": "Alles herunterladen (.zip)" + "downloadZip": "Alles herunterladen (.zip)", + "loadError": "Bild konnte nicht geladen werden", + "saveError": "Datei konnte nicht gespeichert werden" }, "JsonDiff": { "title": "JSON-Diff", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index d441276c..9b378fb1 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -4890,7 +4890,9 @@ "copy": "Αντιγραφή", "headSnippet": "Απόσπασμα HTML head", "downloadIco": "Λήψη favicon.ico", - "downloadZip": "Λήψη όλων (.zip)" + "downloadZip": "Λήψη όλων (.zip)", + "loadError": "Αποτυχία φόρτωσης εικόνας", + "saveError": "Δεν ήταν δυνατή η αποθήκευση του αρχείου" }, "JsonDiff": { "title": "Διαφορές JSON", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index a430419d..2059c89a 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -4977,7 +4977,9 @@ "copy": "Copy", "headSnippet": "HTML head snippet", "downloadIco": "Download favicon.ico", - "downloadZip": "Download all (.zip)" + "downloadZip": "Download all (.zip)", + "loadError": "Failed to load image", + "saveError": "Could not save the file" }, "JsonDiff": { "title": "JSON Diff", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index b5bd3aa9..0ad27d95 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -4890,7 +4890,9 @@ "copy": "Copiar", "headSnippet": "Fragmento para el head HTML", "downloadIco": "Descargar favicon.ico", - "downloadZip": "Descargar todo (.zip)" + "downloadZip": "Descargar todo (.zip)", + "loadError": "No se pudo cargar la imagen", + "saveError": "No se pudo guardar el archivo" }, "JsonDiff": { "title": "Diff de JSON", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 10b2a386..f62ba95c 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -4890,7 +4890,9 @@ "copy": "کپی", "headSnippet": "قطعه‌کد HTML head", "downloadIco": "دانلود favicon.ico", - "downloadZip": "دانلود همه (.zip)" + "downloadZip": "دانلود همه (.zip)", + "loadError": "بارگذاری تصویر ناموفق بود", + "saveError": "ذخیره فایل ممکن نشد" }, "JsonDiff": { "title": "تفاوت JSON", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index e4420437..be69b258 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -4890,7 +4890,9 @@ "copy": "Copier", "headSnippet": "Extrait pour le head HTML", "downloadIco": "Télécharger favicon.ico", - "downloadZip": "Tout télécharger (.zip)" + "downloadZip": "Tout télécharger (.zip)", + "loadError": "Échec du chargement de l'image", + "saveError": "Impossible d'enregistrer le fichier" }, "JsonDiff": { "title": "Diff JSON", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index ba78f61d..c671a9dd 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -4890,7 +4890,9 @@ "copy": "Salin", "headSnippet": "Cuplikan HTML head", "downloadIco": "Unduh favicon.ico", - "downloadZip": "Unduh semua (.zip)" + "downloadZip": "Unduh semua (.zip)", + "loadError": "Gagal memuat gambar", + "saveError": "Gagal menyimpan file" }, "JsonDiff": { "title": "Diff JSON", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 5cf8185b..bdc2d923 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -4890,7 +4890,9 @@ "copy": "Copia", "headSnippet": "Snippet per l'head HTML", "downloadIco": "Scarica favicon.ico", - "downloadZip": "Scarica tutto (.zip)" + "downloadZip": "Scarica tutto (.zip)", + "loadError": "Impossibile caricare l'immagine", + "saveError": "Impossibile salvare il file" }, "JsonDiff": { "title": "Diff JSON", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 2fe81b64..90f525af 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -4890,7 +4890,9 @@ "copy": "コピー", "headSnippet": "HTML head スニペット", "downloadIco": "favicon.ico をダウンロード", - "downloadZip": "すべてダウンロード (.zip)" + "downloadZip": "すべてダウンロード (.zip)", + "loadError": "画像を読み込めませんでした", + "saveError": "ファイルを保存できませんでした" }, "JsonDiff": { "title": "JSON差分", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 69e705ba..a1d28f7a 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -4890,7 +4890,9 @@ "copy": "복사", "headSnippet": "HTML head 스니펫", "downloadIco": "favicon.ico 다운로드", - "downloadZip": "모두 다운로드 (.zip)" + "downloadZip": "모두 다운로드 (.zip)", + "loadError": "이미지를 불러오지 못했습니다", + "saveError": "파일을 저장하지 못했습니다" }, "JsonDiff": { "title": "JSON 비교", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 86646f8c..e24ec8fa 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -4890,7 +4890,9 @@ "copy": "Salin", "headSnippet": "Cebisan HTML head", "downloadIco": "Muat turun favicon.ico", - "downloadZip": "Muat turun semua (.zip)" + "downloadZip": "Muat turun semua (.zip)", + "loadError": "Gagal memuatkan imej", + "saveError": "Gagal menyimpan fail" }, "JsonDiff": { "title": "Diff JSON", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index a958af60..0e39b225 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -4890,7 +4890,9 @@ "copy": "Kopier", "headSnippet": "HTML head-utdrag", "downloadIco": "Last ned favicon.ico", - "downloadZip": "Last ned alle (.zip)" + "downloadZip": "Last ned alle (.zip)", + "loadError": "Kunne ikke laste inn bildet", + "saveError": "Kunne ikke lagre filen" }, "JsonDiff": { "title": "JSON-diff", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index b93207a8..998413fa 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -4890,7 +4890,9 @@ "copy": "Kopiëren", "headSnippet": "HTML-head-snippet", "downloadIco": "favicon.ico downloaden", - "downloadZip": "Alles downloaden (.zip)" + "downloadZip": "Alles downloaden (.zip)", + "loadError": "Afbeelding kon niet worden geladen", + "saveError": "Bestand kon niet worden opgeslagen" }, "JsonDiff": { "title": "JSON-diff", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index bdec4d87..2e45dbbd 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -4890,7 +4890,9 @@ "copy": "Kopiuj", "headSnippet": "Fragment head HTML", "downloadIco": "Pobierz favicon.ico", - "downloadZip": "Pobierz wszystko (.zip)" + "downloadZip": "Pobierz wszystko (.zip)", + "loadError": "Nie udało się wczytać obrazu", + "saveError": "Nie udało się zapisać pliku" }, "JsonDiff": { "title": "Porównywarka JSON", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index d0afff29..6da40ea9 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -4890,7 +4890,9 @@ "copy": "Copiar", "headSnippet": "Trecho do head HTML", "downloadIco": "Baixar favicon.ico", - "downloadZip": "Baixar tudo (.zip)" + "downloadZip": "Baixar tudo (.zip)", + "loadError": "Falha ao carregar a imagem", + "saveError": "Não foi possível salvar o arquivo" }, "JsonDiff": { "title": "Diff de JSON", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 58a83485..08fb935d 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -4890,7 +4890,9 @@ "copy": "Copiar", "headSnippet": "Fragmento para o head HTML", "downloadIco": "Descarregar favicon.ico", - "downloadZip": "Descarregar tudo (.zip)" + "downloadZip": "Descarregar tudo (.zip)", + "loadError": "Falha ao carregar a imagem", + "saveError": "Não foi possível guardar o ficheiro" }, "JsonDiff": { "title": "Diff de JSON", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index d7459120..8d9f15c1 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -4890,7 +4890,9 @@ "copy": "Копировать", "headSnippet": "Фрагмент для head HTML", "downloadIco": "Скачать favicon.ico", - "downloadZip": "Скачать всё (.zip)" + "downloadZip": "Скачать всё (.zip)", + "loadError": "Не удалось загрузить изображение", + "saveError": "Не удалось сохранить файл" }, "JsonDiff": { "title": "Сравнение JSON", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 1753ab49..f37efa36 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -4890,7 +4890,9 @@ "copy": "Kopiera", "headSnippet": "HTML head-utdrag", "downloadIco": "Ladda ner favicon.ico", - "downloadZip": "Ladda ner alla (.zip)" + "downloadZip": "Ladda ner alla (.zip)", + "loadError": "Bilden kunde inte läsas in", + "saveError": "Filen kunde inte sparas" }, "JsonDiff": { "title": "JSON-diff", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index a4c0333b..cf8ba62f 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -4890,7 +4890,9 @@ "copy": "Kopyala", "headSnippet": "HTML head parçacığı", "downloadIco": "favicon.ico indir", - "downloadZip": "Tümünü indir (.zip)" + "downloadZip": "Tümünü indir (.zip)", + "loadError": "Görsel yüklenemedi", + "saveError": "Dosya kaydedilemedi" }, "JsonDiff": { "title": "JSON Fark", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 2820a96c..663fa7c6 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -4890,7 +4890,9 @@ "copy": "Копіювати", "headSnippet": "Фрагмент для head HTML", "downloadIco": "Завантажити favicon.ico", - "downloadZip": "Завантажити все (.zip)" + "downloadZip": "Завантажити все (.zip)", + "loadError": "Не вдалося завантажити зображення", + "saveError": "Не вдалося зберегти файл" }, "JsonDiff": { "title": "Порівняння JSON", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 344f364c..613e73b7 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -4890,7 +4890,9 @@ "copy": "Sao chép", "headSnippet": "Đoạn mã HTML head", "downloadIco": "Tải favicon.ico", - "downloadZip": "Tải tất cả (.zip)" + "downloadZip": "Tải tất cả (.zip)", + "loadError": "Không tải được hình ảnh", + "saveError": "Không lưu được tệp" }, "JsonDiff": { "title": "JSON Diff", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 461e2643..af6e0625 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -4890,7 +4890,9 @@ "copy": "复制", "headSnippet": "HTML head 代码片段", "downloadIco": "下载 favicon.ico", - "downloadZip": "下载全部 (.zip)" + "downloadZip": "下载全部 (.zip)", + "loadError": "无法加载图片", + "saveError": "无法保存文件" }, "JsonDiff": { "title": "JSON 对比", diff --git a/apps/desktop-ui/src/components/favicon-generator/favicon-generator-layout.tsx b/apps/desktop-ui/src/components/favicon-generator/favicon-generator-layout.tsx index b6eb7286..f6aa7c86 100644 --- a/apps/desktop-ui/src/components/favicon-generator/favicon-generator-layout.tsx +++ b/apps/desktop-ui/src/components/favicon-generator/favicon-generator-layout.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import JSZip from 'jszip' -import { saveAs } from 'file-saver' import { toast } from 'sonner' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@/components/ui/button' @@ -15,6 +14,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Check, Copy, Download, Package, Trash2, Upload } from 'lucide-react' import { IconFavicon } from '@tabler/icons-react' import { ToolShell } from '@/components/tools/tool-shell' +import { saveFile } from '@/lib/desktop/save-file' import { ICON_SIZES, ICO_SIZES, @@ -130,11 +130,11 @@ export function FaviconGeneratorLayout() { URL.revokeObjectURL(url) } img.onerror = () => { - toast.error('Failed to load image') + toast.error(t('loadError')) URL.revokeObjectURL(url) } img.src = url - }, []) + }, [t]) const onDrop = useCallback( (e: React.DragEvent) => { @@ -146,38 +146,48 @@ export function FaviconGeneratorLayout() { [loadFile], ) - const getIcon = (size: number, maskable = false) => - icons.find((i) => i.size === size && i.maskable === maskable) - - const downloadIco = useCallback(() => { - const images = ICO_SIZES.map((size) => { - const icon = getIcon(size) - return { size, png: icon!.bytes } - }) - if (images.some((i) => !i.png)) return - const ico = buildIco(images) - saveAs(new Blob([ico as BlobPart], { type: 'image/x-icon' }), 'favicon.ico') - // eslint-disable-next-line react-hooks/exhaustive-deps + /** ICO payload for the current icons, or null while a size is still missing. */ + const buildIcoBytes = useCallback(() => { + const images = ICO_SIZES.map((size) => icons.find((i) => i.size === size && !i.maskable)) + if (images.some((icon) => !icon)) return null + return buildIco(images.map((icon) => ({ size: icon!.size, png: icon!.bytes }))) }, [icons]) - const downloadPng = (icon: RenderedIcon) => { - saveAs(new Blob([icon.bytes as BlobPart], { type: 'image/png' }), pngFileName(icon.size, icon.maskable)) + const downloadIco = useCallback(async () => { + const ico = buildIcoBytes() + if (!ico) return + try { + await saveFile(ico, 'favicon.ico', 'image/x-icon') + } catch (err) { + toast.error(err instanceof Error ? err.message : t('saveError')) + } + }, [buildIcoBytes, t]) + + const downloadPng = async (icon: RenderedIcon) => { + try { + await saveFile(icon.bytes, pngFileName(icon.size, icon.maskable), 'image/png') + } catch (err) { + toast.error(err instanceof Error ? err.message : t('saveError')) + } } const downloadZip = useCallback(async () => { - if (icons.length === 0) return - const zip = new JSZip() - const icoImages = ICO_SIZES.map((size) => ({ size, png: getIcon(size)!.bytes })) - zip.file('favicon.ico', buildIco(icoImages)) - for (const icon of icons) { - zip.file(pngFileName(icon.size, icon.maskable), icon.bytes) + const ico = buildIcoBytes() + if (!ico) return + try { + const zip = new JSZip() + zip.file('favicon.ico', ico) + for (const icon of icons) { + zip.file(pngFileName(icon.size, icon.maskable), icon.bytes) + } + zip.file('site.webmanifest', manifestSnippet) + zip.file('head-snippet.html', headSnippet) + const bytes = await zip.generateAsync({ type: 'uint8array' }) + await saveFile(bytes, 'favicons.zip', 'application/zip') + } catch (err) { + toast.error(err instanceof Error ? err.message : t('saveError')) } - zip.file('site.webmanifest', manifestSnippet) - zip.file('head-snippet.html', headSnippet) - const blob = await zip.generateAsync({ type: 'blob' }) - saveAs(blob, 'favicons.zip') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [icons, manifestSnippet, headSnippet]) + }, [buildIcoBytes, icons, manifestSnippet, headSnippet, t]) const copySnippet = (key: string, text: string) => { void copyToClipboard(text, { silent: true }) @@ -318,7 +328,7 @@ export function FaviconGeneratorLayout() {
- diff --git a/apps/desktop-ui/src/lib/desktop/save-file.ts b/apps/desktop-ui/src/lib/desktop/save-file.ts new file mode 100644 index 00000000..95b0435f --- /dev/null +++ b/apps/desktop-ui/src/lib/desktop/save-file.ts @@ -0,0 +1,38 @@ +/** + * Save generated bytes to disk from a tool. + * + * The Tauri webview has no download handler, so wry answers every + * `` navigation with `WKNavigationActionPolicy::Cancel` — anchor + * downloads (file-saver included) are silently dropped in the desktop app. + * On desktop we therefore ask for a path with the native Save dialog and write + * the bytes ourselves; on web the anchor download stays the only option. + * + * Returns false when the user dismissed the Save dialog; throws when the write + * itself fails, so callers can surface the reason. + */ +import { isDesktop } from "./is-desktop" + +export async function saveFile( + data: Uint8Array | string, + filename: string, + mime = "application/octet-stream", +): Promise { + if (isDesktop()) { + const { save } = await import("@tauri-apps/plugin-dialog") + const path = await save({ defaultPath: filename }) + if (!path) return false + const { writeFile, writeTextFile } = await import("@tauri-apps/plugin-fs") + if (typeof data === "string") await writeTextFile(path, data) + else await writeFile(path, data) + return true + } + + const url = URL.createObjectURL(new Blob([data as BlobPart], { type: mime })) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + // Revoke on the next tick — revoking synchronously can race the download. + setTimeout(() => URL.revokeObjectURL(url), 0) + return true +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 86facd76..2604fffd 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,9 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capability for the main window", - "windows": ["main"], + "windows": [ + "main" + ], "permissions": [ "core:default", "core:window:allow-start-dragging", @@ -13,6 +15,7 @@ "dialog:default", "fs:allow-read-text-file", "fs:allow-write-text-file", + "fs:allow-write-file", "fs:allow-read-dir", "fs:allow-mkdir", "fs:allow-remove", From 74cc39d2891e3f602d970b3047a7a2f101cd2cc3 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 7 Sep 2026 23:23:44 +0530 Subject: [PATCH 3/7] fix(downloads): route every tool's file download through saveFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tauri webview registers no download handler, so wry cancels every `` navigation — the same reason the favicon generator's downloads did nothing. All 32 remaining tools that built an anchor by hand (or called file-saver) were dead in the desktop app for exactly that reason: exports from Base64, UUID, SVG optimizer, gitignore, Docker Compose, notes, bookmarks, tasks, the password vault, the API client, S3 Drive, the data explorers, and the rest. Move all 41 call sites onto downloadFile(), the fire-and-forget wrapper around saveFile() — native Save dialog plus a plugin-fs write on desktop, anchor download on web — so a failed write surfaces as a toast rather than an unhandled rejection. saveFile now takes a Blob or ArrayBuffer as well as bytes or a string, which is what most call sites already had in hand. Two exports that read back a canvas as a data: URL (code screenshot, gradient wallpaper) now take the toBlob path instead, and file-saver is dropped as a dependency now that nothing imports it. Covered by src/lib/desktop/__tests__/save-file.test.ts: filename, byte fidelity, Blob passthrough and the failure toast. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- apps/desktop-ui/package.json | 2 - .../src/app/app/to-do/ExportImportDialog.tsx | 15 +--- .../components/api-client/response-panel.tsx | 10 +-- .../src/components/base64/base64-layout.tsx | 8 +- .../bookmarks/bookmarks-manager.tsx | 15 +--- .../code-screenshot-layout.tsx | 9 +- .../css-gradient-builder/gradient-layout.tsx | 14 ++- .../data-explorer/mongodb/export-dialog.tsx | 8 +- .../data-explorer/mongodb/gridfs-browser.tsx | 8 +- .../data-explorer/mongodb/schema-view.tsx | 8 +- .../redis/bulk-export-dialog.tsx | 8 +- .../data-explorer/sql/results-table.tsx | 8 +- .../docker-compose-generator-layout.tsx | 8 +- .../email-validator/email-validator.tsx | 10 +-- .../exif-viewer/exif-viewer-layout.tsx | 4 +- .../gitignore-generator/gitignore-layout.tsx | 12 +-- .../image-compressor-layout.tsx | 8 +- .../image-to-base64-layout.tsx | 8 +- .../json-visualizer/graph-canvas.tsx | 6 +- .../lorem-ipsum/lorem-ipsum-layout.tsx | 8 +- .../markdown-preview-html-layout.tsx | 8 +- .../master-password-gate/backup-codes-file.ts | 9 +- .../mock-data-generator-layout.tsx | 15 +--- .../src/components/notes/NotionEditor.tsx | 15 +--- .../password-manager/import-export-dialog.tsx | 10 +-- .../src/components/s3-drive/file-browser.tsx | 14 +-- .../secret-api-key-generator-layout.tsx | 8 +- .../ssh-key-generator-layout.tsx | 8 +- .../svg-optimizer/svg-optimizer-layout.tsx | 8 +- .../url-encode/url-encode-layout.tsx | 8 +- .../url-parser/url-parser-layout.tsx | 8 +- .../uuid-generator/uuid-generator-layout.tsx | 8 +- .../websocket-tester-layout.tsx | 4 +- .../lib/desktop/__tests__/save-file.test.ts | 86 +++++++++++++++++++ apps/desktop-ui/src/lib/desktop/save-file.ts | 35 +++++--- 35 files changed, 192 insertions(+), 229 deletions(-) create mode 100644 apps/desktop-ui/src/lib/desktop/__tests__/save-file.test.ts diff --git a/apps/desktop-ui/package.json b/apps/desktop-ui/package.json index b3f52629..4b3d1e3c 100644 --- a/apps/desktop-ui/package.json +++ b/apps/desktop-ui/package.json @@ -73,7 +73,6 @@ "diff": "^8.0.4", "exifr": "^7.1.3", "fast-xml-parser": "^5.11.1", - "file-saver": "^2.0.5", "framer-motion": "^12.43.0", "geist": "^1.7.2", "gpt-tokenizer": "^4.0.0", @@ -129,7 +128,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.6", "@next/bundle-analyzer": "^16.3.3", - "@types/file-saver": "^2.0.7", "@types/jest": "^30.0.0", "@types/jmespath": "^0.15.2", "@types/js-yaml": "^4.0.9", diff --git a/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx b/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx index ed07cdf2..02d7734a 100644 --- a/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx +++ b/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx @@ -16,6 +16,7 @@ import { Project } from "@/app/app/to-do/types/Project"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { useStatuses } from "./hooks/useStatuses"; +import { downloadFile } from "@/lib/desktop/save-file"; interface ExportImportDialogProps { open: boolean; @@ -66,12 +67,7 @@ export default function ExportImportDialog({ const filteredTasks = getFilteredTasks(); const dataStr = JSON.stringify(filteredTasks, null, 2); const dataBlob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement("a"); - link.href = url; - link.download = `tasks-export-${new Date().toISOString().split("T")[0]}.json`; - link.click(); - URL.revokeObjectURL(url); + downloadFile(dataBlob, `tasks-export-${new Date().toISOString().split("T")[0]}.json`); toast.success(t("toastExportedJson", { count: filteredTasks.length })); }; @@ -115,12 +111,7 @@ export default function ExportImportDialog({ const csvContent = [headers.join(","), ...rows.map((row) => row.join(","))].join("\n"); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `tasks-export-${new Date().toISOString().split("T")[0]}.csv`; - link.click(); - URL.revokeObjectURL(url); + downloadFile(blob, `tasks-export-${new Date().toISOString().split("T")[0]}.csv`); toast.success(t("toastExportedCsv", { count: filteredTasks.length })); }; diff --git a/apps/desktop-ui/src/components/api-client/response-panel.tsx b/apps/desktop-ui/src/components/api-client/response-panel.tsx index 302407d3..aa4cc8f4 100644 --- a/apps/desktop-ui/src/components/api-client/response-panel.tsx +++ b/apps/desktop-ui/src/components/api-client/response-panel.tsx @@ -25,6 +25,7 @@ import { cn } from "@/lib/utils" import type { editor } from "monaco-editor" import { truncateBody } from "./truncate-body" import { ResponsePanelSkeleton } from "./skeletons" +import { downloadFile } from "@/lib/desktop/save-file" const MAX_INLINE_BYTES = 2 * 1024 * 1024 // 2MB @@ -203,14 +204,7 @@ function ResponsePanelImpl({ response, isLoading, scriptResults, onSaveExample } type: contentType || (isHtmlResponse ? "text/html" : "text/plain"), }) } - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `response-${Date.now()}.${getLanguage()}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `response-${Date.now()}.${getLanguage()}`) } const renderPreview = () => { diff --git a/apps/desktop-ui/src/components/base64/base64-layout.tsx b/apps/desktop-ui/src/components/base64/base64-layout.tsx index c88fc850..b4c27d5f 100644 --- a/apps/desktop-ui/src/components/base64/base64-layout.tsx +++ b/apps/desktop-ui/src/components/base64/base64-layout.tsx @@ -20,6 +20,7 @@ import { SendToMenu } from '@/components/ui/send-to-menu'; import { IconTransform } from '@tabler/icons-react'; import { ToolShell } from '@/components/tools/tool-shell'; import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; type Mode = 'encode' | 'decode'; @@ -112,12 +113,7 @@ export function Base64Layout() { const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = mode === 'encode' ? t('download.encodedFilename') : t('download.decodedFilename'); - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, mode === 'encode' ? t('download.encodedFilename') : t('download.decodedFilename')); }; const handleDrop = (e: React.DragEvent) => { diff --git a/apps/desktop-ui/src/components/bookmarks/bookmarks-manager.tsx b/apps/desktop-ui/src/components/bookmarks/bookmarks-manager.tsx index 1a515766..840a49a4 100644 --- a/apps/desktop-ui/src/components/bookmarks/bookmarks-manager.tsx +++ b/apps/desktop-ui/src/components/bookmarks/bookmarks-manager.tsx @@ -40,6 +40,7 @@ import { import { exportBookmarksToHTML, exportBookmarksToJSON } from "@/lib/bookmark-parser" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { downloadFile } from "@/lib/desktop/save-file" /** Lives inside ToolSidebarLayout so it can dismiss the mobile sheet on pick. */ function FolderPanel({ onSelectFolder }: { onSelectFolder: (id: string | null) => void }) { @@ -85,23 +86,13 @@ export default function BookmarksManager() { const handleExportHTML = useCallback(() => { const html = exportBookmarksToHTML(bookmarks, folders) const blob = new Blob([html], { type: 'text/html' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'bookmarks.html' - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, 'bookmarks.html') }, [bookmarks, folders]) const handleExportJSON = useCallback(() => { const json = exportBookmarksToJSON(bookmarks, folders) const blob = new Blob([json], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'bookmarks.json' - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, 'bookmarks.json') }, [bookmarks, folders]) const handleEditBookmark = useCallback((id: string) => { diff --git a/apps/desktop-ui/src/components/code-screenshot/code-screenshot-layout.tsx b/apps/desktop-ui/src/components/code-screenshot/code-screenshot-layout.tsx index 4ee111a9..4016154a 100644 --- a/apps/desktop-ui/src/components/code-screenshot/code-screenshot-layout.tsx +++ b/apps/desktop-ui/src/components/code-screenshot/code-screenshot-layout.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' -import { toBlob, toPng } from 'html-to-image' -import { saveAs } from 'file-saver' +import { toBlob } from 'html-to-image' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -23,6 +22,7 @@ import { IconPhotoCode } from '@tabler/icons-react' import { ToolShell } from '@/components/tools/tool-shell' import { IOPanel, ToolTextArea } from '@/components/tools/io-panel' import { BACKGROUNDS, LANGUAGES, canFormat, formatCode, highlightCode } from '@/lib/code-screenshot' +import { downloadFile } from '@/lib/desktop/save-file' /** * highlight.js theme scoped under .cshot-code so it can never bleed into the @@ -124,8 +124,9 @@ export function CodeScreenshotLayout() { if (!node) return setExporting(true) try { - const dataUrl = await toPng(node, { pixelRatio: scale, cacheBust: true }) - saveAs(dataUrl, 'code-screenshot.png') + const blob = await toBlob(node, { pixelRatio: scale, cacheBust: true }) + if (!blob) throw new Error('no blob') + downloadFile(blob, 'code-screenshot.png') } catch { toast.error(t('exportFailed')) } finally { diff --git a/apps/desktop-ui/src/components/css-gradient-builder/gradient-layout.tsx b/apps/desktop-ui/src/components/css-gradient-builder/gradient-layout.tsx index 2c316325..679980d8 100644 --- a/apps/desktop-ui/src/components/css-gradient-builder/gradient-layout.tsx +++ b/apps/desktop-ui/src/components/css-gradient-builder/gradient-layout.tsx @@ -19,6 +19,7 @@ import { IconColorSwatch } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { ToolShell } from '@/components/tools/tool-shell'; +import { downloadFile } from '@/lib/desktop/save-file'; type GradientType = 'linear' | 'radial' | 'conic'; @@ -94,17 +95,12 @@ export function GradientLayout() { if (ctx) { ctx.drawImage(img, 0, 0); try { - const png = canvas.toDataURL('image/png'); - const a = document.createElement('a'); - a.href = png; - a.download = 'gradient-wallpaper.png'; - a.click(); + canvas.toBlob((png) => { + downloadFile(png ?? blob, png ? 'gradient-wallpaper.png' : 'gradient-wallpaper.svg'); + }, 'image/png'); } catch { // If tainted canvas due to strict mode, download SVG directly as fallback - const a = document.createElement('a'); - a.href = url; - a.download = 'gradient-wallpaper.svg'; - a.click(); + downloadFile(blob, 'gradient-wallpaper.svg'); } } URL.revokeObjectURL(url); diff --git a/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx index 83c4c26a..e593931d 100644 --- a/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx @@ -10,6 +10,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { IconDownload } from "@tabler/icons-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; +import { downloadFile } from "@/lib/desktop/save-file"; interface ExportDialogProps { open: boolean; @@ -57,12 +58,7 @@ export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDi return newDoc; }); const blob = new Blob([JSON.stringify(rows, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, filename); toast.success(t("success", { filename })); onOpenChange(false); return; diff --git a/apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx index e5ae2e01..6dba6fce 100644 --- a/apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx +++ b/apps/desktop-ui/src/components/data-explorer/mongodb/gridfs-browser.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { IconRefresh, IconUpload, IconDownload, IconTrash, IconLoader2, IconFile } from "@tabler/icons-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { downloadFile } from "@/lib/desktop/save-file"; interface GridFsFile { id: unknown; @@ -102,12 +103,7 @@ export function GridFsBrowser({ const data = await res.json(); if (!res.ok) throw new Error(data.error); const bytes = Uint8Array.from(atob(data.data), (c) => c.charCodeAt(0)); - const url = URL.createObjectURL(new Blob([bytes])); - const a = document.createElement("a"); - a.href = url; - a.download = f.filename || "gridfs-file"; - a.click(); - URL.revokeObjectURL(url); + downloadFile(new Blob([bytes]), f.filename || "gridfs-file"); } catch (e: any) { toast.error(e.message || "Download failed"); } finally { diff --git a/apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx index 673cabc9..ae6c570d 100644 --- a/apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx +++ b/apps/desktop-ui/src/components/data-explorer/mongodb/schema-view.tsx @@ -9,6 +9,7 @@ import { IconRefresh, IconShieldCheck, IconChevronRight, IconDownload } from "@t import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import { generateSchemaExport, SchemaExportFormat } from "@/lib/nosql-schema-export"; +import { downloadFile } from "@/lib/desktop/save-file"; export type SampleMode = "random" | "first" | "last" | "all"; export interface SchemaLoadOptions { sampleMode: SampleMode; sampleSize: number } @@ -88,12 +89,7 @@ export function SchemaView({ const exportSchema = (format: SchemaExportFormat) => { const { content, ext, mime } = generateSchemaExport(format, data!.fields, collectionName); - const url = URL.createObjectURL(new Blob([content], { type: mime })); - const a = document.createElement("a"); - a.href = url; - a.download = `${collectionName}.${ext}`; - a.click(); - URL.revokeObjectURL(url); + downloadFile(new Blob([content], { type: mime }), `${collectionName}.${ext}`); }; const validator = data.validator && typeof data.validator === "object" ? data.validator : null; diff --git a/apps/desktop-ui/src/components/data-explorer/redis/bulk-export-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/redis/bulk-export-dialog.tsx index 8a66b6ec..83055849 100644 --- a/apps/desktop-ui/src/components/data-explorer/redis/bulk-export-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/redis/bulk-export-dialog.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import type { ExportedKey } from "./bulk-types"; +import { downloadFile } from "@/lib/desktop/save-file"; export function BulkExportDialog({ open, @@ -41,12 +42,7 @@ export function BulkExportDialog({ const body = await res.json() as { keys?: ExportedKey[]; count?: number; truncated?: boolean; error?: string }; if (body.error) throw new Error(body.error); const blob = new Blob([JSON.stringify(body, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `redis-export-db${db}-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, `redis-export-db${db}-${Date.now()}.json`); toast.success(`Exported ${body.count ?? 0} key(s)${body.truncated ? " (truncated)" : ""}`); onOpenChange(false); } catch (err) { diff --git a/apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx b/apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx index 3b1dd0eb..01ee9fd4 100644 --- a/apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx +++ b/apps/desktop-ui/src/components/data-explorer/sql/results-table.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; import { QueryResult, SchemaInfo } from "./types"; import type { AnySqlConfig } from "@/lib/data-explorer/sql-api"; import { sqlBody } from "@/lib/sql-request"; +import { downloadFile } from "@/lib/desktop/save-file"; /** * A BLOB the server could not decode as text. Rust sends hex plus the true @@ -179,12 +180,7 @@ export function ResultsTable({ result, editable }: ResultsTableProps) { ); const csv = [header, ...rows].join("\n"); const blob = new Blob([csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "query-results.csv"; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, "query-results.csv"); }; const formatCell = (value: unknown): string => { diff --git a/apps/desktop-ui/src/components/docker-compose-generator/docker-compose-generator-layout.tsx b/apps/desktop-ui/src/components/docker-compose-generator/docker-compose-generator-layout.tsx index 8e87ec8a..d4e18b18 100644 --- a/apps/desktop-ui/src/components/docker-compose-generator/docker-compose-generator-layout.tsx +++ b/apps/desktop-ui/src/components/docker-compose-generator/docker-compose-generator-layout.tsx @@ -19,6 +19,7 @@ import { IconBrandDocker } from '@tabler/icons-react' import { CopyTextButton } from '@/components/tools/copy-text-button' import { ToolShell } from '@/components/tools/tool-shell' import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel' +import { downloadFile } from '@/lib/desktop/save-file' const PRESETS: { id: string; services: string[] }[] = [ { id: 'stackWeb', services: ['postgres', 'redis', 'nginx'] }, @@ -96,12 +97,7 @@ export function DockerComposeGeneratorLayout() { const downloadYaml = () => { if (!yaml || selectedList.length === 0) return const blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'docker-compose.yml' - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, 'docker-compose.yml') } return ( diff --git a/apps/desktop-ui/src/components/email-validator/email-validator.tsx b/apps/desktop-ui/src/components/email-validator/email-validator.tsx index 8a32960f..1bc476cc 100644 --- a/apps/desktop-ui/src/components/email-validator/email-validator.tsx +++ b/apps/desktop-ui/src/components/email-validator/email-validator.tsx @@ -44,6 +44,7 @@ import { motion } from "framer-motion"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useTranslations } from "next-intl"; import { validateEmail, validateEmails, type EmailValidationResult } from "@/lib/email-validator"; +import { downloadFile } from "@/lib/desktop/save-file"; type EmailValidation = EmailValidationResult; type BulkResult = EmailValidationResult; @@ -331,14 +332,7 @@ export function EmailValidator() { const csvContent = csvRows.join("\n"); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = validOnly ? "valid_emails.csv" : "email_validation_results.csv"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + downloadFile(blob, validOnly ? "valid_emails.csv" : "email_validation_results.csv"); toast.success(t("toasts.exportSuccessTitle"), { description: validOnly diff --git a/apps/desktop-ui/src/components/exif-viewer/exif-viewer-layout.tsx b/apps/desktop-ui/src/components/exif-viewer/exif-viewer-layout.tsx index 9511b994..c9170ac0 100644 --- a/apps/desktop-ui/src/components/exif-viewer/exif-viewer-layout.tsx +++ b/apps/desktop-ui/src/components/exif-viewer/exif-viewer-layout.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import exifr from 'exifr' -import { saveAs } from 'file-saver' import { toast } from 'sonner' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Badge } from '@/components/ui/badge' @@ -34,6 +33,7 @@ import { import { IconPhotoSearch } from '@tabler/icons-react' import { ToolShell } from '@/components/tools/tool-shell' import { IOPanel } from '@/components/tools/io-panel' +import { downloadFile } from '@/lib/desktop/save-file' import { decimalToDms, formatBytes, @@ -482,7 +482,7 @@ export function ExifViewerLayout() { {t('remove.after')}:{' '} {formatBytes(cleaned.blob.size)}
- diff --git a/apps/desktop-ui/src/components/gitignore-generator/gitignore-layout.tsx b/apps/desktop-ui/src/components/gitignore-generator/gitignore-layout.tsx index 630aa1b8..b334c51c 100644 --- a/apps/desktop-ui/src/components/gitignore-generator/gitignore-layout.tsx +++ b/apps/desktop-ui/src/components/gitignore-generator/gitignore-layout.tsx @@ -17,6 +17,7 @@ import { CopyTextButton } from '@/components/tools/copy-text-button'; import { ToolErrorBanner } from '@/components/tools/tool-error-banner'; import { ToolShell } from '@/components/tools/tool-shell'; import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; export function GitignoreLayout() { const t = useTranslations('GitignoreGenerator'); @@ -90,15 +91,10 @@ export function GitignoreLayout() { void copyToClipboard(output, { silent: true }); }; - const downloadFile = () => { + const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '.gitignore'; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, '.gitignore'); }; const toggleStack = (tag: string) => { @@ -231,7 +227,7 @@ export function GitignoreLayout() { } actions={ <> - diff --git a/apps/desktop-ui/src/components/image-compressor/image-compressor-layout.tsx b/apps/desktop-ui/src/components/image-compressor/image-compressor-layout.tsx index 266e41e7..485bce24 100644 --- a/apps/desktop-ui/src/components/image-compressor/image-compressor-layout.tsx +++ b/apps/desktop-ui/src/components/image-compressor/image-compressor-layout.tsx @@ -26,6 +26,7 @@ import { cn } from '@/lib/utils'; import { useDebouncedCallback } from 'use-debounce'; import { ToolShell } from '@/components/tools/tool-shell'; import { ToolPanels, IOPanel } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; const MAX_FILE_BYTES = 20 * 1024 * 1024; const MAX_CANVAS_EDGE = 8192; @@ -237,11 +238,8 @@ export function ImageCompressorLayout() { }; const handleDownload = () => { - if (!compressedBlob || !compressedUrl) return; - const a = document.createElement('a'); - a.href = compressedUrl; - a.download = `${t('downloadFilename')}.${extForMime(outputMime)}`; - a.click(); + if (!compressedBlob) return; + downloadFile(compressedBlob, `${t('downloadFilename')}.${extForMime(outputMime)}`); }; const savings = diff --git a/apps/desktop-ui/src/components/image-to-base64/image-to-base64-layout.tsx b/apps/desktop-ui/src/components/image-to-base64/image-to-base64-layout.tsx index c877b43e..b750d818 100644 --- a/apps/desktop-ui/src/components/image-to-base64/image-to-base64-layout.tsx +++ b/apps/desktop-ui/src/components/image-to-base64/image-to-base64-layout.tsx @@ -18,6 +18,7 @@ import { useTranslations } from 'next-intl'; import { IconPhoto } from '@tabler/icons-react'; import { ToolShell } from '@/components/tools/tool-shell'; import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; type Mode = 'dataUri' | 'rawString'; @@ -68,12 +69,7 @@ export function ImageToBase64Layout() { const textToDownload = mode === 'rawString' ? output.replace(/^data:image\/[a-z]+;base64,/, '') : output; if (!textToDownload) return; const blob = new Blob([textToDownload], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = t('download.filename'); - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, t('download.filename')); }; const handleDrop = (e: React.DragEvent) => { diff --git a/apps/desktop-ui/src/components/json-visualizer/graph-canvas.tsx b/apps/desktop-ui/src/components/json-visualizer/graph-canvas.tsx index c99eb40b..05726f72 100644 --- a/apps/desktop-ui/src/components/json-visualizer/graph-canvas.tsx +++ b/apps/desktop-ui/src/components/json-visualizer/graph-canvas.tsx @@ -9,7 +9,6 @@ import React, { useRef, useState, } from 'react' -import { saveAs } from 'file-saver' import { layoutGraph, NODE_STYLE, @@ -18,6 +17,7 @@ import { type JsonGraph, type LaidOutNode, } from '@/lib/json-visualizer' +import { downloadFile } from '@/lib/desktop/save-file' const TYPE_COLORS: Record = { string: '#10b981', @@ -123,11 +123,11 @@ export const GraphCanvas = forwardRef(funct if (!svg || !content) return const serialized = serializeGraphSvg(svg, content, l) if (format === 'svg') { - saveAs(new Blob([serialized], { type: 'image/svg+xml' }), `${filename}.svg`) + downloadFile(new Blob([serialized], { type: 'image/svg+xml' }), `${filename}.svg`) return } const blob = await svgToPngBlob(serialized, l.width + EXPORT_PADDING * 2, l.height + EXPORT_PADDING * 2) - if (blob) saveAs(blob, `${filename}.png`) + if (blob) downloadFile(blob, `${filename}.png`) }, []) useImperativeHandle(ref, () => ({ fitView, zoomBy, centerOn, exportImage }), [ diff --git a/apps/desktop-ui/src/components/lorem-ipsum/lorem-ipsum-layout.tsx b/apps/desktop-ui/src/components/lorem-ipsum/lorem-ipsum-layout.tsx index cce8772e..2cf27dfe 100644 --- a/apps/desktop-ui/src/components/lorem-ipsum/lorem-ipsum-layout.tsx +++ b/apps/desktop-ui/src/components/lorem-ipsum/lorem-ipsum-layout.tsx @@ -24,6 +24,7 @@ import { type LoremUnit, } from '@/lib/lorem-ipsum'; import { useTranslations } from 'next-intl'; +import { downloadFile } from '@/lib/desktop/save-file'; const UNIT_VALUES: LoremUnit[] = ['paragraphs', 'sentences', 'words', 'list']; @@ -65,12 +66,7 @@ export function LoremIpsumLayout() { const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: asHtml ? 'text/html' : 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = asHtml ? 'lorem-ipsum.html' : 'lorem-ipsum.txt'; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, asHtml ? 'lorem-ipsum.html' : 'lorem-ipsum.txt'); }; return ( diff --git a/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx b/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx index a3dd48f9..af36c900 100644 --- a/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx +++ b/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx @@ -14,6 +14,7 @@ import { Copy, Check, Download, Eye, Code } from 'lucide-react'; import { IconMarkdown } from '@tabler/icons-react'; import { ToolShell } from '@/components/tools/tool-shell'; import { IOPanel, ToolTextArea } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; // Configure marked for safe, synchronous rendering marked.setOptions({ async: false }); @@ -101,12 +102,7 @@ export function MarkdownPreviewLayout() { if (!renderedHtml) return; const name = `${safeFileName(fileName)}.html`; const blob = new Blob([buildFullHtml(renderedHtml)], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = name; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, name); toast.success(t('exportSuccess', { name })); }, [renderedHtml, fileName, t]); diff --git a/apps/desktop-ui/src/components/master-password-gate/backup-codes-file.ts b/apps/desktop-ui/src/components/master-password-gate/backup-codes-file.ts index 1b01316a..7710e69d 100644 --- a/apps/desktop-ui/src/components/master-password-gate/backup-codes-file.ts +++ b/apps/desktop-ui/src/components/master-password-gate/backup-codes-file.ts @@ -1,3 +1,5 @@ +import { downloadFile } from "@/lib/desktop/save-file" + export function downloadBackupCodesFile(codes: string[], userEmail?: string | null) { const date = new Date().toLocaleDateString("en-US", { year: "numeric", @@ -32,10 +34,5 @@ export function downloadBackupCodesFile(codes: string[], userEmail?: string | nu "========================================", ] const blob = new Blob([lines.join("\n")], { type: "text/plain" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = "mydevtools-backup-codes.txt" - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, "mydevtools-backup-codes.txt") } diff --git a/apps/desktop-ui/src/components/mock-data-generator/mock-data-generator-layout.tsx b/apps/desktop-ui/src/components/mock-data-generator/mock-data-generator-layout.tsx index 6d49be74..93eae0fa 100644 --- a/apps/desktop-ui/src/components/mock-data-generator/mock-data-generator-layout.tsx +++ b/apps/desktop-ui/src/components/mock-data-generator/mock-data-generator-layout.tsx @@ -34,6 +34,7 @@ import { } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' +import { downloadFile } from '@/lib/desktop/save-file' import { ALL_FIELD_TYPES, FIELD_TYPE_GROUP_STRUCTURE, @@ -154,12 +155,7 @@ export function MockDataGeneratorLayout() { const handleDownload = () => { if (!output) return const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `mock-data.${extensionForFormat(format)}` - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, `mock-data.${extensionForFormat(format)}`) } const loadPreset = (key: string) => { @@ -208,12 +204,7 @@ export function MockDataGeneratorLayout() { const blob = new Blob([JSON.stringify(toFieldSchema(schemaRows), null, 2)], { type: 'application/json;charset=utf-8', }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'mock-data-schema.json' - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, 'mock-data-schema.json') } const handleImportSchema = async (file: File) => { diff --git a/apps/desktop-ui/src/components/notes/NotionEditor.tsx b/apps/desktop-ui/src/components/notes/NotionEditor.tsx index 6a8b2314..c7c64c4f 100644 --- a/apps/desktop-ui/src/components/notes/NotionEditor.tsx +++ b/apps/desktop-ui/src/components/notes/NotionEditor.tsx @@ -40,6 +40,7 @@ import { readingTimeMinutes, } from "@/app/app/notes/utils/noteContentUtils"; import type { Note } from "@/app/app/notes/types/Note"; +import { downloadFile } from "@/lib/desktop/save-file"; import { mergePendingUpdate, requeuePendingUpdates, @@ -230,12 +231,7 @@ export default function NotionEditor() { const src = latestMarkdownRef.current ?? activeContent; const md = contentToMarkdown(title || "Untitled", src); const blob = new Blob([md], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${(title || "note").replace(/\s+/g, "-")}.md`; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, `${(title || "note").replace(/\s+/g, "-")}.md`); }, [activeContent, title]); const handleExportHtml = useCallback(() => { @@ -244,12 +240,7 @@ export default function NotionEditor() { const bodyHtml = marked.parse(body, { async: false }) as string; const html = `\n\n\n\n${title || "Note"}\n\n\n

${title || "Untitled"}

\n${bodyHtml}\n`; const blob = new Blob([html], { type: "text/html" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${(title || "note").replace(/\s+/g, "-")}.html`; - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, `${(title || "note").replace(/\s+/g, "-")}.html`); }, [activeContent, title]); const handleApplyTemplate = useCallback((tpl: NoteTemplate) => { diff --git a/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx b/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx index 3d3ddb4f..52489ac1 100644 --- a/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx +++ b/apps/desktop-ui/src/components/password-manager/import-export-dialog.tsx @@ -23,6 +23,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { useTranslations } from "next-intl" +import { downloadFile } from "@/lib/desktop/save-file" interface ImportExportDialogProps { children?: React.ReactNode @@ -67,14 +68,7 @@ export function ImportExportDialog({ children }: ImportExportDialogProps) { const download = (contents: string, suffix: string) => { const blob = new Blob([contents], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `password-vault-export-${new Date().toISOString().split("T")[0]}${suffix}.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `password-vault-export-${new Date().toISOString().split("T")[0]}${suffix}.json`) } /** diff --git a/apps/desktop-ui/src/components/s3-drive/file-browser.tsx b/apps/desktop-ui/src/components/s3-drive/file-browser.tsx index 261e7911..283d8ef7 100644 --- a/apps/desktop-ui/src/components/s3-drive/file-browser.tsx +++ b/apps/desktop-ui/src/components/s3-drive/file-browser.tsx @@ -83,6 +83,7 @@ import { RenameDialog } from "./rename-dialog" import { ShareLinkDialog } from "./share-link-dialog" import { UploadProgressPanel, type FileUploadStatus } from "./upload-progress-panel" import { MoveToDialog } from "./move-to-dialog" +import { downloadFile } from "@/lib/desktop/save-file" type ViewMode = "list" | "grid" type SortCol = "name" | "size" | "modified" @@ -372,11 +373,7 @@ export function FileBrowser({ credentials, connectionName }: Props) { // Fetched through the Rust proxy — a cross-origin presigned URL would // hit CORS / unsupported downloads in the Tauri webview. const blob = await getObjectBlob(credentials, key) - const a = document.createElement("a") - a.href = URL.createObjectURL(blob) - a.download = key.split("/").pop() ?? key - a.click() - setTimeout(() => URL.revokeObjectURL(a.href), 30_000) + downloadFile(blob, key.split("/").pop() ?? key) } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to download file") } @@ -496,10 +493,7 @@ export function FileBrowser({ credentials, connectionName }: Props) { const fileKeys = Array.from(selectedKeys).filter((k) => !k.endsWith("/")) if (!fileKeys.length) { toast.error("No files selected (folders are skipped)"); return } setZipProgress({ done: 0, total: fileKeys.length }) - const [{ default: JSZip }, { saveAs }] = await Promise.all([ - import("jszip"), - import("file-saver"), - ]) + const { default: JSZip } = await import("jszip") const zip = new JSZip() let ok = 0 const ZIP_CONCURRENCY = 6 @@ -520,7 +514,7 @@ export function FileBrowser({ credentials, connectionName }: Props) { } if (ok > 0) { const blob = await zip.generateAsync({ type: "blob" }) - saveAs(blob, `download-${Date.now()}.zip`) + downloadFile(blob, `download-${Date.now()}.zip`) } setZipProgress(null) } diff --git a/apps/desktop-ui/src/components/secret-api-key-generator/secret-api-key-generator-layout.tsx b/apps/desktop-ui/src/components/secret-api-key-generator/secret-api-key-generator-layout.tsx index 5673c081..6890f5d9 100644 --- a/apps/desktop-ui/src/components/secret-api-key-generator/secret-api-key-generator-layout.tsx +++ b/apps/desktop-ui/src/components/secret-api-key-generator/secret-api-key-generator-layout.tsx @@ -29,6 +29,7 @@ import { type GenerateSecretStringsErrorKey, } from '@/lib/generate-secret-strings'; import { entropyBits, strengthBucket, type SecretStrength } from '@/lib/secret-entropy'; +import { downloadFile } from '@/lib/desktop/save-file'; const PRESET_BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; @@ -130,12 +131,7 @@ export function SecretApiKeyGeneratorLayout() { const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = t('download.filename'); - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, t('download.filename')); }; return ( diff --git a/apps/desktop-ui/src/components/ssh-key-generator/ssh-key-generator-layout.tsx b/apps/desktop-ui/src/components/ssh-key-generator/ssh-key-generator-layout.tsx index f772af28..40daeaad 100644 --- a/apps/desktop-ui/src/components/ssh-key-generator/ssh-key-generator-layout.tsx +++ b/apps/desktop-ui/src/components/ssh-key-generator/ssh-key-generator-layout.tsx @@ -21,6 +21,7 @@ import { ToolShell } from '@/components/tools/tool-shell'; import { IOPanel } from '@/components/tools/io-panel'; import { CopyTextButton } from '@/components/tools/copy-text-button'; import { ToolErrorBanner } from '@/components/tools/tool-error-banner'; +import { downloadFile } from '@/lib/desktop/save-file'; type KeyType = 'ed25519' | 'rsa-2048' | 'rsa-4096'; @@ -215,12 +216,7 @@ async function generateKeys(type: KeyType, comment: string): Promise @@ -74,12 +75,7 @@ export function SvgOptimizerLayout() { const handleDownload = () => { if (!output.trim()) return const blob = new Blob([output], { type: 'image/svg+xml;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'optimized.svg' - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, 'optimized.svg') } return ( diff --git a/apps/desktop-ui/src/components/url-encode/url-encode-layout.tsx b/apps/desktop-ui/src/components/url-encode/url-encode-layout.tsx index dde00d4f..b50068d7 100644 --- a/apps/desktop-ui/src/components/url-encode/url-encode-layout.tsx +++ b/apps/desktop-ui/src/components/url-encode/url-encode-layout.tsx @@ -19,6 +19,7 @@ import { SendToMenu } from '@/components/ui/send-to-menu'; import { IconLink } from '@tabler/icons-react'; import { ToolShell } from '@/components/tools/tool-shell'; import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel'; +import { downloadFile } from '@/lib/desktop/save-file'; type Mode = 'encode' | 'decode'; @@ -104,12 +105,7 @@ export function UrlEncodeLayout() { const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = mode === 'encode' ? t('download.encodedFilename') : t('download.decodedFilename'); - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, mode === 'encode' ? t('download.encodedFilename') : t('download.decodedFilename')); }; const handleDrop = (e: React.DragEvent) => { diff --git a/apps/desktop-ui/src/components/url-parser/url-parser-layout.tsx b/apps/desktop-ui/src/components/url-parser/url-parser-layout.tsx index 4c64e7cd..80becf1f 100644 --- a/apps/desktop-ui/src/components/url-parser/url-parser-layout.tsx +++ b/apps/desktop-ui/src/components/url-parser/url-parser-layout.tsx @@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl' import { IconLink } from '@tabler/icons-react' import { ToolShell } from '@/components/tools/tool-shell' import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel' +import { downloadFile } from '@/lib/desktop/save-file' type ParsedParam = { key: string @@ -71,12 +72,7 @@ function DownloadJsonButton({ data, filename, title }: { data: unknown; filename className="h-6 w-6" onClick={() => { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) + downloadFile(blob, filename) }} title={title} > diff --git a/apps/desktop-ui/src/components/uuid-generator/uuid-generator-layout.tsx b/apps/desktop-ui/src/components/uuid-generator/uuid-generator-layout.tsx index bfa6281d..2dc79ed1 100644 --- a/apps/desktop-ui/src/components/uuid-generator/uuid-generator-layout.tsx +++ b/apps/desktop-ui/src/components/uuid-generator/uuid-generator-layout.tsx @@ -32,6 +32,7 @@ import { ToolShell } from '@/components/tools/tool-shell'; import { IOPanel } from '@/components/tools/io-panel'; import { CopyIconButton } from '@/components/tools/copy-icon-button'; import { ToolErrorBanner } from '@/components/tools/tool-error-banner'; +import { downloadFile } from '@/lib/desktop/save-file'; const KIND_OPTIONS: { value: IdKind; label: string }[] = [ { value: 'ulid', label: 'ULID' }, @@ -118,12 +119,7 @@ export function UuidGeneratorLayout() { const handleDownload = () => { if (!output) return; const blob = new Blob([output], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = t('download.filename', { kind }); - a.click(); - URL.revokeObjectURL(url); + downloadFile(blob, t('download.filename', { kind })); }; return ( diff --git a/apps/desktop-ui/src/components/websocket-tester/websocket-tester-layout.tsx b/apps/desktop-ui/src/components/websocket-tester/websocket-tester-layout.tsx index 257a47c3..6160f104 100644 --- a/apps/desktop-ui/src/components/websocket-tester/websocket-tester-layout.tsx +++ b/apps/desktop-ui/src/components/websocket-tester/websocket-tester-layout.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' -import { saveAs } from 'file-saver' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -27,6 +26,7 @@ import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { ToolShell } from '@/components/tools/tool-shell' import { IOPanel, ToolTextArea } from '@/components/tools/io-panel' +import { downloadFile } from '@/lib/desktop/save-file' import { describeCloseEvent, formatLogEntry, @@ -242,7 +242,7 @@ export function WebsocketTesterLayout() { const blob = new Blob([entries.map(formatLogEntry).join('\n') + '\n'], { type: 'text/plain;charset=utf-8', }) - saveAs(blob, `websocket-log-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`) + downloadFile(blob, `websocket-log-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`) } const isConnected = status === 'connected' diff --git a/apps/desktop-ui/src/lib/desktop/__tests__/save-file.test.ts b/apps/desktop-ui/src/lib/desktop/__tests__/save-file.test.ts new file mode 100644 index 00000000..753506b6 --- /dev/null +++ b/apps/desktop-ui/src/lib/desktop/__tests__/save-file.test.ts @@ -0,0 +1,86 @@ +/** + * Web-branch behaviour of saveFile: every tool routes its downloads through + * here, so the anchor must carry the right filename and the right bytes. + * The desktop branch needs a live Tauri webview and is not covered. + */ +jest.mock("sonner", () => ({ toast: { error: jest.fn() } })) + +import { toast } from "sonner" +import { downloadFile, saveFile } from "@/lib/desktop/save-file" + +type FakeAnchor = { href: string; download: string; click: jest.Mock } + +function stubDom() { + const anchors: FakeAnchor[] = [] + const blobs = new Map() + const origCreate = URL.createObjectURL + const origRevoke = URL.revokeObjectURL + + ;(globalThis as unknown as { document: unknown }).document = { + createElement: () => { + const a: FakeAnchor = { href: "", download: "", click: jest.fn() } + anchors.push(a) + return a + }, + } + URL.createObjectURL = ((blob: Blob) => { + const url = `blob:${blobs.size}` + blobs.set(url, blob) + return url + }) as typeof URL.createObjectURL + URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL + + return { + anchors, + /** The blob the last anchor pointed at. */ + saved: () => blobs.get(anchors[anchors.length - 1].href)!, + restore() { + delete (globalThis as unknown as { document?: unknown }).document + URL.createObjectURL = origCreate + URL.revokeObjectURL = origRevoke + }, + } +} + +describe("saveFile (web)", () => { + let dom: ReturnType + beforeEach(() => { + dom = stubDom() + ;(toast.error as jest.Mock).mockClear() + }) + afterEach(() => dom.restore()) + + it("downloads a string under the given filename", async () => { + expect(await saveFile("hello", "note.txt", "text/plain")).toBe(true) + const a = dom.anchors[0] + expect(a.download).toBe("note.txt") + expect(a.click).toHaveBeenCalled() + await expect(dom.saved().text()).resolves.toBe("hello") + expect(dom.saved().type).toBe("text/plain") + }) + + it("preserves binary payloads byte for byte", async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]) + await saveFile(bytes, "icon.png", "image/png") + const out = new Uint8Array(await dom.saved().arrayBuffer()) + expect(Array.from(out)).toEqual(Array.from(bytes)) + }) + + it("passes a Blob through without rewrapping its type", async () => { + const blob = new Blob([""], { type: "image/svg+xml" }) + await saveFile(blob, "out.svg") + expect(dom.saved()).toBe(blob) + expect(dom.anchors[0].download).toBe("out.svg") + }) + + it("reports a failed save as a toast instead of an unhandled rejection", async () => { + ;(globalThis as unknown as { document: { createElement: () => never } }).document = { + createElement: () => { + throw new Error("no DOM") + }, + } + downloadFile("x", "x.txt") + await new Promise((r) => setTimeout(r, 0)) + expect(toast.error).toHaveBeenCalledWith("no DOM") + }) +}) diff --git a/apps/desktop-ui/src/lib/desktop/save-file.ts b/apps/desktop-ui/src/lib/desktop/save-file.ts index 95b0435f..b2837cbc 100644 --- a/apps/desktop-ui/src/lib/desktop/save-file.ts +++ b/apps/desktop-ui/src/lib/desktop/save-file.ts @@ -6,28 +6,30 @@ * downloads (file-saver included) are silently dropped in the desktop app. * On desktop we therefore ask for a path with the native Save dialog and write * the bytes ourselves; on web the anchor download stays the only option. - * - * Returns false when the user dismissed the Save dialog; throws when the write - * itself fails, so callers can surface the reason. */ +import { toast } from "sonner" import { isDesktop } from "./is-desktop" -export async function saveFile( - data: Uint8Array | string, - filename: string, - mime = "application/octet-stream", -): Promise { +export type SaveData = Uint8Array | ArrayBuffer | Blob | string + +/** + * Returns false when the user dismissed the Save dialog; throws when the write + * itself fails, so callers can surface their own reason. Prefer + * {@link downloadFile} unless you need that distinction. + */ +export async function saveFile(data: SaveData, filename: string, mime?: string): Promise { if (isDesktop()) { const { save } = await import("@tauri-apps/plugin-dialog") const path = await save({ defaultPath: filename }) if (!path) return false const { writeFile, writeTextFile } = await import("@tauri-apps/plugin-fs") if (typeof data === "string") await writeTextFile(path, data) - else await writeFile(path, data) + else await writeFile(path, new Uint8Array(await toArrayBuffer(data))) return true } - const url = URL.createObjectURL(new Blob([data as BlobPart], { type: mime })) + const blob = data instanceof Blob ? data : new Blob([data as BlobPart], { type: mime }) + const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename @@ -36,3 +38,16 @@ export async function saveFile( setTimeout(() => URL.revokeObjectURL(url), 0) return true } + +/** Fire-and-forget {@link saveFile}: a failed write surfaces as a toast. */ +export function downloadFile(data: SaveData, filename: string, mime?: string): void { + void saveFile(data, filename, mime).catch((err: unknown) => { + toast.error(err instanceof Error ? err.message : String(err)) + }) +} + +function toArrayBuffer(data: Exclude): Promise | ArrayBuffer { + if (data instanceof Blob) return data.arrayBuffer() + if (data instanceof ArrayBuffer) return data + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer +} From 3675e054136f18d6e36ffbcf7a7be4cacc2f5d16 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Mon, 7 Sep 2026 23:45:18 +0530 Subject: [PATCH 4/7] chore: refresh lockfile after dropping file-saver CI installs with --frozen-lockfile, which fails while pnpm-lock.yaml still lists file-saver and @types/file-saver as desktop-ui specifiers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- pnpm-lock.yaml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 850b834c..3e09dcee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,9 +197,6 @@ importers: fast-xml-parser: specifier: ^5.11.1 version: 5.11.1 - file-saver: - specifier: ^2.0.5 - version: 2.0.5 framer-motion: specifier: ^12.43.0 version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -360,9 +357,6 @@ importers: '@next/bundle-analyzer': specifier: ^16.3.3 version: 16.3.3 - '@types/file-saver': - specifier: ^2.0.7 - version: 2.0.7 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -3471,9 +3465,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/file-saver@2.0.7': - resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} - '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -4881,9 +4872,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-saver@2.0.5: - resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -11169,8 +11157,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/file-saver@2.0.7': {} - '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -12782,8 +12768,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-saver@2.0.5: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 From a4a85d17e5c786cb12b402dbb4e14db780f84c97 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 8 Sep 2026 00:38:22 +0530 Subject: [PATCH 5/7] fix: drop the JSON Schema download button, finish the download sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the Download button from the JSON Schema generator along with the handler, the FILE_EXT table it was the only user of, and the now-dead `download` key in all 27 locales. Copy and Clear are untouched. The previous sweep searched only src/components and src/app, so 17 more downloads were left broken in the desktop app for the same reason (wry cancels the anchor navigation): * 10 hand-built anchors under src/lib — collection exports (Postman, HAR, Insomnia, OpenAPI, Postman environments), the JUnit run report, the encrypted backup, the JSON import/export helpers, and triggerDownload. * 7 XLSX.writeFile calls, which build an anchor inside SheetJS — task export, email validator results and template, and the MongoDB csv/tsv/xlsx export. triggerDownload is gone; its callers use downloadFile directly. Workbooks go through the new downloadWorkbook(), which serializes with XLSX.write and hands the bytes to downloadFile with the right mime type. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- apps/desktop-ui/messages/af.json | 3 +-- apps/desktop-ui/messages/ar.json | 3 +-- apps/desktop-ui/messages/ca.json | 3 +-- apps/desktop-ui/messages/cs.json | 3 +-- apps/desktop-ui/messages/da.json | 3 +-- apps/desktop-ui/messages/de.json | 3 +-- apps/desktop-ui/messages/el.json | 3 +-- apps/desktop-ui/messages/en.json | 3 +-- apps/desktop-ui/messages/es.json | 3 +-- apps/desktop-ui/messages/fa.json | 3 +-- apps/desktop-ui/messages/fr.json | 3 +-- apps/desktop-ui/messages/id.json | 3 +-- apps/desktop-ui/messages/it.json | 3 +-- apps/desktop-ui/messages/ja.json | 3 +-- apps/desktop-ui/messages/ko.json | 3 +-- apps/desktop-ui/messages/ms.json | 3 +-- apps/desktop-ui/messages/nb.json | 3 +-- apps/desktop-ui/messages/nl.json | 3 +-- apps/desktop-ui/messages/pl.json | 3 +-- apps/desktop-ui/messages/pt-BR.json | 3 +-- apps/desktop-ui/messages/pt.json | 3 +-- apps/desktop-ui/messages/ru.json | 3 +-- apps/desktop-ui/messages/sv.json | 3 +-- apps/desktop-ui/messages/tr.json | 3 +-- apps/desktop-ui/messages/uk.json | 3 +-- apps/desktop-ui/messages/vi.json | 3 +-- apps/desktop-ui/messages/zh.json | 3 +-- .../src/app/app/to-do/ExportImportDialog.tsx | 3 ++- .../csv-excel-json/csv-excel-json-tool.tsx | 7 ++--- .../data-explorer/mongodb/export-dialog.tsx | 7 ++--- .../email-validator/email-validator.tsx | 5 ++-- .../json-schema-generator-layout.tsx | 27 +------------------ .../src/lib/csv-excel-json-utils.ts | 26 ++++++++++++------ apps/desktop-ui/src/lib/desktop/backup.ts | 10 ++----- apps/desktop-ui/src/lib/export/har.ts | 10 ++----- apps/desktop-ui/src/lib/export/insomnia.ts | 10 ++----- apps/desktop-ui/src/lib/export/openapi.ts | 10 ++----- apps/desktop-ui/src/lib/export/postman-env.ts | 10 ++----- apps/desktop-ui/src/lib/export/postman.ts | 10 ++----- .../src/lib/json-utils/import-export.ts | 17 +++--------- apps/desktop-ui/src/lib/runner/junit.ts | 10 ++----- 41 files changed, 74 insertions(+), 169 deletions(-) diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index a61dc012..ea5840a3 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Laai redigeerder…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Beeld na Base64", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 5970e15c..8cee710b 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "جاري تحميل المحرر…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "صورة إلى Base64", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 7a970f83..d5086e07 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "S'està carregant l'editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Imatge a Base64", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 27847daf..1d292750 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Načítání editoru…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Obrázek do Base64", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index 52436954..fce59853 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Indlæser editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Billede til Base64", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 347a786f..1a4ea30f 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Editor wird geladen…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Bild zu Base64", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 9b378fb1..d44204f7 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Φόρτωση επεξεργαστή…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Εικόνα σε Base64", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 2059c89a..87f875bc 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -3158,8 +3158,7 @@ "dart": "Dart", "swift": "Swift (Codable)" }, - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "SecureFiles": { "title": "Files", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index 0ad27d95..62ba0686 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Cargando el editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Imagen a Base64", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index f62ba95c..f70a60f9 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "در حال بارگذاری ویرایشگر…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "تصویر به Base64", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index be69b258..247c45b5 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Chargement de l’éditeur…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Image en Base64", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index c671a9dd..c62d54a1 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Memuat editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Gambar ke Base64", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index bdc2d923..d3219720 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Caricamento editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Immagine in Base64", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 90f525af..459b6da4 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "エディタを読み込み中…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "画像 → Base64", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index a1d28f7a..35e9e7fc 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "편집기 로드 중…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "이미지 → Base64", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index e24ec8fa..243e0198 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Memuatkan editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Imej ke Base64", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 0e39b225..d9b847a5 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Laster inn redigeringsprogrammet…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Bilde til Base64", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 998413fa..3d0c89b2 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Editor laden…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Afbeelding naar Base64", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 2e45dbbd..55e68bf8 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Ładowanie edytora…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Obraz do Base64", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 6da40ea9..b83639c0 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Carregando o editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Imagem para Base64", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 08fb935d..e0f90103 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "A carregar o editor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Imagem para Base64", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 8d9f15c1..017e2d0b 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Загрузка редактора…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Изображение в Base64", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index f37efa36..94a1f23f 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Läser in redigeraren…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Bild till Base64", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index cf8ba62f..e57c213a 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Editör yükleniyor…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Görselden Base64'e", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 663fa7c6..c6d59fe8 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Завантаження редактора…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Зображення в Base64", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 613e73b7..db91e007 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "Đang tải trình soạn thảo…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "Ảnh sang Base64", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index af6e0625..f225bb7a 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -3426,8 +3426,7 @@ "swift": "Swift (Codable)" }, "editorLoading": "正在加载编辑器…", - "detectedFormats": "Detected formats", - "download": "Download" + "detectedFormats": "Detected formats" }, "ImageToBase64": { "title": "图片转 Base64", diff --git a/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx b/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx index 02d7734a..60f74ce6 100644 --- a/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx +++ b/apps/desktop-ui/src/app/app/to-do/ExportImportDialog.tsx @@ -17,6 +17,7 @@ import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { useStatuses } from "./hooks/useStatuses"; import { downloadFile } from "@/lib/desktop/save-file"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; interface ExportImportDialogProps { open: boolean; @@ -144,7 +145,7 @@ export default function ExportImportDialog({ const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "Tasks"); - XLSX.writeFile(workbook, `tasks-export-${new Date().toISOString().split("T")[0]}.xlsx`); + await downloadWorkbook(workbook, `tasks-export-${new Date().toISOString().split("T")[0]}.xlsx`); toast.success(t("toastExportedExcel", { count: filteredTasks.length })); } catch (error) { console.error("Excel export error:", error); diff --git a/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx b/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx index 509b4dd7..c4b30863 100644 --- a/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx +++ b/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx @@ -19,13 +19,13 @@ import { ToolShell } from "@/components/tools/tool-shell"; import { IOPanel, ToolTextArea } from "@/components/tools/io-panel"; import { useToolUsage } from "@/hooks/use-tool-usage"; import { cn } from "@/lib/utils"; +import { downloadFile } from "@/lib/desktop/save-file"; import { csvTextToRows, excelBufferToRows, parseJsonToRows, rowsToCSV, rowsToXlsxFile, - triggerDownload, } from "@/lib/csv-excel-json-utils"; const SAMPLE_JSON = `[ @@ -109,10 +109,7 @@ export function CsvExcelJsonTool() { try { const rows = parseJsonToRows(jsonText); const csv = rowsToCSV(rows); - triggerDownload( - new Blob([csv], { type: "text/csv;charset=utf-8" }), - t("filenames.csv") - ); + downloadFile(csv, t("filenames.csv"), "text/csv;charset=utf-8"); toast.success(t("toastExportedCsv")); } catch (e) { const msg = e instanceof Error ? e.message : t("errors.exportFailed"); diff --git a/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx index e593931d..15448d5e 100644 --- a/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/mongodb/export-dialog.tsx @@ -11,6 +11,7 @@ import { IconDownload } from "@tabler/icons-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { downloadFile } from "@/lib/desktop/save-file"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; interface ExportDialogProps { open: boolean; @@ -84,11 +85,11 @@ export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDi XLSX.utils.book_append_sheet(workbook, worksheet, t("sheetName")); if (format === "csv") { - XLSX.writeFile(workbook, filename, { bookType: "csv" }); + await downloadWorkbook(workbook, filename, "csv"); } else if (format === "tsv") { - XLSX.writeFile(workbook, filename, { bookType: "txt" }); // txt = tab-separated + await downloadWorkbook(workbook, filename, "txt"); // txt = tab-separated } else { - XLSX.writeFile(workbook, filename); + await downloadWorkbook(workbook, filename); } toast.success(t("success", { filename })); diff --git a/apps/desktop-ui/src/components/email-validator/email-validator.tsx b/apps/desktop-ui/src/components/email-validator/email-validator.tsx index 1bc476cc..ac95ea85 100644 --- a/apps/desktop-ui/src/components/email-validator/email-validator.tsx +++ b/apps/desktop-ui/src/components/email-validator/email-validator.tsx @@ -45,6 +45,7 @@ import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useTranslations } from "next-intl"; import { validateEmail, validateEmails, type EmailValidationResult } from "@/lib/email-validator"; import { downloadFile } from "@/lib/desktop/save-file"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; type EmailValidation = EmailValidationResult; type BulkResult = EmailValidationResult; @@ -276,7 +277,7 @@ export function EmailValidator() { const ws = XLSX.utils.json_to_sheet(sheetData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, validOnly ? t("sheets.validEmails") : t("sheets.results")); - XLSX.writeFile(wb, validOnly ? "valid_emails.xlsx" : "email_validation_results.xlsx"); + await downloadWorkbook(wb, validOnly ? "valid_emails.xlsx" : "email_validation_results.xlsx"); toast.success(t("toasts.exportSuccessTitle"), { description: validOnly @@ -347,7 +348,7 @@ export function EmailValidator() { const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, t("sheets.template")); - XLSX.writeFile(wb, "email_validation_template.xlsx"); + await downloadWorkbook(wb, "email_validation_template.xlsx"); }; const getStatusColor = (status: string) => { diff --git a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx index 3ebef174..fe88ccc4 100644 --- a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx +++ b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useIsMobile } from '@/components/hooks/use-mobile'; import { useTranslations } from 'next-intl'; -import { AlertCircle, Check, Copy, Download, Trash2 } from 'lucide-react'; +import { AlertCircle, Check, Copy, Trash2 } from 'lucide-react'; import { IconBraces } from '@tabler/icons-react'; import { ToolShell } from '@/components/tools/tool-shell'; import { IOPanel } from '@/components/tools/io-panel'; @@ -20,7 +20,6 @@ import { } from '@/components/ui/select'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; -import { triggerDownload } from '@/lib/csv-excel-json-utils'; import { highlightSchemaOutput } from '@/lib/hljs-json-schema-output'; import { collectFormats, @@ -45,18 +44,6 @@ const defaultSample = `{ "scores": [9.5, 8.0] }`; -const FILE_EXT: Record = { - jsonSchema: 'json', - python: 'py', - typescript: 'ts', - go: 'go', - rust: 'rs', - java: 'java', - csharp: 'cs', - dart: 'dart', - swift: 'swift', -}; - export function JsonSchemaGeneratorLayout() { const t = useTranslations('JsonSchemaGenerator'); const isMobile = useIsMobile(); @@ -97,14 +84,6 @@ export function JsonSchemaGeneratorLayout() { }); }, [output, t, copyToClipboard]); - const handleDownload = useCallback(() => { - if (!output || error) return; - const ext = FILE_EXT[language]; - const filename = language === 'jsonSchema' ? 'schema.json' : `generated.${ext}`; - const mime = language === 'jsonSchema' ? 'application/json' : 'text/plain'; - triggerDownload(new Blob([output], { type: `${mime};charset=utf-8` }), filename); - }, [output, error, language]); - const handleClear = () => { setInput(''); }; @@ -141,10 +120,6 @@ export function JsonSchemaGeneratorLayout() { )} {t('copy')} - ); diff --git a/apps/desktop-ui/src/lib/csv-excel-json-utils.ts b/apps/desktop-ui/src/lib/csv-excel-json-utils.ts index 258d2425..3caf2514 100644 --- a/apps/desktop-ui/src/lib/csv-excel-json-utils.ts +++ b/apps/desktop-ui/src/lib/csv-excel-json-utils.ts @@ -2,6 +2,8 @@ * CSV / Excel ↔ JSON helpers (browser). Dates: CSV ISO-like strings stay strings; * Excel uses cellDates + serial fallback → ISO strings in JSON. */ +import type { BookType, WorkBook } from "xlsx"; +import { downloadFile } from "@/lib/desktop/save-file"; export function parseCSVLine(line: string): string[] { const result: string[] = []; @@ -140,13 +142,21 @@ export function rowsToCSV(rows: Record[]): string { return [header, ...body].join("\n"); } -export function triggerDownload(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); +const WORKBOOK_MIME: Partial> = { + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + csv: "text/csv;charset=utf-8", + txt: "text/plain;charset=utf-8", +}; + +/** + * Write a SheetJS workbook to a file. `XLSX.writeFile` builds its own anchor + * download, which the Tauri webview cancels — serialize and hand the bytes to + * downloadFile instead. + */ +export async function downloadWorkbook(wb: WorkBook, filename: string, bookType: BookType = "xlsx") { + const XLSX = await import("xlsx"); + const data = XLSX.write(wb, { type: "array", bookType }) as ArrayBuffer; + downloadFile(new Uint8Array(data), filename, WORKBOOK_MIME[bookType]); } export async function rowsToXlsxFile( @@ -158,5 +168,5 @@ export async function rowsToXlsxFile( const ws = XLSX.utils.json_to_sheet(rows); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, sheetName.slice(0, 31) || "Sheet1"); - XLSX.writeFile(wb, filename); + await downloadWorkbook(wb, filename); } diff --git a/apps/desktop-ui/src/lib/desktop/backup.ts b/apps/desktop-ui/src/lib/desktop/backup.ts index 492e5a22..08280808 100644 --- a/apps/desktop-ui/src/lib/desktop/backup.ts +++ b/apps/desktop-ui/src/lib/desktop/backup.ts @@ -12,6 +12,7 @@ * reuse it) and is NOT recoverable — losing it means losing the backup. */ import { localApi } from "./bridge"; +import { downloadFile } from "./save-file"; const MAGIC = "MDTBACKUP"; const FORMAT_VERSION = 1; @@ -91,14 +92,7 @@ export async function exportBackup(passphrase: string): Promise { /** Export and hand the encrypted backup to the browser as a file download. */ export async function downloadBackup(passphrase: string): Promise { const blob = await exportBackup(passphrase); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `mydevtools-backup-${new Date().toISOString().split("T")[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadFile(blob, `mydevtools-backup-${new Date().toISOString().split("T")[0]}.json`); } /** True if the local vault already has a configured master password. */ diff --git a/apps/desktop-ui/src/lib/export/har.ts b/apps/desktop-ui/src/lib/export/har.ts index 6dd54b37..2e9c73f4 100644 --- a/apps/desktop-ui/src/lib/export/har.ts +++ b/apps/desktop-ui/src/lib/export/har.ts @@ -5,6 +5,7 @@ */ import type { Collection, CollectionFolder, CollectionRequest, SavedExample } from "@/components/api-client/types" +import { downloadFile } from "@/lib/desktop/save-file" interface HarHeader { name: string; value: string } @@ -141,12 +142,5 @@ export function exportCollectionAsHar(collection: Collection): string { export function downloadCollectionAsHar(collection: Collection): void { if (typeof window === "undefined") return const blob = new Blob([exportCollectionAsHar(collection)], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "collection"}.har` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "collection"}.har`) } diff --git a/apps/desktop-ui/src/lib/export/insomnia.ts b/apps/desktop-ui/src/lib/export/insomnia.ts index fb9c61fe..d238de11 100644 --- a/apps/desktop-ui/src/lib/export/insomnia.ts +++ b/apps/desktop-ui/src/lib/export/insomnia.ts @@ -5,6 +5,7 @@ */ import type { Collection, CollectionFolder, CollectionRequest } from "@/components/api-client/types" +import { downloadFile } from "@/lib/desktop/save-file" interface InsomniaRequest { _id: string @@ -136,12 +137,5 @@ export function exportCollectionAsInsomnia(collection: Collection): string { export function downloadCollectionAsInsomnia(collection: Collection): void { if (typeof window === "undefined") return const blob = new Blob([exportCollectionAsInsomnia(collection)], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "collection"}.insomnia.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "collection"}.insomnia.json`) } diff --git a/apps/desktop-ui/src/lib/export/openapi.ts b/apps/desktop-ui/src/lib/export/openapi.ts index 5bd1d895..c3c76bb3 100644 --- a/apps/desktop-ui/src/lib/export/openapi.ts +++ b/apps/desktop-ui/src/lib/export/openapi.ts @@ -13,6 +13,7 @@ * Output is a JSON OpenAPI 3.0 document. YAML conversion can be done with `js-yaml` * client-side if needed. */ +import { downloadFile } from "@/lib/desktop/save-file" import type { Collection, @@ -206,12 +207,5 @@ export function downloadCollectionAsOpenApi(collection: Collection): void { if (typeof window === "undefined") return const doc = exportCollectionAsOpenApi(collection) const blob = new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "openapi"}.openapi.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${collection.name.replace(/[^A-Za-z0-9_-]+/g, "_") || "openapi"}.openapi.json`) } diff --git a/apps/desktop-ui/src/lib/export/postman-env.ts b/apps/desktop-ui/src/lib/export/postman-env.ts index 3bdc0b55..f8fe7a99 100644 --- a/apps/desktop-ui/src/lib/export/postman-env.ts +++ b/apps/desktop-ui/src/lib/export/postman-env.ts @@ -3,6 +3,7 @@ */ import type { Environment } from "@/components/api-client/use-environments" +import { downloadFile } from "@/lib/desktop/save-file" export function exportPostmanEnvironment(env: Environment): string { return JSON.stringify( @@ -24,12 +25,5 @@ export function exportPostmanEnvironment(env: Environment): string { export function downloadEnvironmentAsPostman(env: Environment): void { if (typeof window === "undefined") return const blob = new Blob([exportPostmanEnvironment(env)], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${env.name.replace(/[^a-zA-Z0-9-_]+/g, "_") || "environment"}.postman_environment.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${env.name.replace(/[^a-zA-Z0-9-_]+/g, "_") || "environment"}.postman_environment.json`) } diff --git a/apps/desktop-ui/src/lib/export/postman.ts b/apps/desktop-ui/src/lib/export/postman.ts index 10289758..630619c4 100644 --- a/apps/desktop-ui/src/lib/export/postman.ts +++ b/apps/desktop-ui/src/lib/export/postman.ts @@ -3,6 +3,7 @@ * for the same subset of features. Pre-request + test scripts come along * as `event[]` entries. */ +import { downloadFile } from "@/lib/desktop/save-file" import type { Collection, @@ -170,12 +171,5 @@ export function downloadCollectionAsPostman(collection: Collection): void { if (typeof window === "undefined") return const json = exportPostmanCollection(collection) const blob = new Blob([json], { type: "application/json" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${collection.name.replace(/[^a-zA-Z0-9-_]+/g, "_") || "collection"}.postman_collection.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${collection.name.replace(/[^a-zA-Z0-9-_]+/g, "_") || "collection"}.postman_collection.json`) } diff --git a/apps/desktop-ui/src/lib/json-utils/import-export.ts b/apps/desktop-ui/src/lib/json-utils/import-export.ts index 72d40890..10e75c78 100644 --- a/apps/desktop-ui/src/lib/json-utils/import-export.ts +++ b/apps/desktop-ui/src/lib/json-utils/import-export.ts @@ -1,6 +1,7 @@ /** * Import/Export utilities for JSON Editor */ +import { downloadFile } from "@/lib/desktop/save-file" /** * Load JSON from a file @@ -38,13 +39,7 @@ export async function importJSONFromFile(): Promise { * Export JSON to a file */ export function exportJSONToFile(content: string, filename = 'data.json') { - const blob = new Blob([content], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + downloadFile(content, filename, 'application/json'); } /** @@ -85,13 +80,7 @@ export function exportJSONAsCSV(content: string, filename = 'data.csv'): { succe ), ].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + downloadFile(csv, filename, 'text/csv'); return { success: true }; } catch (err) { diff --git a/apps/desktop-ui/src/lib/runner/junit.ts b/apps/desktop-ui/src/lib/runner/junit.ts index f5314eb1..a1ad264b 100644 --- a/apps/desktop-ui/src/lib/runner/junit.ts +++ b/apps/desktop-ui/src/lib/runner/junit.ts @@ -5,6 +5,7 @@ */ import type { RequestRunResult } from "./types" +import { downloadFile } from "@/lib/desktop/save-file" function escapeXml(s: string): string { return String(s) @@ -63,12 +64,5 @@ export function downloadJUnitXml(results: RequestRunResult[], suiteName: string) if (typeof window === "undefined") return const xml = buildJUnitXml(results, suiteName) const blob = new Blob([xml], { type: "application/xml" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${suiteName.replace(/[^A-Za-z0-9_-]+/g, "_") || "run"}-junit.xml` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + downloadFile(blob, `${suiteName.replace(/[^A-Za-z0-9_-]+/g, "_") || "run"}-junit.xml`) } From e851efeb545700a06719df36e667d60abfd3039a Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 8 Sep 2026 01:16:47 +0530 Subject: [PATCH 6/7] fix(desktop-ui): fail legibly when the UI runs outside the Tauri window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/desktop-ui has no HTTP API routes — `/api/v1/...` is only the Rust local router's contract. Outside the Tauri window `apiFetch` fell through to a plain fetch, so every data call hit Next's 404 page and `apiRequest` threw the whole HTML document as its error message. Return a 501 with a readable detail instead, and do the same for the api-client proxy (which parsed that HTML with `res.json()`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- .../lib/__tests__/api-fetch-web-guard.test.ts | 27 ++++++++++++++ apps/desktop-ui/src/lib/desktop/api-fetch.ts | 36 ++++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts diff --git a/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts b/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts new file mode 100644 index 00000000..4195bcd9 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts @@ -0,0 +1,27 @@ +/** + * Outside the Tauri window there is no local store and no HTTP API route, so + * `/api/...` must fail with a readable 501 instead of falling through to + * Next's 404 HTML page (which used to surface verbatim in error toasts). + */ +import { apiFetch, sendProxyRequest } from "@/lib/desktop/api-fetch" + +describe("apiFetch outside the desktop app", () => { + it("returns a 501 with a readable detail, never a network fetch", async () => { + const fetchSpy = jest.spyOn(global, "fetch") + const res = await apiFetch("/api/v1/user-preferences") + + expect(fetchSpy).not.toHaveBeenCalled() + expect(res.status).toBe(501) + const { detail } = await res.json() + expect(detail).toContain("/api/v1/user-preferences") + expect(detail).toContain("pnpm dev:desktop") + fetchSpy.mockRestore() + }) + + it("fails the api-client proxy with an envelope error", async () => { + await expect(sendProxyRequest({ url: "https://example.com" })).resolves.toMatchObject({ + status: 0, + error: expect.stringContaining("outside the desktop app"), + }) + }) +}) diff --git a/apps/desktop-ui/src/lib/desktop/api-fetch.ts b/apps/desktop-ui/src/lib/desktop/api-fetch.ts index b33a27ee..9c0a81c3 100644 --- a/apps/desktop-ui/src/lib/desktop/api-fetch.ts +++ b/apps/desktop-ui/src/lib/desktop/api-fetch.ts @@ -1,12 +1,27 @@ import { isDesktop } from "./is-desktop"; import { localApi, normalizeBackendPath, toResponse } from "./bridge"; +/** + * Dev-only failure message. It can only appear when this UI runs outside the + * Tauri window (`pnpm dev` instead of `pnpm dev:desktop`), never in the + * shipped app — so it is intentionally untranslated. + */ +const NOT_IN_DESKTOP = + "No local data store: this UI is running outside the desktop app. Run `pnpm dev:desktop` and use the app window."; + +function notInDesktopResponse(path: string): Response { + return new Response(JSON.stringify({ detail: `${NOT_IN_DESKTOP} (${path})` }), { + status: 501, + headers: { "Content-Type": "application/json" }, + }); +} + /** * Desktop-aware fetch for same-origin `/api/...` paths. * - * On web this is a plain `fetch` pass-through. On desktop it routes - * `/api/backend/*` (and `/api/v1/*`) paths to the Rust local router. - * Remote routing (shared workspaces / sync) is layered on in Phase 4. + * On desktop it routes `/api/backend/*` (and `/api/v1/*`) paths to the Rust + * local router. There is no server, so outside the Tauri window every path + * here fails with a 501 instead of hitting Next's 404 page. */ /** * Send an api-client request and return the `/api/proxy` envelope directly. @@ -32,19 +47,16 @@ export async function sendProxyRequest(input: unknown, signal?: AbortSignal): Pr const { invoke } = await import("@tauri-apps/api/core"); return (await invoke("http_request", { input })) as ProxyEnvelope; } - const res = await fetch("/api/proxy", { - method: "POST", - credentials: "include", - signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - return res.json(); + void signal; + return { status: 0, error: NOT_IN_DESKTOP }; } export async function apiFetch(path: string, init?: RequestInit): Promise { if (!isDesktop()) { - return fetch(path, init); + // This app has no HTTP API routes — `/api/...` is the Tauri local router's + // contract only. Outside the desktop window those paths hit Next's 404 + // page, and its HTML would surface verbatim in a toast. Fail legibly. + return notInDesktopResponse(path); } const method = (init?.method || "GET").toUpperCase(); const body = typeof init?.body === "string" ? init.body : undefined; From c3775322143b3e005617b193705dea7c86380653 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 8 Sep 2026 01:21:32 +0530 Subject: [PATCH 7/7] fix(desktop-ui): scope the off-desktop 501 to local-store paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit failed every `/api/...` path outside the Tauri window, including `/api/proxy` and `/api/proxy-grpc` — those are real HTTP endpoints and their transports are covered by tests that mock `fetch`, so 9 tests in graphql-introspect, grpc-native and grpc-reflection broke. Only `/api/v1/*` and `/api/backend/*` have no HTTP equivalent, so only those fail fast now; everything else is a plain fetch pass-through again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU --- .../lib/__tests__/api-fetch-web-guard.test.ts | 45 +++++++++++-------- apps/desktop-ui/src/lib/desktop/api-fetch.ts | 25 +++++++---- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts b/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts index 4195bcd9..f4ea87b7 100644 --- a/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts @@ -1,27 +1,36 @@ /** - * Outside the Tauri window there is no local store and no HTTP API route, so - * `/api/...` must fail with a readable 501 instead of falling through to + * Outside the Tauri window there is no local store, so `/api/v1/*` and + * `/api/backend/*` must fail with a readable 501 instead of falling through to * Next's 404 HTML page (which used to surface verbatim in error toasts). + * Every other `/api/...` path stays a plain fetch — the api-client proxies + * are real HTTP endpoints. */ -import { apiFetch, sendProxyRequest } from "@/lib/desktop/api-fetch" +import { apiFetch } from "@/lib/desktop/api-fetch" describe("apiFetch outside the desktop app", () => { - it("returns a 501 with a readable detail, never a network fetch", async () => { - const fetchSpy = jest.spyOn(global, "fetch") - const res = await apiFetch("/api/v1/user-preferences") - - expect(fetchSpy).not.toHaveBeenCalled() - expect(res.status).toBe(501) - const { detail } = await res.json() - expect(detail).toContain("/api/v1/user-preferences") - expect(detail).toContain("pnpm dev:desktop") - fetchSpy.mockRestore() + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch }) - it("fails the api-client proxy with an envelope error", async () => { - await expect(sendProxyRequest({ url: "https://example.com" })).resolves.toMatchObject({ - status: 0, - error: expect.stringContaining("outside the desktop app"), - }) + it.each(["/api/v1/user-preferences", "/api/backend/notes"])( + "fails %s with a readable 501 and never hits the network", + async (path) => { + global.fetch = jest.fn() as unknown as typeof fetch + const res = await apiFetch(path) + + expect(global.fetch).not.toHaveBeenCalled() + expect(res.status).toBe(501) + const { detail } = await res.json() + expect(detail).toContain(path) + expect(detail).toContain("pnpm dev:desktop") + }, + ) + + it("passes other /api paths through to fetch", async () => { + global.fetch = jest.fn().mockResolvedValue(new Response("{}")) as unknown as typeof fetch + await apiFetch("/api/proxy-grpc", { method: "POST" }) + + expect(global.fetch).toHaveBeenCalledWith("/api/proxy-grpc", { method: "POST" }) }) }) diff --git a/apps/desktop-ui/src/lib/desktop/api-fetch.ts b/apps/desktop-ui/src/lib/desktop/api-fetch.ts index 9c0a81c3..90c4be60 100644 --- a/apps/desktop-ui/src/lib/desktop/api-fetch.ts +++ b/apps/desktop-ui/src/lib/desktop/api-fetch.ts @@ -20,8 +20,8 @@ function notInDesktopResponse(path: string): Response { * Desktop-aware fetch for same-origin `/api/...` paths. * * On desktop it routes `/api/backend/*` (and `/api/v1/*`) paths to the Rust - * local router. There is no server, so outside the Tauri window every path - * here fails with a 501 instead of hitting Next's 404 page. + * local router. Off desktop those two prefixes have nowhere to go and fail + * with a 501; anything else is a plain `fetch` pass-through. */ /** * Send an api-client request and return the `/api/proxy` envelope directly. @@ -47,16 +47,25 @@ export async function sendProxyRequest(input: unknown, signal?: AbortSignal): Pr const { invoke } = await import("@tauri-apps/api/core"); return (await invoke("http_request", { input })) as ProxyEnvelope; } - void signal; - return { status: 0, error: NOT_IN_DESKTOP }; + const res = await fetch("/api/proxy", { + method: "POST", + credentials: "include", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return res.json(); } export async function apiFetch(path: string, init?: RequestInit): Promise { if (!isDesktop()) { - // This app has no HTTP API routes — `/api/...` is the Tauri local router's - // contract only. Outside the desktop window those paths hit Next's 404 - // page, and its HTML would surface verbatim in a toast. Fail legibly. - return notInDesktopResponse(path); + // Store paths have no HTTP equivalent — they are the Rust local router's + // contract. Outside the desktop window they hit Next's 404 page, whose + // HTML would surface verbatim in a toast, so fail legibly instead. + if (path.startsWith("/api/v1/") || path.startsWith("/api/backend/")) { + return notInDesktopResponse(path); + } + return fetch(path, init); } const method = (init?.method || "GET").toUpperCase(); const body = typeof init?.body === "string" ? init.body : undefined;