From 241f9ba2b91c196dfc6f632b36cdbc7c8e8aab99 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:54:20 +0200 Subject: [PATCH] fix: Web Remote file transfer via dedicated FILE_TRANSFER relay (#217) Opens a separate relay session for the file manager, adds a RustDesk-style transfer modal, and cleans up rdclient toolbar/fullscreen UX. Refs #217 --- CHANGELOG.md | 5 +- web-nodejs/lang/ar.json | 14 +- web-nodejs/lang/cs.json | 14 +- web-nodejs/lang/da.json | 14 +- web-nodejs/lang/de.json | 14 +- web-nodejs/lang/en.json | 14 +- web-nodejs/lang/es.json | 14 +- web-nodejs/lang/fi.json | 14 +- web-nodejs/lang/fr.json | 14 +- web-nodejs/lang/hi.json | 14 +- web-nodejs/lang/hu.json | 14 +- web-nodejs/lang/id.json | 14 +- web-nodejs/lang/it.json | 14 +- web-nodejs/lang/ja.json | 14 +- web-nodejs/lang/ko.json | 14 +- web-nodejs/lang/nb.json | 14 +- web-nodejs/lang/nl.json | 14 +- web-nodejs/lang/pl.json | 14 +- web-nodejs/lang/pt.json | 14 +- web-nodejs/lang/ro.json | 14 +- web-nodejs/lang/sv.json | 14 +- web-nodejs/lang/th.json | 14 +- web-nodejs/lang/tr.json | 14 +- web-nodejs/lang/uk.json | 14 +- web-nodejs/lang/vi.json | 14 +- web-nodejs/lang/zh-TW.json | 14 +- web-nodejs/lang/zh.json | 14 +- web-nodejs/public/css/remote.css | 342 +++++++++++- web-nodejs/public/js/rdclient/client.js | 52 +- .../public/js/rdclient/file-connection.js | 488 ++++++++++++++++++ web-nodejs/public/js/rdclient/file-modal.js | 400 ++++++++++++++ web-nodejs/public/js/rdclient/filetransfer.js | 49 +- web-nodejs/public/js/rdclient/local-files.js | 121 +++++ web-nodejs/public/js/rdclient/protocol.js | 33 +- web-nodejs/public/js/remote.js | 357 +++---------- web-nodejs/tests/rdclient.protocol.test.js | 64 +++ web-nodejs/views/layouts/viewer.ejs | 3 + web-nodejs/views/remote.ejs | 49 +- 38 files changed, 1901 insertions(+), 426 deletions(-) create mode 100644 web-nodejs/public/js/rdclient/file-connection.js create mode 100644 web-nodejs/public/js/rdclient/file-modal.js create mode 100644 web-nodejs/public/js/rdclient/local-files.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ebc03a5..576b2fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ ## [Unreleased] +### Fixed +- **Web Remote file transfer (#217)**: opens a dedicated `FILE_TRANSFER` relay session (separate from the desktop tunnel) so RustDesk peers handle `FileAction` messages. Replaces the side panel with a RustDesk-style centered modal (local | remote | transfer queue); local folder browsing uses the File System Access API on Chrome/Edge with HTTPS fallback. CDAP snapshot sessions disable the file-transfer toolbar button. + ### Changed -- _(none yet)_ +- **Web Remote toolbar UX**: hides “Connected” status text and frame count while streaming; removes the in-viewer language picker. Fullscreen (toolbar button, handle, F11) now covers the tab bar, viewer, and floating toolbar via `#rd-viewer-shell`. --- diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index 44821da5..05757c87 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -866,7 +866,8 @@ "view_all": "عرض الكل", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "إعادة المحاولة" }, "time": { "just_now": "Just now", @@ -1139,7 +1140,16 @@ "phone_unsupported_title": "سطح المكتب البعيد يتطلب شاشة أكبر", "phone_unsupported_body": "استخدم جهاز لوحي أو foldable مفتوح أو سطح مكتب. إدارة الأجهزة متاحة في وحدة التحكم.", "phone_unsupported_back_devices": "العودة إلى الأجهزة", - "phone_unsupported_get_desktop": "احصل على RdClient desktop" + "phone_unsupported_get_desktop": "احصل على RdClient desktop", + "file_local": "الكمبيوتر المحلي", + "file_remote": "الكمبيوتر البعيد", + "file_send": "إرسال", + "file_pick_folder": "اختر مجلدًا", + "file_queue_empty": "لا توجد عمليات نقل جارية", + "file_connecting": "جارٍ الاتصال بجلسة نقل الملفات…", + "file_connect_failed": "تعذر فتح جلسة نقل الملفات", + "file_local_limited": "يتطلب تصفح المجلدات المحلية بالكامل Chrome أو Edge عبر HTTPS. لا يزال الرفع والتنزيل متاحين.", + "file_transfer_unavailable_cdap": "نقل الملفات غير متاح لجلسات لقطة CDAP." }, "branding": { "identity_title": "هوية العلامة التجارية", diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index dc00b74d..e61d961c 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -859,7 +859,8 @@ "view_all": "Zobrazit vše", "cdap_viewer": "Prohlížeč snímků CDAP", "add": "Přidat", - "remove": "Odebrat" + "remove": "Odebrat", + "retry": "Zkusit znovu" }, "time": { "just_now": "Právě teď", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "Vzdálená plocha vyžaduje větší obrazovku", "phone_unsupported_body": "Použijte tablet, rozložený fold nebo desktop. Správa zařízení zůstává v konzoli.", "phone_unsupported_back_devices": "Zpět na zařízení", - "phone_unsupported_get_desktop": "Stáhnout RdClient desktop" + "phone_unsupported_get_desktop": "Stáhnout RdClient desktop", + "file_local": "Místní počítač", + "file_remote": "Vzdálený počítač", + "file_send": "Odeslat", + "file_pick_folder": "Vybrat složku", + "file_queue_empty": "Žádné probíhající přenosy", + "file_connecting": "Připojování relace přenosu souborů…", + "file_connect_failed": "Nepodařilo se otevřít relaci přenosu souborů", + "file_local_limited": "Úplné procházení místních složek vyžaduje Chrome nebo Edge přes HTTPS. Nahrávání a stahování stále funguje.", + "file_transfer_unavailable_cdap": "Přenos souborů není k dispozici pro relace snímku CDAP." }, "branding": { "identity_title": "Identita značky", diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index cd2ca963..a2fce3f4 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -865,7 +865,8 @@ "view_all": "Se alle", "cdap_viewer": "CDAP Snapshot-visning", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Prøv igen" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Fjernskrivebord kræver en større skærm", "phone_unsupported_body": "Brug tablet, foldet foldbar eller desktop. Enhedsstyring er stadig i konsollen.", "phone_unsupported_back_devices": "Tilbage til enheder", - "phone_unsupported_get_desktop": "Hent RdClient desktop" + "phone_unsupported_get_desktop": "Hent RdClient desktop", + "file_local": "Lokal computer", + "file_remote": "Fjerncomputer", + "file_send": "Send", + "file_pick_folder": "Vælg mappe", + "file_queue_empty": "Ingen overførsler i gang", + "file_connecting": "Opretter filoverførselssession…", + "file_connect_failed": "Kunne ikke åbne filoverførselssession", + "file_local_limited": "Fuld lokal mappevisning kræver Chrome eller Edge over HTTPS. Upload og download virker stadig.", + "file_transfer_unavailable_cdap": "Filoverførsel er ikke tilgængelig for CDAP-snapshot-sessioner." }, "branding": { "identity_title": "Brand Identitet", diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index 9c75522a..f9373271 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -859,7 +859,8 @@ "view_all": "Alle anzeigen", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Erneut versuchen" }, "time": { "just_now": "gerade eben", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "Remote-Desktop erfordert einen größeren Bildschirm", "phone_unsupported_body": "Verwenden Sie ein Tablet, aufgeklapptes Foldable oder Desktop. Geräteverwaltung bleibt in der Konsole verfügbar.", "phone_unsupported_back_devices": "Zurück zu Geräten", - "phone_unsupported_get_desktop": "RdClient Desktop holen" + "phone_unsupported_get_desktop": "RdClient Desktop holen", + "file_local": "Lokaler Computer", + "file_remote": "Entfernter Computer", + "file_send": "Senden", + "file_pick_folder": "Ordner wählen", + "file_queue_empty": "Keine laufenden Übertragungen", + "file_connecting": "Dateiübertragungssitzung wird verbunden…", + "file_connect_failed": "Dateiübertragungssitzung konnte nicht geöffnet werden", + "file_local_limited": "Vollständiges lokales Ordnerbrowsing erfordert Chrome oder Edge über HTTPS. Hoch- und Herunterladen funktioniert weiterhin.", + "file_transfer_unavailable_cdap": "Dateiübertragung ist für CDAP-Snapshot-Sitzungen nicht verfügbar." }, "branding": { "identity_title": "Markenidentität", diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index cba2d655..262d5132 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -880,7 +880,8 @@ "dismiss": "Dismiss", "view_all": "View All", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Retry" }, "time": { "just_now": "Just now", @@ -1153,7 +1154,16 @@ "phone_unsupported_title": "Remote desktop requires a larger screen", "phone_unsupported_body": "Use a tablet, unfolded foldable, or desktop to control devices remotely. You can still manage devices from the console.", "phone_unsupported_back_devices": "Back to devices", - "phone_unsupported_get_desktop": "Get RdClient desktop" + "phone_unsupported_get_desktop": "Get RdClient desktop", + "file_local": "Local computer", + "file_remote": "Remote computer", + "file_send": "Send", + "file_pick_folder": "Choose folder", + "file_queue_empty": "No transfers in progress", + "file_connecting": "Connecting file transfer session…", + "file_connect_failed": "Could not open file transfer session", + "file_local_limited": "Full local folder browsing requires Chrome or Edge over HTTPS. You can still upload and download files.", + "file_transfer_unavailable_cdap": "File transfer is not available for CDAP snapshot sessions." }, "remote_dashboard": { "title": "Remote Desktop Client", diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index 6a38ba0d..63b9da74 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -859,7 +859,8 @@ "view_all": "Ver todo", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Reintentar" }, "time": { "just_now": "ahora mismo", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "El escritorio remoto requiere una pantalla más grande", "phone_unsupported_body": "Use una tablet, plegable desplegado o escritorio. La gestión de dispositivos sigue en la consola.", "phone_unsupported_back_devices": "Volver a dispositivos", - "phone_unsupported_get_desktop": "Obtener RdClient desktop" + "phone_unsupported_get_desktop": "Obtener RdClient desktop", + "file_local": "Equipo local", + "file_remote": "Equipo remoto", + "file_send": "Enviar", + "file_pick_folder": "Elegir carpeta", + "file_queue_empty": "No hay transferencias en curso", + "file_connecting": "Conectando sesión de transferencia de archivos…", + "file_connect_failed": "No se pudo abrir la sesión de transferencia de archivos", + "file_local_limited": "La navegación completa de carpetas locales requiere Chrome o Edge con HTTPS. Aún puede subir y descargar archivos.", + "file_transfer_unavailable_cdap": "La transferencia de archivos no está disponible en sesiones de instantánea CDAP." }, "branding": { "identity_title": "Identidad de marca", diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 378bc3c5..b805062a 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -865,7 +865,8 @@ "view_all": "Näytä kaikki", "cdap_viewer": "CDAP Snapshot -katseluohjelma", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Yritä uudelleen" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Etätyöpöytä vaatii suuremman näytön", "phone_unsupported_body": "Käytä tablettia, avattua taittopuhelinta tai työpöytää. Laitteiden hallinta toimii konsolissa.", "phone_unsupported_back_devices": "Takaisin laitteisiin", - "phone_unsupported_get_desktop": "Hae RdClient desktop" + "phone_unsupported_get_desktop": "Hae RdClient desktop", + "file_local": "Paikallinen tietokone", + "file_remote": "Etätietokone", + "file_send": "Lähetä", + "file_pick_folder": "Valitse kansio", + "file_queue_empty": "Ei siirtoja käynnissä", + "file_connecting": "Yhdistetään tiedostonsiirtosessiota…", + "file_connect_failed": "Tiedostonsiirtosessiota ei voitu avata", + "file_local_limited": "Täysi paikallinen kansioselaus vaatii Chromen tai Edgen HTTPS-yhteydellä. Lataus ja lähetys toimivat silti.", + "file_transfer_unavailable_cdap": "Tiedostonsiirto ei ole käytettävissä CDAP-tilannekuvasessioissa." }, "branding": { "identity_title": "Brändin identiteetti", diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index ff1e3810..5201a75c 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -876,7 +876,8 @@ "dismiss": "Ignorer", "view_all": "Tout afficher", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Réessayer" }, "time": { "just_now": "à l'instant", @@ -1149,7 +1150,16 @@ "phone_unsupported_title": "Le bureau à distance nécessite un écran plus grand", "phone_unsupported_body": "Utilisez une tablette, un foldable déplié ou un ordinateur. La gestion des appareils reste disponible dans la console.", "phone_unsupported_back_devices": "Retour aux appareils", - "phone_unsupported_get_desktop": "Obtenir RdClient desktop" + "phone_unsupported_get_desktop": "Obtenir RdClient desktop", + "file_local": "Ordinateur local", + "file_remote": "Ordinateur distant", + "file_send": "Envoyer", + "file_pick_folder": "Choisir un dossier", + "file_queue_empty": "Aucun transfert en cours", + "file_connecting": "Connexion à la session de transfert de fichiers…", + "file_connect_failed": "Impossible d'ouvrir la session de transfert de fichiers", + "file_local_limited": "La navigation complète des dossiers locaux nécessite Chrome ou Edge en HTTPS. L'envoi et le téléchargement restent disponibles.", + "file_transfer_unavailable_cdap": "Le transfert de fichiers n'est pas disponible pour les sessions instantanées CDAP." }, "branding": { "identity_title": "Identité de marque", diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index 10ed906e..6a4ac5e9 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -865,7 +865,8 @@ "view_all": "सभी को देखें", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "पुनः प्रयास" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "रिमोट डेस्कटॉप के लिए बड़ी स्क्रीन चाहिए", "phone_unsupported_body": "टैबलेट, खुला foldable या डेस्कटॉप उपयोग करें। डिवाइस प्रबंधन कंसोल में उपलब्ध है।", "phone_unsupported_back_devices": "डिवाइस पर वापस", - "phone_unsupported_get_desktop": "RdClient desktop प्राप्त करें" + "phone_unsupported_get_desktop": "RdClient desktop प्राप्त करें", + "file_local": "स्थानीय कंप्यूटर", + "file_remote": "रिमोट कंप्यूटर", + "file_send": "भेजें", + "file_pick_folder": "फ़ोल्डर चुनें", + "file_queue_empty": "कोई स्थानांतरण प्रगति में नहीं", + "file_connecting": "फ़ाइल स्थानांतरण सत्र कनेक्ट हो रहा है…", + "file_connect_failed": "फ़ाइल स्थानांतरण सत्र नहीं खोला जा सका", + "file_local_limited": "पूर्ण स्थानीय फ़ोल्डर ब्राउज़िंग के लिए HTTPS पर Chrome या Edge आवश्यक है। अपलोड और डाउनलोड अभी भी काम करते हैं।", + "file_transfer_unavailable_cdap": "CDAP स्नैपशॉट सत्रों के लिए फ़ाइल स्थानांतरण उपलब्ध नहीं है।" }, "branding": { "identity_title": "ब्रांड पहचान", diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index 28db576f..af6642f3 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -865,7 +865,8 @@ "view_all": "Összes megtekintése", "cdap_viewer": "CDAP Snapshot nézető", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Újra" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "A távoli asztal nagyobb képernyőt igényel", "phone_unsupported_body": "Használjon tabletet, kinyitott foldot vagy asztali gépet. Az eszközkezelés továbbra is elérhető a konzolon.", "phone_unsupported_back_devices": "Vissza az eszközökhöz", - "phone_unsupported_get_desktop": "RdClient desktop letöltése" + "phone_unsupported_get_desktop": "RdClient desktop letöltése", + "file_local": "Helyi számítógép", + "file_remote": "Távoli számítógép", + "file_send": "Küldés", + "file_pick_folder": "Mappa kiválasztása", + "file_queue_empty": "Nincs folyamatban lévő átvitel", + "file_connecting": "Fájlátviteli munkamenet csatlakoztatása…", + "file_connect_failed": "Nem sikerült megnyitni a fájlátviteli munkamenetet", + "file_local_limited": "A teljes helyi mappaböngészés Chrome-ot vagy Edge-et igényel HTTPS-en. A feltöltés és letöltés továbbra is működik.", + "file_transfer_unavailable_cdap": "A fájlátvitel nem érhető el CDAP pillanatkép-munkamenetekben." }, "branding": { "identity_title": "Márkaidentitás", diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index ad30ddc7..ee08bcb3 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -865,7 +865,8 @@ "view_all": "Lihat Semua", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Coba lagi" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Desktop jarak jauh memerlukan layar lebih besar", "phone_unsupported_body": "Gunakan tablet, foldable terbuka, atau desktop. Kelola perangkat tetap di konsol.", "phone_unsupported_back_devices": "Kembali ke perangkat", - "phone_unsupported_get_desktop": "Dapatkan RdClient desktop" + "phone_unsupported_get_desktop": "Dapatkan RdClient desktop", + "file_local": "Komputer lokal", + "file_remote": "Komputer jarak jauh", + "file_send": "Kirim", + "file_pick_folder": "Pilih folder", + "file_queue_empty": "Tidak ada transfer yang berlangsung", + "file_connecting": "Menghubungkan sesi transfer file…", + "file_connect_failed": "Tidak dapat membuka sesi transfer file", + "file_local_limited": "Penelusuran folder lokal penuh memerlukan Chrome atau Edge melalui HTTPS. Unggah dan unduh tetap berfungsi.", + "file_transfer_unavailable_cdap": "Transfer file tidak tersedia untuk sesi snapshot CDAP." }, "branding": { "identity_title": "Identitas Merek", diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index ce01ac62..6ffcc7d6 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -859,7 +859,8 @@ "view_all": "Vedi tutto", "cdap_viewer": "Visualizzatore snapshot CDAP", "add": "Aggiungi", - "remove": "Rimuovi" + "remove": "Rimuovi", + "retry": "Riprova" }, "time": { "just_now": "Proprio ora", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "Il desktop remoto richiede uno schermo più grande", "phone_unsupported_body": "Usa tablet, foldable aperto o desktop. La gestione dispositivi resta nella console.", "phone_unsupported_back_devices": "Torna ai dispositivi", - "phone_unsupported_get_desktop": "Scarica RdClient desktop" + "phone_unsupported_get_desktop": "Scarica RdClient desktop", + "file_local": "Computer locale", + "file_remote": "Computer remoto", + "file_send": "Invia", + "file_pick_folder": "Scegli cartella", + "file_queue_empty": "Nessun trasferimento in corso", + "file_connecting": "Connessione alla sessione di trasferimento file…", + "file_connect_failed": "Impossibile aprire la sessione di trasferimento file", + "file_local_limited": "La navigazione completa delle cartelle locali richiede Chrome o Edge su HTTPS. Caricamento e download restano disponibili.", + "file_transfer_unavailable_cdap": "Il trasferimento file non è disponibile per le sessioni snapshot CDAP." }, "branding": { "identity_title": "Identità del marchio", diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 69df67bc..ed496fa7 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -859,7 +859,8 @@ "view_all": "すべて表示", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "再試行" }, "time": { "just_now": "Just now", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "リモートデスクトップには大きな画面が必要です", "phone_unsupported_body": "タブレット、開いた折りたたみ端末、またはデスクトップをご利用ください。デバイス管理はコンソールで利用できます。", "phone_unsupported_back_devices": "デバイスに戻る", - "phone_unsupported_get_desktop": "RdClient desktop を取得" + "phone_unsupported_get_desktop": "RdClient desktop を取得", + "file_local": "ローカル PC", + "file_remote": "リモート PC", + "file_send": "送信", + "file_pick_folder": "フォルダを選択", + "file_queue_empty": "進行中の転送はありません", + "file_connecting": "ファイル転送セッションに接続中…", + "file_connect_failed": "ファイル転送セッションを開けませんでした", + "file_local_limited": "ローカルフォルダの完全な閲覧には HTTPS 上の Chrome または Edge が必要です。アップロードとダウンロードは引き続き利用できます。", + "file_transfer_unavailable_cdap": "CDAP スナップショットセッションではファイル転送は利用できません。" }, "branding": { "identity_title": "ブランドアイデンティティ", diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 10fcb367..dd34e53c 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -859,7 +859,8 @@ "view_all": "모두 보기", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "다시 시도" }, "time": { "just_now": "Just now", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "원격 데스크톱은 더 큰 화면이 필요합니다", "phone_unsupported_body": "태블릿, 펼친 폴더블 또는 데스크톱을 사용하세요. 장치 관리는 콘솔에서 계속 가능합니다.", "phone_unsupported_back_devices": "장치로 돌아가기", - "phone_unsupported_get_desktop": "RdClient desktop 받기" + "phone_unsupported_get_desktop": "RdClient desktop 받기", + "file_local": "로컬 컴퓨터", + "file_remote": "원격 컴퓨터", + "file_send": "보내기", + "file_pick_folder": "폴더 선택", + "file_queue_empty": "진행 중인 전송 없음", + "file_connecting": "파일 전송 세션 연결 중…", + "file_connect_failed": "파일 전송 세션을 열 수 없습니다", + "file_local_limited": "로컬 폴더 전체 탐색은 HTTPS에서 Chrome 또는 Edge가 필요합니다. 업로드와 다운로드는 계속 사용할 수 있습니다.", + "file_transfer_unavailable_cdap": "CDAP 스냅샷 세션에서는 파일 전송을 사용할 수 없습니다." }, "branding": { "identity_title": "브랜드 아이덴티티", diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index 91a2bca8..64edcdc0 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -865,7 +865,8 @@ "view_all": "Vis alle", "cdap_viewer": "CDAP Snapshot-visning", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Prøv igjen" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Fjernskrivebord krever større skjerm", "phone_unsupported_body": "Bruk nettbrett, utfoldet foldbar eller desktop. Enhetsstyring er fortsatt i konsollen.", "phone_unsupported_back_devices": "Tilbake til enheter", - "phone_unsupported_get_desktop": "Last ned RdClient desktop" + "phone_unsupported_get_desktop": "Last ned RdClient desktop", + "file_local": "Lokal datamaskin", + "file_remote": "Ekstern datamaskin", + "file_send": "Send", + "file_pick_folder": "Velg mappe", + "file_queue_empty": "Ingen overføringer pågår", + "file_connecting": "Kobler til filoverføringsøkt…", + "file_connect_failed": "Kunne ikke åpne filoverføringsøkt", + "file_local_limited": "Full lokal mappevisning krever Chrome eller Edge over HTTPS. Opplasting og nedlasting fungerer fortsatt.", + "file_transfer_unavailable_cdap": "Filoverføring er ikke tilgjengelig for CDAP-øyeblikksbildeøkter." }, "branding": { "identity_title": "Merkeidentitet", diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 9fb6102f..38ce61eb 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -859,7 +859,8 @@ "view_all": "Alles bekijken", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Toevoegen", - "remove": "Verwijderen" + "remove": "Verwijderen", + "retry": "Opnieuw" }, "time": { "just_now": "Zojuist", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "Remote desktop vereist een groter scherm", "phone_unsupported_body": "Gebruik tablet, opengeklapt foldable of desktop. Apparaatbeheer blijft in de console.", "phone_unsupported_back_devices": "Terug naar apparaten", - "phone_unsupported_get_desktop": "RdClient desktop downloaden" + "phone_unsupported_get_desktop": "RdClient desktop downloaden", + "file_local": "Lokale computer", + "file_remote": "Externe computer", + "file_send": "Verzenden", + "file_pick_folder": "Map kiezen", + "file_queue_empty": "Geen overdrachten bezig", + "file_connecting": "Bestandsoverdrachtsessie verbinden…", + "file_connect_failed": "Kon bestandsoverdrachtsessie niet openen", + "file_local_limited": "Volledig lokaal mapbladeren vereist Chrome of Edge via HTTPS. Uploaden en downloaden blijft mogelijk.", + "file_transfer_unavailable_cdap": "Bestandsoverdracht is niet beschikbaar voor CDAP-snapshot-sessies." }, "branding": { "identity_title": "Merkidentiteit", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 8bee7120..39f78479 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -872,7 +872,8 @@ "dismiss": "Ukryj", "view_all": "Zobacz wszystkie", "add": "Dodaj", - "remove": "Usuń" + "remove": "Usuń", + "retry": "Ponów" }, "time": { "just_now": "Przed chwilą", @@ -1145,7 +1146,16 @@ "phone_unsupported_title": "Pulpit zdalny wymaga większego ekranu", "phone_unsupported_body": "Użyj tabletu, rozłożonego folda lub komputera, aby zdalnie sterować urządzeniami. Zarządzanie urządzeniami nadal działa w konsoli.", "phone_unsupported_back_devices": "Wróć do urządzeń", - "phone_unsupported_get_desktop": "Pobierz RdClient desktop" + "phone_unsupported_get_desktop": "Pobierz RdClient desktop", + "file_local": "Komputer lokalny", + "file_remote": "Komputer zdalny", + "file_send": "Wyślij", + "file_pick_folder": "Wybierz folder", + "file_queue_empty": "Brak transferów w toku", + "file_connecting": "Łączenie sesji transferu plików…", + "file_connect_failed": "Nie udało się otworzyć sesji transferu plików", + "file_local_limited": "Pełne przeglądanie folderów lokalnych wymaga Chrome lub Edge przez HTTPS. Wysyłanie i pobieranie plików nadal działa.", + "file_transfer_unavailable_cdap": "Transfer plików jest niedostępny w sesjach podglądu CDAP." }, "branding": { "identity_title": "Identyfikacja marki", diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index dee58f8e..cfda076c 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -859,7 +859,8 @@ "view_all": "Ver tudo", "cdap_viewer": "Visualizador de snapshot CDAP", "add": "Adicionar", - "remove": "Remover" + "remove": "Remover", + "retry": "Tentar novamente" }, "time": { "just_now": "Agora mesmo", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "Área de trabalho remota requer ecrã maior", "phone_unsupported_body": "Use tablet, foldable aberto ou desktop. A gestão de dispositivos continua na consola.", "phone_unsupported_back_devices": "Voltar aos dispositivos", - "phone_unsupported_get_desktop": "Obter RdClient desktop" + "phone_unsupported_get_desktop": "Obter RdClient desktop", + "file_local": "Computador local", + "file_remote": "Computador remoto", + "file_send": "Enviar", + "file_pick_folder": "Escolher pasta", + "file_queue_empty": "Nenhuma transferência em andamento", + "file_connecting": "A conectar sessão de transferência de ficheiros…", + "file_connect_failed": "Não foi possível abrir a sessão de transferência de ficheiros", + "file_local_limited": "A navegação completa de pastas locais requer Chrome ou Edge com HTTPS. Enviar e transferir ficheiros continua disponível.", + "file_transfer_unavailable_cdap": "A transferência de ficheiros não está disponível em sessões de instantâneo CDAP." }, "branding": { "identity_title": "Identidade da marca", diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 581f1442..2e7d14ce 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -865,7 +865,8 @@ "view_all": "Vezi toate", "cdap_viewer": "Vizualizator Snapshot CDAP", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Reîncearcă" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Desktopul remote necesită un ecran mai mare", "phone_unsupported_body": "Folosiți tabletă, foldable desfăcut sau desktop. Gestionarea dispozitivelor rămâne în consolă.", "phone_unsupported_back_devices": "Înapoi la dispozitive", - "phone_unsupported_get_desktop": "Obține RdClient desktop" + "phone_unsupported_get_desktop": "Obține RdClient desktop", + "file_local": "Calculator local", + "file_remote": "Calculator la distanță", + "file_send": "Trimite", + "file_pick_folder": "Alege folder", + "file_queue_empty": "Nicio transfer în curs", + "file_connecting": "Se conectează sesiunea de transfer fișiere…", + "file_connect_failed": "Nu s-a putut deschide sesiunea de transfer fișiere", + "file_local_limited": "Navigarea completă a folderelor locale necesită Chrome sau Edge prin HTTPS. Încărcarea și descărcarea funcționează în continuare.", + "file_transfer_unavailable_cdap": "Transferul de fișiere nu este disponibil pentru sesiunile snapshot CDAP." }, "branding": { "identity_title": "Identitatea de marcă", diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index d6b866ab..c5d5b549 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -865,7 +865,8 @@ "view_all": "Visa alla", "cdap_viewer": "CDAP Snapshot-visning", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Försök igen" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Fjärrskrivbord kräver större skärm", "phone_unsupported_body": "Använd surfplatta, uppfälld foldable eller desktop. Enhetshantering finns kvar i konsolen.", "phone_unsupported_back_devices": "Tillbaka till enheter", - "phone_unsupported_get_desktop": "Hämta RdClient desktop" + "phone_unsupported_get_desktop": "Hämta RdClient desktop", + "file_local": "Lokal dator", + "file_remote": "Fjärrdator", + "file_send": "Skicka", + "file_pick_folder": "Välj mapp", + "file_queue_empty": "Inga pågående överföringar", + "file_connecting": "Ansluter filöverföringssession…", + "file_connect_failed": "Kunde inte öppna filöverföringssession", + "file_local_limited": "Fullständig lokal mappsökning kräver Chrome eller Edge över HTTPS. Uppladdning och nedladdning fungerar fortfarande.", + "file_transfer_unavailable_cdap": "Filöverföring är inte tillgänglig för CDAP-ögonblicksbildssessioner." }, "branding": { "identity_title": "Varumärkesidentitet", diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index cd2a7026..0a7e3ea5 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -865,7 +865,8 @@ "view_all": "ดูทั้งหมด", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "ลองอีกครั้ง" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "เดสก์ท็อpระยะไกลต้องใช้หน้าจอที่ใหญ่กว่า", "phone_unsupported_body": "ใช้แท็บเล็ต foldable ที่เปิด หรือเดสก์ท็อป จัดการอุปกรณ์ได้ใน console", "phone_unsupported_back_devices": "กลับไปยังอุปกรณ์", - "phone_unsupported_get_desktop": "รับ RdClient desktop" + "phone_unsupported_get_desktop": "รับ RdClient desktop", + "file_local": "คอมพิวเตอร์ในเครื่อง", + "file_remote": "คอมพิวเตอร์ระยะไกล", + "file_send": "ส่ง", + "file_pick_folder": "เลือกโฟลเดอร์", + "file_queue_empty": "ไม่มีการถ่ายโอนที่กำลังดำเนินการ", + "file_connecting": "กำลังเชื่อมต่อเซสชันการถ่ายโอนไฟล์…", + "file_connect_failed": "ไม่สามารถเปิดเซสชันการถ่ายโอนไฟล์ได้", + "file_local_limited": "การเรียกดูโฟลเดอร์ในเครื่องแบบเต็มต้องใช้ Chrome หรือ Edge ผ่าน HTTPS การอัปโหลดและดาวน์โหลดยังใช้งานได้", + "file_transfer_unavailable_cdap": "การถ่ายโอนไฟล์ไม่พร้อมใช้งานสำหรับเซสชันภาพรวม CDAP" }, "branding": { "identity_title": "เอกลักษณ์ของแบรนด์", diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index b9e20906..963794ec 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -865,7 +865,8 @@ "view_all": "Tümünü Görüntüle", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Yeniden dene" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Uzak masaüstü daha büyük ekran gerektirir", "phone_unsupported_body": "Tablet, açık katlanabilir veya masaüstü kullanın. Cihaz yönetimi konsolda devam eder.", "phone_unsupported_back_devices": "Cihazlara dön", - "phone_unsupported_get_desktop": "RdClient desktop indir" + "phone_unsupported_get_desktop": "RdClient desktop indir", + "file_local": "Yerel bilgisayar", + "file_remote": "Uzak bilgisayar", + "file_send": "Gönder", + "file_pick_folder": "Klasör seç", + "file_queue_empty": "Devam eden aktarım yok", + "file_connecting": "Dosya aktarım oturumu bağlanıyor…", + "file_connect_failed": "Dosya aktarım oturumu açılamadı", + "file_local_limited": "Tam yerel klasör gezintisi HTTPS üzerinden Chrome veya Edge gerektirir. Yükleme ve indirme hâlâ çalışır.", + "file_transfer_unavailable_cdap": "Dosya aktarımı CDAP anlık görüntü oturumlarında kullanılamaz." }, "branding": { "identity_title": "Marka Kimliği", diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index 40b86537..f5fff967 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -865,7 +865,8 @@ "view_all": "Переглянути всі", "cdap_viewer": "Переглядач знімків CDAP", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Повторити" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Віддалений робочий стіл потребує більшого екрана", "phone_unsupported_body": "Використовуйте планшет, розгорнутий fold або комп’ютер. Керування пристроями залишається в консолі.", "phone_unsupported_back_devices": "Назад до пристроїв", - "phone_unsupported_get_desktop": "Завантажити RdClient desktop" + "phone_unsupported_get_desktop": "Завантажити RdClient desktop", + "file_local": "Локальний комп'ютер", + "file_remote": "Віддалений комп'ютер", + "file_send": "Надіслати", + "file_pick_folder": "Обрати теку", + "file_queue_empty": "Немає активних передач", + "file_connecting": "Підключення сеансу передачі файлів…", + "file_connect_failed": "Не вдалося відкрити сеанс передачі файлів", + "file_local_limited": "Повний перегляд локальних тек потребує Chrome або Edge через HTTPS. Завантаження та вивантаження файлу все одно працюють.", + "file_transfer_unavailable_cdap": "Передача файлів недоступна для сеансів знімка CDAP." }, "branding": { "identity_title": "Ідентичність бренду", diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 29ce6e7b..e7ff7ed1 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -865,7 +865,8 @@ "view_all": "Xem tất cả", "cdap_viewer": "CDAP Snapshot Viewer", "add": "Add", - "remove": "Remove" + "remove": "Remove", + "retry": "Thử lại" }, "time": { "just_now": "Just now", @@ -1138,7 +1139,16 @@ "phone_unsupported_title": "Máy tính từ xa cần màn hình lớn hơn", "phone_unsupported_body": "Dùng máy tính bảng, foldable mở hoặc desktop. Quản lý thiết bị vẫn ở console.", "phone_unsupported_back_devices": "Quay lại thiết bị", - "phone_unsupported_get_desktop": "Tải RdClient desktop" + "phone_unsupported_get_desktop": "Tải RdClient desktop", + "file_local": "Máy tính cục bộ", + "file_remote": "Máy tính từ xa", + "file_send": "Gửi", + "file_pick_folder": "Chọn thư mục", + "file_queue_empty": "Không có truyền tải đang diễn ra", + "file_connecting": "Đang kết nối phiên truyền tệp…", + "file_connect_failed": "Không thể mở phiên truyền tệp", + "file_local_limited": "Duyệt thư mục cục bộ đầy đủ cần Chrome hoặc Edge qua HTTPS. Tải lên và tải xuống vẫn hoạt động.", + "file_transfer_unavailable_cdap": "Truyền tệp không khả dụng cho phiên ảnh chụp CDAP." }, "branding": { "identity_title": "Nhận diện thương hiệu", diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index 617e4e01..400c6b90 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -876,7 +876,8 @@ "dismiss": "忽略", "view_all": "查看全部", "add": "添加", - "remove": "移除" + "remove": "移除", + "retry": "重試" }, "time": { "just_now": "剛剛", @@ -1149,7 +1150,16 @@ "phone_unsupported_title": "遠端桌面需要更大的螢幕", "phone_unsupported_body": "請使用平板、展開的折疊裝置或桌面。仍可在主控台管理裝置。", "phone_unsupported_back_devices": "返回裝置", - "phone_unsupported_get_desktop": "取得 RdClient 桌面版" + "phone_unsupported_get_desktop": "取得 RdClient 桌面版", + "file_local": "本機電腦", + "file_remote": "遠端電腦", + "file_send": "傳送", + "file_pick_folder": "選擇資料夾", + "file_queue_empty": "沒有進行中的傳輸", + "file_connecting": "正在連線檔案傳輸工作階段…", + "file_connect_failed": "無法開啟檔案傳輸工作階段", + "file_local_limited": "完整的本機資料夾瀏覽需要 HTTPS 下的 Chrome 或 Edge。仍可上傳和下載檔案。", + "file_transfer_unavailable_cdap": "CDAP 快照工作階段不支援檔案傳輸。" }, "branding": { "identity_title": "品牌標識", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index d8bd3cdc..95d323aa 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -859,7 +859,8 @@ "view_all": "查看全部", "add": "添加", "remove": "移除", - "cdap_viewer": "CDAP Snapshot Viewer" + "cdap_viewer": "CDAP Snapshot Viewer", + "retry": "重试" }, "time": { "just_now": "刚刚", @@ -1132,7 +1133,16 @@ "phone_unsupported_title": "远程桌面需要更大的屏幕", "phone_unsupported_body": "请使用平板、展开的折叠屏或桌面设备。仍可在控制台管理设备。", "phone_unsupported_back_devices": "返回设备", - "phone_unsupported_get_desktop": "获取 RdClient 桌面版" + "phone_unsupported_get_desktop": "获取 RdClient 桌面版", + "file_local": "本地计算机", + "file_remote": "远程计算机", + "file_send": "发送", + "file_pick_folder": "选择文件夹", + "file_queue_empty": "没有进行中的传输", + "file_connecting": "正在连接文件传输会话…", + "file_connect_failed": "无法打开文件传输会话", + "file_local_limited": "完整的本地文件夹浏览需要 HTTPS 下的 Chrome 或 Edge。仍可上传和下载文件。", + "file_transfer_unavailable_cdap": "CDAP 快照会话不支持文件传输。" }, "branding": { "identity_title": "品牌标识", diff --git a/web-nodejs/public/css/remote.css b/web-nodejs/public/css/remote.css index 5822d7fd..de9c06f0 100644 --- a/web-nodejs/public/css/remote.css +++ b/web-nodejs/public/css/remote.css @@ -1259,20 +1259,336 @@ } } +/* ---- RdClient viewer shell (fullscreen target) ---- */ +.rd-viewer-shell { + position: relative; + width: 100%; + height: 100%; +} + +.rd-viewer-shell:fullscreen { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: #000; +} + +.rd-viewer-shell:fullscreen .session-tab-bar { + position: relative; + top: auto; + left: auto; + right: auto; + flex-shrink: 0; +} + +.rd-viewer-shell:fullscreen .viewer-container { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + flex: 1; + min-height: 0; +} + +.toolbar-streaming .toolbar-status, +.toolbar-streaming .toolbar-status-sep { + display: none !important; +} + +.toolbar-btn.disabled, +.toolbar-btn:disabled { + opacity: 0.35; + pointer-events: none; + cursor: not-allowed; +} + +/* ---- File Transfer Modal (RustDesk-style) ---- */ +.file-transfer-modal-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + padding: 24px; +} + +.file-transfer-modal { + width: min(1100px, 96vw); + height: min(560px, 88vh); + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-primary, #30363d); + border-radius: 12px; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.ft-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-primary, #30363d); + font-weight: 600; + font-size: 14px; +} + +.ft-modal-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ft-modal-close { + background: none; + border: none; + color: var(--text-secondary, #8b949e); + cursor: pointer; + padding: 4px; + border-radius: 6px; +} + +.ft-modal-close:hover { + color: var(--text-primary, #e6edf3); + background: rgba(255, 255, 255, 0.06); +} + +.ft-modal-body { + display: grid; + grid-template-columns: 1fr 1fr 220px; + flex: 1; + min-height: 0; + gap: 0; +} + +.ft-pane { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--border-primary, #30363d); +} + +.ft-pane-queue { + border-right: none; +} + +.ft-pane-head { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #8b949e); + border-bottom: 1px solid var(--border-primary, #30363d); +} + +.ft-pane-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid var(--border-primary, #30363d); + flex-wrap: wrap; +} + +.ft-path { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--text-secondary, #8b949e); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 4px; +} + +.ft-btn { + background: none; + border: none; + color: var(--text-secondary, #8b949e); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: inline-flex; + align-items: center; +} + +.ft-btn:hover { + color: var(--text-primary, #e6edf3); + background: rgba(255, 255, 255, 0.06); +} + +.ft-btn-action { + margin-left: auto; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + color: var(--accent, #3884ff); + white-space: nowrap; +} + +.ft-local-hint { + padding: 6px 12px; + font-size: 11px; + color: var(--text-secondary, #8b949e); + border-bottom: 1px solid var(--border-primary, #30363d); +} + +.ft-hint-text { + display: inline; +} + +.ft-list { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.ft-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.ft-row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.ft-row.selected { + background: rgba(56, 132, 255, 0.15); +} + +.ft-row-icon { + font-size: 18px !important; + color: var(--text-secondary, #8b949e); + flex-shrink: 0; +} + +.ft-row-dir .ft-row-icon { + color: #e3b341; +} + +.ft-row-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ft-row-size { + font-size: 11px; + color: var(--text-secondary, #8b949e); + flex-shrink: 0; +} + +.ft-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-secondary, #8b949e); + font-size: 12px; +} + +.ft-empty.ft-error { + color: var(--warning, #d29922); +} + +.ft-pane-remote.ft-drag-over { + outline: 2px dashed var(--accent, #3884ff); + outline-offset: -4px; +} + +.ft-queue-list { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 8px; +} + +.ft-queue-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-secondary, #8b949e); + font-size: 12px; + padding: 16px; + text-align: center; +} + +.ft-transfer-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + margin-bottom: 6px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + font-size: 11px; +} + +.ft-transfer-row.done .ft-transfer-pct { + color: #3fb950; +} + +.ft-transfer-row.error .ft-transfer-pct { + color: #f85149; +} + +.ft-transfer-info { + flex: 1; + min-width: 0; +} + +.ft-transfer-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.ft-transfer-bar { + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; + margin-bottom: 2px; +} + +.ft-transfer-fill { + height: 100%; + background: var(--accent, #3884ff); + width: 0; + transition: width 0.15s; +} + +@media (max-width: 900px) { + .ft-modal-body { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr auto; + } + + .ft-pane-queue { + grid-column: 1 / -1; + max-height: 120px; + border-top: 1px solid var(--border-primary, #30363d); + } +} + @media (max-width: 480px) { - .toolbar-right { - gap: 0; - } - - .toolbar-btn { - width: 32px; - height: 32px; - } - - .toolbar-btn .material-icons { - font-size: 18px; - } - .session-tab { max-width: 100px; } diff --git a/web-nodejs/public/js/rdclient/client.js b/web-nodejs/public/js/rdclient/client.js index 5d8c0586..cc3d1739 100644 --- a/web-nodejs/public/js/rdclient/client.js +++ b/web-nodejs/public/js/rdclient/client.js @@ -13,7 +13,7 @@ * client.disconnect(); */ -/* global RDConnection, RDProtocol, RDCrypto, RDVideo, RDAudio, RDRenderer, RDInput */ +/* global RDConnection, RDProtocol, RDCrypto, RDVideo, RDAudio, RDRenderer, RDInput, RDFileConnection */ // eslint-disable-next-line no-unused-vars class RDClient { @@ -40,10 +40,13 @@ class RDClient { this.audio = new RDAudio(); this.renderer = new RDRenderer(canvas); this.input = new RDInput(canvas, this.renderer, (msg) => this._sendPeerMessage(msg)); + this._fileConnection = null; + this._sessionPassword = ''; this.fileTransfer = new RDFileTransfer({ proto: this.proto, - sendMessage: (msg) => this._sendPeerMessage(msg), - emit: (event, ...args) => this._emit(event, ...args) + sendMessage: (msg) => this._sendFileTransferMessage(msg), + emit: (event, ...args) => this._emit(event, ...args), + ensureConnected: () => this.ensureFileConnection() }); // State @@ -247,6 +250,46 @@ class RDClient { } } + /** + * Open dedicated FILE_TRANSFER relay (lazy). Reuses desktop session password. + * @returns {Promise} + */ + async ensureFileConnection() { + if (typeof RDFileConnection !== 'function') { + throw new Error('File transfer module not loaded'); + } + if (!this.proto.loaded) await this.proto.load(); + if (!this._fileConnection) { + this._fileConnection = new RDFileConnection({ + deviceId: this.deviceId, + serverPubKey: this.opts.serverPubKey || '', + myName: this.opts.myName || 'BetterDesk Web', + proto: this.proto + }); + this._fileConnection.on('file_response', (resp) => { + this.fileTransfer.handleFileResponse(resp); + }); + this._fileConnection.on('2fa_required', () => this._emit('2fa_required')); + this._fileConnection.on('2fa_error', (err) => this._emit('2fa_error', err)); + this._fileConnection.on('login_error', (err) => this._emit('login_error', err)); + } + if (this._fileConnection.state === 'ready') return; + await this._fileConnection.connect(this._sessionPassword || ''); + } + + _sendFileTransferMessage(msgObj) { + if (this._fileConnection && this._fileConnection.state === 'ready') { + this._fileConnection.sendMessage(msgObj); + } + } + + disconnectFileConnection() { + if (this._fileConnection) { + this._fileConnection.disconnect(); + this._fileConnection = null; + } + } + /** * Authenticate with password * @param {string} password @@ -255,6 +298,7 @@ class RDClient { try { this._setState('authenticating'); this._emit('log', 'Authenticating...'); + this._sessionPassword = password != null ? String(password) : ''; // Hash the password const challenge = this._loginChallenge || ''; @@ -1329,6 +1373,8 @@ class RDClient { this.video.close(); this.audio.close(); this.fileTransfer.disable(); + this.disconnectFileConnection(); + this._sessionPassword = ''; this.conn.close(); this._codecFallbackDone = false; } diff --git a/web-nodejs/public/js/rdclient/file-connection.js b/web-nodejs/public/js/rdclient/file-connection.js new file mode 100644 index 00000000..2039ec05 --- /dev/null +++ b/web-nodejs/public/js/rdclient/file-connection.js @@ -0,0 +1,488 @@ +/** + * BetterDesk Web Remote — dedicated FILE_TRANSFER relay connection. + * RustDesk peers only handle FileAction on ConnType::FILE_TRANSFER sessions. + */ + +/* global RDConnection, RDProtocol, RDCrypto */ + +// eslint-disable-next-line no-unused-vars +class RDFileConnection { + /** + * @param {Object} opts + * @param {string} opts.deviceId + * @param {string} [opts.serverPubKey] + * @param {string} [opts.myName] + * @param {RDProtocol} [opts.proto] - shared loaded protocol instance + */ + constructor(opts = {}) { + if (!opts.deviceId) throw new Error('deviceId required'); + this.deviceId = opts.deviceId; + this.opts = opts; + this.conn = new RDConnection(); + this.proto = opts.proto || new RDProtocol(); + this.crypto = new RDCrypto(); + this._state = 'idle'; // idle | connecting | authenticating | ready | error | disconnected + this._listeners = {}; + this._rendezvousDecoder = null; + this._relayDecoder = null; + this._relayConfirmReceived = false; + this._keyExchangePending = false; + this._keyExchangeDone = false; + this._peerEncryptionConfirmed = false; + this._loginChallenge = ''; + this._loginSalt = ''; + this._connectPromise = null; + this._loginResolve = null; + this._loginReject = null; + } + + get state() { return this._state; } + + on(event, fn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + return this; + } + + off(event, fn) { + const arr = this._listeners[event]; + if (arr) this._listeners[event] = arr.filter(f => f !== fn); + return this; + } + + _emit(event, ...args) { + const arr = this._listeners[event]; + if (arr) arr.forEach(fn => { try { fn(...args); } catch (e) { console.error(e); } }); + } + + _setState(s) { + if (this._state !== s) { + this._state = s; + this._emit('state', s); + } + } + + _connType() { + return this.proto.enums.ConnType.values.FILE_TRANSFER; + } + + _randomUUID() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + if (window.crypto && window.crypto.getRandomValues) { + window.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + /** + * Open FILE_TRANSFER relay and authenticate (reuse desktop session password). + * @param {string} [password] + * @returns {Promise} + */ + connect(password) { + if (this._state === 'ready') return Promise.resolve(); + if (this._connectPromise) return this._connectPromise; + + this._connectPromise = this._doConnect(password || '').finally(() => { + this._connectPromise = null; + }); + return this._connectPromise; + } + + async _doConnect(password) { + try { + this._setState('connecting'); + this._emit('log', 'Opening file transfer session…'); + + if (!this.proto.loaded) await this.proto.load(); + + this._rendezvousDecoder = this.proto.createStreamDecoder(); + this._relayDecoder = this.proto.createStreamDecoder(); + this._relayConfirmReceived = false; + this._keyExchangePending = false; + this._keyExchangeDone = false; + this._peerEncryptionConfirmed = false; + + const ct = this._connType(); + await this.conn.connectRendezvous(); + + const punchData = this.proto.encodeRendezvous( + this.proto.buildPunchHoleRequest(this.deviceId, this.opts.serverPubKey, ct) + ); + this.conn.sendRendezvous(punchData); + + const rendezvousResponse = await this._waitForRendezvousResponse(); + if (rendezvousResponse.error) { + throw new Error(rendezvousResponse.error); + } + + let relayUUID = rendezvousResponse.uuid || ''; + let relayServer = rendezvousResponse.relayServer || ''; + + if (!relayUUID) { + relayUUID = this._randomUUID(); + const signalData = this.proto.encodeRendezvous( + this.proto.buildRequestRelay( + this.deviceId, relayUUID, relayServer, this.opts.serverPubKey, ct + ) + ); + this.conn.sendRendezvous(signalData); + const relayConfirm = await this._waitForSignalRelayResponse(); + if (relayConfirm.error) throw new Error(relayConfirm.error); + relayUUID = relayConfirm.uuid || relayUUID; + relayServer = relayConfirm.relayServer || relayServer; + } + + this.conn.closeRendezvous(); + await this.conn.connectRelay(); + + this.conn.on('relay:message', (data) => this._handleRelayData(data)); + this.conn.on('relay:close', () => { + if (this._state !== 'disconnected' && this._state !== 'error') { + this._setState('disconnected'); + this._emit('disconnected', 'File transfer relay closed'); + } + }); + + const relayData = this.proto.encodeRendezvous( + this.proto.buildRequestRelay( + this.deviceId, relayUUID, relayServer, this.opts.serverPubKey, ct + ) + ); + this.conn.sendRelay(relayData); + + await this._waitForLogin(password); + this._setState('ready'); + this._emit('ready'); + this._emit('log', 'File transfer session ready'); + } catch (err) { + this._setState('error'); + this._emit('error', err.message || String(err)); + this.disconnect(); + throw err; + } + } + + _waitForLogin(password) { + return new Promise((resolve, reject) => { + this._loginResolve = resolve; + this._loginReject = reject; + const timeout = setTimeout(() => { + reject(new Error('File transfer login timeout (30s)')); + }, 30000); + + const done = () => { + clearTimeout(timeout); + this._loginResolve = null; + this._loginReject = null; + }; + + const origResolve = resolve; + const origReject = reject; + this._loginResolve = () => { done(); origResolve(); }; + this._loginReject = (e) => { done(); origReject(e); }; + + // If password provided upfront, authenticate when hash arrives or immediately after signedId + this._pendingPassword = password; + }); + } + + async _authenticate(password) { + this._setState('authenticating'); + const hash = await this.crypto.hashPassword( + password, this._loginSalt || '', this._loginChallenge || '' + ); + const loginReq = this.proto.buildFileTransferLoginRequest(hash, { + username: this.deviceId, + myId: 'betterdesk-web-ft-' + Date.now().toString(36), + myName: this.opts.myName || 'BetterDesk Web', + showHidden: false + }); + this._sendPeerMessageRaw(loginReq); + } + + submit2FA(code) { + this._sendPeerMessageRaw(this.proto.buildAuth2FA(String(code || '').trim())); + } + + sendMessage(msgObj) { + this._sendPeerMessage(msgObj); + } + + _sendPeerMessage(msgObj) { + if (!this.proto.loaded || this._state !== 'ready') return; + let data = this.proto.serializeMessage(msgObj); + if (this.crypto.enabled) { + data = this.crypto.processOutgoing(data); + } + this.conn.sendRelay(this.proto.frameBytes(data)); + } + + disconnect() { + if (this._loginReject) { + this._loginReject(new Error('Disconnected')); + } + this.conn.close(); + this._setState('disconnected'); + } + + _waitForRendezvousResponse() { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.conn.off('rendezvous:message', handler); + reject(new Error('File transfer rendezvous timeout')); + }, 30000); + + const handler = (rawData) => { + const frames = this._rendezvousDecoder.feed(rawData); + for (const frame of frames) { + try { + const msg = this.proto.decodeRendezvous(frame); + if (msg.keyExchange || msg.hc) continue; + + if (msg.punchHoleResponse) { + clearTimeout(timeout); + this.conn.off('rendezvous:message', handler); + const resp = msg.punchHoleResponse; + const hasRelay = resp.relayServer && resp.relayServer.length > 0; + const hasSocket = resp.socketAddr && resp.socketAddr.length > 0; + if (hasRelay || hasSocket) { + resolve({ + relayServer: resp.relayServer || '', + uuid: resp.uuid || '', + pk: resp.pk || null + }); + } else { + const failureNames = { + 0: 'Device not found', + 2: 'Device offline', + 3: 'License mismatch', + 4: 'Too many connections' + }; + resolve({ + error: resp.otherFailure || failureNames[resp.failure] || 'Connection failed' + }); + } + return; + } + + if (msg.relayResponse) { + clearTimeout(timeout); + this.conn.off('rendezvous:message', handler); + const rr = msg.relayResponse; + if (rr.refuseReason && rr.refuseReason.length > 0) { + resolve({ error: 'Relay refused: ' + rr.refuseReason }); + } else { + resolve({ + relayServer: rr.relayServer || '', + uuid: rr.uuid || '', + pk: rr.pk || null + }); + } + return; + } + } catch (err) { + console.warn('[RDFileConnection] rendezvous decode:', err.message); + } + } + }; + this.conn.on('rendezvous:message', handler); + }); + } + + _waitForSignalRelayResponse() { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.conn.off('rendezvous:message', handler); + reject(new Error('File transfer RelayResponse timeout')); + }, 15000); + + const handler = (rawData) => { + const frames = this._rendezvousDecoder.feed(rawData); + for (const frame of frames) { + try { + const msg = this.proto.decodeRendezvous(frame); + if (msg.keyExchange || msg.hc) continue; + if (msg.relayResponse) { + clearTimeout(timeout); + this.conn.off('rendezvous:message', handler); + const rr = msg.relayResponse; + if (rr.refuseReason && rr.refuseReason.length > 0) { + resolve({ error: 'Relay refused: ' + rr.refuseReason }); + } else { + resolve({ + relayServer: rr.relayServer || '', + uuid: rr.uuid || '', + pk: rr.pk || null + }); + } + return; + } + } catch (err) { + console.warn('[RDFileConnection] signal relay decode:', err.message); + } + } + }; + this.conn.on('rendezvous:message', handler); + }); + } + + _handleRelayData(rawData) { + try { + const frames = this._relayDecoder.feed(rawData); + for (const frame of frames) { + this._handleRelayMessage(frame); + } + } catch (err) { + console.warn('[RDFileConnection] relay decode:', err.message); + } + } + + _handleRelayMessage(frameData) { + if (!this._relayConfirmReceived) { + this._relayConfirmReceived = true; + try { + const rdvMsg = this.proto.decodeRendezvous(frameData); + if (rdvMsg.relayResponse) return; + } catch (_e) { + // First frame is a peer Message, not relay confirmation + } + } + + let data = frameData; + + if (this._keyExchangePending && !this._keyExchangeDone) { + let isPlaintext = false; + try { + const probe = this.proto.decodeMessage(frameData); + const fields = Object.keys(probe).filter(k => probe[k] != null && k !== 'union'); + if (fields.length > 0) isPlaintext = true; + } catch (_e) { /* encrypted */ } + + if (isPlaintext) { + this._keyExchangePending = false; + this._keyExchangeDone = false; + } else { + this._completeKeyExchange(); + } + } + + if (this.crypto.secretKey && this._keyExchangeDone) { + const spec = this.crypto.tryDecrypt(new Uint8Array(data)); + if (spec) { + this.crypto.commitDecrypt(spec.seq); + data = spec.plaintext; + this._peerEncryptionConfirmed = true; + } else if (this._peerEncryptionConfirmed) { + return; + } + } + + const msg = this.proto.decodeMessage(data); + this._dispatchMessage(msg); + } + + _dispatchMessage(msg) { + if (msg.signedId) { + this._handleSignedId(msg.signedId); + return; + } + if (msg.hash) { + this._loginChallenge = msg.hash.challenge || ''; + this._loginSalt = msg.hash.salt || ''; + if (this._pendingPassword != null && this._pendingPassword !== undefined) { + this._authenticate(this._pendingPassword).catch((e) => { + if (this._loginReject) this._loginReject(e); + }); + } + return; + } + if (msg.loginResponse) { + this._handleLoginResponse(msg.loginResponse); + return; + } + if (msg.fileResponse) { + this._emit('file_response', msg.fileResponse); + return; + } + if (msg.peerInfo) { + // Some peers send peerInfo before login completes on FT sessions + return; + } + } + + _handleSignedId(signedId) { + const idBytes = signedId.id; + if (!idBytes || idBytes.length === 0) return; + + const parsed = this.crypto.parseSignedId( + new Uint8Array(idBytes), + this.proto.types.IdPk + ); + if (!parsed) return; + + const serverPubKey = this.opts.serverPubKey || ''; + if (serverPubKey && serverPubKey.length >= 64) { + RDCrypto.verifySignedId(parsed.signature, parsed.payload, serverPubKey); + } + + this.crypto.generateKeyPair(); + this.crypto.generateSymmetricKey(); + this.crypto.setPeerPublicKey(parsed.peerPk); + this._keyExchangePending = true; + this._keyExchangeDone = false; + + // Unattended / no-password peers may send loginResponse without Hash + if (this._pendingPassword === '' && this._loginResolve) { + this._authenticate('').catch((e) => { + if (this._loginReject) this._loginReject(e); + }); + } + } + + _completeKeyExchange() { + if (this._keyExchangeDone || !this.crypto.peerPk) return; + const keyMsg = this.crypto.createSymmetricKeyMsg(this.crypto.peerPk); + const pkMsg = this.proto.buildPublicKey(keyMsg.asymmetricValue, keyMsg.symmetricValue); + this._sendPeerMessageRaw(pkMsg); + this.crypto.enabled = true; + this.crypto._sendSeq = 0; + this.crypto._recvSeq = 0; + this._keyExchangePending = false; + this._keyExchangeDone = true; + } + + _sendPeerMessageRaw(msgObj) { + let data = this.proto.serializeMessage(msgObj); + if (this.crypto.enabled) data = this.crypto.processOutgoing(data); + this.conn.sendRelay(this.proto.frameBytes(data)); + } + + _handleLoginResponse(resp) { + if (resp.error && resp.error.length > 0) { + const errLower = (resp.error || '').toLowerCase(); + if (errLower.includes('wrong 2fa') || errLower.includes('invalid 2fa')) { + this._emit('2fa_error', resp.error); + return; + } + if (errLower.includes('2fa required') || errLower.includes('totp')) { + this._emit('2fa_required'); + return; + } + if (this._loginReject) this._loginReject(new Error(resp.error)); + this._emit('login_error', resp.error); + return; + } + if (this._loginResolve) this._loginResolve(); + } +} + +window.RDFileConnection = RDFileConnection; diff --git a/web-nodejs/public/js/rdclient/file-modal.js b/web-nodejs/public/js/rdclient/file-modal.js new file mode 100644 index 00000000..46333771 --- /dev/null +++ b/web-nodejs/public/js/rdclient/file-modal.js @@ -0,0 +1,400 @@ +/** + * RustDesk-style centered file transfer modal for Web Remote. + */ +(function () { + 'use strict'; + + function t(key, fallback) { + if (typeof window.t === 'function') { + var val = window.t(key); + if (val && val !== key) return val; + } + return fallback !== undefined ? fallback : key; + } + + function escapeHtml(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function formatSize(bytes) { + if (typeof RDFileTransfer !== 'undefined' && RDFileTransfer.formatSize) { + return RDFileTransfer.formatSize(bytes); + } + if (!bytes) return '0 B'; + var i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + ['B', 'KB', 'MB', 'GB'][i]; + } + + function FileTransferModal() { + this._el = null; + this._session = null; + this._local = new LocalFiles(); + this._localEntries = []; + this._remoteEntries = []; + this._selectedLocal = null; + this._selectedRemote = null; + this._transfers = new Map(); + this._wiredSessionId = null; + } + + FileTransferModal.prototype._ensureDom = function () { + if (this._el) return; + var el = document.createElement('div'); + el.className = 'file-transfer-modal-overlay'; + el.id = 'file-transfer-modal'; + el.style.display = 'none'; + el.innerHTML = + ''; + document.body.appendChild(el); + this._el = el; + el.querySelector('.ft-pane-local .ft-pane-label').textContent = t('remote.file_local', 'Local computer'); + el.querySelector('.ft-pane-remote .ft-pane-label').textContent = t('remote.file_remote', 'Remote computer'); + el.querySelector('.ft-pane-queue .ft-pane-label').textContent = t('remote.file_transfers', 'Transfers'); + el.querySelector('.ft-local-send').innerHTML = t('remote.file_send', 'Send') + ' chevron_right'; + el.querySelector('.ft-remote-download').innerHTML = 'chevron_left ' + t('remote.file_download', 'Download'); + el.querySelector('.ft-queue-empty span:last-child').textContent = t('remote.file_queue_empty', 'No transfers in progress'); + + var self = this; + el.querySelector('.ft-modal-close').addEventListener('click', function () { self.close(); }); + el.addEventListener('click', function (e) { if (e.target === el) self.close(); }); + el.querySelector('.ft-local-pick').addEventListener('click', function () { self._pickLocalFolder(); }); + el.querySelector('.ft-local-up').addEventListener('click', function () { self._localGoUp(); }); + el.querySelector('.ft-local-home').addEventListener('click', function () { self._localGoHome(); }); + el.querySelector('.ft-local-send').addEventListener('click', function () { self._sendSelectedLocal(); }); + el.querySelector('.ft-remote-up').addEventListener('click', function () { self._remoteGoUp(); }); + el.querySelector('.ft-remote-home').addEventListener('click', function () { self._remoteGoHome(); }); + el.querySelector('.ft-remote-download').addEventListener('click', function () { self._downloadSelectedRemote(); }); + el.querySelector('.ft-local-file-input').addEventListener('change', function (e) { + self._uploadFiles(e.target.files); + e.target.value = ''; + }); + var remotePane = el.querySelector('.ft-pane-remote'); + remotePane.addEventListener('dragover', function (e) { e.preventDefault(); remotePane.classList.add('ft-drag-over'); }); + remotePane.addEventListener('dragleave', function () { remotePane.classList.remove('ft-drag-over'); }); + remotePane.addEventListener('drop', function (e) { + e.preventDefault(); + remotePane.classList.remove('ft-drag-over'); + if (e.dataTransfer && e.dataTransfer.files.length) self._uploadFiles(e.dataTransfer.files); + }); + }; + + FileTransferModal.prototype.open = function (session) { + this._ensureDom(); + this._session = session; + if (!session.client || !session.client.fileTransfer) return; + this._el.querySelector('.ft-modal-title').textContent = + t('remote.file_transfer', 'File Transfer') + ' — ' + (session.deviceName || session.deviceId); + this._updateLocalHint(); + this._el.style.display = 'flex'; + document.getElementById('btn-file-transfer')?.classList.add('active'); + if (this._wiredSessionId !== session.deviceId) { + this._wireClient(session); + this._wiredSessionId = session.deviceId; + } + var ft = session.client.fileTransfer; + var self = this; + ft._saveDownload = function (fileName, blob) { + return self._local.saveDownload(fileName, blob); + }; + session.client.fileTransfer.browseDir(''); + }; + + FileTransferModal.prototype.close = function () { + if (this._el) this._el.style.display = 'none'; + document.getElementById('btn-file-transfer')?.classList.remove('active'); + if (this._session && this._session.client && this._session.client.fileTransfer) { + this._session.client.fileTransfer._saveDownload = null; + } + if (this._session && this._session.client && this._session.client.disconnectFileConnection) { + this._session.client.disconnectFileConnection(); + } + }; + + FileTransferModal.prototype.isOpen = function () { + return this._el && this._el.style.display !== 'none'; + }; + + FileTransferModal.prototype._wireClient = function (session) { + var self = this; + var client = session.client; + client.on('file_browsing', function () { self._renderRemoteLoading(); }); + client.on('file_connecting', function () { + self._renderRemoteLoading(t('remote.file_connecting', 'Connecting file transfer session…')); + }); + client.on('file_connect_error', function (data) { + self._renderRemoteError(data.error || t('remote.file_connect_failed', 'Could not open file transfer session')); + }); + client.on('file_dir', function (data) { + self._remoteEntries = data.entries || []; + self._selectedRemote = null; + self._el.querySelector('.ft-remote-path').textContent = data.path || '/'; + self._renderRemoteList(); + }); + client.on('file_browse_timeout', function () { + self._renderRemoteError(t('remote.file_timeout', 'Remote device did not respond.')); + }); + client.on('file_transfer_start', function (data) { self._addTransfer(data); }); + client.on('file_transfer_progress', function (data) { self._updateTransfer(data); }); + client.on('file_transfer_complete', function (data) { self._completeTransfer(data); }); + client.on('file_transfer_error', function (data) { self._errorTransfer(data); }); + client.on('file_transfer_cancelled', function (data) { self._removeTransfer(data.id); }); + }; + + FileTransferModal.prototype._updateLocalHint = function () { + var hint = this._el.querySelector('.ft-local-hint'); + if (LocalFiles.isSupported()) { + hint.innerHTML = ''; + hint.querySelector('.ft-local-pick-inline').addEventListener('click', () => this._pickLocalFolder()); + } else { + hint.innerHTML = '' + + escapeHtml(t('remote.file_local_limited', 'Full local folder browsing requires Chrome/Edge over HTTPS. Upload still works.')) + + ' '; + hint.querySelector('.ft-local-upload-fallback').addEventListener('click', () => { + this._el.querySelector('.ft-local-file-input').click(); + }); + } + }; + + FileTransferModal.prototype._pickLocalFolder = async function () { + try { + await this._local.pickRoot(); + this._el.querySelector('.ft-local-path').textContent = this._local.currentPath; + this._localEntries = await this._local.listCurrent(); + this._selectedLocal = null; + this._renderLocalList(); + this._el.querySelector('.ft-local-hint').innerHTML = ''; + } catch (e) { + if (e.name !== 'AbortError' && e.message !== 'unsupported') console.warn('[FileModal]', e); + } + }; + + FileTransferModal.prototype._localGoUp = async function () { + if (!this._local.hasRoot) return; + await this._local.goUp(); + this._el.querySelector('.ft-local-path').textContent = this._local.currentPath; + this._localEntries = await this._local.listCurrent(); + this._selectedLocal = null; + this._renderLocalList(); + }; + + FileTransferModal.prototype._localGoHome = async function () { + if (!this._local.hasRoot) return; + await this._local.goHome(); + this._el.querySelector('.ft-local-path').textContent = this._local.currentPath; + this._localEntries = await this._local.listCurrent(); + this._selectedLocal = null; + this._renderLocalList(); + }; + + FileTransferModal.prototype._renderLocalList = function () { + var list = this._el.querySelector('.ft-local-list'); + list.innerHTML = ''; + if (!this._localEntries.length) { + list.innerHTML = '
' + escapeHtml(t('remote.file_empty', 'No files')) + '
'; + return; + } + var self = this; + this._localEntries.forEach(function (entry) { + var row = document.createElement('div'); + row.className = 'ft-row' + (entry.isDir ? ' ft-row-dir' : ''); + if (self._selectedLocal === entry) row.classList.add('selected'); + row.innerHTML = '' + (entry.isDir ? 'folder' : 'insert_drive_file') + '' + + '' + escapeHtml(entry.name) + '' + + '' + (entry.isDir ? '' : formatSize(entry.size)) + ''; + row.addEventListener('click', function () { + if (entry.isDir) { + self._local.enterDir(entry).then(function () { + self._el.querySelector('.ft-local-path').textContent = self._local.currentPath; + return self._local.listCurrent(); + }).then(function (entries) { + self._localEntries = entries; + self._selectedLocal = null; + self._renderLocalList(); + }); + } else { + self._selectedLocal = entry; + self._renderLocalList(); + } + }); + row.addEventListener('dblclick', function () { + if (!entry.isDir) self._uploadLocalEntry(entry); + }); + list.appendChild(row); + }); + }; + + FileTransferModal.prototype._renderRemoteLoading = function (msg) { + this._el.querySelector('.ft-remote-list').innerHTML = + '
sync ' + + escapeHtml(msg || t('remote.file_loading', 'Loading…')) + '
'; + }; + + FileTransferModal.prototype._renderRemoteError = function (msg) { + this._el.querySelector('.ft-remote-list').innerHTML = + '
warning ' + escapeHtml(msg) + + '
'; + var self = this; + this._el.querySelector('.ft-retry')?.addEventListener('click', function () { + var ft = self._session.client?.fileTransfer; + if (ft) ft.browseDir(ft.currentPath || ''); + }); + }; + + FileTransferModal.prototype._renderRemoteList = function () { + var list = this._el.querySelector('.ft-remote-list'); + list.innerHTML = ''; + if (!this._remoteEntries.length) { + list.innerHTML = '
' + escapeHtml(t('remote.file_empty', 'No files')) + '
'; + return; + } + var self = this; + var ft = this._session.client.fileTransfer; + this._remoteEntries.forEach(function (entry) { + var row = document.createElement('div'); + row.className = 'ft-row' + (entry.isDir ? ' ft-row-dir' : ''); + if (self._selectedRemote === entry) row.classList.add('selected'); + var icon = typeof RDFileTransfer !== 'undefined' ? RDFileTransfer.getFileIcon(entry) : (entry.isDir ? 'folder' : 'insert_drive_file'); + row.innerHTML = '' + icon + '' + + '' + escapeHtml(entry.name) + '' + + '' + (entry.isDir ? '' : formatSize(entry.size)) + ''; + row.addEventListener('click', function () { + if (entry.isDir) { + var sep = (ft.currentPath || '').includes('\\') ? '\\' : '/'; + var base = ft.currentPath || ''; + var next = base ? (base.replace(/[\\/]+$/, '') + sep + entry.name) : entry.name; + ft.browseDir(next); + } else { + self._selectedRemote = entry; + self._renderRemoteList(); + } + }); + row.addEventListener('dblclick', function () { + if (!entry.isDir) self._downloadRemoteEntry(entry); + }); + list.appendChild(row); + }); + }; + + FileTransferModal.prototype._remoteGoUp = function () { + this._session.client?.fileTransfer?.browseParent(); + }; + + FileTransferModal.prototype._remoteGoHome = function () { + this._session.client?.fileTransfer?.browseDir(''); + }; + + FileTransferModal.prototype._sendSelectedLocal = function () { + if (this._selectedLocal && !this._selectedLocal.isDir) this._uploadLocalEntry(this._selectedLocal); + }; + + FileTransferModal.prototype._uploadLocalEntry = async function (entry) { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + var file = await this._local.readFile(entry); + if (file) ft.uploadFile(file, ft.currentPath || ''); + }; + + FileTransferModal.prototype._uploadFiles = function (fileList) { + var ft = this._session.client?.fileTransfer; + if (!ft || !fileList) return; + for (var i = 0; i < fileList.length; i++) ft.uploadFile(fileList[i], ft.currentPath || ''); + }; + + FileTransferModal.prototype._downloadSelectedRemote = function () { + if (this._selectedRemote && !this._selectedRemote.isDir) this._downloadRemoteEntry(this._selectedRemote); + }; + + FileTransferModal.prototype._downloadRemoteEntry = function (entry) { + var ft = this._session.client?.fileTransfer; + if (!ft) return; + ft.downloadFile(ft.currentPath || '', entry); + }; + + FileTransferModal.prototype._addTransfer = function (data) { + this._el.querySelector('.ft-queue-empty').style.display = 'none'; + var row = document.createElement('div'); + row.className = 'ft-transfer-row'; + row.id = 'ftm-' + data.id; + row.innerHTML = '' + (data.type === 'download' ? 'download' : 'upload') + '' + + '
' + escapeHtml(data.fileName) + '
' + + '
' + + '
0%
'; + this._el.querySelector('.ft-queue-list').appendChild(row); + this._transfers.set(data.id, row); + }; + + FileTransferModal.prototype._updateTransfer = function (data) { + var row = this._transfers.get(data.id); + if (!row) return; + row.querySelector('.ft-transfer-fill').style.width = (data.percent || 0) + '%'; + row.querySelector('.ft-transfer-pct').textContent = (data.percent || 0) + '%'; + }; + + FileTransferModal.prototype._completeTransfer = function (data) { + var row = this._transfers.get(data.id); + if (row) { + row.querySelector('.ft-transfer-pct').textContent = t('remote.file_complete', 'Complete'); + row.classList.add('done'); + } + }; + + FileTransferModal.prototype._errorTransfer = function (data) { + var row = this._transfers.get(data.id); + if (row) { + row.querySelector('.ft-transfer-pct').textContent = t('remote.file_error', 'Failed'); + row.classList.add('error'); + } + }; + + FileTransferModal.prototype._removeTransfer = function (id) { + var row = this._transfers.get(id); + if (row) row.remove(); + this._transfers.delete(id); + if (!this._el.querySelector('.ft-queue-list').children.length) { + this._el.querySelector('.ft-queue-empty').style.display = ''; + } + }; + + window.FileTransferModal = FileTransferModal; + window.__fileTransferModal = new FileTransferModal(); +})(); diff --git a/web-nodejs/public/js/rdclient/filetransfer.js b/web-nodejs/public/js/rdclient/filetransfer.js index f40ab857..1241ed11 100644 --- a/web-nodejs/public/js/rdclient/filetransfer.js +++ b/web-nodejs/public/js/rdclient/filetransfer.js @@ -18,11 +18,13 @@ class RDFileTransfer { * @param {RDProtocol} opts.proto - Protocol handler * @param {Function} opts.sendMessage - Function to send peer message: (msgObj) => void * @param {Function} opts.emit - Event emitter: (event, ...args) => void + * @param {Function} [opts.ensureConnected] - Async hook before browse/upload */ constructor(opts) { this._proto = opts.proto; this._sendMessage = opts.sendMessage; this._emit = opts.emit; + this._ensureConnected = opts.ensureConnected || null; /** @type {string} Current remote directory path */ this._currentPath = ''; @@ -84,20 +86,29 @@ class RDFileTransfer { return; } const dir = path != null ? path : ''; - console.log('[FileTransfer] browseDir:', JSON.stringify(dir)); - this._sendMessage(this._proto.buildReadDir(dir, this._showHidden)); - this._emit('file_browsing', { path: dir }); - - // Set timeout — if no file_dir response within 5s, emit timeout event - if (this._browseTimeout) clearTimeout(this._browseTimeout); - this._browseTimedOut = false; - this._browseTimeout = setTimeout(() => { - if (!this._browseTimedOut) { - this._browseTimedOut = true; - console.warn('[FileTransfer] browseDir timeout — no response from peer after 5s'); - this._emit('file_browse_timeout', { path: dir }); - } - }, 5000); + const self = this; + const run = function () { + console.log('[FileTransfer] browseDir:', JSON.stringify(dir)); + self._sendMessage(self._proto.buildReadDir(dir, self._showHidden)); + self._emit('file_browsing', { path: dir }); + if (self._browseTimeout) clearTimeout(self._browseTimeout); + self._browseTimedOut = false; + self._browseTimeout = setTimeout(function () { + if (!self._browseTimedOut) { + self._browseTimedOut = true; + console.warn('[FileTransfer] browseDir timeout — no response from peer after 5s'); + self._emit('file_browse_timeout', { path: dir }); + } + }, 5000); + }; + if (this._ensureConnected) { + this._emit('file_connecting'); + this._ensureConnected().then(run).catch(function (err) { + self._emit('file_connect_error', { error: err.message || String(err) }); + }); + } else { + run(); + } } /** @@ -523,6 +534,16 @@ class RDFileTransfer { try { const blob = new Blob(transfer.blocks, { type: 'application/octet-stream' }); + if (typeof this._saveDownload === 'function') { + Promise.resolve(this._saveDownload(transfer.fileName, blob)).catch((err) => { + this._emit('file_transfer_error', { + id: transfer.id, + fileName: transfer.fileName, + error: 'Failed to save file: ' + (err.message || 'unknown error') + }); + }); + return; + } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/web-nodejs/public/js/rdclient/local-files.js b/web-nodejs/public/js/rdclient/local-files.js new file mode 100644 index 00000000..e1c5c091 --- /dev/null +++ b/web-nodejs/public/js/rdclient/local-files.js @@ -0,0 +1,121 @@ +/** + * Local filesystem helpers for Web Remote file transfer modal. + * Uses File System Access API when available (Chrome/Edge + HTTPS). + */ +(function () { + 'use strict'; + + function isSupported() { + return typeof window.showDirectoryPicker === 'function' + && window.isSecureContext === true; + } + + function LocalFiles() { + this._rootHandle = null; + this._currentHandle = null; + this._pathStack = []; + this._currentPath = ''; + } + + LocalFiles.isSupported = isSupported; + + LocalFiles.prototype.get currentPath() { return this._currentPath; } + LocalFiles.prototype.get hasRoot() { return !!this._rootHandle; } + + LocalFiles.prototype.pickRoot = async function () { + if (!isSupported()) { + throw new Error('unsupported'); + } + this._rootHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); + this._currentHandle = this._rootHandle; + this._pathStack = []; + this._currentPath = this._rootHandle.name || ''; + return this._currentPath; + }; + + LocalFiles.prototype.listCurrent = async function () { + if (!this._currentHandle) return []; + const entries = []; + for await (const entry of this._currentHandle.values()) { + entries.push({ + name: entry.name, + isDir: entry.kind === 'directory', + size: 0, + modifiedTime: 0, + handle: entry + }); + } + entries.sort((a, b) => { + if (a.isDir && !b.isDir) return -1; + if (!a.isDir && b.isDir) return 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + for (const e of entries) { + if (!e.isDir && e.handle) { + try { + const file = await e.handle.getFile(); + e.size = file.size; + e.modifiedTime = file.lastModified ? Math.floor(file.lastModified / 1000) : 0; + } catch { /* ignore */ } + } + } + return entries; + }; + + LocalFiles.prototype.enterDir = async function (entry) { + if (!entry || !entry.isDir || !entry.handle) return; + this._pathStack.push({ handle: this._currentHandle, path: this._currentPath }); + this._currentHandle = entry.handle; + this._currentPath = this._currentPath + (this._currentPath.endsWith('/') ? '' : '/') + entry.name; + return this._currentPath; + }; + + LocalFiles.prototype.goUp = async function () { + if (!this._pathStack.length) return this._currentPath; + const prev = this._pathStack.pop(); + this._currentHandle = prev.handle; + this._currentPath = prev.path; + return this._currentPath; + }; + + LocalFiles.prototype.goHome = async function () { + if (!this._rootHandle) return ''; + this._currentHandle = this._rootHandle; + this._pathStack = []; + this._currentPath = this._rootHandle.name || ''; + return this._currentPath; + }; + + LocalFiles.prototype.readFile = async function (entry) { + if (!entry || entry.isDir || !entry.handle) return null; + return entry.handle.getFile(); + }; + + LocalFiles.prototype.saveDownload = async function (fileName, blob) { + if (this._currentHandle && isSupported()) { + try { + const fh = await this._currentHandle.getFileHandle(fileName, { create: true }); + const w = await fh.createWritable(); + await w.write(blob); + await w.close(); + return true; + } catch (e) { + console.warn('[LocalFiles] save to folder failed:', e); + } + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + URL.revokeObjectURL(url); + a.remove(); + }, 5000); + return false; + }; + + window.LocalFiles = LocalFiles; +})(); diff --git a/web-nodejs/public/js/rdclient/protocol.js b/web-nodejs/public/js/rdclient/protocol.js index b2c72bb2..9400e357 100644 --- a/web-nodejs/public/js/rdclient/protocol.js +++ b/web-nodejs/public/js/rdclient/protocol.js @@ -160,13 +160,14 @@ class RDProtocol { * @param {string} deviceId * @param {string} [serverKey] - Server public key (base64) for licence_key validation */ - buildPunchHoleRequest(deviceId, serverKey) { + buildPunchHoleRequest(deviceId, serverKey, connType) { + const ct = connType != null ? connType : this.enums.ConnType.values.DEFAULT_CONN; return { punchHoleRequest: { id: deviceId, natType: this.enums.NatType.values.SYMMETRIC, // Browser always NAT licenceKey: serverKey || '', - connType: this.enums.ConnType.values.DEFAULT_CONN, + connType: ct, token: '', version: 'BetterDesk-Web/1.0', forceRelay: true // Browser must use relay @@ -181,7 +182,8 @@ class RDProtocol { * @param {string} relayServer - Relay server address * @param {string} [serverKey] - Server public key for licence_key validation (hbbr checks this) */ - buildRequestRelay(deviceId, uuid, relayServer, serverKey) { + buildRequestRelay(deviceId, uuid, relayServer, serverKey, connType) { + const ct = connType != null ? connType : this.enums.ConnType.values.DEFAULT_CONN; return { requestRelay: { id: deviceId, @@ -189,7 +191,7 @@ class RDProtocol { relayServer: relayServer || '', licenceKey: serverKey || '', secure: false, - connType: this.enums.ConnType.values.DEFAULT_CONN, + connType: ct, token: '' } }; @@ -223,6 +225,29 @@ class RDProtocol { * @param {Uint8Array} passwordHash * @param {Object} opts */ + /** + * Build LoginRequest for a dedicated FILE_TRANSFER session (RustDesk file manager). + * @param {Uint8Array} passwordHash + * @param {Object} opts + */ + buildFileTransferLoginRequest(passwordHash, opts = {}) { + return { + loginRequest: { + username: opts.username || '', + password: passwordHash, + myId: opts.myId || 'web-client-ft', + myName: opts.myName || 'BetterDesk Web', + myPlatform: 'Web', + version: 'BetterDesk-Web/1.0', + sessionId: Date.now(), + fileTransfer: { + dir: opts.dir != null ? opts.dir : '', + showHidden: !!opts.showHidden + } + } + }; + } + buildLoginRequest(passwordHash, opts = {}) { // Dynamically detect codec support const hasWebCodecs = typeof VideoDecoder !== 'undefined'; diff --git a/web-nodejs/public/js/remote.js b/web-nodejs/public/js/remote.js index bac89ce7..743071f6 100644 --- a/web-nodejs/public/js/remote.js +++ b/web-nodejs/public/js/remote.js @@ -153,12 +153,6 @@ this.chatPanel = panel.querySelector('.session-chat-panel'); this.chatMessages = panel.querySelector('.session-chat-messages'); this.chatInput = panel.querySelector('.session-chat-input'); - this.filePanel = panel.querySelector('.session-file-panel'); - this.fileList = panel.querySelector('.session-file-list'); - this.filePathText = panel.querySelector('.session-file-path-text'); - this.fileTransfersPanel = panel.querySelector('.session-file-transfers'); - this.fileTransfersList = panel.querySelector('.session-file-transfers-list'); - this.fileUploadInput = panel.querySelector('.session-file-upload-input'); this.tfaOverlay = panel.querySelector('.session-2fa-overlay'); this.tfaInput = panel.querySelector('.session-2fa-input'); this.tfaError = panel.querySelector('.session-2fa-error'); @@ -177,6 +171,7 @@ // ---- DOM References (shared) ---- const viewerContainer = document.getElementById('viewer-container'); + const viewerShell = document.getElementById('rd-viewer-shell'); const toolbar = document.getElementById('viewer-toolbar'); const toolbarStatus = document.getElementById('toolbar-status'); const toolbarStats = document.getElementById('toolbar-stats'); @@ -419,7 +414,6 @@ } wireSessionEvents(session); wireSessionDomEvents(session); - wireFileTransferEvents(session); attachMobileTouch(session); } @@ -437,6 +431,10 @@ setActiveTab(deviceId); toolbarDeviceId.textContent = deviceId; + if (window.__fileTransferModal?.isOpen()) { + window.__fileTransferModal.close(); + } + // Sync toolbar state syncToolbarToSession(session); syncToolbarFromSession(session); @@ -467,6 +465,10 @@ } try { if (session.client) session.client.disconnect(); } catch { /* ignore */ } + if (window.__fileTransferModal?.isOpen() && + window.__fileTransferModal._session?.deviceId === deviceId) { + window.__fileTransferModal.close(); + } if (session.mediaRecorder && session.mediaRecorder.state === 'recording') { try { session.mediaRecorder.stop(); } catch { /* ignore */ } } @@ -631,14 +633,9 @@ if (isActive(session)) showAutoplayOverlay(session); }; } - if (window.__openFilePanelOnConnect && isActive(session) && session.filePanel) { + if (window.__openFilePanelOnConnect && isActive(session)) { window.__openFilePanelOnConnect = false; - session.filePanel.style.display = 'flex'; - const fileBtn = document.getElementById('btn-file-transfer'); - if (fileBtn) fileBtn.classList.add('active'); - if (session.client && session.client.fileTransfer) { - session.client.fileTransfer.browseDir(''); - } + window.__fileTransferModal?.open(session); } }); @@ -769,260 +766,6 @@ if (e.key === 'Enter') sendChat(session); e.stopPropagation(); }); - - // File transfer panel listeners - session.panel.querySelector('.session-file-btn-close') - ?.addEventListener('click', () => { - session.filePanel.style.display = 'none'; - document.getElementById('btn-file-transfer')?.classList.remove('active'); - }); - - session.panel.querySelector('.session-file-btn-up') - ?.addEventListener('click', () => { - if (session.client) session.client.fileTransfer.browseParent(); - }); - - session.panel.querySelector('.session-file-btn-home') - ?.addEventListener('click', () => { - if (session.client) session.client.fileTransfer.browseDir(''); - }); - - session.panel.querySelector('.session-file-btn-hidden') - ?.addEventListener('click', function () { - if (!session.client) return; - const ft = session.client.fileTransfer; - ft._showHidden = !ft._showHidden; - this.classList.toggle('active', ft._showHidden); - ft.browseDir(ft.currentPath); - }); - - session.panel.querySelector('.session-file-btn-newdir') - ?.addEventListener('click', () => { - if (!session.client) return; - const name = prompt(_('remote.file_new_folder_prompt') || 'Enter folder name:'); - if (name && name.trim()) { - const ft = session.client.fileTransfer; - const sep = ft.currentPath.includes('\\') ? '\\' : '/'; - ft.createDir(ft.currentPath + sep + name.trim()); - setTimeout(() => ft.browseDir(ft.currentPath), 500); - } - }); - - session.panel.querySelector('.session-file-btn-upload') - ?.addEventListener('click', () => { - session.fileUploadInput?.click(); - }); - - session.fileUploadInput?.addEventListener('change', (e) => { - if (!session.client || !e.target.files.length) return; - const ft = session.client.fileTransfer; - for (const file of e.target.files) { - ft.uploadFile(file, ft.currentPath); - } - e.target.value = ''; - }); - } - - /** - * Wire file transfer events from RDClient to UI - */ - function wireFileTransferEvents(session) { - const client = session.client; - if (!client) return; - - client.on('file_dir', (data) => { - renderFileList(session, data.path, data.entries); - }); - - client.on('file_browsing', () => { - // Show loading state - if (session.fileList) { - session.fileList.innerHTML = '
sync ' + - (_('remote.file_loading') || 'Loading...') + '
'; - } - }); - - client.on('file_browse_timeout', (data) => { - // Peer didn't respond — show timeout message with retry button - if (session.fileList) { - session.fileList.innerHTML = - '
' + - 'warning ' + - (_('remote.file_timeout') || 'Remote device did not respond. File transfer may not be supported or is disabled on the remote machine.') + - '
' + - '
'; - session.fileList.querySelector('.file-retry-btn')?.addEventListener('click', () => { - client.fileTransfer.browseDir(data.path); - }); - } - }); - - client.on('file_transfer_start', (data) => { - showTransferEntry(session, data); - }); - - client.on('file_transfer_progress', (data) => { - updateTransferProgress(session, data); - }); - - client.on('file_transfer_complete', (data) => { - completeTransferEntry(session, data); - }); - - client.on('file_transfer_error', (data) => { - errorTransferEntry(session, data); - }); - - client.on('file_transfer_cancelled', (data) => { - removeTransferEntry(session, data.id); - }); - } - - /** - * Render file list in file panel - */ - function renderFileList(session, path, entries) { - session.filePathText.textContent = path || '/'; - session.fileList.innerHTML = ''; - - if (!entries || entries.length === 0) { - session.fileList.innerHTML = '
' + (_('remote.file_empty') || 'No files') + '
'; - return; - } - - entries.forEach((entry) => { - const row = document.createElement('div'); - row.className = 'file-entry' + (entry.isDir ? ' file-entry-dir' : ''); - row.innerHTML = - '' + RDFileTransfer.getFileIcon(entry) + '' + - '' + escapeHtml(entry.name) + '' + - '' + (entry.isDir ? '' : RDFileTransfer.formatSize(entry.size)) + '' + - '' + RDFileTransfer.formatTime(entry.modifiedTime) + '' + - '
' + - (entry.isDir ? '' : '') + - '' + - '' + - '
'; - - // Double click / click on directory → navigate - if (entry.isDir) { - row.addEventListener('dblclick', () => { - const ft = session.client?.fileTransfer; - if (!ft) return; - const sep = path.includes('\\') ? '\\' : '/'; - ft.browseDir(path + sep + entry.name); - }); - } - - // Download button - row.querySelector('.file-btn-download')?.addEventListener('click', (e) => { - e.stopPropagation(); - session.client?.fileTransfer?.downloadFile(path, entry); - }); - - // Rename button - row.querySelector('.file-btn-rename')?.addEventListener('click', (e) => { - e.stopPropagation(); - const newName = prompt(_('remote.file_rename_prompt') || 'New name:', entry.name); - if (newName && newName.trim() && newName !== entry.name) { - const ft = session.client?.fileTransfer; - if (!ft) return; - const sep = path.includes('\\') ? '\\' : '/'; - ft.rename(path + sep + entry.name, newName.trim()); - setTimeout(() => ft.browseDir(ft.currentPath), 500); - } - }); - - // Delete button - row.querySelector('.file-btn-delete')?.addEventListener('click', (e) => { - e.stopPropagation(); - if (!confirm((_('remote.file_delete_confirm') || 'Delete') + ' "' + entry.name + '"?')) return; - const ft = session.client?.fileTransfer; - if (!ft) return; - const sep = path.includes('\\') ? '\\' : '/'; - const fullPath = path + sep + entry.name; - if (entry.isDir) { - ft.removeDir(fullPath, true); - } else { - ft.removeFile(fullPath); - } - setTimeout(() => ft.browseDir(ft.currentPath), 500); - }); - - session.fileList.appendChild(row); - }); - } - - function escapeHtml(text) { - const el = document.createElement('span'); - el.textContent = text; - return el.innerHTML; - } - - /** - * Show new transfer entry in transfers panel - */ - function showTransferEntry(session, data) { - session.fileTransfersPanel.style.display = 'block'; - const entry = document.createElement('div'); - entry.className = 'file-transfer-entry'; - entry.id = 'ft-' + data.id; - entry.innerHTML = - '' + (data.type === 'download' ? 'download' : 'upload') + '' + - '
' + - '
' + escapeHtml(data.fileName) + '
' + - '
' + - '
0%
' + - '
' + - ''; - - entry.querySelector('.file-transfer-cancel')?.addEventListener('click', () => { - session.client?.fileTransfer?.cancelTransfer(data.id); - }); - session.fileTransfersList.appendChild(entry); - } - - function updateTransferProgress(session, data) { - const entry = session.fileTransfersList.querySelector('#ft-' + data.id); - if (!entry) return; - const fill = entry.querySelector('.file-transfer-bar-fill'); - const status = entry.querySelector('.file-transfer-status'); - if (fill) fill.style.width = data.percent + '%'; - if (status) status.textContent = data.percent + '% — ' + RDFileTransfer.formatSize(data.transferred) + ' / ' + RDFileTransfer.formatSize(data.fileSize); - } - - function completeTransferEntry(session, data) { - const entry = session.fileTransfersList.querySelector('#ft-' + data.id); - if (!entry) return; - entry.classList.add('complete'); - const status = entry.querySelector('.file-transfer-status'); - if (status) status.textContent = (_('remote.file_complete') || 'Complete') + ' — ' + RDFileTransfer.formatSize(data.fileSize); - const cancel = entry.querySelector('.file-transfer-cancel'); - if (cancel) cancel.style.display = 'none'; - // Auto-remove after 5s - setTimeout(() => { - entry.remove(); - if (!session.fileTransfersList.children.length) { - session.fileTransfersPanel.style.display = 'none'; - } - }, 5000); - } - - function errorTransferEntry(session, data) { - const entry = session.fileTransfersList.querySelector('#ft-' + data.id); - if (!entry) return; - entry.classList.add('error'); - const status = entry.querySelector('.file-transfer-status'); - if (status) status.textContent = (_('remote.file_error') || 'Error') + ': ' + data.error; - const cancel = entry.querySelector('.file-transfer-cancel'); - if (cancel) cancel.style.display = 'none'; - } - - function removeTransferEntry(id) { - const el = document.querySelector('#ft-' + id); - if (el) el.remove(); } // ---- Session state helpers ---- @@ -1096,7 +839,17 @@ 'disconnected': _('remote.disconnected'), 'error': _('remote.error') }; - toolbarStatus.textContent = stateLabels[session.state] || session.state; + const isStreaming = session.state === 'streaming'; + toolbar.classList.toggle('toolbar-streaming', isStreaming); + if (toolbarStatus) { + if (isStreaming) { + toolbarStatus.style.display = 'none'; + } else { + toolbarStatus.style.display = ''; + toolbarStatus.textContent = stateLabels[session.state] || session.state; + } + } + document.querySelector('.toolbar-status-sep')?.style.setProperty('display', isStreaming ? 'none' : ''); if (session.lastStats) { updateStats(session.lastStats, session.latency); @@ -1141,7 +894,6 @@ if (stats.video) { const fps = stats.video.videoFps || 0; parts.push(fps + ' FPS'); - if (stats.video.frameCount !== undefined) parts.push(stats.video.frameCount + ' frames'); } if (stats.video && stats.video.displayWidth && stats.video.displayHeight) { parts.push(stats.video.displayWidth + 'x' + stats.video.displayHeight); @@ -1211,6 +963,27 @@ if (session && session.client) fn(session.client, session); } + function toggleViewerShellFullscreen() { + const target = viewerShell || viewerContainer; + if (!target) return; + if (!document.fullscreenElement) { + target.requestFullscreen().catch(function () { /* ignore */ }); + } else { + document.exitFullscreen().catch(function () { /* ignore */ }); + } + } + + function applyTransportCapabilities() { + const fileBtn = document.getElementById('btn-file-transfer'); + if (!fileBtn) return; + if (getTransportName() === 'cdap') { + fileBtn.disabled = true; + fileBtn.classList.add('disabled'); + fileBtn.title = t('remote.file_transfer_unavailable_cdap', + 'File transfer is not available for CDAP snapshot sessions.'); + } + } + // Disconnect document.getElementById('btn-disconnect')?.addEventListener('click', () => { withClient(c => c.disconnect()); @@ -1218,7 +991,7 @@ // Fullscreen document.getElementById('btn-fullscreen')?.addEventListener('click', () => { - withClient(c => c.toggleFullscreen(viewerContainer)); + toggleViewerShellFullscreen(); }); // Audio toggle @@ -1500,15 +1273,17 @@ if (!isOpen && session.chatInput) session.chatInput.focus(); }); - // File transfer toggle + // File transfer modal toggle document.getElementById('btn-file-transfer')?.addEventListener('click', function () { const session = getActiveSession(); - if (!session) return; - const isOpen = session.filePanel.style.display !== 'none'; - session.filePanel.style.display = isOpen ? 'none' : 'flex'; - this.classList.toggle('active', !isOpen); - if (!isOpen && session.client) { - session.client.fileTransfer.browseDir(''); + if (!session || !session.client?.fileTransfer) return; + if (getTransportName() === 'cdap') return; + const modal = window.__fileTransferModal; + if (!modal) return; + if (modal.isOpen()) { + modal.close(); + } else { + modal.open(session); } }); @@ -1585,7 +1360,7 @@ // ---- Compact handle: fullscreen ---- document.getElementById('btn-handle-fullscreen')?.addEventListener('click', (e) => { e.stopPropagation(); - withClient(c => c.toggleFullscreen(viewerContainer)); + toggleViewerShellFullscreen(); }); // ---- Compact handle: drag the floating toolbar horizontally ---- @@ -1685,8 +1460,13 @@ }, 100); }); - // Escape to show toolbar + // Escape to show toolbar; F11 toggles viewer shell fullscreen document.addEventListener('keydown', (e) => { + if (e.key === 'F11') { + e.preventDefault(); + toggleViewerShellFullscreen(); + return; + } if (e.key === 'Escape' && !document.fullscreenElement) showToolbar(); }); @@ -1757,19 +1537,6 @@ // ---- Initialize ---- - function populateViewerLanguageSelect() { - var sel = document.getElementById('viewer-language-select'); - if (!sel || sel.options.length > 0) return; - var langs = (window.BetterDesk && window.BetterDesk.availableLanguages) || []; - langs.forEach(function (lang) { - var opt = document.createElement('option'); - opt.value = lang.code; - opt.textContent = lang.native || lang.name || lang.code; - if (lang.code === (window.BetterDesk && window.BetterDesk.lang)) opt.selected = true; - sel.appendChild(opt); - }); - } - // ---- Mobile / tablet rdclient ---- const touchHandlers = new Map(); let mobileTouchMode = 'direct'; @@ -1848,7 +1615,7 @@ }); document.getElementById('rd-mob-fullscreen')?.addEventListener('click', function() { - withClient(function(c) { c.toggleFullscreen(viewerContainer); }); + toggleViewerShellFullscreen(); }); specialPanel?.querySelectorAll('.rd-special-key-btn').forEach(function(btn) { @@ -1903,7 +1670,7 @@ if (viewerInitialized) return; viewerInitialized = true; - populateViewerLanguageSelect(); + applyTransportCapabilities(); if (!mobileBindingsDone) { initMobileViewerBindings(); mobileBindingsDone = true; diff --git a/web-nodejs/tests/rdclient.protocol.test.js b/web-nodejs/tests/rdclient.protocol.test.js index 4549c738..54b66f62 100644 --- a/web-nodejs/tests/rdclient.protocol.test.js +++ b/web-nodejs/tests/rdclient.protocol.test.js @@ -1,6 +1,9 @@ /** * Regression: Auth2FA must serialize under auth_2fa (not auth2Fa). * Wrong field names produce empty Message payloads — peer never receives the code. + * + * FILE_TRANSFER: dedicated relay session uses ConnType.FILE_TRANSFER and + * LoginRequest.file_transfer union — required for RustDesk file manager (#217). */ const protobuf = require('protobufjs'); const path = require('path'); @@ -30,3 +33,64 @@ describe('RDClient Auth2FA protobuf encoding', () => { expect(buf.length).toBe(0); }); }); + +describe('RDClient FILE_TRANSFER protobuf encoding', () => { + let RendezvousMessage; + let Message; + let ConnType; + let LoginRequest; + + beforeAll(async () => { + const rendezvousRoot = await protobuf.load([ + path.join(__dirname, '../protos/rendezvous.proto') + ]); + RendezvousMessage = rendezvousRoot.lookupType('hbb.RendezvousMessage'); + ConnType = rendezvousRoot.lookupEnum('hbb.ConnType'); + + const messageRoot = await protobuf.load([ + path.join(__dirname, '../protos/message.proto') + ]); + Message = messageRoot.lookupType('hbb.Message'); + LoginRequest = messageRoot.lookupType('hbb.LoginRequest'); + }); + + it('encodes PunchHoleRequest with FILE_TRANSFER conn_type', () => { + const msg = RendezvousMessage.create({ + punchHoleRequest: { + id: '1234567', + natType: 0, + licenceKey: 'test-key', + connType: ConnType.values.FILE_TRANSFER, + forceRelay: true + } + }); + const buf = RendezvousMessage.encode(msg).finish(); + expect(buf.length).toBeGreaterThan(0); + + const decoded = RendezvousMessage.decode(buf).toJSON(); + expect(decoded.punchHoleRequest.connType).toBe('FILE_TRANSFER'); + }); + + it('encodes LoginRequest with fileTransfer union (not empty payload)', () => { + const login = LoginRequest.create({ + username: '', + password: Buffer.from([1, 2, 3, 4]), + myId: 'web-client-ft', + myName: 'BetterDesk Web', + fileTransfer: { dir: '', showHidden: false } + }); + const buf = LoginRequest.encode(login).finish(); + expect(buf.length).toBeGreaterThan(0); + + const decoded = LoginRequest.decode(buf).toJSON(); + expect(decoded.fileTransfer).toEqual({ dir: '', showHidden: false }); + }); + + it('regression: empty LoginRequest without fileTransfer union', () => { + const login = LoginRequest.create({ + password: Buffer.from([1, 2, 3, 4]) + }); + const decoded = LoginRequest.decode(LoginRequest.encode(login).finish()).toJSON(); + expect(decoded.fileTransfer).toBeUndefined(); + }); +}); diff --git a/web-nodejs/views/layouts/viewer.ejs b/web-nodejs/views/layouts/viewer.ejs index 4367ea63..4665909b 100644 --- a/web-nodejs/views/layouts/viewer.ejs +++ b/web-nodejs/views/layouts/viewer.ejs @@ -57,6 +57,9 @@ + + + diff --git a/web-nodejs/views/remote.ejs b/web-nodejs/views/remote.ejs index d7b76b3e..b0017385 100644 --- a/web-nodejs/views/remote.ejs +++ b/web-nodejs/views/remote.ejs @@ -18,6 +18,9 @@ + +
+
-
- +