diff --git a/crates/hydra-gui/assets/locale/ar.json b/crates/hydra-gui/assets/locale/ar.json index f390952..6b7a588 100644 --- a/crates/hydra-gui/assets/locale/ar.json +++ b/crates/hydra-gui/assets/locale/ar.json @@ -68,6 +68,7 @@ "Delete": "حذف", "Delete All Completed": "حذف كل المكتملة", "Delete Completed": "حذف المكتملة", + "Delete file": "Delete file", "Description": "الوصف", "Disconnect.": "قطع الاتصال.", "Do you want to delete all successfully completed files from the list?": "هل تريد حذف جميع الملفات المكتملة بنجاح من القائمة؟", @@ -142,6 +143,7 @@ "Import": "استيراد", "Info": "معلومات", "KBytes/sec": "ك.بايت/ثانية", + "Keep file": "Keep file", "Keyboard Shortcuts": "اختصارات لوحة المفاتيح", "Language": "اللغة", "Large": "كبير", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "لا", "No proxy/socks": "بدون وكيل/socks", + "No virus found": "No virus found", "Not on store yet": "غير متوفرة في المتجر بعد", "Number": "العدد", "Number of retries for each file if downloading failed :": "عدد محاولات الإعادة لكل ملف في حال فشل التنزيل :", @@ -266,6 +269,7 @@ "Sites Logins": "بيانات الدخول للمواقع", "Size": "الحجم", "Size:": "الحجم:", + "Skip": "Skip", "Small": "صغير", "Sound file": "ملف الصوت", "Sound settings": "إعدادات الصوت", @@ -281,6 +285,7 @@ "Start now": "البدء الآن", "Start positions and download progress by connections": "مواضع البداية وتقدم التنزيل حسب الاتصالات", "Start queue": "بدء قائمة الانتظار", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "يبدأ كل تنزيل باتصال واحد وتُضاف اتصالات أخرى فقط ما دامت تحسّن السرعة بشكل ملموس؛ ويُعد الحد الأقصى الافتراضي سقفًا.", "Status": "الحالة", "Status:": "الحالة:", @@ -341,7 +346,11 @@ "View": "عرض", "View release page": "View release page", "Virus checking": "فحص الفيروسات", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "برنامج فحص الفيروسات", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "في الانتظار (حد الاتصالات)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "يراقب الحافظة لروابط التنزيل حسب نوع الملف والمواقع المعروفة؛ رابط واحد يفتح مربع حوار الملف، وعدة روابط تفتح القائمة المجمعة.", "Wednesday": "الأربعاء", diff --git a/crates/hydra-gui/assets/locale/de.json b/crates/hydra-gui/assets/locale/de.json index 3bae85d..aa6a064 100644 --- a/crates/hydra-gui/assets/locale/de.json +++ b/crates/hydra-gui/assets/locale/de.json @@ -68,6 +68,7 @@ "Delete": "Löschen", "Delete All Completed": "Alle abgeschlossenen löschen", "Delete Completed": "Abgeschlossenen löschen", + "Delete file": "Delete file", "Description": "Beschreibung", "Disconnect.": "Trennen.", "Do you want to delete all successfully completed files from the list?": "Möchten Sie alle erfolgreich abgeschlossenen Dateien aus der Liste löschen?", @@ -142,6 +143,7 @@ "Import": "Importieren", "Info": "Info", "KBytes/sec": "KBytes/sek", + "Keep file": "Keep file", "Keyboard Shortcuts": "Tastaturkürzel", "Language": "Sprache", "Large": "Groß", @@ -170,6 +172,7 @@ "New version:": "Neue Version:", "No": "Nein", "No proxy/socks": "Kein Proxy/Socks", + "No virus found": "No virus found", "Not on store yet": "Noch nicht im Store", "Number": "Nummer", "Number of retries for each file if downloading failed :": "Anzahl der Wiederholungsversuche für jede Datei, wenn der Download fehlschlägt :", @@ -266,6 +269,7 @@ "Sites Logins": "Webseiten-Anmeldungen", "Size": "Größe", "Size:": "Größe:", + "Skip": "Skip", "Small": "Klein", "Sound file": "Sounddatei", "Sound settings": "Soundeinstellungen", @@ -281,6 +285,7 @@ "Start now": "Jetzt starten", "Start positions and download progress by connections": "Startpositionen und Download-Fortschritt nach Verbindungen", "Start queue": "Warteschlange starten", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Beginnt jede Übertragung mit einer Verbindung und fügt nur dann weitere hinzu, während diese die Geschwindigkeit messbar verbessern; die Standardeinstellung dient als Obergrenze.", "Status": "Status", "Status:": "Status:", @@ -341,7 +346,11 @@ "View": "Ansicht", "View release page": "Release-Seite ansehen", "Virus checking": "Virenprüfung", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Virenscanner-Programm", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Warte (Verbindungslimit)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Überwacht die Zwischenablage auf Download-Links nach Dateityp und bekannten Download-Seiten; Ein Link öffnet den Dateidialog, viele öffnen die Stapelliste.", "Wednesday": "Mittwoch", diff --git a/crates/hydra-gui/assets/locale/en.json b/crates/hydra-gui/assets/locale/en.json index f2ab65f..42823df 100644 --- a/crates/hydra-gui/assets/locale/en.json +++ b/crates/hydra-gui/assets/locale/en.json @@ -68,6 +68,7 @@ "Delete": "Delete", "Delete All Completed": "Delete All Completed", "Delete Completed": "Delete Completed", + "Delete file": "Delete file", "Description": "Description", "Disconnect.": "Disconnect.", "Do you want to delete all successfully completed files from the list?": "Do you want to delete all successfully completed files from the list?", @@ -142,6 +143,7 @@ "Import": "Import", "Info": "Info", "KBytes/sec": "KBytes/sec", + "Keep file": "Keep file", "Keyboard Shortcuts": "Keyboard Shortcuts", "Language": "Language", "Large": "Large", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "No", "No proxy/socks": "No proxy/socks", + "No virus found": "No virus found", "Not on store yet": "Not on store yet", "Number": "Number", "Number of retries for each file if downloading failed :": "Number of retries for each file if downloading failed :", @@ -266,6 +269,7 @@ "Sites Logins": "Sites Logins", "Size": "Size", "Size:": "Size:", + "Skip": "Skip", "Small": "Small", "Sound file": "Sound file", "Sound settings": "Sound settings", @@ -281,6 +285,7 @@ "Start now": "Start now", "Start positions and download progress by connections": "Start positions and download progress by connections", "Start queue": "Start queue", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.", "Status": "Status", "Status:": "Status:", @@ -341,7 +346,11 @@ "View": "View", "View release page": "View release page", "Virus checking": "Virus checking", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Virus scanner program", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Waiting (connection limit)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.", "Wednesday": "Wednesday", diff --git a/crates/hydra-gui/assets/locale/es.json b/crates/hydra-gui/assets/locale/es.json index 006bc4d..2ac1955 100644 --- a/crates/hydra-gui/assets/locale/es.json +++ b/crates/hydra-gui/assets/locale/es.json @@ -68,6 +68,7 @@ "Delete": "Eliminar", "Delete All Completed": "Eliminar todos los completados", "Delete Completed": "Eliminar completados", + "Delete file": "Delete file", "Description": "Descripción", "Disconnect.": "Desconectar.", "Do you want to delete all successfully completed files from the list?": "¿Desea eliminar de la lista todos los archivos completados correctamente?", @@ -142,6 +143,7 @@ "Import": "Importar", "Info": "Información", "KBytes/sec": "KB/s", + "Keep file": "Keep file", "Keyboard Shortcuts": "Atajos de teclado", "Language": "Idioma", "Large": "Grande", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "No", "No proxy/socks": "Sin proxy/socks", + "No virus found": "No virus found", "Not on store yet": "Aún no está en la tienda", "Number": "Número", "Number of retries for each file if downloading failed :": "Número de reintentos por archivo si la descarga falla :", @@ -266,6 +269,7 @@ "Sites Logins": "Inicios de sesión de sitios", "Size": "Tamaño", "Size:": "Tamaño:", + "Skip": "Skip", "Small": "Pequeño", "Sound file": "Archivo de sonido", "Sound settings": "Configuración de sonido", @@ -281,6 +285,7 @@ "Start now": "Iniciar ahora", "Start positions and download progress by connections": "Posiciones iniciales y progreso de descarga por conexión", "Start queue": "Iniciar cola", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Cada transferencia comienza con una sola conexión y solo añade más mientras mejoren la velocidad de forma medible; el número máximo predeterminado actúa como límite.", "Status": "Estado", "Status:": "Estado:", @@ -341,7 +346,11 @@ "View": "Ver", "View release page": "View release page", "Virus checking": "Comprobación de virus", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Programa antivirus", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Esperando (límite de conexiones)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Monitorea el portapapeles en busca de enlaces según el tipo de archivo y sitios conocidos; un enlace abre el diálogo de archivo, varios abren la lista por lotes.", "Wednesday": "Miércoles", diff --git a/crates/hydra-gui/assets/locale/fa.json b/crates/hydra-gui/assets/locale/fa.json index 0258e42..2649556 100644 --- a/crates/hydra-gui/assets/locale/fa.json +++ b/crates/hydra-gui/assets/locale/fa.json @@ -68,6 +68,7 @@ "Delete": "حذف", "Delete All Completed": "حذف همه تکمیل‌شده‌ها", "Delete Completed": "حذف تکمیل‌شده‌ها", + "Delete file": "Delete file", "Description": "توضیحات", "Disconnect.": "قطع اتصال.", "Do you want to delete all successfully completed files from the list?": "آیا می‌خواهید همه فایل‌های تکمیل‌شده از فهرست حذف شوند؟", @@ -142,6 +143,7 @@ "Import": "درون‌برد", "Info": "اطلاعات", "KBytes/sec": "کیلوبایت/ثانیه", + "Keep file": "Keep file", "Keyboard Shortcuts": "میان‌برهای صفحه‌کلید", "Language": "زبان", "Large": "بزرگ", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "خیر", "No proxy/socks": "بدون پراکسی/ساکس", + "No virus found": "No virus found", "Not on store yet": "هنوز در فروشگاه نیست", "Number": "تعداد", "Number of retries for each file if downloading failed :": "تعداد تلاش مجدد برای هر فایل در صورت شکست دانلود:", @@ -266,6 +269,7 @@ "Sites Logins": "ورود سایت‌ها", "Size": "اندازه", "Size:": "اندازه:", + "Skip": "Skip", "Small": "کوچک", "Sound file": "فایل صدا", "Sound settings": "تنظیمات صدا", @@ -281,6 +285,7 @@ "Start now": "شروع اکنون", "Start positions and download progress by connections": "موقعیت شروع و پیشرفت دانلود به تفکیک اتصال‌ها", "Start queue": "شروع صف", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "هر انتقال با یک اتصال آغاز می‌شود و تنها زمانی اتصال‌های بیشتری افزوده می‌شود که سرعت را به‌طور قابل اندازه‌گیری بهبود دهند؛ حداکثر تعداد پیش‌فرض به‌عنوان سقف عمل می‌کند.", "Status": "وضعیت", "Status:": "وضعیت:", @@ -341,7 +346,11 @@ "View": "نما", "View release page": "View release page", "Virus checking": "بررسی ویروس", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "برنامه ویروس‌یاب", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "در انتظار (محدودیت اتصال)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "کلیپ‌بورد را برای پیوندهای دانلود بر اساس نوع فایل و سایت‌های شناخته‌شده می‌پاید؛ یک پیوند پنجره فایل و چند پیوند فهرست گروهی را باز می‌کند.", "Wednesday": "چهارشنبه", diff --git a/crates/hydra-gui/assets/locale/fr.json b/crates/hydra-gui/assets/locale/fr.json index 022b2b8..6fbb42e 100644 --- a/crates/hydra-gui/assets/locale/fr.json +++ b/crates/hydra-gui/assets/locale/fr.json @@ -68,6 +68,7 @@ "Delete": "Supprimer", "Delete All Completed": "Supprimer tous les terminés", "Delete Completed": "Supprimer les terminés", + "Delete file": "Delete file", "Description": "Description", "Disconnect.": "Déconnecter.", "Do you want to delete all successfully completed files from the list?": "Voulez-vous supprimer de la liste tous les fichiers téléchargés avec succès ?", @@ -142,6 +143,7 @@ "Import": "Importer", "Info": "Infos", "KBytes/sec": "Ko/s", + "Keep file": "Keep file", "Keyboard Shortcuts": "Raccourcis clavier", "Language": "Langue", "Large": "Grand", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "Non", "No proxy/socks": "Pas de proxy/socks", + "No virus found": "No virus found", "Not on store yet": "Pas encore sur le store", "Number": "Nombre", "Number of retries for each file if downloading failed :": "Nombre de tentatives pour chaque fichier en cas d'échec :", @@ -266,6 +269,7 @@ "Sites Logins": "Identifiants des sites", "Size": "Taille", "Size:": "Taille :", + "Skip": "Skip", "Small": "Petit", "Sound file": "Fichier sonore", "Sound settings": "Paramètres sonores", @@ -281,6 +285,7 @@ "Start now": "Démarrer maintenant", "Start positions and download progress by connections": "Positions de départ et progression du téléchargement par connexion", "Start queue": "Démarrer la file", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Chaque transfert démarre avec une seule connexion et n'en ajoute que tant qu'elles améliorent réellement la vitesse ; le nombre maximal par défaut sert de plafond.", "Status": "État", "Status:": "État :", @@ -341,7 +346,11 @@ "View": "Affichage", "View release page": "View release page", "Virus checking": "Vérification antivirus", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Programme antivirus", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "En attente (limite de connexions)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Surveille le presse-papiers à la recherche de liens de téléchargement par type de fichier et sites connus ; un lien ouvre le dialogue de fichier, plusieurs ouvrent la liste groupée.", "Wednesday": "Mercredi", diff --git a/crates/hydra-gui/assets/locale/he.json b/crates/hydra-gui/assets/locale/he.json index e03b6d4..f77eb02 100644 --- a/crates/hydra-gui/assets/locale/he.json +++ b/crates/hydra-gui/assets/locale/he.json @@ -68,6 +68,7 @@ "Delete": "מחק", "Delete All Completed": "מחק את כל מה שהושלם", "Delete Completed": "מחק שהושלמו", + "Delete file": "Delete file", "Description": "תיאור", "Disconnect.": "התנתק.", "Do you want to delete all successfully completed files from the list?": "האם ברצונך למחוק מהרשימה את כל הקבצים שהורדתם הושלמה בהצלחה?", @@ -142,6 +143,7 @@ "Import": "ייבוא", "Info": "מידע", "KBytes/sec": "ק\"ב/שניה (KB/s)", + "Keep file": "Keep file", "Keyboard Shortcuts": "קיצורי מקלדת", "Language": "שפה", "Large": "גדול", @@ -170,6 +172,7 @@ "New version:": "גרסה חדשה:", "No": "לא", "No proxy/socks": "ללא proxy/socks", + "No virus found": "No virus found", "Not on store yet": "טרם זמין בחנות", "Number": "מספר", "Number of retries for each file if downloading failed :": "מספר ניסיונות חוזרים לכל קובץ אם ההורדה נכשלה:", @@ -266,6 +269,7 @@ "Sites Logins": "התחברויות לאתרים", "Size": "גודל", "Size:": "גודל:", + "Skip": "Skip", "Small": "קטן", "Sound file": "קובץ קול", "Sound settings": "הגדרות צליל", @@ -281,6 +285,7 @@ "Start now": "התחל עכשיו", "Start positions and download progress by connections": "מיקומי התחלה והתקדמות הורדה לפי חיבורים", "Start queue": "התחל תור", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "מתחיל כל העברה עם חיבור אחד ומוסיף חיבורים נוספים רק כאשר הם משפרים באופן מדיד את המהירות; המספר המקסימלי שבברירת המחדל משמש כתקרה.", "Status": "סטטוס", "Status:": "סטטוס:", @@ -341,7 +346,11 @@ "View": "תצוגה", "View release page": "הצג את עמוד השחרור (Release page)", "Virus checking": "בדיקת וירוסים", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "תוכנת סריקת וירוסים", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "ממתין (מגבלת חיבורים)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "עוקב אחר הלוח לאיתור קישורי הורדה לפי סוג קובץ ואתרי הורדה מוכרים; קישור אחד פותח את חלונית הקובץ, הרבה קישורים פותחים את רשימת הורדות האצווה (Batch).", "Wednesday": "רביעי", diff --git a/crates/hydra-gui/assets/locale/ja.json b/crates/hydra-gui/assets/locale/ja.json index 837457b..a5dacf7 100644 --- a/crates/hydra-gui/assets/locale/ja.json +++ b/crates/hydra-gui/assets/locale/ja.json @@ -68,6 +68,7 @@ "Delete": "削除", "Delete All Completed": "完了済みをすべて削除", "Delete Completed": "完了済みを削除", + "Delete file": "Delete file", "Description": "説明", "Disconnect.": "切断する。", "Do you want to delete all successfully completed files from the list?": "正常に完了したすべてのファイルを一覧から削除しますか?", @@ -142,6 +143,7 @@ "Import": "インポート", "Info": "情報", "KBytes/sec": "KB/秒", + "Keep file": "Keep file", "Keyboard Shortcuts": "キーボードショートカット", "Language": "言語", "Large": "大", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "いいえ", "No proxy/socks": "プロキシ/SOCKS なし", + "No virus found": "No virus found", "Not on store yet": "ストアには未公開", "Number": "番号", "Number of retries for each file if downloading failed :": "ダウンロード失敗時の再試行回数:", @@ -266,6 +269,7 @@ "Sites Logins": "サイトログイン情報", "Size": "サイズ", "Size:": "サイズ:", + "Skip": "Skip", "Small": "小", "Sound file": "音声ファイル", "Sound settings": "サウンド設定", @@ -281,6 +285,7 @@ "Start now": "今すぐ開始", "Start positions and download progress by connections": "接続ごとの開始位置とダウンロード進行状況", "Start queue": "キューを開始", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "各転送は 1 本の接続から始まり、速度が実際に向上する間だけ接続を追加します。既定の最大接続数が上限になります。", "Status": "状態", "Status:": "状態:", @@ -341,7 +346,11 @@ "View": "表示", "View release page": "View release page", "Virus checking": "ウイルスチェック", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "ウイルススキャンプログラム", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "待機中(接続数制限)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "ファイルタイプや対象サイトのダウンロードリンクをクリップボードで監視します。1件ならファイルダイアログ、複数なら一括リストを開きます。", "Wednesday": "水曜日", diff --git a/crates/hydra-gui/assets/locale/ko.json b/crates/hydra-gui/assets/locale/ko.json index 7aab696..28d743a 100644 --- a/crates/hydra-gui/assets/locale/ko.json +++ b/crates/hydra-gui/assets/locale/ko.json @@ -68,6 +68,7 @@ "Delete": "삭제", "Delete All Completed": "완료된 항목 모두 삭제", "Delete Completed": "완료된 항목 삭제", + "Delete file": "Delete file", "Description": "설명", "Disconnect.": "연결 끊기.", "Do you want to delete all successfully completed files from the list?": "성공적으로 완료된 모든 파일을 목록에서 삭제하시겠습니까?", @@ -142,6 +143,7 @@ "Import": "가져오기", "Info": "정보", "KBytes/sec": "KB/초", + "Keep file": "Keep file", "Keyboard Shortcuts": "키보드 단축키", "Language": "언어", "Large": "크게", @@ -170,6 +172,7 @@ "New version:": "새 버전:", "No": "아니요", "No proxy/socks": "프록시/SOCKS 없음", + "No virus found": "No virus found", "Not on store yet": "아직 스토어에 없음", "Number": "번호", "Number of retries for each file if downloading failed :": "다운로드 실패 시 파일당 재시도 횟수:", @@ -266,6 +269,7 @@ "Sites Logins": "사이트 로그인 정보", "Size": "크기", "Size:": "크기:", + "Skip": "Skip", "Small": "작게", "Sound file": "소리 파일", "Sound settings": "소리 설정", @@ -281,6 +285,7 @@ "Start now": "지금 시작", "Start positions and download progress by connections": "연결별 시작 위치 및 다운로드 진행률", "Start queue": "대기열 시작", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "각 전송은 연결 1개로 시작하며 속도가 실제로 향상되는 동안에만 연결을 추가합니다. 기본 최대 연결 수가 상한이 됩니다.", "Status": "상태", "Status:": "상태:", @@ -341,7 +346,11 @@ "View": "보기", "View release page": "릴리스 페이지 보기", "Virus checking": "바이러스 검사", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "바이러스 검사 프로그램", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "대기 중(연결 수 제한)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "파일 형식 및 알려진 다운로드 사이트의 링크가 클립보드에 복사되는지 감시합니다. 단일 링크는 파일 대화상자를, 여러 개는 일괄 목록을 엽니다.", "Wednesday": "수요일", diff --git a/crates/hydra-gui/assets/locale/nl.json b/crates/hydra-gui/assets/locale/nl.json index 730ee28..00d61d4 100644 --- a/crates/hydra-gui/assets/locale/nl.json +++ b/crates/hydra-gui/assets/locale/nl.json @@ -68,6 +68,7 @@ "Delete": "Verwijderen", "Delete All Completed": "Alle voltooide verwijderen", "Delete Completed": "Voltooide verwijderen", + "Delete file": "Delete file", "Description": "Beschrijving", "Disconnect.": "Verbinding verbreken.", "Do you want to delete all successfully completed files from the list?": "Wilt u alle succesvol voltooide bestanden uit de lijst verwijderen?", @@ -142,6 +143,7 @@ "Import": "Importeren", "Info": "Info", "KBytes/sec": "KB/s", + "Keep file": "Keep file", "Keyboard Shortcuts": "Sneltoetsen", "Language": "Taal", "Large": "Groot", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "Nee", "No proxy/socks": "Geen proxy/socks", + "No virus found": "No virus found", "Not on store yet": "Nog niet in de store", "Number": "Nummer", "Number of retries for each file if downloading failed :": "Aantal pogingen per bestand bij mislukte download:", @@ -266,6 +269,7 @@ "Sites Logins": "Site-aanmeldingen", "Size": "Grootte", "Size:": "Grootte:", + "Skip": "Skip", "Small": "Klein", "Sound file": "Geluidsbestand", "Sound settings": "Geluidsinstellingen", @@ -281,6 +285,7 @@ "Start now": "Nu starten", "Start positions and download progress by connections": "Startposities en downloadvoortgang per verbinding", "Start queue": "Wachtrij starten", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Elke overdracht begint met één verbinding en voegt er alleen toe zolang ze de snelheid meetbaar verbeteren; het standaard maximumaantal geldt als plafond.", "Status": "Status", "Status:": "Status:", @@ -341,7 +346,11 @@ "View": "Beeld", "View release page": "View release page", "Virus checking": "Viruscontrole", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Virusscannerprogramma", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Wachten (verbindingslimiet)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Houdt het klembord in de gaten voor downloadkoppelingen op bestandstype en bekende sites; één koppeling opent het bestandsdialoogvenster, meerdere openen de batchlijst.", "Wednesday": "Woensdag", diff --git a/crates/hydra-gui/assets/locale/pt-BR.json b/crates/hydra-gui/assets/locale/pt-BR.json index f2ab65f..42823df 100644 --- a/crates/hydra-gui/assets/locale/pt-BR.json +++ b/crates/hydra-gui/assets/locale/pt-BR.json @@ -68,6 +68,7 @@ "Delete": "Delete", "Delete All Completed": "Delete All Completed", "Delete Completed": "Delete Completed", + "Delete file": "Delete file", "Description": "Description", "Disconnect.": "Disconnect.", "Do you want to delete all successfully completed files from the list?": "Do you want to delete all successfully completed files from the list?", @@ -142,6 +143,7 @@ "Import": "Import", "Info": "Info", "KBytes/sec": "KBytes/sec", + "Keep file": "Keep file", "Keyboard Shortcuts": "Keyboard Shortcuts", "Language": "Language", "Large": "Large", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "No", "No proxy/socks": "No proxy/socks", + "No virus found": "No virus found", "Not on store yet": "Not on store yet", "Number": "Number", "Number of retries for each file if downloading failed :": "Number of retries for each file if downloading failed :", @@ -266,6 +269,7 @@ "Sites Logins": "Sites Logins", "Size": "Size", "Size:": "Size:", + "Skip": "Skip", "Small": "Small", "Sound file": "Sound file", "Sound settings": "Sound settings", @@ -281,6 +285,7 @@ "Start now": "Start now", "Start positions and download progress by connections": "Start positions and download progress by connections", "Start queue": "Start queue", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.", "Status": "Status", "Status:": "Status:", @@ -341,7 +346,11 @@ "View": "View", "View release page": "View release page", "Virus checking": "Virus checking", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Virus scanner program", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Waiting (connection limit)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.", "Wednesday": "Wednesday", diff --git a/crates/hydra-gui/assets/locale/ru.json b/crates/hydra-gui/assets/locale/ru.json index 2cde1e4..cfc8a78 100644 --- a/crates/hydra-gui/assets/locale/ru.json +++ b/crates/hydra-gui/assets/locale/ru.json @@ -68,6 +68,7 @@ "Delete": "Удалить", "Delete All Completed": "Удалить все завершенные", "Delete Completed": "Удалить завершенные", + "Delete file": "Delete file", "Description": "Описание", "Disconnect.": "Отключить.", "Do you want to delete all successfully completed files from the list?": "Удалить все успешно завершенные файлы из списка?", @@ -142,6 +143,7 @@ "Import": "Импорт", "Info": "Информация", "KBytes/sec": "КБ/с", + "Keep file": "Keep file", "Keyboard Shortcuts": "Сочетания клавиш", "Language": "Язык", "Large": "Крупный", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "Нет", "No proxy/socks": "Без прокси/socks", + "No virus found": "No virus found", "Not on store yet": "Пока нет в магазине", "Number": "Номер", "Number of retries for each file if downloading failed :": "Количество повторных попыток при ошибке скачивания :", @@ -266,6 +269,7 @@ "Sites Logins": "Логины на сайтах", "Size": "Размер", "Size:": "Размер:", + "Skip": "Skip", "Small": "Мелкий", "Sound file": "Звуковой файл", "Sound settings": "Настройки звука", @@ -281,6 +285,7 @@ "Start now": "Начать сейчас", "Start positions and download progress by connections": "Начальные позиции и ход загрузки по соединениям", "Start queue": "Запустить очередь", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "Каждая загрузка начинается с одного соединения, и новые добавляются только пока они ощутимо повышают скорость; максимальное число служит потолком.", "Status": "Статус", "Status:": "Статус:", @@ -341,7 +346,11 @@ "View": "Вид", "View release page": "View release page", "Virus checking": "Проверка на вирусы", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "Антивирусная программа", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "Ожидание (ограничение подключений)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "Отслеживает ссылки на скачивание в буфере обмена по типу файла и известным сайтам; одна ссылка открывает диалог файла, несколько — пакетный список.", "Wednesday": "Среда", diff --git a/crates/hydra-gui/assets/locale/zh.json b/crates/hydra-gui/assets/locale/zh.json index bab9427..c5bbac1 100644 --- a/crates/hydra-gui/assets/locale/zh.json +++ b/crates/hydra-gui/assets/locale/zh.json @@ -68,6 +68,7 @@ "Delete": "删除", "Delete All Completed": "删除所有已完成", "Delete Completed": "删除已完成", + "Delete file": "Delete file", "Description": "描述", "Disconnect.": "断开连接。", "Do you want to delete all successfully completed files from the list?": "要从列表中删除所有已成功完成的文件吗?", @@ -142,6 +143,7 @@ "Import": "导入", "Info": "信息", "KBytes/sec": "KB/秒", + "Keep file": "Keep file", "Keyboard Shortcuts": "键盘快捷键", "Language": "语言", "Large": "大", @@ -170,6 +172,7 @@ "New version:": "New version:", "No": "否", "No proxy/socks": "不使用代理/SOCKS", + "No virus found": "No virus found", "Not on store yet": "尚未上架", "Number": "编号", "Number of retries for each file if downloading failed :": "下载失败时每个文件的重试次数:", @@ -266,6 +269,7 @@ "Sites Logins": "站点登录信息", "Size": "大小", "Size:": "大小:", + "Skip": "Skip", "Small": "小", "Sound file": "声音文件", "Sound settings": "声音设置", @@ -281,6 +285,7 @@ "Start now": "立即开始", "Start positions and download progress by connections": "各连接的起始位置与下载进度", "Start queue": "启动队列", + "Start scanning downloaded data...": "Start scanning downloaded data...", "Starts each transfer with one connection and adds more only while they measurably improve speed; the default max. number acts as a ceiling.": "每个传输从一个连接开始,只有在能明显提升速度时才增加连接;默认最大连接数作为上限。", "Status": "状态", "Status:": "状态:", @@ -341,7 +346,11 @@ "View": "视图", "View release page": "View release page", "Virus checking": "病毒检查", + "Virus detected": "Virus detected", + "Virus scan failed": "Virus scan failed", + "Virus scanner output": "Virus scanner output", "Virus scanner program": "病毒扫描程序", + "Virus scanning...": "Virus scanning...", "Waiting (connection limit)...": "等待中(连接数限制)...", "Watches the clipboard for download links by file type and known download sites; one link opens the file dialog, many open the batch list.": "按文件类型和已知下载站点监视剪贴板中的下载链接;单个链接打开文件对话框,多个链接打开批量列表。", "Wednesday": "星期三", diff --git a/crates/hydra-gui/src/app.rs b/crates/hydra-gui/src/app.rs index 8317188..5a8f0cb 100644 --- a/crates/hydra-gui/src/app.rs +++ b/crates/hydra-gui/src/app.rs @@ -312,6 +312,49 @@ pub struct ProgState { pub remember_limit: bool, } +/// A virus scan over one finished file: what the scanner has printed so far, +/// and the verdict once it exits. Lives beside the download rather than in +/// it — a scan belongs to this session, not to the persisted item. +#[derive(Debug, Default)] +pub struct ScanState { + /// Console output, oldest first (see `scan::MAX_LINES` for the cap). + pub log: Vec, + /// The last line came in as a terminal redraw, so the next redraw + /// overwrites it instead of piling up behind it. + redraw_tail: bool, + /// `None` while the scanner is still running. + pub outcome: Option, + /// Marquee position of the indeterminate bar, ping-ponging over 0..2; + /// advanced by `AnimTick`. + pub phase: f32, +} + +impl ScanState { + /// Append one console line, or overwrite the previous one when both it + /// and this one were drawn over the same terminal row. + pub fn push(&mut self, text: String, redraw: bool) { + match self.log.last_mut() { + Some(last) if redraw && self.redraw_tail => *last = text, + _ => self.log.push(text), + } + self.redraw_tail = redraw; + } + + pub fn running(&self) -> bool { + self.outcome.is_none() + } + + /// 0..1 sweep of the marquee block, folded from the 0..2 phase so the + /// block travels back the way it came instead of jumping to the start. + pub fn sweep(&self) -> f32 { + if self.phase <= 1.0 { + self.phase + } else { + 2.0 - self.phase + } + } +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum OptTab { General, @@ -577,6 +620,14 @@ pub enum Message { ProgLimitOn(DlId, bool), ProgLimitKb(DlId, String), ProgLimitRemember(DlId, bool), + /// Skip the virus scan running over the finished file. + ProgScanSkip(DlId), + /// Infected (or unscannable): keep the file, close the dialog. + ProgScanKeep(DlId), + /// Infected (or unscannable): take the file off the list and the disk. + ProgScanDelete(DlId), + /// Console line / verdict from the virus scanner. + Scan(crate::scan::ScanEvent), // complete dialog OpenFile(DlId), OpenFolder(DlId), @@ -749,6 +800,8 @@ pub struct App { pub add_url: AddUrlState, pub file_info: FileInfoState, pub prog: HashMap, + /// Virus scans in flight (and verdicts still on screen), by download. + pub scans: HashMap, pub options: OptionsState, pub updater: UpdateUiState, pub sch: SchState, @@ -838,6 +891,11 @@ impl App { self.state.downloads.iter_mut().find(|d| d.id == id) } + /// A virus scanner is still working on some file. + pub fn scanning(&self) -> bool { + self.scans.values().any(|s| s.running()) + } + pub fn selected_item(&self) -> Option<&DownloadItem> { self.selected.first().and_then(|id| self.item(*id)) } @@ -1431,6 +1489,10 @@ impl App { } let user_agent = self.cfg.settings.user_agent.clone(); let adaptive = self.cfg.settings.adaptive_conns; + // Re-downloading over a file a scan flagged: the old verdict and its + // log describe bytes that are being replaced. + crate::scan::skip(id); + self.scans.remove(&id); let Some(d) = self.item_mut(id) else { return Task::none(); }; @@ -1597,6 +1659,10 @@ impl App { self.state.downloads.retain(|d| d.id != id); self.selected.retain(|x| *x != id); self.prog.remove(&id); + // A scanner still holding the file the row just took with it has + // nothing left to report. + crate::scan::skip(id); + self.scans.remove(&id); self.save_state(); } @@ -1660,6 +1726,56 @@ impl App { id } + // -------------------------------------------------------- virus scan + + /// Hand a finished file to the configured scanner. `true` when a scan + /// really started — the caller then defers the completion tail + /// ([`Self::finish_completion`]) until the verdict arrives. + fn start_virus_scan(&mut self, id: DlId) -> bool { + let program = self.cfg.settings.virus_scanner.trim().to_string(); + if program.is_empty() { + return false; + } + let Some(path) = self.item(id).map(|d| d.full_path()) else { + return false; + }; + // A scanner path that no longer resolves must not swallow the + // completion tail: the file is downloaded either way. + if !std::path::Path::new(&program).is_file() { + crate::log::warn(&format!("virus scanner not found: {program}")); + return false; + } + let args = self.cfg.settings.virus_args.clone(); + let mut st = ScanState::default(); + st.push(i18n::tr("Start scanning downloaded data..."), false); + self.scans.insert(id, st); + if let Some(d) = self.item_mut(id) { + d.status_line = i18n::tr("Virus scanning..."); + // The connection rows describe a transfer that is over; the + // details panel shows the scanner log in their place. + d.conns.clear(); + } + crate::scan::start(id, &program, &args, &path.to_string_lossy()); + true + } + + /// The tail every finished download ends with: chime, close the progress + /// dialog, pop the complete dialog. Runs straight away when no scanner is + /// configured, and after a clean (or skipped) scan otherwise. + fn finish_completion(&mut self, id: DlId) -> Task { + if let Some(row) = self.cfg.settings.sounds.first().filter(|r| r.enabled) { + sounds::play( + (!row.file.is_empty()).then(|| row.file.clone()), + sounds::Event::DownloadComplete, + ); + } + let mut task = self.close_window(WinKind::Progress(id)); + if self.cfg.settings.show_complete_dialog { + task = Task::batch([task, self.open_window(WinKind::Complete(id))]); + } + task + } + // --------------------------------------------------------------- queues fn queue_tick(&mut self) -> Task { @@ -2009,16 +2125,13 @@ impl App { { fi_close = self.close_window(WinKind::FileInfo(id)); } - if let Some(row) = self.cfg.settings.sounds.first().filter(|r| r.enabled) { - sounds::play( - (!row.file.is_empty()).then(|| row.file.clone()), - sounds::Event::DownloadComplete, - ); - } - let mut task = self.close_window(WinKind::Progress(id)); - if self.cfg.settings.show_complete_dialog { - task = Task::batch([task, self.open_window(WinKind::Complete(id))]); + // With a scanner configured the file goes to it first: the + // progress dialog stays open showing the scan, and the chime + // plus complete dialog wait for the verdict. + if self.start_virus_scan(id) { + return Task::batch([fi_close, self.queue_tick()]); } + let task = self.finish_completion(id); Task::batch([fi_close, task, self.queue_tick()]) } engine::Event::Stopped { id, done, held } => { @@ -2353,6 +2466,11 @@ impl App { } } Message::AnimTick => { + // The indeterminate scan bar: one sweep every ~1.6 s, folded + // at 2.0 so the block returns instead of snapping back. + for st in self.scans.values_mut().filter(|s| s.running()) { + st.phase = (st.phase + 0.05) % 2.0; + } for d in &mut self.state.downloads { let target = d.progress(); let diff = target - d.disp_progress; @@ -3165,6 +3283,77 @@ impl App { self.save_config(); Task::none() } + Message::ProgScanSkip(id) => { + // Instant, not "ask the scanner to stop and wait": the file + // is downloaded, the user said move on. The killed process + // reports nothing back — its Done is never sent. + crate::scan::skip(id); + self.scans.remove(&id); + if let Some(d) = self.item_mut(id) { + d.status_line = i18n::tr("Complete"); + } + self.finish_completion(id) + } + Message::ProgScanKeep(id) => { + // The verdict has been read: drop it and let the dialog go. + // The file stays exactly where the download left it. + self.scans.remove(&id); + self.close_window(WinKind::Progress(id)) + } + Message::ProgScanDelete(id) => { + self.scans.remove(&id); + let close = self.close_window(WinKind::Progress(id)); + // Straight through, no confirmation: the user is answering a + // dialog that already names the file and the signature. + self.remove_item_opts(id, true); + close + } + Message::Scan(ev) => match ev { + crate::scan::ScanEvent::Line { id, text, redraw } => { + if let Some(st) = self.scans.get_mut(&id) { + st.push(text, redraw); + } + Task::none() + } + crate::scan::ScanEvent::Done(id, outcome) => { + // Skip removed the entry already; a late verdict for a + // scan nobody is waiting on is dropped. + if !self.scans.contains_key(&id) { + return Task::none(); + } + let (status, keep_open) = match &outcome { + crate::scan::Outcome::Clean => (i18n::tr("No virus found"), false), + crate::scan::Outcome::Infected(sig) if sig.is_empty() => { + (i18n::tr("Virus detected"), true) + } + crate::scan::Outcome::Infected(sig) => { + (format!("{}: {sig}", i18n::tr("Virus detected")), true) + } + crate::scan::Outcome::Failed(e) => { + (format!("{}: {e}", i18n::tr("Virus scan failed")), true) + } + }; + if let Some(st) = self.scans.get_mut(&id) { + st.push(status.clone(), false); + st.outcome = Some(outcome); + } + if let Some(d) = self.item_mut(id) { + d.status_line = status; + } + if keep_open { + // The downloaded file is kept: an infected (or + // unscannable) file is reported, never deleted behind + // the user's back. The dialog stays up with the log. + crate::log::log(&format!("#{id} virus scan: kept, dialog left open")); + return Task::none(); + } + self.scans.remove(&id); + if let Some(d) = self.item_mut(id) { + d.status_line = i18n::tr("Complete"); + } + self.finish_completion(id) + } + }, Message::ProgLimitOn(id, on) => { self.prog.entry(id).or_default().limit_on = on; let kb: u64 = self diff --git a/crates/hydra-gui/src/main.rs b/crates/hydra-gui/src/main.rs index 7293698..91af8b6 100644 --- a/crates/hydra-gui/src/main.rs +++ b/crates/hydra-gui/src/main.rs @@ -27,6 +27,7 @@ mod macos_menu; mod macos_surface; mod menubus; mod model; +mod scan; mod sounds; mod theme; mod tray; @@ -119,6 +120,7 @@ fn boot() -> (App, Task) { add_url: app::AddUrlState::default(), file_info: app::FileInfoState::default(), prog: std::collections::HashMap::new(), + scans: std::collections::HashMap::new(), options: app::OptionsState::default(), updater: app::UpdateUiState::default(), sch: app::SchState::default(), @@ -310,6 +312,7 @@ fn subscription(app: &App) -> Subscription { Subscription::run(engine_events).map(Message::Engine), Subscription::run(native_menu_events).map(Message::NativeMenu), Subscription::run(ext_events).map(Message::Ext), + Subscription::run(scan_events).map(Message::Scan), iced::time::every(std::time::Duration::from_secs(if power_save { 3 } else { @@ -366,7 +369,9 @@ fn subscription(app: &App) -> Subscription { // The glide tick exists only while something is moving; an idle list // costs zero redraws. Power save drops the animation entirely — the bar // then steps at the engine's event rate. - if !power_save && app.state.downloads.iter().any(|d| d.state.is_active()) { + // The marquee of a running virus scan needs the same tick even when no + // transfer is left moving, so it is part of the condition. + if !power_save && (app.state.downloads.iter().any(|d| d.state.is_active()) || app.scanning()) { subs.push( iced::time::every(std::time::Duration::from_millis(80)).map(|_| Message::AnimTick), ); @@ -402,6 +407,19 @@ fn ext_events() -> impl iced::futures::Stream { }) } +/// Console output of the virus scanner running over a finished file. +fn scan_events() -> impl iced::futures::Stream { + iced::futures::stream::unfold(scan::take_events(), |rx| async move { + match rx { + Some(mut r) => { + let ev = r.recv().await?; + Some((ev, Some(r))) + } + None => iced::futures::future::pending().await, + } + }) +} + /// Menu-bar and tray activations, one stream on every platform (the channel /// just stays quiet where there are no native menus). fn native_menu_events() -> impl iced::futures::Stream { diff --git a/crates/hydra-gui/src/scan.rs b/crates/hydra-gui/src/scan.rs new file mode 100644 index 0000000..b757b2f --- /dev/null +++ b/crates/hydra-gui/src/scan.rs @@ -0,0 +1,393 @@ +// Copyright (C) 2026 Javad Rajabzadeh +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Post-download virus scanning. +//! +//! When Options > Downloads > "Virus scanner program" names an executable, +//! every finished file is handed to it before the completion tail runs (sound, +//! progress window close, complete dialog). The scanner's console output is +//! streamed back line by line so the progress dialog can show it where the +//! per-connection table normally sits. +//! +//! The scanner runs in its own OS thread, not on the engine runtime: it is an +//! external process whose lifetime has nothing to do with a transfer, and a +//! blocking `wait()` must never sit on a tokio worker. Exit codes follow +//! clamscan's convention (0 = clean, 1 = infected, anything else = error), +//! which is also what most command-line scanners use. + +use crate::model::DlId; +use std::io::{BufReader, Read}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; + +/// Console lines are capped per scan: a scanner pointed at a directory by a +/// bad `%1` substitution can print for a very long time, and the dialog only +/// ever shows the tail. +const MAX_LINES: usize = 2000; + +#[derive(Clone, Debug)] +pub enum ScanEvent { + /// One console line from the scanner, verbatim. + Line { + id: DlId, + text: String, + /// The line ended in a bare carriage return: a terminal would have + /// drawn it over the previous one. clamscan's "Loading: 2s, ETA: 0s + /// [====>]" bar is nothing but these, and appending each redraw would + /// bury the real output under hundreds of near-identical rows. + redraw: bool, + }, + Done(DlId, Outcome), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Clean, + /// Signature name where the scanner named one, empty otherwise. + Infected(String), + /// The scanner could not be run, or exited with an error status. + Failed(String), +} + +// ------------------------------------------------------------------- bus + +struct Bus { + tx: UnboundedSender, + rx: Mutex>>, +} + +static BUS: OnceLock = OnceLock::new(); + +fn bus() -> &'static Bus { + BUS.get_or_init(|| { + let (tx, rx) = unbounded_channel(); + Bus { + tx, + rx: Mutex::new(Some(rx)), + } + }) +} + +/// The subscription side: take the event receiver (once). +pub fn take_events() -> Option> { + bus().rx.lock().ok()?.take() +} + +/// Running scanners, so Skip can kill one. Entries are removed by the +/// scanner thread when the process is reaped. +type Live = (DlId, Arc>>, Arc); +static LIVE: Mutex> = Mutex::new(Vec::new()); + +// ---------------------------------------------------------------- argv + +/// Split the "Command line parameters" box into argv, honouring double +/// quotes so a switch carrying a path with spaces survives. +fn split_args(args: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quoted = false; + let mut any = false; + for ch in args.chars() { + match ch { + '"' => { + quoted = !quoted; + any = true; + } + c if c.is_whitespace() && !quoted => { + if any { + out.push(std::mem::take(&mut cur)); + any = false; + } + } + c => { + cur.push(c); + any = true; + } + } + } + if any { + out.push(cur); + } + out +} + +/// The full argument list for one scan. `%1` (IDM's placeholder) is replaced +/// by the file path wherever it appears; without it the path is appended +/// last, which is what `clamscan ` and friends expect. +pub fn argv(args: &str, path: &str) -> Vec { + let mut argv = split_args(args); + let mut substituted = false; + for a in &mut argv { + if a.contains("%1") { + *a = a.replace("%1", path); + substituted = true; + } + } + if !substituted { + argv.push(path.to_string()); + } + argv +} + +/// The signature name out of a clamscan hit line: `/path/f.zip: Eicar-Test-Signature FOUND`. +fn signature(line: &str) -> Option { + let head = line.trim_end().strip_suffix("FOUND")?.trim_end(); + let sig = match head.rfind(": ") { + Some(i) => &head[i + 2..], + None => head, + }; + (!sig.is_empty()).then(|| sig.to_string()) +} + +// --------------------------------------------------------------- runner + +/// Run `program` over `path` and stream its output back as [`ScanEvent`]s. +/// Returns immediately; exactly one [`ScanEvent::Done`] follows unless the +/// scan is [`skip`]ped. +pub fn start(id: DlId, program: &str, args: &str, path: &str) { + let tx = bus().tx.clone(); + let program = program.to_string(); + let argv = argv(args, path); + std::thread::spawn(move || { + crate::log::debug(&format!("#{id} virus scan: {program} {}", argv.join(" "))); + let spawned = Command::new(&program) + .args(&argv) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let mut child = match spawned { + Ok(c) => c, + Err(e) => { + crate::log::warn(&format!("#{id} virus scan: {program}: {e}")); + let _ = tx.send(ScanEvent::Line { + id, + text: format!("{program}: {e}"), + redraw: false, + }); + let _ = tx.send(ScanEvent::Done(id, Outcome::Failed(e.to_string()))); + return; + } + }; + let out = child.stdout.take(); + let err = child.stderr.take(); + let killed = Arc::new(AtomicBool::new(false)); + let handle = Arc::new(Mutex::new(Some(child))); + if let Ok(mut live) = LIVE.lock() { + live.push((id, handle.clone(), killed.clone())); + } + + // stderr on its own thread: a scanner that fills one pipe while + // nothing drains the other would deadlock on the write. + let errt = { + let tx = tx.clone(); + std::thread::spawn(move || pump(id, err, &tx)) + }; + let found = pump(id, out, &tx); + let found = errt.join().ok().flatten().or(found); + + // Both pipes are at EOF, so the process has exited or is about to: + // the wait cannot block the Skip path for any meaningful time. + let status = handle + .lock() + .ok() + .and_then(|mut g| g.as_mut().map(|c| c.wait())); + if let Ok(mut live) = LIVE.lock() { + live.retain(|(x, _, _)| *x != id); + } + if killed.load(Ordering::Relaxed) { + // Skipped: the GUI already moved on when it killed the process. + return; + } + let outcome = match status { + Some(Ok(st)) => match st.code() { + Some(0) => Outcome::Clean, + Some(1) => Outcome::Infected(found.unwrap_or_default()), + Some(c) => Outcome::Failed(format!("exit code {c}")), + // Killed by a signal without Skip being pressed. + None => Outcome::Failed("terminated".into()), + }, + Some(Err(e)) => Outcome::Failed(e.to_string()), + None => Outcome::Failed("no process".into()), + }; + crate::log::debug(&format!("#{id} virus scan: {outcome:?}")); + let _ = tx.send(ScanEvent::Done(id, outcome)); + }); +} + +/// Forward every line of one pipe, returning the signature name if the +/// scanner reported a hit on the way past. +/// +/// Line breaking is done by hand rather than with [`BufRead::lines`]: a +/// progress bar redrawn with bare carriage returns carries no `\n` at all, so +/// `lines()` would hold the whole animation back as one enormous line and +/// release it only at the next real newline. +fn pump(id: DlId, pipe: Option, tx: &UnboundedSender) -> Option { + let pipe = pipe?; + let mut rdr = BufReader::new(pipe); + let mut chunk = [0u8; 4096]; + let mut pending: Vec = Vec::new(); + let mut found = None; + let mut sent = 0usize; + let mut cr = false; + let mut alive = true; + + // Emitting is a closure over the counters so the CRLF fold-in below can + // reach it from three places without repeating the bookkeeping. + let mut emit = |pending: &mut Vec, redraw: bool, found: &mut Option| -> bool { + let text = String::from_utf8_lossy(pending).trim_end().to_string(); + pending.clear(); + if text.ends_with("FOUND") { + *found = signature(&text).or(found.take()); + } + sent += 1; + if sent > MAX_LINES { + return true; + } + tx.send(ScanEvent::Line { id, text, redraw }).is_ok() + }; + + while alive { + let n = match rdr.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + for &b in &chunk[..n] { + if cr { + cr = false; + // CRLF is one ordinary line ending, a lone CR is a redraw. + if b == b'\n' { + alive = emit(&mut pending, false, &mut found); + continue; + } + alive = emit(&mut pending, true, &mut found); + } + match b { + b'\r' => cr = true, + // Blank lines are part of the scanner's own formatting; + // keeping them preserves the layout the user knows. + b'\n' => alive = emit(&mut pending, false, &mut found), + _ => pending.push(b), + } + if !alive { + break; + } + } + } + if alive && (cr || !pending.is_empty()) { + emit(&mut pending, cr, &mut found); + } + found +} + +/// Stop a running scan (the Skip button). No [`ScanEvent::Done`] follows — +/// the caller has already decided the download is finished. +pub fn skip(id: DlId) { + let entry = LIVE + .lock() + .ok() + .and_then(|live| live.iter().find(|(x, _, _)| *x == id).cloned()); + if let Some((_, handle, killed)) = entry { + killed.store(true, Ordering::Relaxed); + if let Ok(mut g) = handle.lock() { + if let Some(c) = g.as_mut() { + let _ = c.kill(); + } + } + crate::log::debug(&format!("#{id} virus scan skipped")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argv_appends_the_path_when_there_is_no_placeholder() { + assert_eq!(argv("", "/a/b.zip"), vec!["/a/b.zip"]); + assert_eq!( + argv("--no-summary -i", "/a/b.zip"), + vec!["--no-summary", "-i", "/a/b.zip"] + ); + } + + #[test] + fn argv_substitutes_the_placeholder_in_place() { + assert_eq!( + argv("--scan %1 --verbose", "/a/b.zip"), + vec!["--scan", "/a/b.zip", "--verbose"] + ); + } + + #[test] + fn quoted_arguments_stay_one_token() { + assert_eq!( + argv("--db \"/opt/my defs\"", "/a/b.zip"), + vec!["--db", "/opt/my defs", "/a/b.zip"] + ); + } + + /// The whole runner over a stand-in scanner: streamed output, a bare + /// carriage return marked as a redraw, and both verdicts the exit code + /// carries. One test, because the event receiver can be taken only once. + #[cfg(unix)] + #[test] + fn a_scanner_streams_its_console_and_its_verdict() { + fn lines(rx: &mut UnboundedReceiver, n: usize) -> Vec<(String, bool)> { + (0..n) + .map(|_| match rx.blocking_recv().expect("event") { + ScanEvent::Line { text, redraw, .. } => (text, redraw), + ev => panic!("expected a line, got {ev:?}"), + }) + .collect() + } + let mut rx = take_events().expect("the scan event receiver"); + + start( + 7, + "/bin/sh", + r#"-c "printf 'load\rdone\n%1: OK\n'""#, + "/tmp/x.zip", + ); + assert_eq!( + lines(&mut rx, 3), + vec![ + ("load".to_string(), true), + ("done".to_string(), false), + ("/tmp/x.zip: OK".to_string(), false), + ] + ); + match rx.blocking_recv().expect("verdict") { + ScanEvent::Done(7, Outcome::Clean) => {} + ev => panic!("expected a clean verdict, got {ev:?}"), + } + + start( + 8, + "/bin/sh", + r#"-c "printf '%1: Eicar-Test-Signature FOUND\n'; exit 1""#, + "/tmp/x.zip", + ); + assert_eq!( + lines(&mut rx, 1), + vec![("/tmp/x.zip: Eicar-Test-Signature FOUND".to_string(), false)] + ); + match rx.blocking_recv().expect("verdict") { + ScanEvent::Done(8, Outcome::Infected(sig)) => { + assert_eq!(sig, "Eicar-Test-Signature") + } + ev => panic!("expected an infected verdict, got {ev:?}"), + } + } + + #[test] + fn signature_reads_the_clamscan_hit_line() { + assert_eq!( + signature("/Users/j/Downloads/x.zip: Eicar-Test-Signature FOUND").as_deref(), + Some("Eicar-Test-Signature") + ); + assert_eq!(signature("/Users/j/Downloads/x.zip: OK"), None); + } +} diff --git a/crates/hydra-gui/src/theme.rs b/crates/hydra-gui/src/theme.rs index 80e493d..8fa4b00 100644 --- a/crates/hydra-gui/src/theme.rs +++ b/crates/hydra-gui/src/theme.rs @@ -496,6 +496,36 @@ pub fn progress(theme: &Theme) -> progress_bar::Style { } } +/// Track of the indeterminate virus-scan bar: the same well the determinate +/// progress bar draws, so the swap is invisible except for the motion. +pub fn scan_track(theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(if is_dark(theme) { + c(0x3A3A3A) + } else { + c(0xE0E0E0) + })), + border: border( + if is_dark(theme) { + c(GRID_DARK) + } else { + c(0xC8C8C8) + }, + 1.0, + 0.0, + ), + ..Default::default() + } +} + +/// The block sliding inside [`scan_track`]. +pub fn scan_block(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(c(PROGRESS_GREEN))), + ..Default::default() + } +} + pub fn picker(theme: &Theme, status: pick_list::Status) -> pick_list::Style { let dark = is_dark(theme); pick_list::Style { diff --git a/crates/hydra-gui/src/windows/progress.rs b/crates/hydra-gui/src/windows/progress.rs index 54f55ee..0ac71fe 100644 --- a/crates/hydra-gui/src/windows/progress.rs +++ b/crates/hydra-gui/src/windows/progress.rs @@ -5,7 +5,7 @@ //! completion tabs, overall progress bar, the "start positions and download //! progress by connections" strip, and the per-connection table. -use crate::app::{App, El, Message, ProgTab}; +use crate::app::{App, El, Message, ProgTab, ScanState}; use crate::model::{DlState, DownloadItem}; use crate::windows::{dlg_btn, dlg_btn_primary}; use crate::{fmt, i18n::tr, theme}; @@ -51,7 +51,13 @@ fn status_tab<'a>(app: &'a App, d: &'a DownloadItem) -> El<'a> { d.status_line.clone() }) .size(theme::FONT_SIZE) - .color(iced::Color::from_rgb8(0x1F, 0x3F, 0xC4)), + // A virus verdict is the one status worth shouting: red for + // "infected" or a scanner that could not run, the usual blue for + // everything else, scanning included. + .color(match app.scans.get(&d.id) { + Some(st) if !st.running() => iced::Color::from_rgb8(0xC4, 0x1F, 0x1F), + _ => iced::Color::from_rgb8(0x1F, 0x3F, 0xC4), + }), ] .spacing(8), kv( @@ -162,6 +168,79 @@ fn completion_tab<'a>(app: &'a App, d: &'a DownloadItem) -> El<'a> { .into() } +/// The indeterminate bar shown while the virus scanner works: iced's +/// `progress_bar` is determinate only, so the moving block is a FillPortion +/// row inside the same track the real bar draws. +fn marquee<'a>(sweep: f32) -> El<'a> { + const TOTAL: u16 = 1000; + const BLOCK: u16 = 220; + let travel = f32::from(TOTAL - BLOCK); + // Both spacers keep a portion: FillPortion(0) would divide the row by a + // zero total. + let left = (sweep.clamp(0.0, 1.0) * travel) + .round() + .clamp(1.0, travel - 1.0) as u16; + let right = (TOTAL - BLOCK).saturating_sub(left).max(1); + let bar = row![ + iced::widget::space::horizontal().width(Length::FillPortion(left)), + container(iced::widget::space::horizontal()) + .width(Length::FillPortion(BLOCK)) + .height(Length::Fill) + .style(theme::scan_block), + iced::widget::space::horizontal().width(Length::FillPortion(right)), + ] + .height(Length::Fill); + container(bar) + .width(Length::Fill) + .height(18.0) + .style(theme::scan_track) + .into() +} + +/// The scanner's console output, in the box the per-connection table uses. +/// Anchored to the bottom so the newest line is the one on screen. +fn scan_log(st: &ScanState) -> El<'_> { + const ROW_H: f32 = 20.0; + // The connections box is a header plus 8 rows; the log has no header, so + // it takes the ninth row instead and the panel keeps the same footprint. + const VISIBLE: usize = 9; + let mut rows = column![].width(Length::Fill); + let n_rows = st.log.len().max(VISIBLE); + for i in 0..n_rows { + let line = st.log.get(i).cloned().unwrap_or_default(); + rows = rows.push( + container( + container( + text(line) + .size(theme::FONT_SIZE) + .wrapping(iced::widget::text::Wrapping::None), + ) + .padding([1, 6]) + .height(ROW_H), + ) + .width(Length::Fill) + .style(|t: &iced::Theme| container::Style { + background: Some(iced::Background::Color(if theme::is_dark(t) { + iced::Color::from_rgb8(0x33, 0x2E, 0x38) + } else { + iced::Color::from_rgb8(0xC9, 0xBF, 0xC9) + })), + text_color: Some(theme::text_color(t)), + ..Default::default() + }), + ); + } + container( + scrollable(rows) + .height(ROW_H * VISIBLE as f32) + .width(Length::Fill) + .anchor_bottom(), + ) + .width(Length::Fill) + .style(theme::panel) + .into() +} + /// The blue chunk strip: 120 buckets over the object, filled where held. fn chunk_strip<'a>(d: &DownloadItem) -> El<'a> { const BUCKETS: usize = 120; @@ -306,6 +385,11 @@ pub fn view(app: &App, id: crate::model::DlId) -> El<'_> { let active = d.state.is_active(); let pause_label = if active { tr("Pause") } else { tr("Start") }; + let scan = app.scans.get(&id); + let scanning = scan.map(ScanState::running).unwrap_or(false); + // A verdict still on screen: the scanner found something (or could not + // run) and the file is waiting on the user's decision. + let verdict = scan.is_some() && !scanning; // The tab body is pinned to one height for every tab, so the progress // bar, buttons and connections panel below never jump when switching. @@ -316,9 +400,16 @@ pub fn view(app: &App, id: crate::model::DlId) -> El<'_> { .height(210.0) .padding(12) .style(theme::panel), - progress_bar(0.0..=1.0, d.disp_progress.clamp(0.0, 1.0)) - .girth(18.0) - .style(theme::progress), + // The bytes are all in while a scan runs: the determinate bar has + // nothing left to say, so it gives way to the marquee. + if scanning { + marquee(scan.map(ScanState::sweep).unwrap_or(0.0)) + } else { + progress_bar(0.0..=1.0, d.disp_progress.clamp(0.0, 1.0)) + .girth(18.0) + .style(theme::progress) + .into() + }, row![ dlg_btn( if p.details { @@ -329,8 +420,24 @@ pub fn view(app: &App, id: crate::model::DlId) -> El<'_> { Some(Message::ProgToggleDetails(id)), ), iced::widget::space::horizontal(), - dlg_btn_primary(pause_label, Some(Message::ProgPauseResume(id))), - dlg_btn(tr("Cancel"), Some(Message::ProgCancel(id))), + // Scanning: Pause becomes Skip (the transfer is over, only the + // scanner can still be stopped) and Cancel goes read-only — + // there is nothing left to cancel, the file is on disk. Once a + // verdict is in, the pair becomes the decision it asks for: + // keep the file, or take it off the list and the disk. Keep is + // the default button — deleting is the irreversible one. + if scanning { + dlg_btn_primary(tr("Skip"), Some(Message::ProgScanSkip(id))) + } else if verdict { + dlg_btn_primary(tr("Keep file"), Some(Message::ProgScanKeep(id))) + } else { + dlg_btn_primary(pause_label, Some(Message::ProgPauseResume(id))) + }, + if verdict { + dlg_btn(tr("Delete file"), Some(Message::ProgScanDelete(id))) + } else { + dlg_btn(tr("Cancel"), (!scanning).then_some(Message::ProgCancel(id))) + }, ] .spacing(10) .align_y(iced::Alignment::Center), @@ -340,12 +447,22 @@ pub fn view(app: &App, id: crate::model::DlId) -> El<'_> { .width(Length::Fill); if p.details { - col = col.push(crate::windows::centered( - tr("Start positions and download progress by connections"), - theme::FONT_SIZE, - )); - col = col.push(chunk_strip(d)); - col = col.push(conn_table(d)); + // Once the scanner has the file, the details panel is its console: + // the chunk strip and connection rows describe a finished transfer. + if let Some(st) = scan { + col = col.push(crate::windows::centered( + tr("Virus scanner output"), + theme::FONT_SIZE, + )); + col = col.push(scan_log(st)); + } else { + col = col.push(crate::windows::centered( + tr("Start positions and download progress by connections"), + theme::FONT_SIZE, + )); + col = col.push(chunk_strip(d)); + col = col.push(conn_table(d)); + } } else { col = col.push(iced::widget::space::vertical()); }