mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
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:
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": "品牌标识",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* RustDesk-style centered file transfer modal for Web Remote.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function t(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
var val = window.t(key);
|
||||
if (val && val !== key) return val;
|
||||
}
|
||||
return fallback !== undefined ? fallback : key;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (typeof RDFileTransfer !== 'undefined' && RDFileTransfer.formatSize) {
|
||||
return RDFileTransfer.formatSize(bytes);
|
||||
}
|
||||
if (!bytes) return '0 B';
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + ['B', 'KB', 'MB', 'GB'][i];
|
||||
}
|
||||
|
||||
function FileTransferModal() {
|
||||
this._el = null;
|
||||
this._session = null;
|
||||
this._local = new LocalFiles();
|
||||
this._localEntries = [];
|
||||
this._remoteEntries = [];
|
||||
this._selectedLocal = null;
|
||||
this._selectedRemote = null;
|
||||
this._transfers = new Map();
|
||||
this._wiredSessionId = null;
|
||||
}
|
||||
|
||||
FileTransferModal.prototype._ensureDom = function () {
|
||||
if (this._el) return;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'file-transfer-modal-overlay';
|
||||
el.id = 'file-transfer-modal';
|
||||
el.style.display = 'none';
|
||||
el.innerHTML =
|
||||
'<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();
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user