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
This commit is contained in:
UNITRONIX
2026-06-24 19:54:20 +02:00
parent e4637f2fd0
commit 241f9ba2b9
38 changed files with 1901 additions and 426 deletions
+4 -1
View File
@@ -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`.
---
+12 -2
View File
@@ -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": "هوية العلامة التجارية",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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": "ब्रांड पहचान",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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": "ブランドアイデンティティ",
+12 -2
View File
@@ -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": "브랜드 아이덴티티",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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ă",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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": "เอกลักษณ์ของแบรนด์",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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": "Ідентичність бренду",
+12 -2
View File
@@ -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",
+12 -2
View File
@@ -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": "品牌標識",
+12 -2
View File
@@ -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": "品牌标识",
+329 -13
View File
@@ -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;
}
+49 -3
View File
@@ -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<void>}
*/
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;
}
@@ -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<void>}
*/
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;
+400
View File
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 =
'<div class="file-transfer-modal" role="dialog" aria-modal="true">' +
'<div class="ft-modal-header">' +
'<span class="material-icons">folder_open</span>' +
'<span class="ft-modal-title"></span>' +
'<button type="button" class="ft-modal-close"><span class="material-icons">close</span></button>' +
'</div>' +
'<div class="ft-modal-body">' +
'<div class="ft-pane ft-pane-local">' +
'<div class="ft-pane-head"><span class="material-icons">computer</span><span class="ft-pane-label"></span></div>' +
'<div class="ft-pane-toolbar">' +
'<button type="button" class="ft-btn ft-local-pick"><span class="material-icons">folder_open</span></button>' +
'<button type="button" class="ft-btn ft-local-up"><span class="material-icons">arrow_upward</span></button>' +
'<button type="button" class="ft-btn ft-local-home"><span class="material-icons">home</span></button>' +
'<span class="ft-path ft-local-path"></span>' +
'<button type="button" class="ft-btn ft-btn-action ft-local-send"></button>' +
'</div>' +
'<div class="ft-local-hint"></div>' +
'<div class="ft-list ft-local-list"></div>' +
'<input type="file" class="ft-local-file-input" multiple hidden />' +
'</div>' +
'<div class="ft-pane ft-pane-remote">' +
'<div class="ft-pane-head"><span class="material-icons">desktop_windows</span><span class="ft-pane-label"></span></div>' +
'<div class="ft-pane-toolbar">' +
'<button type="button" class="ft-btn ft-remote-up"><span class="material-icons">arrow_upward</span></button>' +
'<button type="button" class="ft-btn ft-remote-home"><span class="material-icons">home</span></button>' +
'<span class="ft-path ft-remote-path"></span>' +
'<button type="button" class="ft-btn ft-btn-action ft-remote-download"></button>' +
'</div>' +
'<div class="ft-list ft-remote-list"></div>' +
'</div>' +
'<div class="ft-pane ft-pane-queue">' +
'<div class="ft-pane-head"><span class="material-icons">swap_vert</span><span class="ft-pane-label"></span></div>' +
'<div class="ft-queue-list"></div>' +
'<div class="ft-queue-empty"><span class="material-icons">sync_alt</span><span></span></div>' +
'</div>' +
'</div>' +
'</div>';
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') + ' <span class="material-icons">chevron_right</span>';
el.querySelector('.ft-remote-download').innerHTML = '<span class="material-icons">chevron_left</span> ' + 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 = '<button type="button" class="btn btn-sm btn-secondary ft-local-pick-inline">' +
escapeHtml(t('remote.file_pick_folder', 'Choose folder')) + '</button>';
hint.querySelector('.ft-local-pick-inline').addEventListener('click', () => this._pickLocalFolder());
} else {
hint.innerHTML = '<span class="ft-hint-text">' +
escapeHtml(t('remote.file_local_limited', 'Full local folder browsing requires Chrome/Edge over HTTPS. Upload still works.')) +
'</span> <button type="button" class="btn btn-sm btn-secondary ft-local-upload-fallback">' +
escapeHtml(t('remote.file_upload', 'Upload')) + '</button>';
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 = '<div class="ft-empty">' + escapeHtml(t('remote.file_empty', 'No files')) + '</div>';
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 = '<span class="material-icons ft-row-icon">' + (entry.isDir ? 'folder' : 'insert_drive_file') + '</span>' +
'<span class="ft-row-name">' + escapeHtml(entry.name) + '</span>' +
'<span class="ft-row-size">' + (entry.isDir ? '' : formatSize(entry.size)) + '</span>';
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 =
'<div class="ft-empty"><span class="material-icons spinning">sync</span> ' +
escapeHtml(msg || t('remote.file_loading', 'Loading…')) + '</div>';
};
FileTransferModal.prototype._renderRemoteError = function (msg) {
this._el.querySelector('.ft-remote-list').innerHTML =
'<div class="ft-empty ft-error"><span class="material-icons">warning</span> ' + escapeHtml(msg) +
'<br><button type="button" class="btn btn-sm btn-primary ft-retry">' +
escapeHtml(t('actions.retry', 'Retry')) + '</button></div>';
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 = '<div class="ft-empty">' + escapeHtml(t('remote.file_empty', 'No files')) + '</div>';
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 = '<span class="material-icons ft-row-icon">' + icon + '</span>' +
'<span class="ft-row-name">' + escapeHtml(entry.name) + '</span>' +
'<span class="ft-row-size">' + (entry.isDir ? '' : formatSize(entry.size)) + '</span>';
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 = '<span class="material-icons">' + (data.type === 'download' ? 'download' : 'upload') + '</span>' +
'<div class="ft-transfer-info"><div class="ft-transfer-name">' + escapeHtml(data.fileName) + '</div>' +
'<div class="ft-transfer-bar"><div class="ft-transfer-fill"></div></div>' +
'<div class="ft-transfer-pct">0%</div></div>';
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();
})();
+35 -14
View File
@@ -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;
@@ -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;
})();
+29 -4
View File
@@ -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';
+62 -295
View File
@@ -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 = '<div class="file-empty"><span class="material-icons spinning">sync</span> ' +
(_('remote.file_loading') || 'Loading...') + '</div>';
}
});
client.on('file_browse_timeout', (data) => {
// Peer didn't respond — show timeout message with retry button
if (session.fileList) {
session.fileList.innerHTML =
'<div class="file-empty">' +
'<span class="material-icons" style="color:var(--warning)">warning</span> ' +
(_('remote.file_timeout') || 'Remote device did not respond. File transfer may not be supported or is disabled on the remote machine.') +
'<br><button class="btn btn-sm btn-primary file-retry-btn" style="margin-top:8px">' +
'<span class="material-icons" style="font-size:16px;vertical-align:middle">refresh</span> ' +
(_('actions.retry') || 'Retry') + '</button>' +
'</div>';
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 = '<div class="file-empty">' + (_('remote.file_empty') || 'No files') + '</div>';
return;
}
entries.forEach((entry) => {
const row = document.createElement('div');
row.className = 'file-entry' + (entry.isDir ? ' file-entry-dir' : '');
row.innerHTML =
'<span class="material-icons file-entry-icon">' + RDFileTransfer.getFileIcon(entry) + '</span>' +
'<span class="file-entry-name" title="' + escapeHtml(entry.name) + '">' + escapeHtml(entry.name) + '</span>' +
'<span class="file-entry-size">' + (entry.isDir ? '' : RDFileTransfer.formatSize(entry.size)) + '</span>' +
'<span class="file-entry-time">' + RDFileTransfer.formatTime(entry.modifiedTime) + '</span>' +
'<div class="file-entry-actions">' +
(entry.isDir ? '' : '<button class="file-btn-icon file-btn-download" title="' + (_('remote.file_download') || 'Download') + '"><span class="material-icons">download</span></button>') +
'<button class="file-btn-icon file-btn-rename" title="' + (_('remote.file_rename') || 'Rename') + '"><span class="material-icons">edit</span></button>' +
'<button class="file-btn-icon file-btn-delete" title="' + (_('actions.delete') || 'Delete') + '"><span class="material-icons">delete</span></button>' +
'</div>';
// 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 =
'<span class="material-icons file-transfer-icon">' + (data.type === 'download' ? 'download' : 'upload') + '</span>' +
'<div class="file-transfer-info">' +
'<div class="file-transfer-name">' + escapeHtml(data.fileName) + '</div>' +
'<div class="file-transfer-bar"><div class="file-transfer-bar-fill" style="width:0%"></div></div>' +
'<div class="file-transfer-status">0%</div>' +
'</div>' +
'<button class="file-btn-icon file-transfer-cancel" title="Cancel"><span class="material-icons">close</span></button>';
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;
@@ -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();
});
});
+3
View File
@@ -57,6 +57,9 @@
<script src="/js/rdclient/renderer.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/input.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/touch.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/file-connection.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/local-files.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/file-modal.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/filetransfer.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/client.js?v=<%= cacheVersion %>"></script>
<!-- CDAP transport adapter (RDClient-compatible surface for OS-agent devices) -->
+5 -44
View File
@@ -18,6 +18,9 @@
</div>
</div>
<!-- RdClient shell: tabs + viewer + toolbar (fullscreen target) -->
<div id="rd-viewer-shell" class="rd-viewer-shell">
<!-- Session Tab Bar -->
<div class="session-tab-bar" id="session-tab-bar">
<button type="button" class="tab-bar-back" id="btn-back-devices" title="${_('remote.back_to_devices')}">
@@ -39,7 +42,7 @@
<div class="toolbar-left">
<span class="toolbar-device-info">
<span class="toolbar-device-id" id="toolbar-device-id"></span>
<span class="toolbar-separator">|</span>
<span class="toolbar-separator toolbar-status-sep">|</span>
<span class="toolbar-status" id="toolbar-status">${_('remote.connecting')}</span>
</span>
</div>
@@ -47,7 +50,6 @@
<span class="toolbar-stats" id="toolbar-stats"></span>
</div>
<div class="toolbar-right">
<select id="viewer-language-select" class="toolbar-lang-select" data-language-select aria-label="${_('settings.language')}"></select>
<!-- Actions Menu -->
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-actions" title="${_('remote.actions')}">
@@ -208,7 +210,7 @@
</div>
</div>
<!-- Mobile toolbar (tablet / fold) -->
</div><!-- /#rd-viewer-shell -->
<nav class="rd-mobile-toolbar" id="rd-mobile-toolbar" aria-label="${_('remote.mobile_toolbar') || 'Remote controls'}">
<button type="button" class="rd-mobile-toolbar-btn active" id="rd-mob-input-touch" data-mode="direct" title="${_('remote.input_mode_touch')}">
<span class="material-icons">touch_app</span>
@@ -343,47 +345,6 @@
<button class="chat-send session-btn-chat-send"><span class="material-icons">send</span></button>
</div>
</div>
<!-- File Transfer Panel -->
<div class="file-panel session-file-panel" style="display:none;">
<div class="file-panel-header">
<span class="material-icons">folder_open</span>
<span>${_('remote.file_transfer') || 'File Transfer'}</span>
<div class="file-panel-actions">
<button class="file-btn-icon session-file-btn-hidden" title="${_('remote.file_show_hidden') || 'Show Hidden'}">
<span class="material-icons">visibility</span>
</button>
<button class="file-btn-icon session-file-btn-newdir" title="${_('remote.file_new_folder') || 'New Folder'}">
<span class="material-icons">create_new_folder</span>
</button>
<button class="file-btn-icon session-file-btn-upload" title="${_('remote.file_upload') || 'Upload'}">
<span class="material-icons">upload_file</span>
</button>
<button class="file-btn-icon session-file-btn-close">
<span class="material-icons">close</span>
</button>
</div>
</div>
<div class="file-panel-path">
<button class="file-btn-icon session-file-btn-up" title="${_('remote.file_parent') || 'Parent Directory'}">
<span class="material-icons">arrow_upward</span>
</button>
<button class="file-btn-icon session-file-btn-home" title="${_('remote.file_root') || 'Root'}">
<span class="material-icons">home</span>
</button>
<span class="file-path-text session-file-path-text">/</span>
</div>
<div class="file-panel-list session-file-list">
<div class="file-empty">${_('remote.file_empty') || 'No files'}</div>
</div>
<div class="file-panel-transfers session-file-transfers" style="display:none;">
<div class="file-transfers-header">
<span class="material-icons">swap_vert</span>
<span>${_('remote.file_transfers') || 'Transfers'}</span>
</div>
<div class="file-transfers-list session-file-transfers-list"></div>
</div>
<input type="file" class="session-file-upload-input" multiple style="display:none;" />
</div>
</div>
</template>