diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index aa61f875..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4977,7 +4976,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,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..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", @@ -4890,7 +4889,9 @@ "copy": "复制", "headSnippet": "HTML head 代码片段", "downloadIco": "下载 favicon.ico", - "downloadZip": "下载全部 (.zip)" + "downloadZip": "下载全部 (.zip)", + "loadError": "无法加载图片", + "saveError": "无法保存文件" }, "JsonDiff": { "title": "JSON 对比", 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..60f74ce6 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,8 @@ 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"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; interface ExportImportDialogProps { open: boolean; @@ -66,12 +68,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 +112,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 })); }; @@ -153,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/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/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 83c4c26a..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 @@ -10,6 +10,8 @@ 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"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; interface ExportDialogProps { open: boolean; @@ -57,12 +59,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; @@ -88,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/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..ac95ea85 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,8 @@ 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"; +import { downloadWorkbook } from "@/lib/csv-excel-json-utils"; type EmailValidation = EmailValidationResult; type BulkResult = EmailValidationResult; @@ -275,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 @@ -331,14 +333,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 @@ -353,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/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/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/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-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/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/__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..f4ea87b7 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/api-fetch-web-guard.test.ts @@ -0,0 +1,36 @@ +/** + * 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 } from "@/lib/desktop/api-fetch" + +describe("apiFetch outside the desktop app", () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + 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/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/__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/api-fetch.ts b/apps/desktop-ui/src/lib/desktop/api-fetch.ts index b33a27ee..90c4be60 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. 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. @@ -44,6 +59,12 @@ export async function sendProxyRequest(input: unknown, signal?: AbortSignal): Pr export async function apiFetch(path: string, init?: RequestInit): Promise { if (!isDesktop()) { + // 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(); 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/desktop/save-file.ts b/apps/desktop-ui/src/lib/desktop/save-file.ts new file mode 100644 index 00000000..b2837cbc --- /dev/null +++ b/apps/desktop-ui/src/lib/desktop/save-file.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ +import { toast } from "sonner" +import { isDesktop } from "./is-desktop" + +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, new Uint8Array(await toArrayBuffer(data))) + return true + } + + 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 + a.click() + // Revoke on the next tick — revoking synchronously can race the download. + 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 +} 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`) } 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", 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..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 @@ -454,8 +448,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)) @@ -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==} @@ -3754,12 +3745,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 +3763,8 @@ packages: optional: true next: optional: true + nuxt: + optional: true react: optional: true svelte: @@ -4878,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'} @@ -11166,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 @@ -11430,7 +11419,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 @@ -12779,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