diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index ba1eff7d7..2b4ada7c7 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -164,6 +164,7 @@ const String kOptionPeerTabVisible = "peer-tab-visible"; const String kOptionPeerCardUiType = "peer-card-ui-type"; const String kOptionCurrentAbName = "current-ab-name"; const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs"; +const String kOptionEnablePortForwardMux = "enable-port-forward-mux"; const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render"; const String kOptionEnableCheckUpdate = "enable-check-update"; const String kOptionAllowAutoUpdate = "allow-auto-update"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index baa632892..b8dd80ba7 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -509,6 +509,15 @@ class _GeneralState extends State<_General> { kOptionOpenNewConnInTabs, isServer: false, ), + Tooltip( + message: translate('port-forward-mux-tip'), + child: _OptionCheckBox( + context, + 'Reuse one connection for port forwarding', + kOptionEnablePortForwardMux, + isServer: false, + ), + ), // though this is related to GUI, but opengl problem affects all users, so put in config rather than local if (isLinux) Tooltip( diff --git a/libs/hbb_common b/libs/hbb_common index 05ed68fed..f94e3fef6 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 05ed68fed8198cbb72edb64c7b4ae4d38c70c6ae +Subproject commit f94e3fef6c962814e71b7547848e06526b235062 diff --git a/src/client.rs b/src/client.rs index 5b059a7cc..0cd70189c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1753,6 +1753,14 @@ pub struct LoginConfigHandler { pub remember: bool, config: PeerConfig, pub port_forward: (String, i32), + /// This login's `multiplex`, filled with `port_forward` under the turn + /// lock. `port_forward_mux` says whether a mapping probes for the tunnel; + /// one the probe latched to the raw pipe logs in without asking, so an + /// upgraded peer keeps giving it the raw pipe. + pub(crate) port_forward_multiplex: bool, + /// Set once per window, before its mappings start: every accept's claim + /// reads it. + pub(crate) port_forward_mux: bool, /// Held by a port-forward mapping from filling `port_forward` and `hash` /// until its login is built from them; a window's mappings log in /// concurrently. @@ -2769,6 +2777,7 @@ impl LoginConfigHandler { ConnType::PORT_FORWARD | ConnType::RDP => lr.set_port_forward(PortForward { host: self.port_forward.0.clone(), port: self.port_forward.1, + multiplex: self.port_forward_multiplex, ..Default::default() }), ConnType::TERMINAL => { @@ -4066,6 +4075,26 @@ mod retry_tests { } } +#[cfg(test)] +mod port_forward_mux_tests { + use super::*; + + #[test] + fn a_login_asks_for_the_tunnel_when_its_mapping_probes() { + let mut lc = LoginConfigHandler::default(); + lc.conn_type = ConnType::PORT_FORWARD; + let asks = |lc: &LoginConfigHandler| { + lc.create_login_msg(String::new(), String::new(), vec![]) + .login_request() + .port_forward() + .multiplex + }; + assert!(!asks(&lc)); + lc.port_forward_multiplex = true; + assert!(asks(&lc)); + } +} + pub async fn hc_connection( feedback: i32, rendezvous_server: String, diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 8e358caec..47e93d5c5 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"), ("screenshot-action-tip", "إجراء لقطة الشاشة"), ("Save as", "حفظ باسم"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "تصدير"), + ("Export Logs", "تصدير السجلات"), + ("Import Folder", "استيراد مجلد"), ("Copy to clipboard", "نسخ إلى الحافظة"), ("Enable remote printer", "تمكين الطابعة عن بُعد"), ("Downloading {}", "جارٍ تنزيل {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "تفعيل"), + ("Reuse one connection for port forwarding", "إعادة استخدام اتصال واحد لإعادة توجيه المنافذ"), + ("port-forward-mux-tip", "تمرير جميع اتصالات إعادة توجيه المنافذ عبر اتصال واحد بالجهاز الآخر، بدلاً من الاتصال وتسجيل الدخول من جديد لكل اتصال."), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index e72c4027f..78cc5f455 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."), ("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."), ("Save as", "Захаваць у файл"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Экспартаваць"), + ("Export Logs", "Экспартаваць журналы"), + ("Import Folder", "Імпартаваць папку"), ("Copy to clipboard", "Скапіяваць у буфер абмену"), ("Enable remote printer", "Выкарыстоўваць аддалены прынтар"), ("Downloading {}", "Ідзе спампоўванне {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Уключыць"), + ("Reuse one connection for port forwarding", "Выкарыстоўваць адно злучэнне для перанакіравання партоў"), + ("port-forward-mux-tip", "Перадаваць усе злучэнні аднаго перанакіравання партоў праз адно злучэнне з аддаленай прыладай замест паўторнага падлучэння і ўваходу для кожнага з іх."), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 98ae557fe..effb91514 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."), ("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."), ("Save as", "Запазване като"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Изнасяне"), + ("Export Logs", "Изнасяне на дневниците"), + ("Import Folder", "Внасяне на папка"), ("Copy to clipboard", "Копиране в клипборда"), ("Enable remote printer", "Позволяване на отдалечен принтер"), ("Downloading {}", "Изтегляне на {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Активирай"), + ("Reuse one connection for port forwarding", "Използване на една връзка за пренасочване на портове"), + ("port-forward-mux-tip", "Всички връзки на едно пренасочване на портове минават през една връзка към отсрещния компютър, вместо да се свързвате и влизате отново за всяка от тях."), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index a0bc96b9f..9ac80dd8c 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."), ("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."), ("Save as", "Anomena i desa"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exporta"), + ("Export Logs", "Exporta els registres"), + ("Import Folder", "Importa una carpeta"), ("Copy to clipboard", "Copia al porta-retalls"), ("Enable remote printer", "Habilita l'impressora remota"), ("Downloading {}", "Descarregant {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Habilita"), + ("Reuse one connection for port forwarding", "Reutilitza una connexió per a la redirecció de ports"), + ("port-forward-mux-tip", "Fa passar totes les connexions d'una redirecció de ports per una única connexió amb l'altre equip, en lloc de connectar i iniciar la sessió de nou per a cadascuna."), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index bce02e3b0..9e2e3f991 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", "允许终端应用复制到剪贴板"), ("Enable", "启用"), + ("Reuse one connection for port forwarding", "端口转发复用同一条连接"), + ("port-forward-mux-tip", "同一条端口转发规则上的所有连接共用一条到对方的连接,而不是每条连接都重新连接并登录一次。"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index d3df00e95..5a7dabcb4 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."), ("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."), ("Save as", "Uložit jako"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportovat"), + ("Export Logs", "Exportovat protokoly"), + ("Import Folder", "Importovat složku"), ("Copy to clipboard", "Kopírovat do schránky"), ("Enable remote printer", "Povolit vzdálenou tiskárnu"), ("Downloading {}", "Stahuje se {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Povolit"), + ("Reuse one connection for port forwarding", "Znovu použít jedno připojení pro přesměrování portů"), + ("port-forward-mux-tip", "Vede všechna připojení jednoho přesměrování portů přes jediné připojení k protějšku místo opakovaného připojování a přihlašování pro každé z nich."), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index d8b50b9dd..1b96ead6b 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."), ("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."), ("Save as", "Gem som"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksportér"), + ("Export Logs", "Eksportér logfiler"), + ("Import Folder", "Importér mappe"), ("Copy to clipboard", "Kopiér til udklipsholder"), ("Enable remote printer", "Aktivér fjernprinter"), ("Downloading {}", "Downloader {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktivér"), + ("Reuse one connection for port forwarding", "Genbrug én forbindelse til portvideresendelse"), + ("port-forward-mux-tip", "Fører alle forbindelser i en portvideresendelse gennem én enkelt forbindelse til modparten i stedet for at forbinde og logge ind igen for hver enkelt."), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 4fde0cf8e..06d8eac45 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."), ("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."), ("Save as", "Speichern unter"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportieren"), + ("Export Logs", "Protokolle exportieren"), + ("Import Folder", "Ordner importieren"), ("Copy to clipboard", "In Zwischenablage kopieren"), ("Enable remote printer", "Entfernten Drucker aktivieren"), ("Downloading {}", "{} herunterladen"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktivieren"), + ("Reuse one connection for port forwarding", "Eine Verbindung für die Portweiterleitung wiederverwenden"), + ("port-forward-mux-tip", "Alle Verbindungen einer Portweiterleitung über eine einzige Verbindung zur Gegenstelle führen, statt sich für jede einzelne neu zu verbinden und anzumelden."), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index a2ff41c99..87a097898 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."), ("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."), ("Save as", "Αποθήκευση ως"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Εξαγωγή"), + ("Export Logs", "Εξαγωγή αρχείων καταγραφής"), + ("Import Folder", "Εισαγωγή φακέλου"), ("Copy to clipboard", "Αντιγραφή στο πρόχειρο"), ("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"), ("Downloading {}", "Γίνεται Λήψη {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Ενεργοποίηση"), + ("Reuse one connection for port forwarding", "Επαναχρησιμοποίηση μίας σύνδεσης για την προώθηση θυρών"), + ("port-forward-mux-tip", "Όλες οι συνδέσεις μιας προώθησης θυρών περνούν από μία μόνο σύνδεση προς τον απομακρυσμένο υπολογιστή, αντί να πραγματοποιείται νέα σύνδεση και ταυτοποίηση για κάθε μία."), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 6256bfc0d..7cf31d209 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -277,5 +277,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Your ip is blocked by the peer", "Your IP is blocked by the peer"), ("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."), ("terminal-clipboard-write-tip", "An app in the terminal wants to copy text to this device's clipboard. If granted, this permission applies to terminal apps in all connections until you turn it off in Settings. Manual copy and paste are unaffected."), + ("port-forward-mux-tip", "Carry every connection of a port-forward mapping over a single connection to the peer, instead of connecting and logging in again for each one."), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index ef7452257..dbd4e5d0c 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."), ("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."), ("Save as", "Konservi kiel"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksporti"), + ("Export Logs", "Eksporti protokolojn"), + ("Import Folder", "Importi dosierujon"), ("Copy to clipboard", "Kopii al la poŝo"), ("Enable remote printer", "Ebligi foran presilon"), ("Downloading {}", "Elŝutas {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Ebligi"), + ("Reuse one connection for port forwarding", "Reuzi unu konekton por pordo-plusendado"), + ("port-forward-mux-tip", "Ĉiuj konektoj de unu pordo-plusendado iras tra unu sola konekto al la alia komputilo, anstataŭ konekti kaj ensaluti denove por ĉiu el ili."), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index 86e10f0a2..d36ee2281 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."), ("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."), ("Save as", "Guardar como"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportar"), + ("Export Logs", "Exportar registros"), + ("Import Folder", "Importar carpeta"), ("Copy to clipboard", "Copiar al portapapeles"), ("Enable remote printer", "Habilitar impresora remota"), ("Downloading {}", "Descargando {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Habilitar"), + ("Reuse one connection for port forwarding", "Reutilizar una conexión para la redirección de puertos"), + ("port-forward-mux-tip", "Llevar todas las conexiones de una redirección de puertos por una única conexión con el otro equipo, en lugar de conectar e iniciar sesión de nuevo para cada una."), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index bb1c12cff..792457cb3 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."), ("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."), ("Save as", "Salvesta kui"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Ekspordi"), + ("Export Logs", "Ekspordi logid"), + ("Import Folder", "Impordi kaust"), ("Copy to clipboard", "Kopeeri lõikelauale"), ("Enable remote printer", "Luba kaugprinter"), ("Downloading {}", "Allalaadimine: {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Luba"), + ("Reuse one connection for port forwarding", "Kasuta pordi suunamiseks üht ühendust"), + ("port-forward-mux-tip", "Juhib ühe pordisuunamise kõik ühendused ühe teise arvutiga loodud ühenduse kaudu, selle asemel et iga ühenduse jaoks uuesti ühenduda ja sisse logida."), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index cbf98dc01..322f2822d 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."), ("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."), ("Save as", "Gorde honela"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Esportatu"), + ("Export Logs", "Esportatu erregistroak"), + ("Import Folder", "Inportatu karpeta"), ("Copy to clipboard", "Kopiatu arbelera"), ("Enable remote printer", "Gaitu urruneko inprimagailua"), ("Downloading {}", "{} deskargatzen"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Gaitu"), + ("Reuse one connection for port forwarding", "Berrerabili konexio bakarra portuen birbideratzerako"), + ("port-forward-mux-tip", "Portu-birbideratze baten konexio guztiak beste ordenagailurako konexio bakar batetik eramaten ditu, bakoitzerako berriro konektatu eta saioa hasi beharrean."), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index a2682c0df..5c46c5de3 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."), ("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."), ("Save as", "ذخیره به عنوان"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "خروجی گرفتن"), + ("Export Logs", "خروجی گرفتن از گزارش‌ها"), + ("Import Folder", "درون‌ریزی پوشه"), ("Copy to clipboard", "در کلیپ بورد کپی کنید"), ("Enable remote printer", "چاپگر از راه دور را فعال کنید"), ("Downloading {}", "بارگیری {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "فعال‌سازی"), + ("Reuse one connection for port forwarding", "استفاده مجدد از یک اتصال برای هدایت پورت"), + ("port-forward-mux-tip", "همه اتصال‌های یک هدایت پورت از یک اتصال واحد به دستگاه مقابل عبور می‌کنند، به‌جای اتصال و ورود دوباره برای هر کدام."), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 150f9d38c..96616fad3 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"), ("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"), ("Save as", "Tallenna nimellä"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Vie"), + ("Export Logs", "Vie lokit"), + ("Import Folder", "Tuo kansio"), ("Copy to clipboard", "Kopioi leikepöydälle"), ("Enable remote printer", "Ota etätulostin käyttöön"), ("Downloading {}", "Ladataan {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Ota käyttöön"), + ("Reuse one connection for port forwarding", "Käytä yhtä yhteyttä portin edelleenohjaukseen"), + ("port-forward-mux-tip", "Välittää kaikki yhden portin edelleenohjauksen yhteydet yhden vastapuoleen avatun yhteyden kautta sen sijaan, että jokaista varten muodostettaisiin yhteys ja kirjauduttaisiin uudelleen."), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 19d9ee6e3..55872b320 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."), ("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."), ("Save as", "Enregistrer sous"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exporter"), + ("Export Logs", "Exporter les journaux"), + ("Import Folder", "Importer un dossier"), ("Copy to clipboard", "Copier dans le presse-papier"), ("Enable remote printer", "Activer l’impression à distance"), ("Downloading {}", "Téléchargement de {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Activer"), + ("Reuse one connection for port forwarding", "Réutiliser une seule connexion pour la redirection de ports"), + ("port-forward-mux-tip", "Faire passer toutes les connexions d'une redirection de ports par une seule connexion vers le pair, au lieu de se connecter et de s'authentifier à nouveau pour chacune."), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index e3ca18da4..4f0e77606 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."), ("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."), ("Save as", "შენახვა როგორც"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "ექსპორტი"), + ("Export Logs", "ჟურნალების ექსპორტი"), + ("Import Folder", "საქაღალდის იმპორტი"), ("Copy to clipboard", "ბუფერში კოპირება"), ("Enable remote printer", "დისტანციური პრინტერის ჩართვა"), ("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "ჩართვა"), + ("Reuse one connection for port forwarding", "პორტის გადამისამართებისთვის ერთი კავშირის ხელახლა გამოყენება"), + ("port-forward-mux-tip", "ერთი პორტის გადამისამართების ყველა კავშირი გადის მეორე კომპიუტერთან დამყარებული ერთი კავშირით, ნაცვლად იმისა, რომ თითოეულისთვის თავიდან დაუკავშირდეს და შევიდეს სისტემაში."), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 434a3bf01..6f9282bb9 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."), ("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"), ("Save as", "તરીકે સાચવો"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "એક્સપોર્ટ કરો"), + ("Export Logs", "લોગ એક્સપોર્ટ કરો"), + ("Import Folder", "ફોલ્ડર ઇમ્પોર્ટ કરો"), ("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"), ("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"), ("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "સક્ષમ કરો"), + ("Reuse one connection for port forwarding", "પોર્ટ ફોરવર્ડિંગ માટે એક જ કનેક્શન ફરી વાપરો"), + ("port-forward-mux-tip", "એક પોર્ટ ફોરવર્ડિંગનાં બધાં કનેક્શન સામેના કમ્પ્યુટર સાથેના એક જ કનેક્શન મારફતે જાય છે, દરેક માટે ફરીથી કનેક્ટ અને લોગિન કરવાને બદલે."), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 8fb61c405..a69b49c82 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"), ("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"), ("Save as", "שמור בשם"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "ייצוא"), + ("Export Logs", "ייצוא יומנים"), + ("Import Folder", "ייבוא תיקייה"), ("Copy to clipboard", "העתק ללוח"), ("Enable remote printer", "אפשר מדפסת מרוחקת"), ("Downloading {}", "מוריד את {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "הפעל"), + ("Reuse one connection for port forwarding", "שימוש חוזר בחיבור אחד להעברת פורטים"), + ("port-forward-mux-tip", "כל החיבורים של העברת פורטים אחת עוברים דרך חיבור יחיד למחשב המרוחק, במקום ליצור חיבור חדש ולהיכנס מחדש עבור כל אחד מהם."), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 5961bc649..cd7f46b0b 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"), ("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"), ("Save as", "इस रूप में सहेजें"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "एक्सपोर्ट करें"), + ("Export Logs", "लॉग एक्सपोर्ट करें"), + ("Import Folder", "फ़ोल्डर इंपोर्ट करें"), ("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"), ("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"), ("Downloading {}", "{} डाउनलोड हो रहा है"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "सक्षम करें"), + ("Reuse one connection for port forwarding", "पोर्ट फ़ॉरवर्डिंग के लिए एक ही कनेक्शन दोबारा उपयोग करें"), + ("port-forward-mux-tip", "एक पोर्ट फ़ॉरवर्डिंग के सभी कनेक्शन दूसरे कंप्यूटर से बने एक ही कनेक्शन से होकर जाते हैं, हर एक के लिए दोबारा कनेक्ट और लॉगिन करने के बजाय।"), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index ffd58a08e..a1118a1c3 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."), ("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."), ("Save as", "Spremi kao"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Izvoz"), + ("Export Logs", "Izvoz zapisnika"), + ("Import Folder", "Uvoz mape"), ("Copy to clipboard", "Kopiraj u međuspremnik"), ("Enable remote printer", "Omogući udaljeni pisač"), ("Downloading {}", "Preuzimanje {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Omogući"), + ("Reuse one connection for port forwarding", "Ponovno koristi jednu vezu za prosljeđivanje portova"), + ("port-forward-mux-tip", "Sve veze jednog prosljeđivanja portova idu kroz jednu vezu prema drugoj strani, umjesto ponovnog povezivanja i prijave za svaku od njih."), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 0622a4248..1fff7804d 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"), ("screenshot-action-tip", "Képernyőkép-művelet"), ("Save as", "Mentés másként"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportálás"), + ("Export Logs", "Naplók exportálása"), + ("Import Folder", "Mappa importálása"), ("Copy to clipboard", "Másolás a vágólapra"), ("Enable remote printer", "Távoli nyomtatók engedélyezése"), ("Downloading {}", "{} letöltése"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Engedélyezés"), + ("Reuse one connection for port forwarding", "Egyetlen kapcsolat újrafelhasználása a portátirányításhoz"), + ("port-forward-mux-tip", "Egy portátirányítás összes kapcsolatát egyetlen, a másik géppel létesített kapcsolaton vezeti át, ahelyett hogy mindegyikhez újra csatlakozna és bejelentkezne."), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 9e637b065..e45b87472 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."), ("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."), ("Save as", "Simpan sebagai"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Ekspor"), + ("Export Logs", "Ekspor Log"), + ("Import Folder", "Impor Folder"), ("Copy to clipboard", "Salin ke papan klip"), ("Enable remote printer", "Aktifkan printer jarak jauh"), ("Downloading {}", "Mendownload {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktifkan"), + ("Reuse one connection for port forwarding", "Gunakan ulang satu koneksi untuk penerusan port"), + ("port-forward-mux-tip", "Menyalurkan semua koneksi dari satu penerusan port melalui satu koneksi ke perangkat lain, alih-alih menyambung dan masuk lagi untuk setiap koneksi."), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 18c44ce76..a0017ce06 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."), ("screenshot-action-tip", "Seleziona come continuare con la schermata."), ("Save as", "Salva come"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Esporta"), + ("Export Logs", "Esporta i log"), + ("Import Folder", "Importa cartella"), ("Copy to clipboard", "Copia negli appunti"), ("Enable remote printer", "Abilita stampante remota"), ("Downloading {}", "Download {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Abilita"), + ("Reuse one connection for port forwarding", "Riutilizza una sola connessione per l'inoltro delle porte"), + ("port-forward-mux-tip", "Fa passare tutte le connessioni di un inoltro di porte su un'unica connessione verso il dispositivo remoto, invece di connettersi e autenticarsi di nuovo per ognuna."), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index d63a975c2..0d41f3f85 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"), ("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"), ("Save as", "保存先"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "エクスポート"), + ("Export Logs", "ログをエクスポート"), + ("Import Folder", "フォルダをインポート"), ("Copy to clipboard", "クリップボードにコピー"), ("Enable remote printer", "リモートプリンターを有効化する"), ("Downloading {}", "{} をダウンロード中"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "有効にする"), + ("Reuse one connection for port forwarding", "ポート転送で 1 つの接続を再利用する"), + ("port-forward-mux-tip", "1 つのポート転送のすべての接続を、相手への 1 本の接続にまとめます。接続ごとに接続とログインをやり直しません。"), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index d83c16c15..98b76cc6f 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -378,7 +378,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Share", "화면 공유"), ("ubuntu-21-04-required", "Wayland는 Ubuntu 21.04 이상 버전이 필요합니다."), ("wayland-requires-higher-linux-version", "Wayland는 상위 버전의 Linux 배포판이 필요합니다. X11 데스크탑을 사용하거나 OS를 변경하세요."), - ("xdp-portal-unavailable", ""), + ("xdp-portal-unavailable", "Wayland 화면 캡처에 실패했습니다. XDG Desktop Portal이 중단되었거나 사용할 수 없습니다. `systemctl --user restart xdg-desktop-portal` 명령으로 다시 시작해 보세요."), ("JumpLink", "점프 링크"), ("Please Select the screen to be shared(Operate on the peer side).", "공유할 화면을 선택하세요 (피어 측에서 작동)"), ("Show RustDesk", "RustDesk 표시"), @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."), ("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."), ("Save as", "다른 이름으로 저장"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "내보내기"), + ("Export Logs", "로그 내보내기"), + ("Import Folder", "폴더 가져오기"), ("Copy to clipboard", "클립보드에 복사"), ("Enable remote printer", "원격 프린터 허용"), ("Downloading {}", "{} 다운로드 중"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "활성화"), + ("Reuse one connection for port forwarding", "포트 포워딩에 연결 하나를 재사용"), + ("port-forward-mux-tip", "포트 포워딩 하나의 모든 연결을 상대방과의 단일 연결로 전달합니다. 연결마다 다시 접속하고 로그인하지 않습니다."), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index b7e55f44c..1ca770788 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."), ("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."), ("Save as", "Басқаша сақтау"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Экспорттау"), + ("Export Logs", "Журналдарды экспорттау"), + ("Import Folder", "Қалтаны импорттау"), ("Copy to clipboard", "Көшіру-тақтаға көшіру"), ("Enable remote printer", "Қашықтағы принтерді іске қосу"), ("Downloading {}", "{} жүктелуде"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Қосу"), + ("Reuse one connection for port forwarding", "Порт бағыттау үшін бір қосылымды қайта пайдалану"), + ("port-forward-mux-tip", "Бір порт бағыттаудың барлық қосылымдары әрқайсысы үшін қайта қосылып кірудің орнына қарсы құрылғымен орнатылған бір қосылым арқылы өтеді."), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 6a94e5125..1f3e0c83e 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."), ("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."), ("Save as", "Įrašyti kaip"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksportuoti"), + ("Export Logs", "Eksportuoti žurnalus"), + ("Import Folder", "Importuoti aplanką"), ("Copy to clipboard", "Kopijuoti į iškarpinę"), ("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"), ("Downloading {}", "Atsisiunčiama {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Įgalinti"), + ("Reuse one connection for port forwarding", "Prievadų peradresavimui naudoti vieną ryšį"), + ("port-forward-mux-tip", "Visi vieno prievadų peradresavimo ryšiai eina per vieną ryšį su kitu kompiuteriu, užuot kiekvienam iš jų jungiantis ir prisijungiant iš naujo."), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 747475090..895957d2d 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."), ("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."), ("Save as", "Saglabāt kā"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksportēt"), + ("Export Logs", "Eksportēt žurnālus"), + ("Import Folder", "Importēt mapi"), ("Copy to clipboard", "Kopēt starpliktuvē"), ("Enable remote printer", "Iespējot attālo printeri"), ("Downloading {}", "Notiek {} lejupielāde"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Iespējot"), + ("Reuse one connection for port forwarding", "Atkārtoti izmantot vienu savienojumu portu pārsūtīšanai"), + ("port-forward-mux-tip", "Visi viena portu pārsūtījuma savienojumi tiek novadīti pa vienu savienojumu ar otru datoru, nevis katram no tiem izveidojot jaunu savienojumu un pieteikšanos."), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 130f4ed6b..1c7a72790 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."), ("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"), ("Save as", "പേരിൽ സേവ് ചെയ്യുക"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "എക്‌സ്‌പോർട്ട് ചെയ്യുക"), + ("Export Logs", "ലോഗുകൾ എക്‌സ്‌പോർട്ട് ചെയ്യുക"), + ("Import Folder", "ഫോൾഡർ ഇംപോർട്ട് ചെയ്യുക"), ("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"), ("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"), ("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "അനുവദിക്കുക"), + ("Reuse one connection for port forwarding", "പോർട്ട് ഫോർവേഡിംഗിന് ഒരേ കണക്ഷൻ വീണ്ടും ഉപയോഗിക്കുക"), + ("port-forward-mux-tip", "ഒരു പോർട്ട് ഫോർവേഡിംഗിന്റെ എല്ലാ കണക്ഷനുകളും മറ്റേ കമ്പ്യൂട്ടറിലേക്കുള്ള ഒരൊറ്റ കണക്ഷനിലൂടെ കടന്നുപോകുന്നു, ഓരോന്നിനും വീണ്ടും കണക്റ്റ് ചെയ്ത് ലോഗിൻ ചെയ്യുന്നതിനു പകരം."), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 7d77bd7ea..74e7c4202 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."), ("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."), ("Save as", "Lagre som"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksporter"), + ("Export Logs", "Eksporter logger"), + ("Import Folder", "Importer mappe"), ("Copy to clipboard", "Kopier til utklipstavlen"), ("Enable remote printer", "Aktiver fjernskriver"), ("Downloading {}", "Laster ned {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktiver"), + ("Reuse one connection for port forwarding", "Gjenbruk én tilkobling for portvideresending"), + ("port-forward-mux-tip", "Fører alle tilkoblinger i en portvideresending gjennom én enkelt tilkobling til motparten i stedet for å koble til og logge inn på nytt for hver enkelt."), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 6bb2f4828..de1197127 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Inschakelen"), + ("Reuse one connection for port forwarding", "Eén verbinding hergebruiken voor poortdoorschakeling"), + ("port-forward-mux-tip", "Alle verbindingen van een poortdoorschakeling via één enkele verbinding met de andere computer laten lopen, in plaats van voor elke verbinding opnieuw verbinding te maken en in te loggen."), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 4ff145386..3960f3f55 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."), ("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."), ("Save as", "Zapisz jako"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksportuj"), + ("Export Logs", "Eksportuj dzienniki"), + ("Import Folder", "Importuj folder"), ("Copy to clipboard", "Kopiuj do schowka"), ("Enable remote printer", "Włącz zdalne drukowanie"), ("Downloading {}", "Pobieranie {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Włącz"), + ("Reuse one connection for port forwarding", "Użyj ponownie jednego połączenia do przekierowania portów"), + ("port-forward-mux-tip", "Przekazuj wszystkie połączenia jednego przekierowania portów przez jedno połączenie ze zdalnym komputerem, zamiast łączyć się i logować od nowa dla każdego z nich."), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index cbec87b50..ab4b8d805 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."), ("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."), ("Save as", "Guardar como"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportar"), + ("Export Logs", "Exportar Registos"), + ("Import Folder", "Importar Pasta"), ("Copy to clipboard", "Copiar para a área de transferência"), ("Enable remote printer", "Ativar impressora remota"), ("Downloading {}", "A transferir {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Ativar"), + ("Reuse one connection for port forwarding", "Reutilizar uma ligação para o reencaminhamento de portas"), + ("port-forward-mux-tip", "Encaminhar todas as ligações de um reencaminhamento de portas por uma única ligação ao outro computador, em vez de ligar e iniciar sessão novamente para cada uma."), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 130d0bc03..aa97c5d57 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Habilitar"), + ("Reuse one connection for port forwarding", "Reutilizar uma conexão para encaminhamento de portas"), + ("port-forward-mux-tip", "Levar todas as conexões de um encaminhamento de portas por uma única conexão com o outro computador, em vez de conectar e fazer login novamente para cada uma."), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 30ecd7874..5d377ab35 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."), ("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."), ("Save as", "Salvează ca"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportă"), + ("Export Logs", "Exportă jurnalele"), + ("Import Folder", "Importă folder"), ("Copy to clipboard", "Copiază în clipboard"), ("Enable remote printer", "Activează imprimanta la distanță"), ("Downloading {}", "Se descarcă {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Activează"), + ("Reuse one connection for port forwarding", "Reutilizează o singură conexiune pentru redirecționarea porturilor"), + ("port-forward-mux-tip", "Trece toate conexiunile unei redirecționări de porturi printr-o singură conexiune către celălalt calculator, în loc să se conecteze și să se autentifice din nou pentru fiecare."), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index bd57b91a5..ecc9a78f8 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."), ("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."), ("Save as", "Сохранить в файл"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Экспортировать"), + ("Export Logs", "Экспортировать журналы"), + ("Import Folder", "Импортировать папку"), ("Copy to clipboard", "Копировать в буфер обмена"), ("Enable remote printer", "Использовать удалённый принтер"), ("Downloading {}", "Скачивание"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Включить"), + ("Reuse one connection for port forwarding", "Использовать одно подключение для перенаправления портов"), + ("port-forward-mux-tip", "Передавать все соединения одного перенаправления портов через одно подключение к удалённому устройству вместо повторного подключения и входа для каждого из них."), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index ada1bc23b..62b248b8a 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."), ("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."), ("Save as", "Sarva comente"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Esporta"), + ("Export Logs", "Esporta is registros"), + ("Import Folder", "Importa cartella"), ("Copy to clipboard", "Còpia in punta de billete"), ("Enable remote printer", "Abìlita imprentadora remota"), ("Downloading {}", "Iscarrighende {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Abìlita"), + ("Reuse one connection for port forwarding", "Torra a impreare una connessione pro s'imbiu de is portas"), + ("port-forward-mux-tip", "Totu is connessiones de un'imbiu de portas passant in una connessione ebbia a s'àteru computadore, in logu de si connètere e intrare torra pro dontzi una."), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 640eec48a..80674b5fb 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."), ("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."), ("Save as", "Uložiť ako"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Exportovať"), + ("Export Logs", "Exportovať protokoly"), + ("Import Folder", "Importovať priečinok"), ("Copy to clipboard", "Kopírovať do schránky"), ("Enable remote printer", "Povoliť vzdialenú tlačiareň"), ("Downloading {}", "Sťahuje sa {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Povoliť"), + ("Reuse one connection for port forwarding", "Znovu použiť jedno pripojenie na presmerovanie portov"), + ("port-forward-mux-tip", "Vedie všetky pripojenia jedného presmerovania portov cez jediné pripojenie k druhej strane namiesto opakovaného pripájania a prihlasovania pre každé z nich."), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 8db91c36b..ec62c9cec 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."), ("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."), ("Save as", "Shrani kot"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Izvozi"), + ("Export Logs", "Izvozi dnevnike"), + ("Import Folder", "Uvozi mapo"), ("Copy to clipboard", "Kopiraj v odložišče"), ("Enable remote printer", "Omogoči oddaljeni tiskalnik"), ("Downloading {}", "Prenašanje {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Omogoči"), + ("Reuse one connection for port forwarding", "Ponovno uporabi eno povezavo za posredovanje vrat"), + ("port-forward-mux-tip", "Vse povezave enega posredovanja vrat potekajo prek ene same povezave do druge strani, namesto ponovnega povezovanja in prijave za vsako od njih."), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index f94e17537..c3e2a97be 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."), ("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."), ("Save as", "Ruaj si"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Eksporto"), + ("Export Logs", "Eksporto regjistrat"), + ("Import Folder", "Importo dosjen"), ("Copy to clipboard", "Kopjo te clipboard"), ("Enable remote printer", "Aktivizo printerin në distancë"), ("Downloading {}", "Duke shkarkuar {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktivizo"), + ("Reuse one connection for port forwarding", "Ripërdor një lidhje për përcjelljen e porteve"), + ("port-forward-mux-tip", "Të gjitha lidhjet e një përcjelljeje portesh kalojnë përmes një lidhjeje të vetme me kompjuterin tjetër, në vend që të lidhet dhe të hyjë sërish për secilën prej tyre."), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index 006dfaccb..0429c34f9 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."), ("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."), ("Save as", "Sačuvaj kao"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Izvoz"), + ("Export Logs", "Izvoz dnevnika"), + ("Import Folder", "Uvoz fascikle"), ("Copy to clipboard", "Kopiraj u clipboard"), ("Enable remote printer", "Omogući udaljeni štampač"), ("Downloading {}", "Preuzimanje {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Omogući"), + ("Reuse one connection for port forwarding", "Ponovo koristi jednu vezu za prosleđivanje portova"), + ("port-forward-mux-tip", "Sve veze jednog prosleđivanja portova idu kroz jednu vezu ka drugoj strani, umesto povezivanja i prijavljivanja iznova za svaku od njih."), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 990e62ca7..0018572b2 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Aktivera"), + ("Reuse one connection for port forwarding", "Återanvänd en anslutning för portvidarebefordran"), + ("port-forward-mux-tip", "Låt alla anslutningar i en portvidarebefordran gå via en enda anslutning till motparten, i stället för att ansluta och logga in på nytt för varje anslutning."), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index ce4119f8b..e2903f0e8 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ஸ்கிரீன்ஷாட்_இணைக்கப்பட்ட_திரை_ஆதரவற்ற_குறிப்பு"), ("screenshot-action-tip", "ஸ்கிரீன்ஷாட்_செயல்_குறிப்பு"), ("Save as", "இப்படி சேமி"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "ஏற்றுமதி"), + ("Export Logs", "பதிவுகளை ஏற்றுமதி செய்"), + ("Import Folder", "கோப்புறையை இறக்குமதி செய்"), ("Copy to clipboard", "கிளிப்போர்டில் நகல்"), ("Enable remote printer", "தொலை அச்சுப்பொறி இயக்கு"), ("Downloading {}", "{} பதிவிறக்குகிறது"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "இயக்கு"), + ("Reuse one connection for port forwarding", "போர்ட் ஃபார்வேர்டிங்கிற்கு ஒரே இணைப்பை மீண்டும் பயன்படுத்து"), + ("port-forward-mux-tip", "ஒரு போர்ட் ஃபார்வேர்டிங்கின் அனைத்து இணைப்புகளும் மறுமுனைக்கான ஒரே இணைப்பின் வழியாகச் செல்லும், ஒவ்வொன்றுக்கும் மீண்டும் இணைந்து உள்நுழைவதற்குப் பதிலாக."), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index ce3d245ae..6daa1f306 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", ""), + ("Reuse one connection for port forwarding", ""), + ("port-forward-mux-tip", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 544bcdc04..a067ba050 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ขณะนี้ยังไม่รองรับการรวมภาพหน้าจอจากหลายจอแสดงผล กรุณาสลับไปใช้จอแสดงผลเดียวแล้วลองใหม่"), ("screenshot-action-tip", "กรุณาเลือกวิธีดำเนินการต่อกับภาพหน้าจอ"), ("Save as", "บันทึกเป็น"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "ส่งออก"), + ("Export Logs", "ส่งออกบันทึกการทำงาน"), + ("Import Folder", "นำเข้าโฟลเดอร์"), ("Copy to clipboard", "คัดลอกไปยังคลิปบอร์ด"), ("Enable remote printer", "เปิดใช้งานเครื่องพิมพ์ระยะไกล"), ("Downloading {}", "กำลังดาวน์โหลด {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "เปิดใช้งาน"), + ("Reuse one connection for port forwarding", "ใช้การเชื่อมต่อเดียวร่วมกันสำหรับการส่งต่อพอร์ต"), + ("port-forward-mux-tip", "ส่งการเชื่อมต่อทั้งหมดของการส่งต่อพอร์ตหนึ่งรายการผ่านการเชื่อมต่อเดียวไปยังอีกฝ่าย แทนการเชื่อมต่อและเข้าสู่ระบบใหม่ทุกครั้ง"), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index b70a1c44a..63c8f6a40 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Birden fazla ekranın ekran görüntülerinin birleştirilmesi şu anda desteklenmiyor. Lütfen tek bir ekrana geçin ve tekrar deneyin."), ("screenshot-action-tip", "Lütfen ekran görüntüsüyle nasıl devam edeceğinizi seçin."), ("Save as", "Farklı kaydet"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Dışa aktar"), + ("Export Logs", "Günlükleri dışa aktar"), + ("Import Folder", "Klasör içe aktar"), ("Copy to clipboard", "Panoya kopyala"), ("Enable remote printer", "Uzak yazıcıyı etkinleştir"), ("Downloading {}", "{} indiriliyor"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Etkinleştir"), + ("Reuse one connection for port forwarding", "Port yönlendirme için tek bağlantıyı yeniden kullan"), + ("port-forward-mux-tip", "Bir port yönlendirmesindeki tüm bağlantıları, her biri için yeniden bağlanıp oturum açmak yerine karşı tarafa açılan tek bir bağlantı üzerinden taşır."), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 2f823cc6b..cdfe85fbc 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "啟用"), + ("Reuse one connection for port forwarding", "連接埠轉送重複使用同一條連線"), + ("port-forward-mux-tip", "同一條連接埠轉送規則上的所有連線共用一條到對方的連線,而不是每條連線都重新連線並登入一次。"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 2cca68973..9bb94d002 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Об'єднання знімків кількох дисплеїв наразі не підтримується. Перейдіть на один дисплей і спробуйте знову."), ("screenshot-action-tip", "Виберіть, що робити зі знімком екрана."), ("Save as", "Зберегти як"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Експортувати"), + ("Export Logs", "Експортувати журнали"), + ("Import Folder", "Імпортувати теку"), ("Copy to clipboard", "Скопіювати до буфера обміну"), ("Enable remote printer", "Увімкнути віддалений принтер"), ("Downloading {}", "Завантаження {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Увімкнути"), + ("Reuse one connection for port forwarding", "Використовувати одне з'єднання для перенаправлення портів"), + ("port-forward-mux-tip", "Передавати всі з'єднання одного перенаправлення портів через одне з'єднання з віддаленим пристроєм замість повторного під'єднання та входу для кожного з них."), ].iter().cloned().collect(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs index 28465d386..cb37b0bcf 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -3,7 +3,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "حالت"), ("Your Desktop", "آپ کا ڈیسک ٹاپ"), - ("desk_tip", ""), + ("desk_tip", "آپ کے ڈیسک ٹاپ تک اس ID اور پاس ورڈ کے ذریعے رسائی حاصل کی جا سکتی ہے۔"), ("Password", "پاس ورڈ"), ("Ready", "تیار"), ("Established", "قائم کیا گیا"), @@ -12,7 +12,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Start service", "سروس شروع کریں"), ("Service is running", "سروس چل رہی ہے"), ("Service is not running", "سروس نہیں چل رہی ہے"), - ("not_ready_status", ""), + ("not_ready_status", "تیار نہیں۔ براہِ کرم اپنا کنکشن جانچیں"), ("Control Remote Desktop", "ریموٹ ڈیسک ٹاپ کو کنٹرول کریں"), ("Transfer file", "فائل منتقل کریں"), ("Connect", "کنیکٹ کریں"), @@ -41,12 +41,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "لمبائی %min% سے %max%"), ("starts with a letter", "حرف سے شروع ہوتا ہے"), ("allowed characters", "اجازت یافتہ حروف"), - ("id_change_tip", ""), + ("id_change_tip", "صرف a-z، A-Z، 0-9، - (ڈیش) اور _ (انڈر اسکور) حروف کی اجازت ہے۔ پہلا حرف a-z یا A-Z ہونا چاہیے۔ لمبائی 6 سے 16 کے درمیان ہو۔"), ("Website", "ویب سائٹ"), ("About", "کے بارے میں"), ("Slogan_tip", "سلوگن_ٹپ"), ("Privacy Statement", "رازداری کا بیان"), - ("License", "لائسنس"), ("Mute", "خاموش"), ("Build Date", "بنیاد کی تاریخ"), ("Version", "ورژن"), @@ -149,21 +148,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("install_tip", "انسٹال کرنے کا مشورہ"), ("Click to upgrade", "اپگریڈ کرنے کے لئے کلک کریں"), ("Configure", "ترتیب دینا"), - ("config_acc", ""), - ("config_screen", ""), + ("config_acc", "اپنے ڈیسک ٹاپ کو دور سے کنٹرول کرنے کے لیے آپ کو RustDesk کو \"Accessibility\" کی اجازتیں دینا ہوں گی۔"), + ("config_screen", "اپنے ڈیسک ٹاپ تک دور سے رسائی کے لیے آپ کو RustDesk کو \"Screen Recording\" کی اجازتیں دینا ہوں گی۔"), ("Installing ...", "انسٹال ہو رہا ہے..."), ("Install", "انسٹال کریں"), ("Installation", "انسٹالیشن"), ("Installation Path", "انسٹالیشن کا راستہ"), ("Create start menu shortcuts", "اسٹارٹ مینو شارٹ کٹس بنائیں"), ("Create desktop icon", "ڈیسکٹاپ آئیکن بنائیں"), - ("agreement_tip", ""), + ("agreement_tip", "انسٹالیشن شروع کرنے سے آپ لائسنس معاہدہ قبول کرتے ہیں۔"), ("Accept and Install", "قبول کریں اور انسٹال کریں"), ("End-user license agreement", "اختتامی صارف کے لائسنس کا معاہدہ"), ("Generating ...", "بنا رہے ہیں..."), ("Your installation is lower version.", "آپ کی تنصیب کم ورژن ہے۔"), - ("Please install the latest version.", "براہِ مہربانی تازہ ترین ورژن انسٹال کریں۔"), - ("not_close_tcp_tip", ""), + ("not_close_tcp_tip", "جب تک آپ ٹنل استعمال کر رہے ہیں، یہ ونڈو بند نہ کریں"), ("Listening ...", "سن رہا ہے..."), ("Remote Host", "ریموٹ میزبان"), ("Remote Port", "ریموٹ پورٹ"), @@ -212,7 +210,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Run without install", "انسٹال کے بغیر چلائیں"), ("Connect via relay", "ریلے کے ذریعے کنیکٹ کریں"), ("Always connect via relay", "ہمیشہ ریلے کے ذریعے کنیکٹ کریں"), - ("whitelist_tip", ""), + ("whitelist_tip", "صرف وائٹ لسٹ میں شامل IP مجھ تک رسائی حاصل کر سکتے ہیں"), ("Login", "لاگ ان کریں"), ("Verify", "تصدیق کریں"), ("Remember me", "یاد رکھیں"), @@ -222,7 +220,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Logout", "لاگ آؤٹ"), ("Tags", "ٹیگز"), ("Search ID", "ID تلاش کریں"), - ("whitelist_sep", ""), + ("whitelist_sep", "کوما، سیمی کولن، خالی جگہ یا نئی سطر سے الگ کریں"), ("Add ID", "ID شامل کریں"), ("Add Tag", "ٹیگ شامل کریں"), ("Unselect all tags", "تمام ٹیگز کو غیر منتخب کریں"), @@ -241,7 +239,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Socks5 Proxy", "پروکسی ساکس5"), ("Socks5/Http(s) Proxy", "ساکس5/Http(s) پروکسی"), ("Discovered", "دریافت شدہ"), - ("install_daemon_tip", ""), + ("install_daemon_tip", "بوٹ پر شروع ہونے کے لیے آپ کو سسٹم سروس انسٹال کرنا ہوگی۔"), ("Remote ID", "ریموٹ ID"), ("Paste", "چسپاں کریں"), ("Paste here?", "یہاں چسپاں کریں؟"), @@ -278,14 +276,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Do you accept?", "کیا آپ قبول کرتے ہیں؟"), ("Open System Setting", "سسٹم کی ترتیبات کھولیں"), ("How to get Android input permission?", "Android کی درآمد کی اجازت کیسے حاصل کریں؟"), - ("android_input_permission_tip1", ""), - ("android_input_permission_tip2", ""), - ("android_new_connection_tip", ""), - ("android_service_will_start_tip", ""), - ("android_stop_service_tip", ""), - ("android_version_audio_tip", ""), - ("android_start_service_tip", ""), - ("android_permission_may_not_change_tip", ""), + ("android_input_permission_tip1", "کسی دور دراز آلے کو ماؤس یا ٹچ کے ذریعے آپ کے Android آلے کو کنٹرول کرنے کے لیے آپ کو RustDesk کو \"Accessibility\" سروس استعمال کرنے کی اجازت دینا ہوگی۔"), + ("android_input_permission_tip2", "براہِ کرم اگلے سسٹم سیٹنگز صفحے پر جائیں، [Installed Services] تلاش کر کے کھولیں اور [RustDesk Input] سروس آن کریں۔"), + ("android_new_connection_tip", "ایک نئی کنٹرول درخواست موصول ہوئی ہے، جو آپ کے موجودہ آلے کو کنٹرول کرنا چاہتی ہے۔"), + ("android_service_will_start_tip", "\"Screen Capture\" آن کرنے سے سروس خودکار طور پر شروع ہو جائے گی، جس سے دوسرے آلات آپ کے آلے سے کنکشن کی درخواست کر سکیں گے۔"), + ("android_stop_service_tip", "سروس بند کرنے سے تمام قائم شدہ کنکشن خودکار طور پر بند ہو جائیں گے۔"), + ("android_version_audio_tip", "موجودہ Android ورژن آڈیو کیپچر کی حمایت نہیں کرتا، براہِ کرم Android 10 یا اس سے نئے ورژن پر اپ گریڈ کریں۔"), + ("android_start_service_tip", "اسکرین شیئرنگ سروس شروع کرنے کے لیے [Start service] پر ٹیپ کریں یا [Screen Capture] کی اجازت فعال کریں۔"), + ("android_permission_may_not_change_tip", "قائم شدہ کنکشنز کی اجازتیں دوبارہ منسلک ہونے تک فوراً تبدیل نہیں ہو سکتیں۔"), ("Account", "کھاتا"), ("Overwrite", "اوور رائٹ کریں"), ("This file exists, skip or overwrite this file?", "یہ فائل موجود ہے، اس فائل کو چھوڑیں یا اوور رائٹ کریں؟"), @@ -296,14 +294,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "کوئی پرائیویسی موڈ آن کرتا ہے، باہر نکلیں"), ("Unsupported", "غیر معاون"), ("Peer denied", "ہم منسب نے انکار کر دیا"), - ("Please install plugins", "براہِ مہربانی پلگ ان انسٹال کریں"), ("Peer exit", "ہم منسب باہر نکل گیا"), ("Failed to turn off", "بند کرنے میں ناکام"), ("Turned off", "بند کر دیا"), ("Language", "زبان"), ("Keep RustDesk background service", "RustDesk پس منظر کی خدمت کو برقرار رکھیں"), ("Ignore Battery Optimizations", "بیٹری کی اصلاحات کو نظر انداز کریں"), - ("android_open_battery_optimizations_tip", ""), + ("android_open_battery_optimizations_tip", "اگر آپ یہ خصوصیت بند کرنا چاہتے ہیں تو براہِ کرم اگلے RustDesk ایپلیکیشن سیٹنگز صفحے پر جائیں، [Battery] تلاش کر کے کھولیں اور [Unrestricted] کا نشان ہٹا دیں"), ("Start on boot", "شروع کرنے پر شروع کریں"), ("Start the screen sharing service on boot, requires special permissions", "بوٹ پر سکرین شیئرنگ سروس شروع کریں، خاص اجازتوں کی ضرورت ہے"), ("Connection not allowed", "جڑنے کی اجازت نہیں ہے"), @@ -317,7 +314,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Restart remote device", "ریموٹ ڈیوائس کو ری اسٹارٹ کریں"), ("Are you sure you want to restart", "کیا آپ واقعی ری اسٹارٹ کرنا چاہتے ہیں؟"), ("Restarting remote device", "ریموٹ ڈیوائس ری اسٹارٹ ہو رہی ہے"), - ("remote_restarting_tip", ""), + ("remote_restarting_tip", "دور دراز آلہ دوبارہ شروع ہو رہا ہے، براہِ کرم یہ پیغام بند کریں اور کچھ دیر بعد مستقل پاس ورڈ کے ساتھ دوبارہ منسلک ہوں"), ("Copied", "نقل ہو گیا"), ("Exit Fullscreen", "مکمل سکرین سے باہر نکلیں"), ("Fullscreen", "مکمل سکرین"), @@ -408,19 +405,19 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by web console", "ویب کنسول کے ذریعے دستی طور پر بند کیا گیا"), ("Local keyboard type", "مقامی کیبورڈ کا قسم"), ("Select local keyboard type", "مقامی کیبورڈ کا قسم منتخب کریں"), - ("software_render_tip", ""), + ("software_render_tip", "اگر آپ Linux پر Nvidia گرافکس کارڈ استعمال کر رہے ہیں اور منسلک ہونے کے فوراً بعد ریموٹ ونڈو بند ہو جاتی ہے، تو اوپن سورس Nouveau ڈرائیور پر منتقل ہونا اور سافٹ ویئر رینڈرنگ کا انتخاب مددگار ہو سکتا ہے۔ سافٹ ویئر کو دوبارہ شروع کرنا ضروری ہے۔"), ("Always use software rendering", "ہم sempre سافٹ ویر رینڈرنگ استعمال کریں"), ("config_input", "config_input"), - ("config_microphone", ""), - ("request_elevation_tip", ""), + ("config_microphone", "دور سے بات کرنے کے لیے آپ کو RustDesk کو \"Record Audio\" کی اجازتیں دینا ہوں گی۔"), + ("request_elevation_tip", "اگر دوسری طرف کوئی موجود ہے تو آپ اختیارات میں اضافے کی درخواست بھی کر سکتے ہیں۔"), ("Wait", "انتظار کریں"), ("Elevation Error", "علیٰ کرنے کی خرابی"), ("Ask the remote user for authentication", "ریموٹ صارف سے تصدیق کے لیے پوچھیں"), ("Choose this if the remote account is administrator", "ریموٹ اکاؤنٹ ایڈمنسٹریٹر ہو تو یہ منتخب کریں"), ("Transmit the username and password of administrator", "ایڈمنسٹریٹر کا صارف نام اور پاس ورڈ پروگرام کے ذریعے بھیجیں"), - ("still_click_uac_tip", ""), + ("still_click_uac_tip", "پھر بھی ضروری ہے کہ دور دراز صارف چل رہے RustDesk کی UAC ونڈو پر OK پر کلک کرے۔"), ("Request Elevation", "علیٰ کرنے کا درخواست دیں"), - ("wait_accept_uac_tip", ""), + ("wait_accept_uac_tip", "براہِ کرم انتظار کریں کہ دور دراز صارف UAC ڈائیلاگ قبول کرے۔"), ("Elevate successfully", "علیٰ کامیابی سے ہو گئے"), ("uppercase", "بڑے حروف"), ("lowercase", "چھوٹے حروف"), @@ -438,7 +435,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Default Image Quality", "ڈیفالٹ تصویر کی معیار"), ("Default Codec", "ڈیفالٹ کوڈک"), ("Bitrate", "بٹ ریٹ"), - ("FPS", ""), + ("FPS", "FPS"), ("Auto", "خودکار"), ("Other Default Options", "دوسروں ڈیفالٹ اختیارات"), ("Voice call", "صوتی کال"), @@ -464,20 +461,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Empty Username", "خالی صارف نام"), ("Empty Password", "خالی پاس ورڈ"), ("Me", "میں"), - ("identical_file_tip", ""), - ("show_monitors_tip", ""), + ("identical_file_tip", "یہ فائل دوسری طرف موجود فائل کے بالکل یکساں ہے۔"), + ("show_monitors_tip", "ٹول بار میں مانیٹر دکھائیں"), ("View Mode", "دیکھنے کا طریقہ"), - ("login_linux_tip", "login_linux_tip"), - ("verify_rustdesk_password_tip", ""), - ("remember_account_tip", ""), - ("os_account_desk_tip", ""), - ("OS Account", "OS اکاؤنٹ"), - ("another_user_login_title_tip", ""), - ("another_user_login_text_tip", ""), - ("xorg_not_found_title_tip", ""), - ("xorg_not_found_text_tip", ""), - ("no_desktop_title_tip", ""), - ("no_desktop_text_tip", ""), + ("verify_rustdesk_password_tip", "RustDesk پاس ورڈ کی تصدیق کریں"), ("No need to elevate", "اپنے کو ہیں نہیں"), ("System Sound", "سسٹم سائونڈ"), ("Default", "ڈیفالٹ"), @@ -485,30 +472,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fingerprint", "فنگر پرنٹ"), ("Copy Fingerprint", "فنگر پرنٹ کاپی کریں"), ("no fingerprints", "کوئی فنگر پرنٹ نہیں"), - ("Select a peer", "ایک پیر منتخب کریں"), - ("Select peers", "پیرز منتخب کریں"), - ("Plugins", "پلگ انز"), - ("Uninstall", "ان انسٹال کریں"), ("Update", "اپڈیٹ کریں"), ("Enable", "فعال کریں"), - ("Disable", "غیر فعال کریں"), - ("Options", "اختیارات"), - ("resolution_original_tip", ""), - ("resolution_fit_local_tip", ""), - ("resolution_custom_tip", ""), + ("resolution_original_tip", "اصل ریزولوشن"), + ("resolution_fit_local_tip", "مقامی ریزولوشن کے مطابق"), + ("resolution_custom_tip", "حسبِ ضرورت ریزولوشن"), ("Collapse toolbar", "ٹول بار کو سکڑیں"), ("Accept and Elevate", "قبول کریں اور علیٰ کریں"), - ("accept_and_elevate_btn_tooltip", ""), - ("clipboard_wait_response_timeout_tip", ""), + ("accept_and_elevate_btn_tooltip", "کنکشن قبول کریں اور UAC اجازتیں بڑھائیں۔"), + ("clipboard_wait_response_timeout_tip", "کاپی کے جواب کا انتظار ختم ہو گیا۔"), ("Incoming connection", "آنے والا کنکشن"), ("Outgoing connection", "جانے والا کنکشن"), ("Exit", "خارج ہوں"), ("Open", "کھولیں"), - ("logout_tip", ""), + ("logout_tip", "کیا آپ واقعی لاگ آؤٹ کرنا چاہتے ہیں؟"), ("Service", "سروس"), ("Start", "شروع کریں"), ("Stop", "روک دیں"), - ("exceed_max_devices", ""), + ("exceed_max_devices", "آپ زیرِ انتظام آلات کی زیادہ سے زیادہ تعداد تک پہنچ چکے ہیں۔"), ("Sync with recent sessions", "پچھلے سیشنز کے ساتھ ہم آہنگ کریں"), ("Sort tags", "ٹیگز کو ترتیب دیں"), ("Open connection in new tab", "کنکشن کو نئے ٹیب میں کھولیں"), @@ -517,14 +498,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Already exists", "پہلے سے موجود ہے"), ("Change Password", "پاسورڈ تبدیل کریں"), ("Refresh Password", "پاسورڈ ریفریش کریں"), - ("ID", ""), + ("ID", "ID"), ("Grid View", "گوڈ ویو"), ("List View", "لسٹ ویو"), ("Select", "منتخب کریں"), ("Toggle Tags", "ٹیگز ٹوگل کریں"), - ("pull_ab_failed_tip", ""), - ("push_ab_failed_tip", ""), - ("synced_peer_readded_tip", ""), + ("pull_ab_failed_tip", "ایڈریس بک تازہ کرنے میں ناکامی"), + ("push_ab_failed_tip", "ایڈریس بک کو سرور سے ہم آہنگ کرنے میں ناکامی"), + ("synced_peer_readded_tip", "حالیہ سیشنز میں موجود آلات دوبارہ ایڈریس بک سے ہم آہنگ کر دیے جائیں گے۔"), ("Change Color", "رنگ تبدیل کریں"), ("Primary Color", "پرائمری رنگ"), ("HSV Color", "HSV رنگ"), @@ -539,11 +520,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("I Agree", "میں قبول کرتا ہوں"), ("Decline", "ناکام کریں"), ("Timeout in minutes", "منٹوں میں ٹائیم آؤٹ"), - ("auto_disconnect_option_tip", ""), + ("auto_disconnect_option_tip", "صارف کی غیر فعالی پر آنے والے سیشنز خودکار طور پر بند کریں"), ("Connection failed due to inactivity", "انفعال کی وजہ سے کنکشن ناکام ہو گیا"), ("Check for software update on startup", "سٹارٹ اپ پر سافٹ ویر اپڈیٹ کے لیے چیک کریں"), - ("upgrade_rustdesk_server_pro_to_{}_tip", ""), - ("pull_group_failed_tip", ""), + ("upgrade_rustdesk_server_pro_to_{}_tip", "براہِ کرم RustDesk Server Pro کو ورژن {} یا اس سے نئے پر اپ گریڈ کریں!"), + ("pull_group_failed_tip", "گروپ تازہ کرنے میں ناکامی"), ("Filter by intersection", "فلٹر بائی انسٹریکشن"), ("Remove wallpaper during incoming sessions", "ان کلینگ سیشنز کے دوران والپیپر کو ہٹائیں"), ("Test", "ٹیسٹ"), @@ -552,7 +533,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Open in new window", "نئی ونڈو میں کھولیں"), ("Show displays as individual windows", "ڈسپلے کو افراد کے طور پر دکھائیں"), ("Use all my displays for the remote session", "ریموٹ سیشن کے لیے میرے تمام ڈسپلے استعمال کریں"), - ("selinux_tip", ""), + ("selinux_tip", "آپ کے آلے پر SELinux فعال ہے، جو RustDesk کو بطور کنٹرول شدہ فریق درست طور پر چلنے سے روک سکتا ہے۔"), ("Change view", "ویو تبدیل کریں"), ("Big tiles", "بڑے ٹائل"), ("Small tiles", "چھوٹے ٹائل"), @@ -561,14 +542,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Plug out all", "تمام پلگ آؤٹ کریں"), ("True color (4:4:4)", "اصل رنگ (4:4:4)"), ("Enable blocking user input", "صارف ان پٹ کو روکنے کی اجازت دیں"), - ("id_input_tip", ""), - ("privacy_mode_impl_mag_tip", ""), - ("privacy_mode_impl_virtual_display_tip", ""), + ("id_input_tip", "آپ ایک ID، براہِ راست IP، یا پورٹ کے ساتھ ڈومین (:) درج کر سکتے ہیں۔\nاگر آپ کسی دوسرے سرور پر موجود آلے تک رسائی چاہتے ہیں تو سرور کا پتہ ساتھ لگائیں (@?key=)، مثلاً،\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=۔\nاگر آپ کسی عوامی سرور پر موجود آلے تک رسائی چاہتے ہیں تو \"@public\" درج کریں، عوامی سرور کے لیے کلید درکار نہیں۔\n\nاگر آپ پہلے کنکشن پر ریلے کنکشن کا استعمال لازمی کرنا چاہتے ہیں تو ID کے آخر میں \"/r\" شامل کریں، مثلاً، \"9123456234/r\"۔"), + ("privacy_mode_impl_mag_tip", "موڈ 1"), + ("privacy_mode_impl_virtual_display_tip", "موڈ 2"), ("Enter privacy mode", "خفیہ موڈ میں داخل ہوں"), ("Exit privacy mode", "خفیہ موڈ سے باہر نکلیں"), - ("idd_not_support_under_win10_2004_tip", ""), - ("input_source_1_tip", ""), - ("input_source_2_tip", ""), + ("idd_not_support_under_win10_2004_tip", "بالواسطہ ڈسپلے ڈرائیور معاون نہیں ہے۔ Windows 10 ورژن 2004 یا اس سے نیا درکار ہے۔"), + ("input_source_1_tip", "ان پٹ ماخذ 1"), + ("input_source_2_tip", "ان پٹ ماخذ 2"), ("Swap control-command key", "control-command کلید کو سوپ کریں"), ("swap-left-right-mouse", "بائی-دائی ماؤس کو سوپ کریں"), ("2FA code", "2FA کوڈ"), @@ -582,8 +563,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Multiple Windows sessions found", "متعدد ونڈوز سیشن ملے"), ("Please select the session you want to connect to", "براہ کرم وہ سیشن منتخب کریں جس سے آپ منسلک ہونا چاہتے ہیں"), ("powered_by_me", "میں کی طرف سے طاقتور"), - ("outgoing_only_desk_tip", ""), - ("preset_password_warning", ""), + ("outgoing_only_desk_tip", "یہ ایک حسبِ ضرورت ایڈیشن ہے۔\nآپ دوسرے آلات سے منسلک ہو سکتے ہیں، لیکن دوسرے آلات آپ کے آلے سے منسلک نہیں ہو سکتے۔"), + ("preset_password_warning", "یہ حسبِ ضرورت ایڈیشن پہلے سے مقرر پاس ورڈ کے ساتھ آتا ہے۔ جو بھی یہ پاس ورڈ جانتا ہو وہ آپ کے آلے کا مکمل کنٹرول حاصل کر سکتا ہے۔ اگر آپ کو اس کی توقع نہیں تھی تو سافٹ ویئر فوراً ان انسٹال کر دیں۔"), ("Security Alert", "سیکورٹی الرٹ"), ("My address book", "میری ایڈریس بک"), ("Personal", "شخصی"), @@ -593,25 +574,25 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Read-only", "صرف پڑھنے کے لیے"), ("Read/Write", "پڑھنے/لکھنے"), ("Full Control", "پورا کنٹرول"), - ("share_warning_tip", ""), + ("share_warning_tip", "اوپر دیے گئے خانے مشترکہ ہیں اور دوسروں کو نظر آتے ہیں۔"), ("Everyone", "ہر کوئی"), - ("ab_web_console_tip", ""), - ("allow-only-conn-window-open-tip", ""), - ("no_need_privacy_mode_no_physical_displays_tip", ""), + ("ab_web_console_tip", "ویب کنسول پر مزید"), + ("allow-only-conn-window-open-tip", "کنکشن کی اجازت صرف اس صورت میں دیں جب RustDesk ونڈو کھلی ہو"), + ("no_need_privacy_mode_no_physical_displays_tip", "کوئی طبعی ڈسپلے نہیں، پرائیویسی موڈ استعمال کرنے کی ضرورت نہیں۔"), ("Follow remote cursor", "ریموٹ کرسر کی پیروی کریں"), ("Follow remote window focus", "ریموٹ ونڈو فوکس کی پیروی کریں"), - ("default_proxy_tip", ""), - ("no_audio_input_device_tip", ""), + ("default_proxy_tip", "پہلے سے طے شدہ پروٹوکول اور پورٹ Socks5 اور 1080 ہیں"), + ("no_audio_input_device_tip", "کوئی آڈیو ان پٹ آلہ نہیں ملا۔"), ("Incoming", "آنے والے"), ("Outgoing", "بھیجے جا رہے"), ("Clear Wayland screen selection", "Wayland سکرین کی انتخاب صاف کریں"), - ("clear_Wayland_screen_selection_tip", ""), - ("confirm_clear_Wayland_screen_selection_tip", ""), - ("android_new_voice_call_tip", ""), - ("texture_render_tip", ""), + ("clear_Wayland_screen_selection_tip", "اسکرین کا انتخاب صاف کرنے کے بعد آپ شیئر کرنے کے لیے اسکرین دوبارہ منتخب کر سکتے ہیں۔"), + ("confirm_clear_Wayland_screen_selection_tip", "کیا آپ واقعی Wayland اسکرین کا انتخاب صاف کرنا چاہتے ہیں؟"), + ("android_new_voice_call_tip", "ایک نئی صوتی کال کی درخواست موصول ہوئی۔ اگر آپ قبول کرتے ہیں تو آڈیو صوتی رابطے پر منتقل ہو جائے گا۔"), + ("texture_render_tip", "تصاویر کو ہموار بنانے کے لیے ٹیکسچر رینڈرنگ استعمال کریں۔ اگر آپ کو رینڈرنگ کے مسائل درپیش ہوں تو یہ اختیار بند کر کے دیکھ سکتے ہیں۔"), ("Use texture rendering", "ٹیکسچر رینڈرنگ کا استعمال کریں"), ("Floating window", "فلوٹنگ ونڈو"), - ("floating_window_tip", ""), + ("floating_window_tip", "یہ RustDesk کی پس منظر سروس کو برقرار رکھنے میں مدد دیتا ہے"), ("Keep screen on", "سکرین کو آن رکھیں"), ("Never", "کبھی نہیں"), ("During controlled", "کنٹرول کے دوران"), @@ -623,13 +604,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Volume down", "آواز کم کریں"), ("Power", "پاور"), ("Telegram bot", "ٹیلیگرام بات"), - ("enable-bot-tip", ""), - ("enable-bot-desc", ""), - ("cancel-2fa-confirm-tip", ""), - ("cancel-bot-confirm-tip", ""), + ("enable-bot-tip", "اگر آپ یہ خصوصیت فعال کریں تو آپ اپنے بوٹ سے 2FA کوڈ وصول کر سکتے ہیں۔ یہ کنکشن کی اطلاع کے طور پر بھی کام کر سکتا ہے۔"), + ("enable-bot-desc", "1. @BotFather کے ساتھ چیٹ کھولیں۔\n2. کمانڈ \"/newbot\" بھیجیں۔ یہ مرحلہ مکمل کرنے کے بعد آپ کو ایک ٹوکن ملے گا۔\n3. اپنے نئے بنائے گئے بوٹ کے ساتھ چیٹ شروع کریں۔ اسے فعال کرنے کے لیے فارورڈ سلیش (\"/\") سے شروع ہونے والا پیغام، جیسے \"/hello\"، بھیجیں۔\n"), + ("cancel-2fa-confirm-tip", "کیا آپ واقعی 2FA منسوخ کرنا چاہتے ہیں؟"), + ("cancel-bot-confirm-tip", "کیا آپ واقعی Telegram بوٹ منسوخ کرنا چاہتے ہیں؟"), ("About RustDesk", "رستڈیسک کے بارے میں"), ("Send clipboard keystrokes", "کلپ بورڈ کی چابیاں بھیجیں"), - ("network_error_tip", ""), + ("network_error_tip", "براہِ کرم اپنا نیٹ ورک کنکشن جانچیں، پھر دوبارہ کوشش پر کلک کریں۔"), ("Unlock with PIN", "PIN کے ساتھ انلاک کریں"), ("Requires at least {} characters", "کم از کم {} حروف کی ضرورت ہے"), ("Wrong PIN", "غلط PIN"), @@ -638,56 +619,56 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Manage trusted devices", "معتبر آلے مینیج کریں"), ("Platform", "پلیٹ فارم"), ("Days remaining", "دن باقی"), - ("enable-trusted-devices-tip", ""), + ("enable-trusted-devices-tip", "قابلِ اعتماد آلات پر 2FA تصدیق چھوڑ دیں"), ("Parent directory", "والد ڈائرکٹری"), ("Resume", "جاری رکھیں"), ("Invalid file name", "غلط فائل کا نام"), - ("one-way-file-transfer-tip", ""), + ("one-way-file-transfer-tip", "کنٹرول شدہ فریق پر یک طرفہ فائل منتقلی فعال ہے۔"), ("Authentication Required", "توثیق کی ضرورت ہے"), ("Authenticate", "توثیق کریں"), - ("web_id_input_tip", ""), + ("web_id_input_tip", "آپ اسی سرور میں ایک ID درج کر سکتے ہیں، ویب کلائنٹ میں براہِ راست IP رسائی معاون نہیں ہے۔\nاگر آپ کسی دوسرے سرور پر موجود آلے تک رسائی چاہتے ہیں تو سرور کا پتہ ساتھ لگائیں (@?key=)، مثلاً،\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=۔\nاگر آپ کسی عوامی سرور پر موجود آلے تک رسائی چاہتے ہیں تو \"@public\" درج کریں، عوامی سرور کے لیے کلید درکار نہیں۔"), ("Download", "ڈاؤن لوڈ کریں"), ("Upload folder", "اپ لوڈ فولڈر"), ("Upload files", "فائلیں اپ لوڈ کریں"), ("Clipboard is synchronized", "کلپ بورڈ مطابق ہے"), ("Update client clipboard", "کلپ بورڈ کو اپ ڈیٹ کریں"), ("Untagged", "غیر تعلق یافتہ"), - ("new-version-of-{}-tip", ""), + ("new-version-of-{}-tip", "{} کا ایک نیا ورژن دستیاب ہے"), ("Accessible devices", "قابلِ رسائی والے آلے"), - ("upgrade_remote_rustdesk_client_to_{}_tip", ""), - ("d3d_render_tip", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "براہِ کرم دور دراز فریق پر RustDesk کلائنٹ کو ورژن {} یا اس سے نئے پر اپ گریڈ کریں!"), + ("d3d_render_tip", "جب D3D رینڈرنگ فعال ہو تو کچھ مشینوں پر ریموٹ کنٹرول اسکرین سیاہ ہو سکتی ہے۔"), ("Use D3D rendering", "D3D رینڈرنگ کا استعمال کریں"), ("Printer", "پرنٹر"), - ("printer-os-requirement-tip", ""), - ("printer-requires-installed-{}-client-tip", ""), - ("printer-{}-not-installed-tip", ""), - ("printer-{}-ready-tip", ""), + ("printer-os-requirement-tip", "پرنٹر کی بیرونی خصوصیت کے لیے Windows 10 یا اس سے نیا درکار ہے۔"), + ("printer-requires-installed-{}-client-tip", "دور دراز پرنٹنگ استعمال کرنے کے لیے اس آلے پر {} انسٹال ہونا ضروری ہے۔"), + ("printer-{}-not-installed-tip", "{} پرنٹر انسٹال نہیں ہے۔"), + ("printer-{}-ready-tip", "{} پرنٹر انسٹال ہے اور استعمال کے لیے تیار ہے۔"), ("Install {} Printer", " {} پرنٹر انسٹال کریں"), ("Outgoing Print Jobs", "بیرونی پرنٹ کام"), ("Incoming Print Jobs", "اندر کے پرنٹ کام"), ("Incoming Print Job", "اندر کا پرنٹ کام"), - ("use-the-default-printer-tip", ""), - ("use-the-selected-printer-tip", ""), - ("auto-print-tip", ""), - ("print-incoming-job-confirm-tip", ""), - ("remote-printing-disallowed-tile-tip", ""), - ("remote-printing-disallowed-text-tip", ""), - ("save-settings-tip", ""), + ("use-the-default-printer-tip", "پہلے سے طے شدہ پرنٹر استعمال کریں"), + ("use-the-selected-printer-tip", "منتخب کردہ پرنٹر استعمال کریں"), + ("auto-print-tip", "منتخب کردہ پرنٹر سے خودکار طور پر پرنٹ کریں۔"), + ("print-incoming-job-confirm-tip", "آپ کو دور دراز سے ایک پرنٹ جاب موصول ہوئی۔ کیا آپ اسے اپنی طرف چلانا چاہتے ہیں؟"), + ("remote-printing-disallowed-tile-tip", "دور دراز پرنٹنگ کی اجازت نہیں"), + ("remote-printing-disallowed-text-tip", "کنٹرول شدہ فریق کی اجازت کی ترتیبات دور دراز پرنٹنگ سے انکار کرتی ہیں۔"), + ("save-settings-tip", "ترتیبات محفوظ کریں"), ("dont-show-again-tip", " ٹپ دوبارہ نہ دکھائیں "), ("Take screenshot", "اسکرین شاٹ لیں"), ("Taking screenshot", "اسکرین شاٹ لے رہے ہیں"), - ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-merged-screen-not-supported-tip", "متعدد ڈسپلے کے اسکرین شاٹس کو ملانا فی الحال معاون نہیں ہے۔ براہِ کرم ایک ڈسپلے پر منتقل ہو کر دوبارہ کوشش کریں۔"), ("screenshot-action-tip", "اسکرین شاٹ ایکشن ٹپ"), ("Save as", "حفظ کے طور پر"), ("Copy to clipboard", "کلپ بورڈ پر کاپی کریں"), ("Enable remote printer", "ریموٹ پرنٹر کو فعال کریں"), ("Downloading {}", "ڈاؤن لوڈ ہو رہا ہے {}"), ("{} Update", "{} اپ ڈیٹ"), - ("{}-to-update-tip", ""), - ("download-new-version-failed-tip", ""), + ("{}-to-update-tip", "{} اب بند ہو کر نیا ورژن انسٹال کرے گا۔"), + ("download-new-version-failed-tip", "ڈاؤن لوڈ ناکام۔ آپ دوبارہ کوشش کر سکتے ہیں یا \"Download\" بٹن پر کلک کر کے ریلیز صفحے سے ڈاؤن لوڈ کر کے دستی طور پر اپ گریڈ کر سکتے ہیں۔"), ("Auto update", "خودکار اپ ڈیٹ"), - ("update-failed-check-msi-tip", ""), - ("websocket_tip", ""), + ("update-failed-check-msi-tip", "انسٹالیشن کے طریقے کی جانچ ناکام۔ براہِ کرم \"Download\" بٹن پر کلک کر کے ریلیز صفحے سے ڈاؤن لوڈ کریں اور دستی طور پر اپ گریڈ کریں۔"), + ("websocket_tip", "WebSocket استعمال کرتے وقت صرف ریلے کنکشنز معاون ہیں۔"), ("Use WebSocket", "WebSocket استعمال کریں"), ("Trackpad speed", "ٹریک پیڈ کی رفتار"), ("Default trackpad speed", "ڈیفالٹ ٹریک پیڈ کی رفتار"), @@ -709,7 +690,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("The user is not an administrator.", "صارف ایڈمنسٹریٹر نہیں ہے"), ("Failed to check if the user is an administrator.", "صارف ایڈمنسٹریٹر ہے یا نہیں چیک کرنے میں ناکام"), ("Supported only in the installed version.", "صرف انسٹال شدہ ورژن میں معاونت کی جاتی ہے۔"), - ("elevation_username_tip", ""), + ("elevation_username_tip", "صارف نام یا ڈومین صارف نام درج کریں"), ("Preparing for installation ...", "انسٹالیشن کی تیاری ..."), ("Show my cursor", "میرا کرسر دکھائیں"), ("Scale custom", "اپنی مرضی کے مطابق پیمانہ"), @@ -725,28 +706,68 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Alias", "عرف نام"), ("ScrollEdge", "اسکرول ایج"), ("Allow insecure TLS fallback", "غیر محفوظ TLS فالبیک کی اجازت دیں"), - ("allow-insecure-tls-fallback-tip", ""), + ("allow-insecure-tls-fallback-tip", "پہلے سے طے شدہ طور پر RustDesk TLS استعمال کرنے والے پروٹوکولز کے لیے سرور کے سرٹیفکیٹ کی تصدیق کرتا ہے۔\nیہ اختیار فعال ہونے پر، تصدیق ناکام ہونے کی صورت میں RustDesk تصدیق کا مرحلہ چھوڑ کر آگے بڑھ جائے گا۔"), ("Disable UDP", "UDP کو غیر فعال کریں"), - ("disable-udp-tip", ""), - ("server-oss-not-support-tip", ""), + ("disable-udp-tip", "طے کرتا ہے کہ صرف TCP استعمال کیا جائے یا نہیں۔\nیہ اختیار فعال ہونے پر RustDesk UDP 21116 مزید استعمال نہیں کرے گا، اس کی جگہ TCP 21116 استعمال ہوگا۔"), + ("server-oss-not-support-tip", "نوٹ: RustDesk سرور OSS میں یہ خصوصیت شامل نہیں ہے۔"), ("input note here", "نوٹ یہاں درج کریں"), - ("note-at-conn-end-tip", ""), + ("note-at-conn-end-tip", "کنکشن کے اختتام پر نوٹ کے لیے پوچھیں"), ("Show terminal extra keys", "ٹرمنل اضافی کیز دکھائیں"), ("Relative mouse mode", "رشتہ دار ماؤس موڈ"), - ("rel-mouse-not-supported-peer-tip", ""), - ("rel-mouse-not-ready-tip", ""), - ("rel-mouse-lock-failed-tip", ""), - ("rel-mouse-exit-{}-tip", ""), - ("rel-mouse-permission-lost-tip", ""), + ("rel-mouse-not-supported-peer-tip", "منسلک فریق نسبتی ماؤس موڈ کی حمایت نہیں کرتا۔"), + ("rel-mouse-not-ready-tip", "نسبتی ماؤس موڈ ابھی تیار نہیں۔ براہِ کرم دوبارہ کوشش کریں۔"), + ("rel-mouse-lock-failed-tip", "کرسر مقفل کرنے میں ناکامی۔ نسبتی ماؤس موڈ بند کر دیا گیا ہے۔"), + ("rel-mouse-exit-{}-tip", "باہر نکلنے کے لیے {} دبائیں۔"), + ("rel-mouse-permission-lost-tip", "کی بورڈ کی اجازت واپس لے لی گئی۔ نسبتی ماؤس موڈ بند کر دیا گیا ہے۔"), ("Changelog", "تبدیلی کا لاگ"), - ("keep-awake-during-outgoing-sessions-label", ""), - ("keep-awake-during-incoming-sessions-label", ""), + ("keep-awake-during-outgoing-sessions-label", "بیرونی سیشنز کے دوران اسکرین بیدار رکھیں"), + ("keep-awake-during-incoming-sessions-label", "آنے والے سیشنز کے دوران اسکرین بیدار رکھیں"), ("Continue with {}", "continue-with-{}"), ("Display Name", "display-name"), - ("password-hidden-tip", ""), - ("preset-password-in-use-tip", ""), - ("terminal-clipboard-write-tip", ""), - ("Allow terminal apps to copy to clipboard", ""), + ("password-hidden-tip", "مستقل پاس ورڈ مقرر ہے (پوشیدہ)۔"), + ("preset-password-in-use-tip", "پہلے سے مقرر پاس ورڈ اس وقت استعمال میں ہے۔"), + ("terminal-clipboard-write-tip", "ٹرمنل میں ایک ایپ اس ڈیوائس کے کلپ بورڈ پر متن کاپی کرنا چاہتی ہے۔ اجازت دینے پر یہ اجازت تمام کنکشن کی ٹرمنل ایپس پر لاگو رہے گی جب تک آپ اسے ترتیبات میں بند نہ کر دیں۔ دستی کاپی اور پیسٹ متاثر نہیں ہوں گے۔"), + ("Allow terminal apps to copy to clipboard", "ٹرمنل ایپس کو کلپ بورڈ پر کاپی کرنے کی اجازت دیں"), + ("Export", "برآمد کریں"), + ("Export Logs", "لاگز برآمد کریں"), + ("Import Folder", "فولڈر درآمد کریں"), + ("Enable privacy mode", "پرائیویسی موڈ فعال کریں"), + ("allow-remote-toolbar-docking-any-edge", "ریموٹ ٹول بار کو ونڈو کے کسی بھی کنارے پر لگانے کی اجازت دیں"), + ("API Token", "API ٹوکن"), + ("Deploy", "تعینات کریں"), + ("Custom ID (optional)", "حسبِ ضرورت ID (اختیاری)"), + ("server_requires_deployment_tip", "سرور کا تقاضا ہے کہ یہ آلہ واضح طور پر تعینات کیا جائے۔ ابھی تعینات کریں؟"), + ("The server does not require explicit deployment.", "سرور کو واضح تعیناتی کی ضرورت نہیں۔"), + ("Unknown response.", "نامعلوم جواب۔"), + ("wayland-keyboard-input-disabled-tip", "کی بورڈ ان پٹ کی اجازت دیں؟"), + ("wayland-keyboard-input-consent-tip", "اس دور دراز کمپیوٹر پر آپ جو کچھ ٹائپ کریں گے (بشمول پاس ورڈ) اسے اس پر موجود دوسری ایپس پڑھ سکتی ہیں۔"), + ("wayland-keyboard-input-applies-to-tip", "یہ انتخاب اس پر لاگو ہوتا ہے:"), + ("wayland-soft-keyboard-input-label", "سافٹ کی بورڈ ان پٹ"), + ("wayland-keyboard-input-reset-choice-tip", "کی بورڈ ان پٹ کا انتخاب دوبارہ ترتیب دیں"), + ("remember-wayland-keyboard-choice-tip", "اس دور دراز کمپیوٹر کے لیے دوبارہ نہ پوچھیں"), + ("Why this happens", "ایسا کیوں ہوتا ہے"), + ("Switch display", "ڈسپلے تبدیل کریں"), + ("Show monitor switch button on the main toolbar", "مرکزی ٹول بار پر مانیٹر تبدیل کرنے کا بٹن دکھائیں"), + ("Show on the minimized toolbar", "چھوٹے کیے گئے ٹول بار پر دکھائیں"), + ("All monitors", "تمام مانیٹر"), + ("#{} monitor", "#{} مانیٹر"), + ("conn-e2ee-unavailable-tip", "اینڈ ٹو اینڈ خفیہ کاری کی تصدیق نہیں ہو سکی۔\nدور دراز آلہ ابھی ترتیب دیا جا رہا ہو سکتا ہے۔ بعد میں دوبارہ کوشش کریں۔\nاگر ایسا بار بار ہو تو ممکن ہے سرور قابلِ اعتماد نہ ہو۔\nپھر بھی جاری رکھیں؟"), + ("ID whitelisting", "ID وائٹ لسٹنگ"), + ("Use ID whitelisting", "ID وائٹ لسٹنگ استعمال کریں"), + ("id_whitelist_tip", "صرف وائٹ لسٹ میں شامل IDs مجھ تک رسائی حاصل کر سکتی ہیں"), + ("id_whitelist_wildcard_tip", "وائلڈ کارڈ معاون ہیں: '*' کسی بھی تعداد میں حروف سے مطابقت رکھتا ہے، '?' بالکل ایک حرف سے"), + ("Invalid ID", "غلط ID"), + ("Your ID is blocked by the peer", "آپ کی ID دوسرے فریق نے مسدود کر دی ہے"), + ("Your ip is blocked by the peer", "آپ کا IP دوسرے فریق نے مسدود کر دیا ہے"), + ("id_whitelist_caveat_tip", "ID کی اطلاع منسلک ہونے والا کلائنٹ خود دیتا ہے۔ یہ وائٹ لسٹ خطرے کو کم کرتی ہے، پاس ورڈ یا 2FA کا متبادل نہیں۔"), + ("whitelist_cidr_tip", "CIDR اشاریہ معاون ہے، مثلاً 192.168.1.0/24"), + ("Continue", "جاری رکھیں"), + ("Browser didn't open? Use the url below to sign in.", "براؤزر نہیں کھلا؟ سائن اِن کرنے کے لیے نیچے دیا گیا URL استعمال کریں۔"), + ("Lock canvas", "کینوس مقفل کریں"), + ("Sync clipboard between sessions", "سیشنز کے درمیان کلپ بورڈ ہم آہنگ کریں"), + ("sync-clipboard-between-sessions-tip", "ایک ریموٹ سیشن میں کاپی کیا گیا متن یا تصاویر آپ کے دیگر منسلک سیشنز کے کلپ بورڈ پر بھی بھیجی جاتی ہیں۔"), + ("Reuse one connection for port forwarding", "پورٹ فارورڈنگ کے لیے ایک ہی کنکشن دوبارہ استعمال کریں"), + ("port-forward-mux-tip", "ایک پورٹ فارورڈنگ کے تمام کنکشن دوسرے کمپیوٹر کے ساتھ بنے ایک ہی کنکشن سے گزرتے ہیں، ہر ایک کے لیے دوبارہ منسلک ہو کر لاگ اِن کرنے کے بجائے۔"), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 48c652f5a..59c94830e 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -659,9 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Không hỗ trợ chụp gộp nhiều màn hình."), ("screenshot-action-tip", "Hành động chụp màn hình"), ("Save as", "Lưu thành"), - ("Export", ""), - ("Export Logs", ""), - ("Import Folder", ""), + ("Export", "Xuất"), + ("Export Logs", "Xuất nhật ký"), + ("Import Folder", "Nhập thư mục"), ("Copy to clipboard", "Sao chép vào Clipboard"), ("Enable remote printer", "Bật máy in từ xa"), ("Downloading {}", "Đang tải xuống {}"), @@ -766,5 +766,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("terminal-clipboard-write-tip", ""), ("Allow terminal apps to copy to clipboard", ""), ("Enable", "Bật"), + ("Reuse one connection for port forwarding", "Dùng chung một kết nối cho chuyển tiếp cổng"), + ("port-forward-mux-tip", "Chuyển toàn bộ kết nối của một quy tắc chuyển tiếp cổng qua một kết nối duy nhất tới máy đối phương, thay vì kết nối và đăng nhập lại cho từng kết nối."), ].iter().cloned().collect(); } diff --git a/src/lib.rs b/src/lib.rs index 20d5d6aab..1bfe2698c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ mod custom_server; mod lang; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod port_forward; +mod port_forward_mux; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod tray; diff --git a/src/port_forward.rs b/src/port_forward.rs index 0cc640663..fd3d1eecf 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, RwLock}; use crate::client::*; +use crate::port_forward_mux::{Claim, Tunnel, CHANNEL_WINDOW}; use hbb_common::{ allow_err, bail, config::READ_TIMEOUT, @@ -88,10 +89,33 @@ pub async fn listen( run_rdp(addr.port(), &rdp_display_name(&lc, &id)); } let mut ui_receiver = ui_receiver; + // One tunnel per mapping; the listener drops it on its way out, and that + // ends the tunnel. + let tunnel = Tunnel::new(); loop { tokio::select! { Ok((forward, addr)) = listener.accept() => { log::info!("new connection from {:?}", addr); + // A multiplexed window takes the connection on the mapping's + // tunnel, or probes for one on its first accept. Everything + // else, the setting off or a peer without the feature, is the + // raw pipe below, as it always was. + let claim = if lc.read().unwrap().port_forward_mux { tunnel.claim() } else { Claim::Legacy }; + match claim { + Claim::Muxed(handle) => { + if let Err(e) = handle.open(&remote_host, remote_port, forward, Vec::new()) { + log::debug!("cannot open channel for {:?}: {}", addr, e); + } + continue; + } + Claim::Claimed => { + if establish_tunnel(&tunnel, &id, &password, &mut ui_receiver, &interface, forward, addr, key, token, is_rdp, &remote_host, remote_port).await { + break; + } + continue; + } + Claim::Legacy => {} + } let id = id.clone(); let password = password.clone(); let mut forward = Framed::new(forward, BytesCodec::new()); @@ -181,7 +205,7 @@ async fn connect_and_login( match msg_in.union { Some(message::Union::Hash(hash)) => { challenge = Some(hash.clone()); - if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, &mut stream).await { + if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, false, &mut stream).await { return Ok(None); } } @@ -213,7 +237,7 @@ async fn connect_and_login( d = ui_receiver.recv() => { match d { Some(Data::Login(login)) => match &challenge { - Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, &mut stream).await, + Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, false, &mut stream).await, None => pending_login = Some(login), }, Some(Data::Message(msg)) => { @@ -240,22 +264,24 @@ async fn connect_and_login( /// A mapping's login is built from the window's shared handler: -/// `create_login_msg` reads `port_forward` and `handle_login_from_ui` reads -/// `hash`. Mappings log in concurrently, so each fills them and sends under -/// the window's turn lock, or one login carried another mapping's target or -/// answered another's challenge. +/// `create_login_msg` reads `port_forward` and `port_forward_multiplex`, +/// `handle_login_from_ui` reads `hash`. Mappings log in concurrently, so each +/// fills them and sends under the window's turn lock, or one login carried +/// another mapping's target or answered another's challenge. async fn login_with_hash( interface: &impl Interface, password: &str, hash: Hash, remote_host: &str, remote_port: i32, + mux: bool, stream: &mut Stream, ) -> bool { let lc = interface.get_lch(); let turn = lc.read().unwrap().port_forward_login_turn.clone(); let _turn = turn.lock().await; lc.write().unwrap().port_forward = (remote_host.to_owned(), remote_port); + lc.write().unwrap().port_forward_multiplex = mux; interface.handle_hash(password, hash, stream).await } @@ -273,14 +299,15 @@ async fn hash_arrived( pending_login: Option, remote_host: &str, remote_port: i32, + mux: bool, stream: &mut Stream, ) -> bool { match pending_login { Some(login) => { - login_from_ui(interface, &hash, login, remote_host, remote_port, stream).await; + login_from_ui(interface, &hash, login, remote_host, remote_port, mux, stream).await; true } - None => login_with_hash(interface, password, hash, remote_host, remote_port, stream).await, + None => login_with_hash(interface, password, hash, remote_host, remote_port, mux, stream).await, } } @@ -292,6 +319,7 @@ async fn login_from_ui( login: UiLogin, remote_host: &str, remote_port: i32, + mux: bool, stream: &mut Stream, ) { let lc = interface.get_lch(); @@ -300,6 +328,7 @@ async fn login_from_ui( { let mut lc = lc.write().unwrap(); lc.port_forward = (remote_host.to_owned(), remote_port); + lc.port_forward_multiplex = mux; lc.set_hash(hash.clone()); } let (os_username, os_password, password, remember) = login; @@ -308,6 +337,227 @@ async fn login_from_ui( .await; } +/// The first accept of a multiplexed mapping. It logs in asking for the +/// tunnel, and the peer's answer fixes this listener's mode until it closes: +/// a peer with the feature gets a tunnel every later accept joins, one +/// without gets today's raw pipe for this connection and `Legacy` for the +/// rest. Re-adding the mapping is how a user picks up an upgraded peer; +/// nothing switches modes underneath live connections. Returns `true` when +/// the listener should stop. +async fn establish_tunnel( + tunnel: &Tunnel, + id: &str, + password: &str, + ui_receiver: &mut mpsc::UnboundedReceiver, + interface: &impl Interface, + forward: TcpStream, + addr: std::net::SocketAddr, + key: &str, + token: &str, + is_rdp: bool, + remote_host: &str, + remote_port: i32, +) -> bool { + let mut forward = Framed::new(forward, BytesCodec::new()); + let mut close_port_forward = false; + match connect_and_login_mux(id, password, ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward, remote_host, remote_port).await { + Ok(Some(outcome)) if outcome.mux => { + let handle = tunnel.set_muxed(outcome.stream, interface.clone()); + if !outcome.local_eof { + let (socket, prebuf) = take_socket(forward, outcome.prebuf); + if let Err(e) = handle.open(remote_host, remote_port, socket, prebuf) { + log::debug!("cannot open channel for {:?}: {}", addr, e); + } + } + } + Ok(Some(outcome)) => { + tunnel.set_legacy(); + if outcome.local_eof { + log::debug!("legacy peer and local {:?} already gone", addr); + } else { + run_legacy(outcome, forward, addr, interface.clone()); + } + } + _ if close_port_forward => { + tunnel.set_failed(); + return true; + } + Err(err) => { + tunnel.set_failed(); + interface.on_establish_connection_error(err.to_string()); + } + _ => tunnel.set_failed(), + } + false +} + +/// `connect_and_login` for a mapping that wants the tunnel: the pre-read +/// stops at one window rather than growing without bound, and a local EOF +/// no longer ends the login, since the tunnel may still be wanted. It +/// reports what the peer answered rather than a raw stream, because the +/// caller's next step depends on it. The login itself is the raw pipe's, +/// told to ask for the tunnel. +async fn connect_and_login_mux( + id: &str, + password: &str, + ui_receiver: &mut mpsc::UnboundedReceiver, + interface: impl Interface, + forward: &mut Framed, + key: &str, + token: &str, + is_rdp: bool, + close_port_forward: &mut bool, + remote_host: &str, + remote_port: i32, +) -> ResultType> { + let conn_type = if is_rdp { + ConnType::RDP + } else { + ConnType::PORT_FORWARD + }; + let ((mut stream, direct, _pk, _kcp, _stream_type), (feedback, rendezvous_server)) = + Client::start(id, key, token, conn_type, interface.clone()).await?; + interface.update_direct(Some(direct)); + if !stream.is_secured() && !crate::common::is_direct_ip_access(id) { + if !confirm_insecure_connection(&interface, ui_receiver).await { + *close_port_forward = true; + return Ok(None); + } + } + let mut buffer = Vec::new(); + let mut local_eof = false; + let mux; + let mut received = false; + let mut challenge = None; + let mut pending_login = None; + + let _keep_it = hc_connection(feedback, rendezvous_server, token).await; + + loop { + tokio::select! { + res = timeout(READ_TIMEOUT, stream.next()) => match res { + Err(_) => { + bail!("Timeout"); + } + Ok(Some(Ok(bytes))) => { + if !received { + received = true; + interface.update_received(true); + } + let msg_in = Message::parse_from_bytes(&bytes)?; + match msg_in.union { + Some(message::Union::Hash(hash)) => { + challenge = Some(hash.clone()); + if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, true, &mut stream).await { + return Ok(None); + } + } + Some(message::Union::LoginResponse(lr)) => match lr.union { + Some(login_response::Union::Error(err)) => { + if !interface.handle_login_error(&err) { + return Ok(None); + } + } + Some(login_response::Union::PeerInfo(pi)) => { + mux = peer_supports_mux(&pi); + interface.handle_peer_info(pi); + break; + } + _ => {} + } + Some(message::Union::TestDelay(t)) => { + interface.handle_test_delay(t, &mut stream).await; + } + _ => {} + } + } + Ok(Some(Err(err))) => { + bail!("Connection closed: {}", err); + } + _ => { + bail!("Reset by the peer"); + } + }, + d = ui_receiver.recv() => { + match d { + Some(Data::Login(login)) => match &challenge { + Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, true, &mut stream).await, + None => pending_login = Some(login), + }, + Some(Data::Message(msg)) => { + allow_err!(stream.send(&msg).await); + } + _ => {} + } + }, + // Stop pulling once the pre-read buffer is a window deep; the + // rest waits in the kernel until the channel opens. A local EOF + // no longer aborts the login: the tunnel may still be wanted. + res = forward.next(), if !local_eof && buffer.len() < CHANNEL_WINDOW as usize => { + if let Some(Ok(bytes)) = res { + buffer.extend(bytes); + } else { + local_eof = true; + } + }, + } + } + Ok(Some(LoginOutcome { + stream, + mux, + prebuf: buffer, + local_eof, + })) +} + +/// Today's raw pipe, for peers without multiplexing. +fn run_legacy( + outcome: LoginOutcome, + forward: Framed, + addr: std::net::SocketAddr, + interface: impl Interface, +) { + let mut stream = outcome.stream; + let prebuf = outcome.prebuf; + tokio::spawn(async move { + stream.set_raw(); + if !prebuf.is_empty() { + allow_err!(stream.send_bytes(prebuf.into()).await); + } + if let Err(err) = run_forward(forward, stream).await { + interface.msgbox("error", "Error", &err.to_string(), ""); + } + log::info!("connection from {:?} closed", addr); + }); +} + +struct LoginOutcome { + stream: Stream, + mux: bool, + prebuf: Vec, + local_eof: bool, +} + +fn peer_supports_mux(pi: &PeerInfo) -> bool { + pi.features.as_ref().map(|f| f.port_forward_mux).unwrap_or(false) +} + +/// `into_inner()` would drop bytes the codec pulled but never yielded. +fn take_socket(forward: Framed, mut prebuf: Vec) -> (TcpStream, Vec) { + let parts = forward.into_parts(); + prebuf.extend_from_slice(&parts.read_buf); + (parts.io, prebuf) +} + +/// The controlling side's `enable-port-forward-mux`: on unless set to `N`. +pub fn mux_enabled() -> bool { + use hbb_common::config::{keys, option2bool, LocalConfig}; + option2bool( + keys::OPTION_ENABLE_PORT_FORWARD_MUX, + &LocalConfig::get_option(keys::OPTION_ENABLE_PORT_FORWARD_MUX), + ) +} + async fn run_forward(forward: Framed, stream: Stream) -> ResultType<()> { log::info!("new port forwarding connection started"); let mut forward = forward; @@ -453,8 +703,8 @@ mod login_tests { let (mut a, mut a_peer) = loopback().await; let (mut b, mut b_peer) = loopback().await; tokio::join!( - login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a), - login_with_hash(&ui, "pw", hash("b"), "b", 2, &mut b), + login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a), + login_with_hash(&ui, "pw", hash("b"), "b", 2, false, &mut b), ); assert_eq!(target(&login_at(&mut a_peer).await), ("a".to_owned(), 1)); assert_eq!(target(&login_at(&mut b_peer).await), ("b".to_owned(), 2)); @@ -472,10 +722,10 @@ mod login_tests { let (mut a, mut a_peer) = loopback().await; let (mut b, mut b_peer) = loopback().await; // A's hash arrived last, so it is the one the handler holds. - assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a).await); + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a).await); login_at(&mut a_peer).await; let typed = (String::new(), String::new(), "pw".to_owned(), false); - login_from_ui(&ui, &hash("b"), typed, "b", 2, &mut b).await; + login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut b).await; let lr = login_at(&mut b_peer).await; assert_eq!(lr.password, digest("b")); assert_eq!(target(&lr), ("b".to_owned(), 2)); @@ -494,10 +744,110 @@ mod login_tests { // The prompt's password reached B before its hash, and no other // mapping has stored it in the handler yet. let typed = (String::new(), String::new(), "pw".to_owned(), false); - assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, &mut b).await); + assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, false, &mut b).await); let lr = login_at(&mut b_peer).await; assert_eq!(lr.password, digest("b")); assert_eq!(target(&lr), ("b".to_owned(), 2)); }); } + + #[test] + fn a_raw_pipe_login_on_a_multiplexed_window_does_not_ask_for_the_tunnel() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ui = window(); + // The window probes for the tunnel, but this mapping latched to + // the raw pipe: its login must read as the raw pipe's, or an + // upgraded peer answers with a tunnel it then never gets. + ui.lc.write().unwrap().port_forward_mux = true; + let (mut a, mut a_peer) = loopback().await; + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, false, &mut a).await); + assert!(!login_at(&mut a_peer).await.port_forward().multiplex); + }); + } + + #[test] + fn a_password_typed_at_the_prompt_keeps_a_raw_pipe_login_raw() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ui = window(); + ui.lc.write().unwrap().port_forward_mux = true; + let (mut b, mut b_peer) = loopback().await; + let typed = (String::new(), String::new(), "pw".to_owned(), false); + login_from_ui(&ui, &hash("b"), typed, "b", 2, false, &mut b).await; + assert!(!login_at(&mut b_peer).await.port_forward().multiplex); + }); + } + + #[test] + fn a_probing_login_asks_for_the_tunnel() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ui = window(); + let (mut a, mut a_peer) = loopback().await; + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, true, &mut a).await); + assert!(login_at(&mut a_peer).await.port_forward().multiplex); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peer_supports_mux_reads_the_features_bit() { + let mut pi = PeerInfo::new(); + assert!(!peer_supports_mux(&pi)); + pi.features = Some(Features { port_forward_mux: false, ..Default::default() }).into(); + assert!(!peer_supports_mux(&pi)); + pi.features = Some(Features { port_forward_mux: true, ..Default::default() }).into(); + assert!(peer_supports_mux(&pi)); + } + + #[test] + fn port_forward_mux_defaults_to_on() { + use hbb_common::config::{keys, option2bool}; + // option2bool's fallback branch is also "on unless N", so the value + // assertions below would pass for a prefixless key too. The `enable-` + // prefix is what actually guarantees the default, and renaming the key + // to an `allow-` one would silently flip it — pin the prefix itself. + assert!(keys::OPTION_ENABLE_PORT_FORWARD_MUX.starts_with("enable-")); + assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "")); + assert!(option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "Y")); + assert!(!option2bool(keys::OPTION_ENABLE_PORT_FORWARD_MUX, "N")); + } + + #[test] + fn take_socket_hands_back_a_working_socket_and_the_prebuf() { + use hbb_common::tokio::io::{AsyncReadExt, AsyncWriteExt}; + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(async { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let mut client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = l.accept().await.unwrap(); + let mut framed = Framed::new(server, BytesCodec::new()); + client.write_all(b"abc").await.unwrap(); + // Read through the codec, as connect_and_login does during login. + let pulled = framed.next().await.unwrap().unwrap(); + assert_eq!(&pulled[..], b"abc"); + let (mut sock, prebuf) = take_socket(framed, pulled.to_vec()); + assert_eq!(prebuf, b"abc".to_vec()); + // Bytes written after the handoff arrive on the bare socket. + client.write_all(b"def").await.unwrap(); + let mut buf = [0u8; 3]; + sock.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"def"); + }); + } } diff --git a/src/port_forward_mux.rs b/src/port_forward_mux.rs new file mode 100644 index 000000000..ec54f1129 --- /dev/null +++ b/src/port_forward_mux.rs @@ -0,0 +1,1739 @@ +use hbb_common::{ + bytes::Bytes, + log, + message_proto::*, + tokio::{ + self, + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::{mpsc, watch, Notify}, + time::Instant, + }, + ResultType, +}; +use std::sync::{Arc, Mutex}; + +/// On the wire and fixed forever: what the controller may have in flight on a +/// channel before `opened` brings the peer's window. +pub const INITIAL_WINDOW: u32 = 64 * 1024; +/// Also on the wire and fixed forever: the window a `data` frame costs at +/// minimum, whatever its length. It bounds the per-frame bookkeeping a peer +/// can make us hold — 1-byte frames would otherwise cost it one byte and us +/// a queue entry. +pub const MIN_FRAME_CHARGE: u32 = 64; +pub const CHANNEL_WINDOW: u32 = 256 * 1024; +pub const MAX_FRAME: usize = 64 * 1024; +/// Cap on one framed packet once the tunnel is up: a `MAX_FRAME` data frame, +/// its protobuf envelope and the 16-byte MAC fit with room to spare. The codec +/// otherwise takes a header declaring up to 1 GiB, and the channel window is +/// only checked once the whole packet has arrived. +pub const MAX_PACKET: usize = 2 * MAX_FRAME; +pub const UPDATE_THRESHOLD: u32 = CHANNEL_WINDOW / 2; +pub const MAX_CHANNELS: usize = 256; +pub const DATA_QUEUE_FRAMES: usize = 128; +/// Never keep more than our own advertised window in flight, whatever the peer +/// offers. The controlled side's sink is unbounded, so credit is the only bound +/// on how much target data it buffers, and the peer chooses that number. +pub const MAX_SEND_CREDIT: u32 = CHANNEL_WINDOW; + +pub fn effective_window(advertised: u32) -> u32 { + advertised.max(INITIAL_WINDOW) +} + +/// For a tunnel's stream once multiplexing is agreed, on both sides. The +/// WebSocket and WebRTC codecs carry caps of their own. +pub fn cap_packet_size(stream: &mut hbb_common::Stream) { + if let hbb_common::Stream::Tcp(s) = stream { + s.0.codec_mut().set_max_packet_length(MAX_PACKET); + } +} + +/// What a `data` frame of this length costs its channel's window. +pub fn charge(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX).max(MIN_FRAME_CHARGE) +} + +/// Receiver-side accounting: the credit the peer still has, and what we have +/// drained to the local socket since the last `window_update`. Both are +/// bounded — a cumulative counter would wear out on a long transfer. +pub struct RecvWindow { + remaining: u32, + drained_since_update: u32, +} + +impl RecvWindow { + pub fn new(granted: u32) -> Self { + Self { + remaining: effective_window(granted), + drained_since_update: 0, + } + } + + /// False means the peer overran the window: a protocol violation. + pub fn accept(&mut self, len: usize) -> bool { + match self.remaining.checked_sub(charge(len)) { + Some(left) => { + self.remaining = left; + true + } + None => false, + } + } + + /// Widens the window without advertising: the grant travels in `opened`. + pub fn grant(&mut self, n: u32) { + self.remaining = self.remaining.saturating_add(n); + } + + #[cfg(test)] + pub fn remaining(&self) -> u32 { + self.remaining + } + + /// Returns the amount to advertise in a `window_update` once enough has + /// been drained; the same amount is credited back. + pub fn drained(&mut self, n: usize) -> Option { + self.drained_since_update = self.drained_since_update.saturating_add(charge(n)); + if self.drained_since_update < UPDATE_THRESHOLD { + return None; + } + let add = self.drained_since_update; + self.drained_since_update = 0; + self.remaining = self.remaining.saturating_add(add); + Some(add) + } +} + +/// Sender-side credit. `take` parks until credit is available; the lock is +/// never held across an await. +pub struct SendCredit { + credit: Mutex, + notify: Notify, +} + +impl SendCredit { + pub fn new(initial: u32) -> Self { + Self { + credit: Mutex::new(initial.min(MAX_SEND_CREDIT)), + notify: Notify::new(), + } + } + + /// `max` must be at least `MIN_FRAME_CHARGE` (`MAX_FRAME` is), and the + /// caller pays `charge(bytes_read)` and refunds the rest — so this parks + /// until a whole minimum charge is available rather than at zero. + pub async fn take(&self, max: usize) -> usize { + debug_assert!(max >= MIN_FRAME_CHARGE as usize); + loop { + { + let mut credit = self.credit.lock().unwrap(); + if *credit >= MIN_FRAME_CHARGE { + let n = (*credit as usize).min(max); + *credit -= n as u32; + return n; + } + } + self.notify.notified().await; + } + } + + pub fn add(&self, n: u32) { + { + let mut credit = self.credit.lock().unwrap(); + *credit = credit.saturating_add(n).min(MAX_SEND_CREDIT); + } + self.notify.notify_one(); + } + + /// The controller starts a channel with `INITIAL_WINDOW` of credit; when + /// `opened` advertises the peer's real window this re-bases to it. + pub fn raise_initial(&self, total: u32) { + let extra = effective_window(total) - INITIAL_WINDOW; + if extra > 0 { + self.add(extra); + } + } +} + +fn channel_msg(union: port_forward_channel::Union) -> Message { + let mut ch = PortForwardChannel::new(); + ch.union = Some(union); + let mut msg = Message::new(); + msg.set_port_forward_channel(ch); + msg +} + +pub fn open_msg(id: i32, host: &str, port: i32, window: u32) -> Message { + channel_msg(port_forward_channel::Union::Open(PortForwardOpen { + channel_id: id, + host: host.to_owned(), + port, + window, + ..Default::default() + })) +} + +pub fn opened_msg(id: i32, success: bool, message: &str, window: u32) -> Message { + channel_msg(port_forward_channel::Union::Opened(PortForwardOpened { + channel_id: id, + success, + message: message.to_owned(), + window, + ..Default::default() + })) +} + +pub fn data_msg(id: i32, data: Bytes) -> Message { + channel_msg(port_forward_channel::Union::Data(PortForwardData { + channel_id: id, + data, + ..Default::default() + })) +} + +pub fn close_msg(id: i32) -> Message { + channel_msg(port_forward_channel::Union::Close(PortForwardClose { + channel_id: id, + ..Default::default() + })) +} + +pub fn window_update_msg(id: i32, add: u32) -> Message { + channel_msg(port_forward_channel::Union::WindowUpdate(PortForwardWindowUpdate { + channel_id: id, + add, + ..Default::default() + })) +} + +/// Where a channel's frames go. The controller keeps two queues so +/// `window_update` can bypass bulk data; the controlled side has the +/// connection's single ordered `inner.tx`. `open` is not control: it rides +/// the ordered queue so it can never arrive after the channel's first `data`. +#[derive(Clone)] +pub enum FrameSink { + Queued { + data: mpsc::Sender, + control: mpsc::UnboundedSender, + }, + Direct(mpsc::UnboundedSender<(Instant, Arc)>), +} + +fn writer_gone(what: &str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, format!("{} gone", what)) +} + +impl FrameSink { + pub async fn send_ordered(&self, msg: Message) -> ResultType<()> { + match self { + FrameSink::Queued { data, .. } => data + .send(msg) + .await + .map_err(|_| writer_gone("tunnel writer").into()), + FrameSink::Direct(tx) => tx + .send((Instant::now(), Arc::new(msg))) + .map_err(|_| writer_gone("connection writer").into()), + } + } + + pub fn send_control(&self, msg: Message) -> ResultType<()> { + match self { + FrameSink::Queued { control, .. } => control + .send(msg) + .map_err(|_| writer_gone("tunnel writer").into()), + FrameSink::Direct(tx) => tx + .send((Instant::now(), Arc::new(msg))) + .map_err(|_| writer_gone("connection writer").into()), + } + } + + pub fn is_closed(&self) -> bool { + match self { + FrameSink::Queued { data, .. } => data.is_closed(), + FrameSink::Direct(tx) => tx.is_closed(), + } + } +} + +pub enum Inbound { + Data(Bytes), + Close, + /// The demultiplexer found the peer over its window; the channel task + /// closes and tells the peer, so the frame still leaves in order. + Violation, +} + +#[derive(Debug, PartialEq)] +pub enum RelayEnd { + LocalEof, + PeerClosed, + Violation, + Cancelled, + TunnelGone, +} + +/// Local socket -> tunnel, under the peer's credit. `prebuf` is simply the +/// head of the byte stream. +async fn relay_socket_to_tunnel( + id: i32, + reader: R, + prebuf: Vec, + credit: Arc, + sink: FrameSink, + mut cancel: watch::Receiver, +) -> RelayEnd { + let mut reader = std::io::Cursor::new(prebuf).chain(reader); + // One scratch buffer per channel and an exact-size copy per frame: a frame + // that owned its read allocation would pin up to MAX_FRAME until sent, + // whatever its length, and interactive traffic is mostly tiny frames. + let mut scratch = vec![0u8; MAX_FRAME]; + loop { + let allow = tokio::select! { + n = credit.take(MAX_FRAME) => n, + _ = cancel.changed() => return RelayEnd::Cancelled, + }; + let got = tokio::select! { + r = reader.read(&mut scratch[..allow]) => r.unwrap_or(0), + _ = cancel.changed() => { + credit.add(allow as u32); + return RelayEnd::Cancelled; + } + }; + let spent = if got == 0 { 0 } else { charge(got) }; + if (spent as usize) < allow { + credit.add(allow as u32 - spent); + } + if got == 0 { + return RelayEnd::LocalEof; + } + let frame = data_msg(id, Bytes::copy_from_slice(&scratch[..got])); + if sink.send_ordered(frame).await.is_err() { + return RelayEnd::TunnelGone; + } + } +} + +/// Tunnel -> local socket. `initial` is written before anything from the +/// queue (the controlled side's bytes buffered while connecting). +async fn relay_tunnel_to_socket( + id: i32, + mut writer: W, + initial: Vec, + mut inbound: mpsc::UnboundedReceiver, + window: Arc>, + sink: FrameSink, + mut cancel: watch::Receiver, +) -> RelayEnd { + let mut pending: std::collections::VecDeque = initial.into(); + loop { + let chunk = match pending.pop_front() { + Some(c) => c, + None => { + let next = tokio::select! { + n = inbound.recv() => n, + _ = cancel.changed() => return RelayEnd::Cancelled, + }; + match next { + Some(Inbound::Data(c)) => c, + Some(Inbound::Close) => return RelayEnd::PeerClosed, + Some(Inbound::Violation) => return RelayEnd::Violation, + None => return RelayEnd::TunnelGone, + } + } + }; + let written = tokio::select! { + r = writer.write_all(&chunk) => r.is_ok(), + _ = cancel.changed() => return RelayEnd::Cancelled, + }; + if !written { + return RelayEnd::LocalEof; + } + let update = window.lock().unwrap().drained(chunk.len()); + if let Some(add) = update { + if sink.send_control(window_update_msg(id, add)).is_err() { + return RelayEnd::TunnelGone; + } + } + } +} + +/// Runs both halves as independent tasks; whichever ends first cancels the +/// other. Sends `close` once, after the last data, and only when the channel +/// ended for a local reason — the peer's own `close` is never echoed. +/// `teardown` is the tunnel closing under the channel: it cancels both halves +/// even when they are parked on the socket, where dropping the inbound sender +/// reaches neither. It is a level, so a channel opened as the tunnel closes, +/// subscribing after the signal went out, still sees it. +pub async fn run_channel( + id: i32, + reader: R, + writer: W, + prebuf: Vec, + initial_out: Vec, + credit: Arc, + window: Arc>, + inbound: mpsc::UnboundedReceiver, + sink: FrameSink, + mut teardown: watch::Receiver, +) where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let (cancel_tx, cancel_rx) = watch::channel(false); + let mut to_tunnel = tokio::spawn(relay_socket_to_tunnel( + id, reader, prebuf, credit, sink.clone(), cancel_rx.clone(), + )); + let mut to_socket = tokio::spawn(relay_tunnel_to_socket( + id, writer, initial_out, inbound, window, sink.clone(), cancel_rx, + )); + let (first, second) = tokio::select! { + r = &mut to_tunnel => { + let _ = cancel_tx.send(true); + (r.unwrap_or(RelayEnd::Cancelled), to_socket.await.unwrap_or(RelayEnd::Cancelled)) + } + r = &mut to_socket => { + let _ = cancel_tx.send(true); + (r.unwrap_or(RelayEnd::Cancelled), to_tunnel.await.unwrap_or(RelayEnd::Cancelled)) + } + // Wrapped so `select!` keeps a `bool`, not the `Ref` (a read guard, + // not `Send`) it would otherwise hold across the joins. + _ = async { teardown.wait_for(|down| *down).await.is_ok() } => { + let _ = cancel_tx.send(true); + (to_tunnel.await.unwrap_or(RelayEnd::Cancelled), to_socket.await.unwrap_or(RelayEnd::Cancelled)) + } + }; + let peer_closed = first == RelayEnd::PeerClosed || second == RelayEnd::PeerClosed; + let tunnel_gone = first == RelayEnd::TunnelGone || second == RelayEnd::TunnelGone; + let local_reason = matches!(first, RelayEnd::LocalEof | RelayEnd::Violation) + || matches!(second, RelayEnd::LocalEof | RelayEnd::Violation); + if !peer_closed && !tunnel_gone && local_reason { + if let Err(e) = sink.send_ordered(close_msg(id)).await { + log::debug!("port forward channel {} close not sent: {}", id, e); + } + } + log::debug!("port forward channel {} ended: {:?} / {:?}", id, first, second); +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use tunnel::{Claim, Tunnel}; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod tunnel { + use super::*; + use crate::client::Interface; + use hbb_common::{ + config::READ_TIMEOUT, + protobuf::Message as _, + tokio::net::TcpStream, + Stream, + }; + use std::{ + collections::HashMap, + sync::atomic::{AtomicI32, Ordering}, + time::Duration, + }; + + /// A refused reason is shown once, then not again until it has been quiet + /// for this long: a page load's dozen refusals make one dialog, and a + /// target that breaks again hours later is reported again. + const REPORT_AGAIN_AFTER: Duration = Duration::from_secs(10); + /// Distinct reasons remembered at once, so the dialog count stays bounded + /// however the peer varies the text it sends. + pub(super) const MAX_REPORTED_OPEN_ERRORS: usize = 8; + + // Internal state only; `Claim` is the API listeners see. + enum TunnelState { + Unset, + Muxed(Arc), + Legacy, + Failed, + } + + pub enum Claim { + Claimed, + Muxed(Arc), + Legacy, + } + + /// One per listener. The accept loop owns it and reads it between + /// accepts; the tunnel loop resets it when it ends, so the next accept + /// establishes again. + pub struct Tunnel { + state: watch::Sender, + /// Never sent on. The loop's receiver errors when the listener drops + /// this `Tunnel`, and that is what ends a tunnel nothing else ends. + lifetime: watch::Sender<()>, + } + + impl Tunnel { + pub fn new() -> Self { + let (state, _) = watch::channel(TunnelState::Unset); + let (lifetime, _) = watch::channel(()); + Self { state, lifetime } + } + + pub fn claim(&self) -> Claim { + match &*self.state.borrow() { + TunnelState::Unset | TunnelState::Failed => Claim::Claimed, + TunnelState::Muxed(h) => Claim::Muxed(h.clone()), + TunnelState::Legacy => Claim::Legacy, + } + } + + pub fn set_muxed(&self, mut stream: Stream, interface: impl Interface) -> Arc { + cap_packet_size(&mut stream); + let (data_tx, data_rx) = mpsc::channel(DATA_QUEUE_FRAMES); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let handle = Arc::new(TunnelHandle { + sink: FrameSink::Queued { data: data_tx, control: control_tx }, + channels: Mutex::new(HashMap::new()), + next_id: AtomicI32::new(1), + reported: Default::default(), + teardown: watch::channel(false).0, + }); + let state = self.state.clone(); + // Publish before spawning: if the loop exits first and resets the + // state, a later publish here would pin it at Muxed with a dead + // handle and the listener could never re-establish. + self.state.send_replace(TunnelState::Muxed(handle.clone())); + tokio::spawn(tunnel_loop( + stream, + handle.clone(), + data_rx, + control_rx, + interface, + state, + self.lifetime.subscribe(), + )); + handle + } + + pub fn set_legacy(&self) { + self.state.send_replace(TunnelState::Legacy); + } + + pub fn set_failed(&self) { + self.state.send_replace(TunnelState::Failed); + } + } + + struct ChannelEntry { + inbound: mpsc::UnboundedSender, + credit: Arc, + window: Arc>, + opened: bool, + } + + pub struct TunnelHandle { + sink: FrameSink, + channels: Mutex>, + next_id: AtomicI32, + reported: Mutex>, + /// Raised once, by `close_all`, for the channels its `clear` cannot + /// reach: one parked on its local socket is not on the inbound queue. + teardown: watch::Sender, + } + + impl TunnelHandle { + /// `open` leaves from inside the channel's own task, down the ordered + /// data queue, ahead of the channel's first `data`. On the control + /// queue it could be overtaken by that `data` whenever the tunnel loop + /// resumes with both queues non-empty. + pub fn open( + self: &Arc, + host: &str, + port: i32, + socket: TcpStream, + prebuf: Vec, + ) -> ResultType<()> { + if self.sink.is_closed() { + hbb_common::bail!("port forward tunnel is gone"); + } + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let credit = Arc::new(SendCredit::new(INITIAL_WINDOW)); + let window = Arc::new(Mutex::new(RecvWindow::new(CHANNEL_WINDOW))); + let id = { + let mut channels = self.channels.lock().unwrap(); + if channels.len() >= MAX_CHANNELS { + hbb_common::bail!("too many port forward channels"); + } + // Once the counter has come all the way round it can land on + // a channel still up; fewer than MAX_CHANNELS are, so a free + // id is a few steps away. + let mut id = self.next_id.fetch_add(1, Ordering::Relaxed); + while channels.contains_key(&id) { + id = self.next_id.fetch_add(1, Ordering::Relaxed); + } + channels.insert( + id, + ChannelEntry { + inbound: inbound_tx, + credit: credit.clone(), + window: window.clone(), + opened: false, + }, + ); + id + }; + let open = open_msg(id, host, port, CHANNEL_WINDOW); + let (reader, writer) = socket.into_split(); + let sink = self.sink.clone(); + let teardown = self.teardown.subscribe(); + let handle = self.clone(); + tokio::spawn(async move { + if sink.send_ordered(open).await.is_ok() { + run_channel(id, reader, writer, prebuf, Vec::new(), credit, window, inbound_rx, sink, teardown).await; + } + handle.channels.lock().unwrap().remove(&id); + }); + Ok(()) + } + + pub(super) fn on_frame(&self, ch: PortForwardChannel) -> Option { + match ch.union { + Some(port_forward_channel::Union::Opened(o)) => { + let refused = { + let mut channels = self.channels.lock().unwrap(); + if o.success { + // A repeated `opened` must not raise the credit again. + if let Some(e) = channels.get_mut(&o.channel_id) { + if !e.opened { + e.opened = true; + e.credit.raise_initial(o.window); + } + } + None + } else if let Some(e) = channels.remove(&o.channel_id) { + log::debug!("port forward channel {} refused: {}", o.channel_id, o.message); + e.inbound.send(Inbound::Close).ok(); + Some(o.message) + } else { + None + } + }; + refused.and_then(|message| self.first_report(message)) + } + Some(port_forward_channel::Union::Data(d)) => { + let mut channels = self.channels.lock().unwrap(); + let Some(e) = channels.get(&d.channel_id) else { + log::debug!("port forward data for unknown channel {}", d.channel_id); + return None; + }; + let accepted = e.window.lock().unwrap().accept(d.data.len()); + let delivered = accepted && e.inbound.send(Inbound::Data(d.data)).is_ok(); + if delivered { + return None; + } + // Dropped here and now, so the peer cannot queue anything more + // for this id while the task is still on its way out. + if let Some(e) = channels.remove(&d.channel_id) { + if !accepted { + log::warn!("port forward channel {} overran its window", d.channel_id); + e.inbound.send(Inbound::Violation).ok(); + } + } + None + } + Some(port_forward_channel::Union::Close(c)) => { + if let Some(e) = self.channels.lock().unwrap().remove(&c.channel_id) { + e.inbound.send(Inbound::Close).ok(); + } + None + } + Some(port_forward_channel::Union::WindowUpdate(u)) => { + if let Some(e) = self.channels.lock().unwrap().get(&u.channel_id) { + e.credit.add(u.add); + } + None + } + Some(port_forward_channel::Union::Open(o)) => { + log::debug!("ignoring open for channel {} on the controller", o.channel_id); + None + } + _ => None, + } + } + + /// The peer's reason for refusing a channel, unless it was reported + /// within `REPORT_AGAIN_AFTER`. One page load can have a dozen + /// connections refused for the same reason, and the user needs one + /// dialog, not a dozen; a burst that keeps going keeps it quiet. + fn first_report(&self, message: String) -> Option { + self.first_report_at(message, Instant::now()) + } + + pub(super) fn first_report_at(&self, message: String, now: Instant) -> Option { + if message.is_empty() { + return None; + } + let mut reported = self.reported.lock().unwrap(); + reported.retain(|_, last| now.duration_since(*last) < REPORT_AGAIN_AFTER); + if let Some(last) = reported.get_mut(&message) { + *last = now; + return None; + } + if reported.len() >= MAX_REPORTED_OPEN_ERRORS { + return None; + } + reported.insert(message.clone(), now); + Some(message) + } + + fn close_all(&self) { + self.channels.lock().unwrap().clear(); + // Not `send`: with no channel live it stores nothing, and one + // opened as the tunnel closes would never see it. + self.teardown.send_replace(true); + } + + #[cfg(test)] + pub fn live_channels(&self) -> usize { + self.channels.lock().unwrap().len() + } + + #[cfg(test)] + pub fn set_next_id(&self, id: i32) { + self.next_id.store(id, Ordering::Relaxed); + } + } + + /// The only task that touches the stream. The three arms keep tokio's + /// default random fairness: a `biased` control -> read -> data order would + /// starve outbound data whenever inbound is saturated (a LAN-speed + /// download keeps the read arm ready on every poll), and the reverse would + /// starve the reads that carry the peer's window updates and pings. + /// Random order is safe only because nothing order-sensitive is split + /// across the queues: `open`, `data` and `close` share the data queue and + /// the control queue carries `window_update` alone. + async fn tunnel_loop( + mut stream: Stream, + handle: Arc, + mut data_rx: mpsc::Receiver, + mut control_rx: mpsc::UnboundedReceiver, + interface: impl Interface, + state: watch::Sender, + mut lifetime: watch::Receiver<()>, + ) { + let err = loop { + tokio::select! { + Some(msg) = control_rx.recv() => { + if let Err(e) = stream.send(&msg).await { + break format!("send failed: {}", e); + } + } + res = stream.next_timeout(READ_TIMEOUT) => match res { + Some(Ok(bytes)) => { + let Ok(msg) = Message::parse_from_bytes(&bytes) else { continue }; + match msg.union { + Some(message::Union::PortForwardChannel(ch)) => { + if let Some(err) = handle.on_frame(ch) { + interface.on_error(&err); + } + } + Some(message::Union::TestDelay(t)) => { + interface.handle_test_delay(t, &mut stream).await; + } + Some(message::Union::Misc(misc)) => { + if let Some(misc::Union::CloseReason(r)) = misc.union { + break format!("closed by peer: {}", r); + } + } + _ => {} + } + } + Some(Err(e)) => break format!("read failed: {}", e), + None => break "timeout or reset by the peer".to_owned(), + }, + Some(msg) = data_rx.recv() => { + if let Err(e) = stream.send(&msg).await { + break format!("send failed: {}", e); + } + } + _ = lifetime.changed() => break "window closed".to_owned(), + } + }; + log::info!("port forward tunnel ended: {}", err); + handle.close_all(); + state.send_replace(TunnelState::Unset); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + #[test] + fn effective_window_clamps_to_initial() { + assert_eq!(effective_window(0), INITIAL_WINDOW); + assert_eq!(effective_window(INITIAL_WINDOW - 1), INITIAL_WINDOW); + assert_eq!(effective_window(INITIAL_WINDOW), INITIAL_WINDOW); + assert_eq!(effective_window(CHANNEL_WINDOW), CHANNEL_WINDOW); + } + + #[test] + fn recv_window_rejects_over_window_data() { + // `new` clamps its grant to INITIAL_WINDOW, so the test must fill that. + let mut w = RecvWindow::new(INITIAL_WINDOW); + assert!(w.accept(INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize)); + assert!(w.accept(MIN_FRAME_CHARGE as usize)); + assert!(!w.accept(1)); + } + + #[test] + fn tiny_frames_are_charged_at_the_minimum() { + let mut w = RecvWindow::new(INITIAL_WINDOW); + for _ in 0..(INITIAL_WINDOW / MIN_FRAME_CHARGE) { + assert!(w.accept(1)); + } + // A 64 KiB window holds 1024 one-byte frames, not 65536 of them. + assert!(!w.accept(1)); + } + + #[test] + fn recv_window_updates_only_past_threshold() { + let mut w = RecvWindow::new(CHANNEL_WINDOW); + assert_eq!( + w.drained(UPDATE_THRESHOLD as usize - MIN_FRAME_CHARGE as usize), + None + ); + assert_eq!(w.drained(MIN_FRAME_CHARGE as usize), Some(UPDATE_THRESHOLD)); + // The update re-grants what was drained, so the same amount is accepted again. + assert!(w.accept(CHANNEL_WINDOW as usize)); + assert!(w.accept(UPDATE_THRESHOLD as usize)); + assert!(!w.accept(1)); + } + + #[test] + fn accounting_survives_a_transfer_far_larger_than_the_window() { + // Cumulative counters used to overflow around 4 GiB on one channel and + // read as a protocol violation mid-transfer. + let mut w = RecvWindow::new(CHANNEL_WINDOW); + let mut moved: u64 = 0; + while moved < 8 * 1024 * 1024 * 1024 { + assert!(w.accept(MAX_FRAME)); + w.drained(MAX_FRAME); + moved += MAX_FRAME as u64; + } + } + + #[test] + fn grant_extends_a_window_that_has_been_used_up() { + let mut w = RecvWindow::new(INITIAL_WINDOW); + assert!(w.accept(INITIAL_WINDOW as usize)); + assert!(!w.accept(1)); + w.grant(CHANNEL_WINDOW - INITIAL_WINDOW); + assert!(w.accept((CHANNEL_WINDOW - INITIAL_WINDOW) as usize)); + assert!(!w.accept(1)); + } + + #[test] + fn send_credit_blocks_at_zero_and_resumes_on_add() { + rt().block_on(async { + let credit = std::sync::Arc::new(SendCredit::new(MIN_FRAME_CHARGE + 4)); + assert_eq!( + credit.take(MAX_FRAME).await, + (MIN_FRAME_CHARGE + 4) as usize + ); + let c = credit.clone(); + let waiter = tokio::spawn(async move { c.take(MAX_FRAME).await }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!waiter.is_finished()); + // Below one minimum charge the taker stays parked: whatever it + // reads next has to be payable. + credit.add(MIN_FRAME_CHARGE - 1); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!waiter.is_finished()); + credit.add(1); + assert_eq!(waiter.await.unwrap(), MIN_FRAME_CHARGE as usize); + }); + } + + #[test] + fn send_credit_is_capped_whatever_the_peer_advertises() { + rt().block_on(async { + let credit = SendCredit::new(u32::MAX); + assert_eq!(credit.take(usize::MAX).await, MAX_SEND_CREDIT as usize); + // A flood of window updates cannot lift it past the cap either. + for _ in 0..10 { + credit.add(u32::MAX); + } + assert_eq!(credit.take(usize::MAX).await, MAX_SEND_CREDIT as usize); + }); + } + + #[test] + fn raise_initial_rebases_credit_from_initial_window() { + rt().block_on(async { + let credit = SendCredit::new(INITIAL_WINDOW); + assert_eq!(credit.take(1000).await, 1000); + credit.raise_initial(CHANNEL_WINDOW); + // Credit is now CHANNEL_WINDOW - 1000, not CHANNEL_WINDOW - 1000 + INITIAL_WINDOW. + assert_eq!( + credit.take(usize::MAX).await, + (CHANNEL_WINDOW - 1000) as usize + ); + credit.raise_initial(0); + // A zero or sub-INITIAL_WINDOW advertisement adds nothing. + let c = std::sync::Arc::new(credit); + let c2 = c.clone(); + let waiter = tokio::spawn(async move { c2.take(MAX_FRAME).await }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!waiter.is_finished()); + c.add(MIN_FRAME_CHARGE); + assert_eq!(waiter.await.unwrap(), MIN_FRAME_CHARGE as usize); + }); + } + + #[test] + fn frame_builders_set_the_expected_union_variant() { + use hbb_common::message_proto::{message, port_forward_channel}; + let m = data_msg(7, Bytes::from_static(b"abc")); + match m.union { + Some(message::Union::PortForwardChannel(ch)) => match ch.union { + Some(port_forward_channel::Union::Data(d)) => { + assert_eq!(d.channel_id, 7); + assert_eq!(d.data, b"abc".to_vec()); + } + other => panic!("unexpected {:?}", other), + }, + other => panic!("unexpected {:?}", other), + } + let m = opened_msg(3, false, "nope", CHANNEL_WINDOW); + match m.union { + Some(message::Union::PortForwardChannel(ch)) => match ch.union { + Some(port_forward_channel::Union::Opened(o)) => { + assert_eq!((o.channel_id, o.success, o.message.as_str(), o.window), + (3, false, "nope", CHANNEL_WINDOW)); + } + other => panic!("unexpected {:?}", other), + }, + other => panic!("unexpected {:?}", other), + } + } + + use hbb_common::message_proto::{message, port_forward_channel}; + use hbb_common::tokio::{self, io::AsyncReadExt, io::AsyncWriteExt, sync::mpsc}; + use std::sync::{Arc, Mutex}; + + struct Harness { + data_rx: mpsc::Receiver, + control_rx: mpsc::UnboundedReceiver, + inbound_tx: mpsc::UnboundedSender, + credit: Arc, + window: Arc>, + local: tokio::io::DuplexStream, + teardown: watch::Sender, + task: tokio::task::JoinHandle<()>, + } + + /// A channel whose "local socket" is one end of a duplex pipe and whose + /// "tunnel" is a pair of queues the test reads directly. + fn harness(id: i32, prebuf: Vec, initial_out: Vec) -> Harness { + harness_on(id, prebuf, initial_out, watch::channel(false).0) + } + + /// The channel subscribes to `teardown` here, so a test can hand in one + /// that has already been raised. + fn harness_on(id: i32, prebuf: Vec, initial_out: Vec, teardown: watch::Sender) -> Harness { + let (data_tx, data_rx) = mpsc::channel(DATA_QUEUE_FRAMES); + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let (local, remote) = tokio::io::duplex(1 << 20); + let (r, w) = tokio::io::split(remote); + let credit = Arc::new(SendCredit::new(INITIAL_WINDOW)); + let window = Arc::new(Mutex::new(RecvWindow::new(CHANNEL_WINDOW))); + let sink = FrameSink::Queued { data: data_tx, control: control_tx }; + let teardown_rx = teardown.subscribe(); + let task = tokio::spawn(run_channel( + id, r, w, prebuf, initial_out, credit.clone(), window.clone(), inbound_rx, sink, teardown_rx, + )); + Harness { data_rx, control_rx, inbound_tx, credit, window, local, teardown, task } + } + + // `PortForwardData.data` is generated as `bytes::Bytes` (hbb_common builds + // rust-protobuf with the bytes feature), so it converts with `to_vec()`, not + // `clone()`, and needs no wrapping when it becomes an `Inbound::Data`. + fn frame_kind(m: &Message) -> (&'static str, i32, Vec) { + match &m.union { + Some(message::Union::PortForwardChannel(ch)) => match &ch.union { + Some(port_forward_channel::Union::Data(d)) => ("data", d.channel_id, d.data.to_vec()), + Some(port_forward_channel::Union::Close(c)) => ("close", c.channel_id, vec![]), + Some(port_forward_channel::Union::WindowUpdate(u)) => { + ("window_update", u.channel_id, u.add.to_le_bytes().to_vec()) + } + Some(port_forward_channel::Union::Open(o)) => ("open", o.channel_id, vec![]), + Some(port_forward_channel::Union::Opened(o)) => ("opened", o.channel_id, vec![]), + None => ("none", 0, vec![]), + // `port_forward_channel::Union` is `#[non_exhaustive]` in the + // generated protobuf code, so it needs a catch-all here even + // though every current variant is already matched above. + _ => ("other", 0, vec![]), + }, + _ => ("other", 0, vec![]), + } + } + + #[test] + fn teardown_ends_a_channel_parked_on_a_socket_nobody_reads() { + rt().block_on(async { + // Nothing reads the local side, so once the duplex buffer is full + // the socket relay parks in write_all; nothing writes it either, + // so the tunnel relay parks in read. Neither is on the inbound + // queue, which stays open here: teardown alone must end them. + let mut h = harness(1, vec![], vec![]); + for _ in 0..17 { + h.inbound_tx.send(Inbound::Data(Bytes::from(vec![0u8; MAX_FRAME]))).unwrap(); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + h.teardown.send_replace(true); + let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await; + assert!(ended.is_ok(), "channel task outlived the tunnel"); + }); + } + + #[test] + fn a_channel_subscribed_after_teardown_ends_at_once() { + rt().block_on(async { + // `open` can race `close_all`: this channel subscribes after the + // signal went out, and its entry sits in a map that was already + // cleared, so nothing will ever drop its inbound sender. No other + // channel was live when the tunnel closed, either. + let teardown = watch::channel(false).0; + teardown.send_replace(true); + let mut h = harness_on(1, vec![], vec![], teardown); + let ended = tokio::time::timeout(std::time::Duration::from_millis(500), &mut h.task).await; + assert!(ended.is_ok(), "late channel outlived the tunnel"); + }); + } + + #[test] + fn local_bytes_become_data_frames_capped_at_max_frame() { + rt().block_on(async { + let mut h = harness(1, vec![], vec![]); + let payload = vec![7u8; MAX_FRAME + 10]; + h.local.write_all(&payload).await.unwrap(); + // INITIAL_WINDOW equals MAX_FRAME, so the first frame exhausts + // it exactly; grant one minimum charge for the 10-byte tail. + h.credit.add(MIN_FRAME_CHARGE); + let mut got = Vec::new(); + while got.len() < payload.len() { + let m = h.data_rx.recv().await.unwrap(); + let (kind, id, bytes) = frame_kind(&m); + assert_eq!((kind, id), ("data", 1)); + assert!(bytes.len() <= MAX_FRAME); + got.extend(bytes); + } + assert_eq!(got, payload); + }); + } + + #[test] + fn prebuf_is_the_head_of_the_send_stream() { + rt().block_on(async { + let mut h = harness(2, b"head".to_vec(), vec![]); + h.local.write_all(b"tail").await.unwrap(); + let mut got = Vec::new(); + while got.len() < 8 { + let m = h.data_rx.recv().await.unwrap(); + got.extend(frame_kind(&m).2); + } + assert_eq!(got, b"headtail".to_vec()); + }); + } + + #[test] + fn send_side_stops_at_credit_and_resumes_on_add() { + rt().block_on(async { + let mut h = harness(3, vec![], vec![]); + let payload = vec![1u8; INITIAL_WINDOW as usize + 5]; + h.local.write_all(&payload).await.unwrap(); + let mut got = 0usize; + while got < INITIAL_WINDOW as usize { + got += frame_kind(&h.data_rx.recv().await.unwrap()).2.len(); + } + assert_eq!(got, INITIAL_WINDOW as usize); + assert!(tokio::time::timeout( + std::time::Duration::from_millis(50), + h.data_rx.recv() + ) + .await + .is_err()); + // One minimum charge is enough to send the 5-byte tail. + h.credit.add(MIN_FRAME_CHARGE); + assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()).2.len(), 5); + }); + } + + #[test] + fn inbound_data_is_written_and_window_update_follows_threshold() { + rt().block_on(async { + let mut h = harness(4, vec![], vec![Bytes::from_static(b"first")]); + let mut buf = [0u8; 5]; + h.local.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"first"); + let chunk = Bytes::from(vec![9u8; UPDATE_THRESHOLD as usize]); + assert!(h.window.lock().unwrap().accept(chunk.len())); + h.inbound_tx.send(Inbound::Data(chunk.clone())).unwrap(); + let mut sink = vec![0u8; chunk.len()]; + h.local.read_exact(&mut sink).await.unwrap(); + let m = h.control_rx.recv().await.unwrap(); + let (kind, id, add) = frame_kind(&m); + assert_eq!((kind, id), ("window_update", 4)); + let add = u32::from_le_bytes([add[0], add[1], add[2], add[3]]); + // The 5-byte `initial` chunk drained a whole minimum charge. + assert_eq!(add, UPDATE_THRESHOLD + MIN_FRAME_CHARGE); + }); + } + + #[test] + fn local_eof_sends_close_exactly_once_after_the_data() { + rt().block_on(async { + let mut h = harness(5, vec![], vec![]); + h.local.write_all(b"bye").await.unwrap(); + drop(h.local); + assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()).0, "data"); + assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()), ("close", 5, vec![])); + h.task.await.unwrap(); + assert!(h.data_rx.try_recv().is_err()); + }); + } + + #[test] + fn peer_close_ends_the_channel_without_echoing_close() { + rt().block_on(async { + let mut h = harness(6, vec![], vec![]); + h.inbound_tx.send(Inbound::Close).unwrap(); + h.task.await.unwrap(); + assert!(h.data_rx.try_recv().is_err()); + assert!(h.control_rx.try_recv().is_err()); + }); + } + + #[test] + fn violation_signalled_by_the_demux_sends_close() { + rt().block_on(async { + let mut h = harness(7, vec![], vec![]); + h.inbound_tx.send(Inbound::Violation).unwrap(); + assert_eq!(frame_kind(&h.data_rx.recv().await.unwrap()), ("close", 7, vec![])); + h.task.await.unwrap(); + }); + } + + #[test] + fn dropped_tunnel_ends_the_channel_silently() { + rt().block_on(async { + let mut h = harness(8, vec![], vec![]); + drop(h.inbound_tx); + h.task.await.unwrap(); + assert!(h.data_rx.try_recv().is_err()); + }); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + mod tunnel { + use super::*; + use crate::port_forward_mux::{tunnel::{TunnelHandle, MAX_REPORTED_OPEN_ERRORS}, Claim, Tunnel}; + use hbb_common::{ + protobuf::Message as _, + tcp::FramedStream, + tokio::net::{TcpListener, TcpStream}, + Stream, + }; + + /// A loopback TCP pair wrapped as two `Stream`s: one for the tunnel, + /// one for the fake peer. + async fn stream_pair() -> (Stream, Stream) { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = l.accept().await.unwrap(); + ( + Stream::Tcp(FramedStream::from(client, addr)), + Stream::Tcp(FramedStream::from(server, addr)), + ) + } + + async fn recv_frame(s: &mut Stream) -> PortForwardChannel { + let bytes = s.next().await.unwrap().unwrap(); + let m = Message::parse_from_bytes(&bytes).unwrap(); + match m.union { + Some(message::Union::PortForwardChannel(ch)) => ch, + other => panic!("unexpected {:?}", other), + } + } + + async fn local_pair() -> (TcpStream, TcpStream) { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let a = TcpStream::connect(addr).await.unwrap(); + let (b, _) = l.accept().await.unwrap(); + (a, b) + } + + #[test] + fn a_packet_declared_over_the_cap_ends_the_tunnel_before_it_arrives() { + rt().block_on(async { + use std::time::{Duration, Instant}; + let (ours, mut theirs) = local_pair().await; + let addr = ours.peer_addr().unwrap(); + let t = Tunnel::new(); + t.claim(); + let _h = t.set_muxed(Stream::Tcp(FramedStream::from(ours, addr)), NoUi::default()); + assert!(matches!(t.claim(), Claim::Muxed(_))); + // The codec's three-byte header form, declaring one byte more + // than the cap, and nothing behind it: an uncapped codec waits + // for the whole packet and the tunnel stays up. + let head = ((MAX_PACKET as u32 + 1) << 2) | 0x2; + theirs.write_all(&head.to_le_bytes()[..3]).await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while !matches!(t.claim(), Claim::Claimed) { + assert!(Instant::now() < deadline, "tunnel still up: the oversized header was accepted"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }); + } + + #[test] + fn claim_follows_the_tunnel_state() { + let t = Tunnel::new(); + assert!(matches!(t.claim(), Claim::Claimed)); + t.set_failed(); + assert!(matches!(t.claim(), Claim::Claimed)); + t.set_legacy(); + assert!(matches!(t.claim(), Claim::Legacy)); + } + + #[test] + fn an_id_still_live_when_the_counter_comes_round_is_skipped() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + let id_of = |ch: &PortForwardChannel| match &ch.union { + Some(port_forward_channel::Union::Open(o)) => o.channel_id, + other => panic!("expected open, got {:?}", other), + }; + let (_a, sock_a) = local_pair().await; + h.open("localhost", 80, sock_a, vec![]).unwrap(); + let a = id_of(&recv_frame(&mut peer).await); + // 2^32 opens later the counter is back at A's id, and A is + // still up. Handing that id out again would replace A's entry + // here while the peer keeps routing it to A's socket. + h.set_next_id(a); + let (_b, sock_b) = local_pair().await; + h.open("localhost", 80, sock_b, vec![]).unwrap(); + let b = id_of(&recv_frame(&mut peer).await); + assert_ne!(b, a, "channel B was handed A's live id"); + assert_eq!(h.live_channels(), 2); + }); + } + + #[test] + fn open_sends_open_then_pipelined_data_and_relays_replies() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + assert!(matches!(t.claim(), Claim::Claimed)); + let h = t.set_muxed(ours, NoUi::default()); + let (mut app, sock) = local_pair().await; + h.open("localhost", 80, sock, b"GET / HTTP/1.0\r\n\r\n".to_vec()).unwrap(); + let open = recv_frame(&mut peer).await; + let id = match &open.union { + Some(port_forward_channel::Union::Open(o)) => { + assert_eq!((o.host.as_str(), o.port, o.window), ("localhost", 80, CHANNEL_WINDOW)); + o.channel_id + } + other => panic!("expected open, got {:?}", other), + }; + let d = recv_frame(&mut peer).await; + match &d.union { + Some(port_forward_channel::Union::Data(d)) => assert_eq!(d.data, b"GET / HTTP/1.0\r\n\r\n".to_vec()), + other => panic!("expected data, got {:?}", other), + } + peer.send(&opened_msg(id, true, "", CHANNEL_WINDOW)).await.unwrap(); + peer.send(&data_msg(id, Bytes::from_static(b"HTTP/1.0 200 OK\r\n"))).await.unwrap(); + let mut buf = [0u8; 17]; + app.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"HTTP/1.0 200 OK\r\n"); + peer.send(&close_msg(id)).await.unwrap(); + assert_eq!(app.read(&mut buf).await.unwrap(), 0); + }); + } + + #[test] + fn dropping_the_tunnel_ends_the_peer_connection() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + assert!(matches!(t.claim(), Claim::Claimed)); + let h = t.set_muxed(ours, NoUi::default()); + let (app, sock) = local_pair().await; + h.open("localhost", 80, sock, Vec::new()).unwrap(); + let id = match recv_frame(&mut peer).await.union { + Some(port_forward_channel::Union::Open(o)) => o.channel_id, + other => panic!("expected open, got {:?}", other), + }; + peer.send(&close_msg(id)).await.unwrap(); + drop(app); + // The loop holds a handle of its own, so dropping ours proves + // nothing; the listener's `Tunnel` is what must end the peer. + drop(h); + drop(t); + let end = hbb_common::timeout(2000, peer.next()).await; + assert!( + matches!(end, Ok(None) | Ok(Some(Err(_)))), + "peer still connected: {:?}", + end + ); + }); + } + + #[test] + fn every_open_precedes_its_own_channels_first_data() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + assert!(matches!(t.claim(), Claim::Claimed)); + let h = t.set_muxed(ours, NoUi::default()); + // Twenty channels, each with one byte of pipelined data behind + // its open. An open on the control queue can lose the loop's + // random tie-break to a data frame, so one channel would be a + // coin flip; twenty make a wrong implementation fail every run. + const N: usize = 20; + let mut apps = Vec::new(); + for i in 0..N { + let (app, sock) = local_pair().await; + h.open("localhost", 1, sock, vec![i as u8]).unwrap(); + apps.push(app); + } + let mut opened = std::collections::HashSet::new(); + let mut seen_data = 0; + while seen_data < N { + let ch = recv_frame(&mut peer).await; + match &ch.union { + Some(port_forward_channel::Union::Open(o)) => { + assert!(opened.insert(o.channel_id), "duplicate open"); + } + Some(port_forward_channel::Union::Data(d)) => { + assert!( + opened.contains(&d.channel_id), + "data for channel {} arrived before its open", + d.channel_id + ); + seen_data += 1; + } + other => panic!("unexpected {:?}", other), + } + } + }); + } + + #[test] + fn failed_open_closes_the_local_socket() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + let (mut app, sock) = local_pair().await; + h.open("localhost", 1, sock, vec![]).unwrap(); + let id = match recv_frame(&mut peer).await.union { + Some(port_forward_channel::Union::Open(o)) => o.channel_id, + other => panic!("expected open, got {:?}", other), + }; + peer.send(&opened_msg(id, false, "refused", 0)).await.unwrap(); + let mut buf = [0u8; 1]; + assert_eq!(app.read(&mut buf).await.unwrap(), 0); + }); + } + + #[test] + fn a_refused_channel_is_reported_once_per_reason() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + assert!(matches!(t.claim(), Claim::Claimed)); + let ui = NoUi::default(); + let h = t.set_muxed(ours, ui.clone()); + for reason in ["unreachable", "unreachable", "no permission"] { + let (mut app, sock) = local_pair().await; + h.open("localhost", 1, sock, vec![]).unwrap(); + let id = match recv_frame(&mut peer).await.union { + Some(port_forward_channel::Union::Open(o)) => o.channel_id, + other => panic!("expected open, got {:?}", other), + }; + peer.send(&opened_msg(id, false, reason, 0)).await.unwrap(); + let mut buf = [0u8; 1]; + assert_eq!(app.read(&mut buf).await.unwrap(), 0); + } + // A page load can have a dozen connections refused for one + // reason; the user gets one dialog per reason, not per socket. + assert_eq!( + ui.messages(), + vec!["unreachable".to_owned(), "no permission".to_owned()] + ); + }); + } + + #[test] + fn a_refused_reason_is_reported_again_after_a_quiet_spell() { + rt().block_on(async { + let (ours, _peer) = stream_pair().await; + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + let t0 = Instant::now(); + let at = |secs: u64| t0 + std::time::Duration::from_secs(secs); + let down = || "down".to_owned(); + assert_eq!(h.first_report_at(down(), at(0)), Some(down())); + // A burst that keeps going keeps the dialog quiet ... + assert_eq!(h.first_report_at(down(), at(8)), None); + assert_eq!(h.first_report_at(down(), at(16)), None); + // ... and one that stopped is reported afresh. + assert_eq!(h.first_report_at(down(), at(27)), Some(down())); + // The cap counts reasons still live, so it cannot silence the + // window for good. + for i in 0..MAX_REPORTED_OPEN_ERRORS { + h.first_report_at(format!("reason {}", i), at(27)); + } + assert_eq!(h.first_report_at("one more".to_owned(), at(27)), None); + assert_eq!( + h.first_report_at("one more".to_owned(), at(40)), + Some("one more".to_owned()) + ); + }); + } + + #[test] + fn an_over_window_frame_drops_the_channel_at_once() { + rt().block_on(async { + let (ours, mut peer) = stream_pair().await; + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + let (_app, sock) = local_pair().await; + h.open("localhost", 1, sock, vec![]).unwrap(); + let id = match recv_frame(&mut peer).await.union { + Some(port_forward_channel::Union::Open(o)) => o.channel_id, + other => panic!("expected open, got {:?}", other), + }; + let mut ch = PortForwardChannel::new(); + ch.set_data(PortForwardData { + channel_id: id, + data: Bytes::from(vec![0u8; CHANNEL_WINDOW as usize + 1]), + ..Default::default() + }); + h.on_frame(ch.clone()); + // Gone on the spot: a peer that keeps sending past the window + // can no longer queue anything for this channel. + assert_eq!(h.live_channels(), 0); + h.on_frame(ch); + assert_eq!(h.live_channels(), 0); + }); + } + + #[test] + fn tunnel_death_closes_channels_and_resets_state() { + rt().block_on(async { + let (ours, peer) = stream_pair().await; + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + let (mut app_a, sock_a) = local_pair().await; + let (mut app_b, sock_b) = local_pair().await; + h.open("localhost", 1, sock_a, vec![]).unwrap(); + h.open("localhost", 1, sock_b, vec![]).unwrap(); + drop(peer); + // One tunnel is one failure domain: every channel on it ends. + let mut buf = [0u8; 1]; + assert_eq!(app_a.read(&mut buf).await.unwrap(), 0); + assert_eq!(app_b.read(&mut buf).await.unwrap(), 0); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(h.open("localhost", 1, local_pair().await.1, vec![]).is_err()); + // The next accept establishes again on the same `Tunnel`. + assert!(matches!(t.claim(), Claim::Claimed)); + let (ours, mut peer) = stream_pair().await; + let h = t.set_muxed(ours, NoUi::default()); + let (_app_c, sock_c) = local_pair().await; + h.open("localhost", 1, sock_c, vec![]).unwrap(); + assert!(matches!( + recv_frame(&mut peer).await.union, + Some(port_forward_channel::Union::Open(_)) + )); + }); + } + + /// An `Interface` that records the dialogs it was asked to show. The + /// tunnel needs it for `handle_test_delay` and for refusal messages. + #[derive(Clone, Default)] + pub struct NoUi(Arc>>); + + impl NoUi { + fn messages(&self) -> Vec { + self.0.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl crate::client::Interface for NoUi { + fn send(&self, _data: crate::client::Data) {} + fn msgbox(&self, _msgtype: &str, _title: &str, text: &str, _link: &str) { + self.0.lock().unwrap().push(text.to_owned()); + } + fn handle_login_error(&self, _err: &str) -> bool { + false + } + fn handle_peer_info(&self, _pi: PeerInfo) {} + fn set_multiple_windows_session(&self, _sessions: Vec) {} + async fn handle_hash(&self, _pass: &str, _hash: Hash, _peer: &mut Stream) -> bool { + false + } + async fn handle_login_from_ui( + &self, + _os_username: String, + _os_password: String, + _password: String, + _remember: bool, + _peer: &mut Stream, + ) { + } + async fn handle_test_delay(&self, t: TestDelay, peer: &mut Stream) { + if !t.from_client { + crate::client::handle_test_delay(t, peer).await; + } + } + fn get_lch(&self) -> Arc> { + Arc::new(std::sync::RwLock::new(Default::default())) + } + } + + use crate::server::port_forward_mux::PortForwardMux; + use hbb_common::tokio::time::Instant; + + /// Stands in for `Connection`: one task owning the stream, draining + /// `inner.tx` into it and dispatching inbound frames to the mux. + fn fake_controlled(mut stream: Stream, login_target: String) { + tokio::spawn(async move { + let (tx, mut rx) = mpsc::unbounded_channel::<(Instant, Arc)>(); + let mut mux = PortForwardMux::new(tx, login_target); + loop { + tokio::select! { + Some((_, m)) = rx.recv() => { + if stream.send(&*m).await.is_err() { return; } + } + res = stream.next() => match res { + Some(Ok(bytes)) => { + let Ok(m) = Message::parse_from_bytes(&bytes) else { continue }; + if let Some(message::Union::PortForwardChannel(ch)) = m.union { + mux.handle(ch, || true); + } + } + _ => return, + }, + } + } + }); + } + + async fn echo_target() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let (mut s, _) = l.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = s.read(&mut buf).await.unwrap_or(0); + if n == 0 || s.write_all(&buf[..n]).await.is_err() { return; } + } + }); + } + }); + port + } + + /// The `Tunnel` comes back too: dropping it is what ends the peer, so + /// a test that wants a live tunnel has to keep holding it. + async fn muxed_tunnel() -> (Tunnel, Arc, u16) { + let (ours, theirs) = stream_pair().await; + let port = echo_target().await; + fake_controlled(theirs, format!("127.0.0.1:{}", port)); + let t = Tunnel::new(); + t.claim(); + let h = t.set_muxed(ours, NoUi::default()); + (t, h, port) + } + + #[test] + fn many_channels_echo_concurrently() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + let mut apps = Vec::new(); + for i in 0..20u8 { + let (app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![i]).unwrap(); + apps.push(app); + } + // Twenty channels round-trip concurrently: channel 0 streams 4 MiB + // while the other nineteen each exchange one byte. + // Read and write the bulk socket from separate tasks: the echo can only + // drain if this side keeps reading while it writes. + let bulk = vec![0xAB; 4 << 20]; + let (mut bulk_rd, mut bulk_wr) = apps.remove(0).into_split(); + let bulk_reader = { + let bulk = bulk.clone(); + tokio::spawn(async move { + let mut back = vec![0u8; bulk.len() + 1]; + bulk_rd.read_exact(&mut back).await.unwrap(); + assert_eq!(back[0], 0); + assert_eq!(&back[1..], &bulk[..]); + }) + }; + let bulk_writer = { + let bulk = bulk.clone(); + tokio::spawn(async move { + bulk_wr.write_all(&bulk).await.unwrap(); + // Hold the write half open: dropping it half-closes the + // socket, which ends the whole channel by design. + bulk_wr + }) + }; + for (i, app) in apps.iter_mut().enumerate() { + let mut b = [0u8; 1]; + tokio::time::timeout(std::time::Duration::from_secs(2), app.read_exact(&mut b)) + .await + .expect("small channel starved") + .unwrap(); + assert_eq!(b[0], (i + 1) as u8); + } + bulk_reader.await.unwrap(); + let _bulk_wr = bulk_writer.await.unwrap(); + }); + } + + #[test] + fn a_channel_opened_during_a_bulk_transfer_is_served_promptly() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + let (bulk_app, bulk_sock) = local_pair().await; + h.open("127.0.0.1", port as i32, bulk_sock, vec![]).unwrap(); + let bulk = vec![0xAB; 4 << 20]; + let (mut bulk_rd, mut bulk_wr) = bulk_app.into_split(); + let bulk_writer = { + let bulk = bulk.clone(); + tokio::spawn(async move { + bulk_wr.write_all(&bulk).await.unwrap(); + // Holding the write half open: dropping it half-closes + // the socket, which ends the channel by design. + bulk_wr + }) + }; + // Wait until a mebibyte is back, so the bulk channel is + // demonstrably mid-flight before anything else is opened. + let mut back = vec![0u8; 1 << 20]; + bulk_rd.read_exact(&mut back).await.unwrap(); + let (mut app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![42]).unwrap(); + let mut b = [0u8; 1]; + tokio::time::timeout(std::time::Duration::from_secs(2), app.read_exact(&mut b)) + .await + .expect("a channel opened during a bulk transfer starved") + .unwrap(); + assert_eq!(b[0], 42); + let mut rest = vec![0u8; bulk.len() - (1 << 20)]; + bulk_rd.read_exact(&mut rest).await.unwrap(); + let _bulk_wr = bulk_writer.await.unwrap(); + }); + } + + #[test] + fn a_local_half_close_ends_the_whole_channel() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + let (app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![]).unwrap(); + let (mut rd, wr) = app.into_split(); + // Dropping the write half is a shutdown(SHUT_WR). Supporting it + // needs a direction flag on the close frame; today's raw pipe + // drops both directions on either EOF too, and this matches it. + drop(wr); + let mut buf = [0u8; 1]; + assert_eq!(rd.read(&mut buf).await.unwrap(), 0); + }); + } + + #[test] + fn tail_before_close_is_delivered_in_both_directions() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + let (app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![]).unwrap(); + let payload = vec![7u8; 300 * 1024]; + let (mut rd, mut wr) = app.into_split(); + let reader = { + let payload = payload.clone(); + tokio::spawn(async move { + let mut back = vec![0u8; payload.len()]; + rd.read_exact(&mut back).await.unwrap(); + assert_eq!(back, payload); + rd + }) + }; + wr.write_all(&payload).await.unwrap(); + // The full echo proves every byte reached the target ahead of anything + // else; only then close, and the peer's `close` must follow cleanly. + let mut rd = reader.await.unwrap(); + drop(wr); + let mut one = [0u8; 1]; + assert_eq!(rd.read(&mut one).await.unwrap(), 0); + }); + } + + #[test] + fn one_byte_frames_never_trip_the_window() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + let (mut app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![]).unwrap(); + for i in 0..5000u32 { + app.write_all(&[(i % 251) as u8]).await.unwrap(); + app.flush().await.unwrap(); + } + let mut back = vec![0u8; 5000]; + app.read_exact(&mut back).await.unwrap(); + for (i, b) in back.iter().enumerate() { + assert_eq!(*b, (i as u32 % 251) as u8); + } + }); + } + + #[test] + fn sequential_connections_far_beyond_max_channels_all_succeed() { + rt().block_on(async { + let (_t, h, port) = muxed_tunnel().await; + for i in 0..(MAX_CHANNELS * 3) { + let (mut app, sock) = local_pair().await; + h.open("127.0.0.1", port as i32, sock, vec![i as u8]).unwrap(); + let mut b = [0u8; 1]; + app.read_exact(&mut b).await.unwrap(); + assert_eq!(b[0], i as u8); + drop(app); + // The controller's entry goes when its coordinator task exits, + // which takes a cancel and a join; wait for it rather than + // trusting a single yield, or the cap trips around round 256. + while h.live_channels() != 0 { + tokio::task::yield_now().await; + } + } + }); + } + } +} diff --git a/src/server.rs b/src/server.rs index 5af982772..753ad5903 100644 --- a/src/server.rs +++ b/src/server.rs @@ -70,6 +70,7 @@ pub mod input_service { mod connection; mod login_failure_check; +pub(crate) mod port_forward_mux; pub mod display_service; #[cfg(windows)] pub mod portable_service; diff --git a/src/server/connection.rs b/src/server/connection.rs index 4767e05fc..67799646d 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -257,6 +257,7 @@ pub struct Connection { view_camera: bool, terminal: bool, port_forward_socket: Option>, + port_forward_mux: Option, port_forward_address: String, tx_to_cm: mpsc::UnboundedSender, authorized: bool, @@ -469,6 +470,7 @@ impl Connection { view_camera: false, terminal: false, port_forward_socket: None, + port_forward_mux: None, port_forward_address: "".to_owned(), tx_to_cm, authorized: false, @@ -1645,7 +1647,7 @@ impl Connection { } } - fn normalize_port_forward_target(pf: &mut PortForward) -> (String, bool) { + pub(super) fn normalize_port_forward_target(pf: &mut PortForward) -> (String, bool) { let mut is_rdp = false; if pf.host == "RDP" && pf.port == 0 { pf.host = "localhost".to_owned(); @@ -1659,12 +1661,21 @@ impl Connection { } async fn connect_port_forward_if_needed(&mut self) -> bool { - if self.port_forward_socket.is_some() { + if self.is_port_forward() { return true; } let Some(login_request::Union::PortForward(pf)) = self.lr.union.as_ref() else { return true; }; + if pf.multiplex { + crate::port_forward_mux::cap_packet_size(&mut self.stream); + // `inner.tx` is set for the connection's whole life; `None` here is + // unreachable, and refusing the login is the only honest answer. + self.port_forward_mux = self.inner.tx.clone().map(|tx| { + super::port_forward_mux::PortForwardMux::new(tx, self.port_forward_address.clone()) + }); + return self.port_forward_mux.is_some(); + } let mut pf = pf.clone(); let (mut addr, is_rdp) = Self::normalize_port_forward_target(&mut pf); self.port_forward_address = addr.clone(); @@ -1752,7 +1763,7 @@ impl Connection { self.clear_id_whitelist_failures(); let (conn_type, auth_conn_type) = if self.file_transfer.is_some() { (1, AuthConnType::FileTransfer) - } else if self.port_forward_socket.is_some() { + } else if self.is_port_forward() { (2, AuthConnType::PortForward) } else if self.view_camera { (3, AuthConnType::ViewCamera) @@ -1865,7 +1876,12 @@ impl Connection { pi.platform_additions = serde_json::to_string(&platform_additions).unwrap_or("".into()); } - if self.port_forward_socket.is_some() { + if self.is_port_forward() { + pi.features = Some(Features { + port_forward_mux: self.port_forward_mux.is_some(), + ..Default::default() + }) + .into(); let mut msg_out = Message::new(); res.set_peer_info(pi); msg_out.set_login_response(res); @@ -2063,11 +2079,16 @@ impl Connection { #[inline] fn is_remote(&self) -> bool { self.file_transfer.is_none() - && self.port_forward_socket.is_none() + && !self.is_port_forward() && !self.view_camera && !self.terminal } + #[inline] + fn is_port_forward(&self) -> bool { + self.port_forward_socket.is_some() || self.port_forward_mux.is_some() + } + fn try_sub_monitor_services(&mut self) { let is_remote = self.is_remote(); if is_remote && !self.services_subed { @@ -2211,6 +2232,16 @@ impl Connection { self.tx_to_cm.send(data).ok(); } + fn handle_port_forward_channel(&mut self, ch: PortForwardChannel) { + let Some(mux) = self.port_forward_mux.as_mut() else { + log::debug!("port forward channel frame on a non-multiplexed connection"); + return; + }; + mux.handle(ch, || { + Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) + }); + } + #[inline] fn send_fs(&mut self, data: ipc::FS) { self.send_to_cm(ipc::Data::FS(data)); @@ -2576,11 +2607,13 @@ impl Connection { let PortForward { host, port, + multiplex, special_fields: _, } = pf; push(b"port_forward"); push(host.as_bytes()); push(&port.to_le_bytes()); + push(&[*multiplex as u8]); } // Variants this build does not know execute as remote, so they latch as remote. None | Some(_) => push(b"remote"), @@ -3865,6 +3898,7 @@ impl Connection { self.refresh_video_display(Some(request.display as usize)); } } + Some(message::Union::PortForwardChannel(ch)) => self.handle_port_forward_channel(ch), Some(message::Union::TerminalAction(action)) => { #[cfg(not(any(target_os = "android", target_os = "ios")))] allow_err!(self.handle_terminal_action(action).await); @@ -5074,6 +5108,9 @@ impl Connection { let data = ipc::Data::Close; self.tx_to_cm.send(data).ok(); self.port_forward_socket.take(); + if let Some(mut mux) = self.port_forward_mux.take() { + mux.close_all(); + } } // The `reason` should be consistent with `check_if_retry` if not empty @@ -5675,7 +5712,7 @@ impl Connection { let allowed = match conn_type { AuthConnType::Remote => true, AuthConnType::FileTransfer => Self::is_file_transfer_scoped_message(msg), - AuthConnType::PortForward => false, + AuthConnType::PortForward => Self::is_port_forward_scoped_message(msg), AuthConnType::ViewCamera => Self::is_view_camera_scoped_message(msg), AuthConnType::Terminal => Self::is_terminal_scoped_message(msg), }; @@ -5749,6 +5786,13 @@ impl Connection { false } + fn is_port_forward_scoped_message(msg: &Message) -> bool { + matches!( + msg.union.as_ref(), + Some(message::Union::PortForwardChannel(_)) + ) + } + fn is_terminal_scoped_message(msg: &Message) -> bool { match msg.union.as_ref() { Some(message::Union::TerminalAction(_)) => true, @@ -5899,6 +5943,7 @@ impl Connection { Some(message::Union::ScreenshotResponse(_)) => "screenshot_response", Some(message::Union::TerminalAction(_)) => "terminal_action", Some(message::Union::TerminalResponse(_)) => "terminal_response", + Some(message::Union::PortForwardChannel(_)) => "port_forward_channel", Some(message::Union::Misc(misc)) => Self::misc_message_family(misc), Some(_) => "message.other", None => "empty", @@ -7228,6 +7273,10 @@ mod test { }), Some("misc.option"), ), + ( + msg(|m| m.set_port_forward_channel(PortForwardChannel::new())), + Some("port_forward_channel"), + ), ], ), ( @@ -7289,6 +7338,10 @@ mod test { }), Some("misc.option"), ), + ( + msg(|m| m.set_port_forward_channel(PortForwardChannel::new())), + Some("port_forward_channel"), + ), ], ), ( @@ -7389,6 +7442,10 @@ mod test { }), None, ), + ( + msg(|m| m.set_port_forward_channel(PortForwardChannel::new())), + None, + ), ], ), ]; diff --git a/src/server/port_forward_mux.rs b/src/server/port_forward_mux.rs new file mode 100644 index 000000000..cbefa15fb --- /dev/null +++ b/src/server/port_forward_mux.rs @@ -0,0 +1,565 @@ +use super::connection::{Connection, Sender}; +use crate::port_forward_mux::{ + charge, close_msg, effective_window, opened_msg, run_channel, FrameSink, Inbound, RecvWindow, + SendCredit, CHANNEL_WINDOW, INITIAL_WINDOW, MAX_CHANNELS, +}; +use hbb_common::{ + bytes::Bytes, + log, + message_proto::*, + timeout, + tokio::{self, net::TcpStream, sync::{mpsc, watch}}, +}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +const CONNECT_TIMEOUT_MS: u64 = 3000; + +/// Before `opened` the controller may only have used `INITIAL_WINDOW`. +/// `charged` is the running total of `charge(len)`, not of raw lengths. +fn pending_fits(charged: usize, add_len: usize) -> bool { + charged.saturating_add(charge(add_len) as usize) <= INITIAL_WINDOW as usize +} + +struct Entry { + inbound: mpsc::UnboundedSender, + credit: Arc, + window: Arc>, +} + +/// The controlled side of one multiplexed tunnel. The main loop owns it and +/// forwards every `PortForwardChannel` frame here; each channel is a task. +pub struct PortForwardMux { + channels: HashMap, + tx: Sender, + login_target: String, + /// Raised once, by `close_all`, for the channels its `clear` cannot reach: + /// one parked on its target socket is not on the inbound queue. + teardown: watch::Sender, +} + +impl PortForwardMux { + pub fn new(tx: Sender, login_target: String) -> Self { + Self { + channels: HashMap::new(), + tx, + login_target, + teardown: watch::channel(false).0, + } + } + + /// `tunnel_permitted` is consulted for `open` alone, so the lookup is not + /// made per 64 KiB of data. + pub fn handle(&mut self, frame: PortForwardChannel, tunnel_permitted: impl FnOnce() -> bool) { + match frame.union { + Some(port_forward_channel::Union::Open(open)) => { + let permitted = tunnel_permitted(); + self.on_open(open, permitted) + } + Some(port_forward_channel::Union::Data(d)) => { + let len = d.data.len(); + let Some(entry) = self.channels.get(&d.channel_id) else { + log::debug!("port forward data for unknown channel {}", d.channel_id); + return; + }; + let accepted = entry.window.lock().unwrap().accept(len); + let delivered = accepted && entry.inbound.send(Inbound::Data(d.data)).is_ok(); + if delivered { + return; + } + // Dropped here and now, so the peer cannot queue anything more + // for this id while the task is still on its way out. + let Some(entry) = self.channels.remove(&d.channel_id) else { + return; + }; + if !accepted { + log::warn!("port forward channel {} overran its window", d.channel_id); + entry.inbound.send(Inbound::Violation).ok(); + } + } + Some(port_forward_channel::Union::Close(c)) => { + if let Some(entry) = self.channels.remove(&c.channel_id) { + entry.inbound.send(Inbound::Close).ok(); + } else { + log::debug!("port forward close for unknown channel {}", c.channel_id); + } + } + Some(port_forward_channel::Union::WindowUpdate(u)) => { + match self.channels.get(&u.channel_id) { + Some(entry) => entry.credit.add(u.add), + None => log::debug!( + "port forward window update for unknown channel {}", + u.channel_id + ), + } + } + Some(port_forward_channel::Union::Opened(o)) => { + log::debug!("ignoring opened for channel {} on the controlled side", o.channel_id); + } + _ => {} + } + } + + fn on_open(&mut self, open: PortForwardOpen, permitted: bool) { + let id = open.channel_id; + self.channels.retain(|_, e| !e.inbound.is_closed()); + if !permitted { + self.reply(opened_msg(id, false, "No permission of IP tunneling", 0)); + return; + } + if self.channels.len() >= MAX_CHANNELS { + self.reply(opened_msg(id, false, "Too many port forward channels", 0)); + return; + } + if self.channels.contains_key(&id) { + log::debug!("ignoring open for live channel {}", id); + return; + } + let mut pf = PortForward { + host: open.host, + port: open.port, + ..Default::default() + }; + let (addr, is_rdp) = Connection::normalize_port_forward_target(&mut pf); + // Approval and permission checks saw the login's target; a tunnel + // serves that one target and nothing else. + if addr != self.login_target { + log::warn!( + "port forward channel {} asked for {} on a tunnel logged in for {}", + id, + addr, + self.login_target + ); + self.reply(opened_msg(id, false, "Port forward target not authorized", 0)); + return; + } + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let credit = Arc::new(SendCredit::new(effective_window(open.window))); + let window = Arc::new(Mutex::new(RecvWindow::new(INITIAL_WINDOW))); + self.channels.insert( + id, + Entry { + inbound: inbound_tx, + credit: credit.clone(), + window: window.clone(), + }, + ); + tokio::spawn(run_controlled_channel( + id, + addr, + is_rdp, + credit, + window, + inbound_rx, + FrameSink::Direct(self.tx.clone()), + self.teardown.subscribe(), + )); + } + + fn reply(&self, msg: Message) { + self.tx + .send((tokio::time::Instant::now(), Arc::new(msg))) + .ok(); + } + + #[cfg(test)] + pub fn live_channels(&self) -> usize { + self.channels.len() + } + + #[cfg(test)] + pub fn recv_window_remaining(&self, id: i32) -> Option { + self.channels.get(&id).map(|e| e.window.lock().unwrap().remaining()) + } + + /// Every task ends and drops its target socket: the queue's senders go for + /// a task on the queue, `teardown` reaches one parked on the socket. + pub fn close_all(&mut self) { + self.channels.clear(); + // Not `send`: with no channel live it stores nothing, and one opened + // as the tunnel closes would never see it. + self.teardown.send_replace(true); + } +} + +/// Owns the whole channel lifecycle: connect under a `select!` in which a +/// queued command always wins over the connect, buffer what arrives +/// meanwhile, then relay. +async fn run_controlled_channel( + id: i32, + addr: String, + is_rdp: bool, + credit: Arc, + window: Arc>, + mut inbound: mpsc::UnboundedReceiver, + sink: FrameSink, + teardown: watch::Receiver, +) { + let mut pending: Vec = Vec::new(); + let mut pending_len = 0usize; + let connect = timeout(CONNECT_TIMEOUT_MS, TcpStream::connect(&addr)); + tokio::pin!(connect); + let socket = loop { + tokio::select! { + // Biased with the command arm first: a `close` that is already + // queued must win over a connect that completed on the same poll, + // or `opened` would go out for a channel the controller has dropped. + biased; + cmd = inbound.recv() => match cmd { + Some(Inbound::Data(b)) => { + if !pending_fits(pending_len, b.len()) { + log::warn!("port forward channel {} sent more than INITIAL_WINDOW before opened", id); + sink.send_ordered(close_msg(id)).await.ok(); + return; + } + pending_len += charge(b.len()) as usize; + pending.push(b); + } + Some(Inbound::Close) | None => return, + Some(Inbound::Violation) => { + sink.send_ordered(close_msg(id)).await.ok(); + return; + } + }, + res = &mut connect => { + let err = match res { + Ok(Ok(s)) => break s, + Ok(Err(e)) => e.to_string(), + Err(e) => e.to_string(), + }; + log::debug!("port forward channel {} connect {} failed: {}", id, addr, err); + sink.send_ordered(opened_msg(id, false, &unreachable_message(&addr, is_rdp), 0)).await.ok(); + return; + } + } + }; + // Granted before `opened` leaves, so the peer can never be ahead of it. + window.lock().unwrap().grant(CHANNEL_WINDOW - INITIAL_WINDOW); + if sink + .send_ordered(opened_msg(id, true, "", CHANNEL_WINDOW)) + .await + .is_err() + { + return; + } + let (reader, writer) = socket.into_split(); + run_channel(id, reader, writer, Vec::new(), pending, credit, window, inbound, sink, teardown).await; +} + +/// The same words the raw pipe puts in its login error, so one problem reads +/// the same whichever path the peer takes. +fn unreachable_message(addr: &str, is_rdp: bool) -> String { + format!( + "Failed to access remote {}. Please make sure it is reachable/open.", + if is_rdp { "RDP" } else { addr } + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::port_forward_mux::{CHANNEL_WINDOW, INITIAL_WINDOW, MAX_CHANNELS, MIN_FRAME_CHARGE}; + use hbb_common::{ + message_proto::{message, port_forward_channel}, + tokio::{ + self, + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::mpsc, + time::Instant, + }, + }; + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + /// An echo server standing in for the forward target. + async fn echo_target() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let (mut s, _) = l.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + loop { + let n = s.read(&mut buf).await.unwrap_or(0); + if n == 0 || s.write_all(&buf[..n]).await.is_err() { + return; + } + } + }); + } + }); + port + } + + fn open(id: i32, port: u16) -> PortForwardChannel { + let mut ch = PortForwardChannel::new(); + ch.set_open(PortForwardOpen { + channel_id: id, + host: "127.0.0.1".to_owned(), + port: port as i32, + window: CHANNEL_WINDOW, + ..Default::default() + }); + ch + } + + fn data(id: i32, bytes: &[u8]) -> PortForwardChannel { + let mut ch = PortForwardChannel::new(); + ch.set_data(PortForwardData { + channel_id: id, + data: Bytes::copy_from_slice(bytes), + ..Default::default() + }); + ch + } + + fn close(id: i32) -> PortForwardChannel { + let mut ch = PortForwardChannel::new(); + ch.set_close(PortForwardClose { channel_id: id, ..Default::default() }); + ch + } + + async fn next_frame(rx: &mut mpsc::UnboundedReceiver<(Instant, Arc)>) -> PortForwardChannel { + let (_, m) = rx.recv().await.unwrap(); + match &m.union { + Some(message::Union::PortForwardChannel(ch)) => ch.clone(), + other => panic!("unexpected {:?}", other), + } + } + + fn opened(ch: &PortForwardChannel) -> (i32, bool) { + match &ch.union { + Some(port_forward_channel::Union::Opened(o)) => (o.channel_id, o.success), + other => panic!("expected opened, got {:?}", other), + } + } + + fn data_of(ch: &PortForwardChannel) -> (i32, Vec) { + match &ch.union { + Some(port_forward_channel::Union::Data(d)) => (d.channel_id, d.data.to_vec()), + other => panic!("expected data, got {:?}", other), + } + } + + #[test] + fn open_connects_and_echoes_pipelined_data() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + mux.handle(data(1, b"ping"), || true); + assert_eq!(opened(&next_frame(&mut rx).await), (1, true)); + assert_eq!(data_of(&next_frame(&mut rx).await), (1, b"ping".to_vec())); + mux.handle(close(1), || true); + }); + } + + #[test] + fn unreachable_target_fails_open_and_discards_pipelined_data() { + rt().block_on(async { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + mux.handle(data(1, b"lost"), || true); + assert_eq!(opened(&next_frame(&mut rx).await), (1, false)); + assert!(tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await.is_err()); + }); + } + + #[test] + fn permission_denied_refuses_without_spawning() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || false); + assert_eq!(opened(&next_frame(&mut rx).await), (1, false)); + assert_eq!(mux.live_channels(), 0); + }); + } + + #[test] + fn a_revoked_permission_refuses_new_channels_and_keeps_live_ones() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + assert_eq!(opened(&next_frame(&mut rx).await), (1, true)); + // `enable-tunnel` is consulted per `open`, so turning it off + // mid-session stops new channels; the live one keeps relaying. + mux.handle(open(2, port), || false); + assert_eq!(opened(&next_frame(&mut rx).await), (2, false)); + mux.handle(data(1, b"still relayed"), || false); + assert_eq!(data_of(&next_frame(&mut rx).await), (1, b"still relayed".to_vec())); + assert_eq!(mux.live_channels(), 1); + }); + } + + #[test] + fn close_while_connecting_sends_no_opened() { + rt().block_on(async { + // `close` is queued before the task is first polled. Its `select!` is + // biased towards the command arm, so even a connect that completes on + // that same poll loses: no `opened` may ever be sent. + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + mux.handle(close(1), || true); + assert!(tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()).await.is_err()); + assert_eq!(mux.live_channels(), 0); + }); + } + + #[test] + fn over_window_data_closes_only_that_channel() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + mux.handle(open(2, port), || true); + let mut seen = 0; + while seen < 2 { + opened(&next_frame(&mut rx).await); + seen += 1; + } + let too_much = vec![0u8; CHANNEL_WINDOW as usize + 1]; + mux.handle(data(1, &too_much), || true); + let ch = next_frame(&mut rx).await; + match &ch.union { + Some(port_forward_channel::Union::Close(c)) => assert_eq!(c.channel_id, 1), + other => panic!("expected close, got {:?}", other), + } + mux.handle(data(2, b"still fine"), || true); + assert_eq!(data_of(&next_frame(&mut rx).await), (2, b"still fine".to_vec())); + }); + } + + #[test] + fn an_over_window_frame_drops_the_channel_at_once() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + opened(&next_frame(&mut rx).await); + let too_much = vec![0u8; CHANNEL_WINDOW as usize + 1]; + mux.handle(data(1, &too_much), || true); + // Gone before the channel task has run: whatever the peer keeps + // sending for this id can no longer queue anything. + assert_eq!(mux.live_channels(), 0); + mux.handle(data(1, &too_much), || true); + assert_eq!(mux.live_channels(), 0); + let ch = next_frame(&mut rx).await; + match &ch.union { + Some(port_forward_channel::Union::Close(c)) => assert_eq!(c.channel_id, 1), + other => panic!("expected close, got {:?}", other), + } + }); + } + + #[test] + fn open_to_a_target_other_than_the_login_target_is_refused() { + rt().block_on(async { + let a = echo_target().await; + let b = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", a)); + mux.handle(open(1, a), || true); + assert_eq!(opened(&next_frame(&mut rx).await), (1, true)); + // Approval was for target a; b needs a login of its own. + mux.handle(open(2, b), || true); + let ch = next_frame(&mut rx).await; + match &ch.union { + Some(port_forward_channel::Union::Opened(o)) => { + assert_eq!((o.channel_id, o.success), (2, false)); + assert!(!o.message.is_empty()); + } + other => panic!("expected opened, got {:?}", other), + } + assert_eq!(mux.live_channels(), 1); + }); + } + + #[test] + fn demux_admits_only_the_initial_window_before_opened() { + rt().block_on(async { + let port = echo_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + mux.handle(open(1, port), || true); + // The channel task has not run yet: the demultiplexer alone + // decides what may sit in the queue before `opened`. + assert_eq!(mux.recv_window_remaining(1), Some(INITIAL_WINDOW)); + assert_eq!(opened(&next_frame(&mut rx).await), (1, true)); + assert_eq!(mux.recv_window_remaining(1), Some(CHANNEL_WINDOW)); + }); + } + + #[test] + fn pending_bytes_are_bounded_by_initial_window_before_opened() { + // A loopback connect completes before a task can observe "connecting", + // so the bound is pinned on the pure predicate the task uses. + assert!(pending_fits(0, INITIAL_WINDOW as usize)); + assert!(pending_fits( + INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize, + 1 + )); + // A 1-byte frame costs a whole minimum charge here too. + assert!(!pending_fits( + INITIAL_WINDOW as usize - MIN_FRAME_CHARGE as usize + 1, + 1 + )); + assert!(!pending_fits(usize::MAX, 1)); + } + + /// A target that accepts and hangs up at once, so every channel ends on + /// the target's EOF — the case where only the next `open` frees the entry. + async fn drop_target() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let (s, _) = l.accept().await.unwrap(); + drop(s); + } + }); + port + } + + #[test] + fn open_frees_dead_entries_so_the_cap_counts_live_channels() { + rt().block_on(async { + let port = drop_target().await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut mux = PortForwardMux::new(tx, format!("127.0.0.1:{}", port)); + for id in 1..=(MAX_CHANNELS as i32 * 2) { + mux.handle(open(id, port), || true); + assert_eq!(opened(&next_frame(&mut rx).await), (id, true)); + // The task sends `close` on the target's EOF and exits; the + // entry is dead until the next `open` drops it. + let ch = next_frame(&mut rx).await; + match &ch.union { + Some(port_forward_channel::Union::Close(c)) => assert_eq!(c.channel_id, id), + other => panic!("expected close, got {:?}", other), + } + tokio::task::yield_now().await; + } + }); + } +} diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 03b59a497..e7c8e7516 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1944,6 +1944,7 @@ pub async fn io_loop(handler: Session, round: u32) { let key = crate::get_key(false).await; #[cfg(not(any(target_os = "android", target_os = "ios")))] if handler.is_port_forward() { + handler.lc.write().unwrap().port_forward_mux = crate::port_forward::mux_enabled(); if handler.is_rdp() { let port = handler .get_option("rdp_port".to_owned())