diff --git a/docs/troubleshooting/RDCLIENT_KEYBOARD.md b/docs/troubleshooting/RDCLIENT_KEYBOARD.md index a5aaeacb..2c3dfe96 100644 --- a/docs/troubleshooting/RDCLIENT_KEYBOARD.md +++ b/docs/troubleshooting/RDCLIENT_KEYBOARD.md @@ -1,6 +1,6 @@ # RdClient Web — keyboard troubleshooting -Symptoms such as wrong characters after some time, broken shortcuts, or Hyper-V / VM Connect typing issues often involve **sticky remote modifiers** or **nested remote keyboard translation**. +Symptoms such as wrong characters after some time, broken Shift/Caps Lock, or Hyper-V / VM Connect typing issues often involve **sticky remote modifiers**, **Legacy vs Map encoding**, or **nested remote keyboard translation**. ## Quick recovery (operator) @@ -12,28 +12,33 @@ Symptoms such as wrong characters after some time, broken shortcuts, or Hyper-V 1. Connect via **Settings → Remote** (rdclient web). 2. Hold **Shift** or **Ctrl**, then switch to another browser tab or click the address bar **without** releasing the modifier. -3. Return to the remote tab and type — if characters are wrong, the issue matches missing keyup sync (fixed in recent builds via blur/tab release). +3. Return to the remote tab and type — if characters are wrong, the issue matches missing keyup sync (fixed via blur/tab release). 4. Click **Reset Keyboard State** — typing should return to normal. ## Compare with native RustDesk client | Step | Native RustDesk | rdclient web | |------|-----------------|--------------| -| Same host, same task (e.g. Hyper-V Manager) | Baseline | If only web fails → web pipeline | -| After tab switch with modifier held | May also stick | Should auto-release after fix | -| Hyper-V VM Connect nested in session | Known fragile | Use **Keyboard Mode → Map** or connect directly to guest | +| Same host, same task (e.g. Hyper-V Manager) | Baseline | Should match after parity encoder | +| Shift + letter | Uppercase | Same (Map scancode + Shift controlKey, or Legacy `a` + Shift modifier) | +| After tab switch with modifier held | May also stick | Auto-releases tracked keys on blur | +| Hyper-V VM Connect nested in session | Known fragile | Prefer **Auto** or **Map**; connect to guest when possible | -**Interpretation:** If native RustDesk works but rdclient web fails on the same host, focus on web client settings (Keyboard Mode, Reset Keyboard). If both fail inside VM Connect, prefer **Enhanced Session**, direct RDP to the guest, or RustDesk installed on the VM. +**Interpretation:** If native RustDesk works but rdclient web fails on the same host, try **Reset Keyboard State**, then **Legacy** mode for accented typing (ą, ü). If both fail inside VM Connect, prefer **Enhanced Session**, direct RDP to the guest, or RustDesk on the VM. ## Keyboard modes (Display settings) | Mode | When to use | |------|-------------| -| **Auto** (default) | Map (scancode) for Windows peers; Legacy elsewhere | -| **Legacy** | Normal text entry, accented characters (ą, ü, …) | -| **Map** | Hyper-V, VM console, BIOS, apps needing physical key positions | +| **Auto** (default) | **Map (scancode) for all keys** — same default as the RustDesk desktop client. Best for Windows, Hyper-V, VM console, Shift+symbols. | +| **Legacy** | Layout-specific characters (ą, ü, …), AltGr, or when Map misbehaves on a specific app | +| **Map** | Explicit scancode mode (same wire format as Auto) | -Map mode sends RustDesk `KeyEvent` with `mode: Map` and scancode in `chr` (same contract as the desktop client). +### Wire contract (matches RustDesk native) + +- **Auto / Map:** printable keys → `KeyEvent.mode = Map`, scancode in `chr`; Shift/Ctrl/Alt sent as separate Legacy `controlKey` events; `modifiers` on Map chr events carry **CapsLock/NumLock only**. +- **Legacy:** printable keys → lowercase `chr` + `modifiers` (Shift/Ctrl/Alt as needed); server applies case via `need_to_uppercase()`. +- **Caps Lock / Num Lock / Scroll Lock keys:** no wire event (state synced via `modifiers` on following letter/numpad keys). ## Hyper-V / nested remote @@ -41,10 +46,11 @@ Chain: **Browser → rdclient → RustDesk on host → Hyper-V / VM Connect → - Avoid configuring VMs through double/triple nested remote when possible. - Connect rdclient **directly to the guest** if it runs RustDesk. -- For host-only Hyper-V Manager work, **Map** or **Auto** mode on Windows peers improves scancode fidelity. +- **Auto** (Map) is the recommended default for host Hyper-V Manager and VM Connect. ## Related code -- [`web-nodejs/public/js/rdclient/input.js`](../../web-nodejs/public/js/rdclient/input.js) — capture, release, Legacy/Map +- [`web-nodejs/public/js/rdclient/keyboard-encoder.js`](../../web-nodejs/public/js/rdclient/keyboard-encoder.js) — RustDesk-compatible KeyEvent encoder +- [`web-nodejs/public/js/rdclient/input.js`](../../web-nodejs/public/js/rdclient/input.js) — DOM capture, blur/stop release - [`web-nodejs/public/js/rdclient/keyboard-scancode.js`](../../web-nodejs/public/js/rdclient/keyboard-scancode.js) — DOM `code` → scancode tables -- [`web-nodejs/tests/rdclient.input.test.js`](../../web-nodejs/tests/rdclient.input.test.js) — regression tests +- [`web-nodejs/tests/rdclient.input.test.js`](../../web-nodejs/tests/rdclient.input.test.js) — parity regression tests diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index a349160f..4fe4f145 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -1194,7 +1194,7 @@ "reset_keyboard": "إعادة تعيين حالة لوحة المفاتيح", "reset_keyboard_done": "تمت إعادة تعيين لوحة المفاتيح على الجهاز البعيد", "keyboard_mode": "وضع لوحة المفاتيح", - "keyboard_mode_auto": "تلقائي (Map على Windows)", + "keyboard_mode_auto": "تلقائي (Map، مثل RustDesk)", "keyboard_mode_legacy": "Legacy (حرف)", "keyboard_mode_map": "Map (رمز مسح فعلي)" }, diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 95b454af..c4d71ab4 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Reset stavu klávesnice", "reset_keyboard_done": "Stav klávesnice na vzdáleném PC byl resetován", "keyboard_mode": "Režim klávesnice", - "keyboard_mode_auto": "Auto (Map ve Windows)", + "keyboard_mode_auto": "Auto (Map, jako RustDesk)", "keyboard_mode_legacy": "Legacy (znak)", "keyboard_mode_map": "Map (fyzický scancode)", "display_settings": "Nastavení zobrazení", diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index 095be83a..15693dda 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Nulstil tastaturtilstand", "reset_keyboard_done": "Tastaturtilstand nulstillet på fjernen", "keyboard_mode": "Tastaturtilstand", - "keyboard_mode_auto": "Auto (Map på Windows)", + "keyboard_mode_auto": "Auto (Map, som RustDesk)", "keyboard_mode_legacy": "Legacy (tegn)", "keyboard_mode_map": "Map (fysisk scancode)", "display_settings": "Skærmindstillinger", diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index bcb89133..a51c4436 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Tastaturzustand zurücksetzen", "reset_keyboard_done": "Tastaturzustand auf dem Remote-PC zurückgesetzt", "keyboard_mode": "Tastaturmodus", - "keyboard_mode_auto": "Auto (Map unter Windows)", + "keyboard_mode_auto": "Auto (Map, wie RustDesk)", "keyboard_mode_legacy": "Legacy (Zeichen)", "keyboard_mode_map": "Map (physischer Scancode)", "display_settings": "Anzeigeeinstellungen", diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index f23a9304..0d085c71 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -1117,7 +1117,7 @@ "reset_keyboard": "Reset Keyboard State", "reset_keyboard_done": "Keyboard state reset on remote", "keyboard_mode": "Keyboard Mode", - "keyboard_mode_auto": "Auto (Map on Windows)", + "keyboard_mode_auto": "Auto (Map, like RustDesk)", "keyboard_mode_legacy": "Legacy (character)", "keyboard_mode_map": "Map (physical scancode)", "display_settings": "Display Settings", diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index ce633dfb..159a0905 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Restablecer estado del teclado", "reset_keyboard_done": "Estado del teclado restablecido en el remoto", "keyboard_mode": "Modo de teclado", - "keyboard_mode_auto": "Auto (Map en Windows)", + "keyboard_mode_auto": "Auto (Map, como RustDesk)", "keyboard_mode_legacy": "Legacy (carácter)", "keyboard_mode_map": "Map (escaneo físico)", "display_settings": "Ajustes de pantalla", diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 7e42ef5b..9d039197 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Nollaa näppäimistön tila", "reset_keyboard_done": "Näppäimistön tila nollattu etäkoneella", "keyboard_mode": "Näppäimistötila", - "keyboard_mode_auto": "Auto (Map Windowsissa)", + "keyboard_mode_auto": "Auto (Map, kuten RustDesk)", "keyboard_mode_legacy": "Legacy (merkki)", "keyboard_mode_map": "Map (fyysinen skannauskoodi)", "display_settings": "Näyttöasetukset", diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index ae9cba83..88c189f5 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -1113,7 +1113,7 @@ "reset_keyboard": "Réinitialiser le clavier", "reset_keyboard_done": "État du clavier réinitialisé à distance", "keyboard_mode": "Mode clavier", - "keyboard_mode_auto": "Auto (Map sous Windows)", + "keyboard_mode_auto": "Auto (Map, comme RustDesk)", "keyboard_mode_legacy": "Legacy (caractère)", "keyboard_mode_map": "Map (scancode physique)", "display_settings": "Paramètres d'affichage", diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index 078d99dd..b1d1b8fb 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "कीबोर्ड स्थिति रीसेट करें", "reset_keyboard_done": "रिमोट पर कीबोर्ड स्थिति रीसेट की गई", "keyboard_mode": "कीबोर्ड मोड", - "keyboard_mode_auto": "ऑटो (Windows पर Map)", + "keyboard_mode_auto": "ऑटो (Map, RustDesk जैसा)", "keyboard_mode_legacy": "Legacy (अक्षर)", "keyboard_mode_map": "Map (भौतिक scancode)", "display_settings": "प्रदर्शन सेटिंग्स", diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index a94d8e36..d649da5a 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Billentyűzet állapot visszaállítása", "reset_keyboard_done": "A távoli billentyűzet állapota visszaállítva", "keyboard_mode": "Billentyűzet mód", - "keyboard_mode_auto": "Auto (Map Windows-on)", + "keyboard_mode_auto": "Auto (Map, mint a RustDesk)", "keyboard_mode_legacy": "Legacy (karakter)", "keyboard_mode_map": "Map (fizikai scancode)", "display_settings": "Megjelenítési beállítások", diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 17856668..7aeb9f58 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Reset status keyboard", "reset_keyboard_done": "Status keyboard di remote direset", "keyboard_mode": "Mode keyboard", - "keyboard_mode_auto": "Otomatis (Map di Windows)", + "keyboard_mode_auto": "Otomatis (Map, seperti RustDesk)", "keyboard_mode_legacy": "Legacy (karakter)", "keyboard_mode_map": "Map (scancode fisik)", "display_settings": "Pengaturan Tampilan", diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index 3813fc2c..8a5a7f5a 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Reimposta stato tastiera", "reset_keyboard_done": "Stato tastiera reimpostato sul remoto", "keyboard_mode": "Modalità tastiera", - "keyboard_mode_auto": "Auto (Map su Windows)", + "keyboard_mode_auto": "Auto (Map, come RustDesk)", "keyboard_mode_legacy": "Legacy (carattere)", "keyboard_mode_map": "Map (scancode fisico)", "display_settings": "Impostazioni schermo", diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index be068173..3fcc1e31 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "キーボード状態をリセット", "reset_keyboard_done": "リモート側のキーボード状態をリセットしました", "keyboard_mode": "キーボードモード", - "keyboard_mode_auto": "自動(Windows では Map)", + "keyboard_mode_auto": "自動(Map、RustDesk と同様)", "keyboard_mode_legacy": "Legacy(文字)", "keyboard_mode_map": "Map(物理スキャンコード)", "display_settings": "表示設定", diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index f7498eb5..834a10a4 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "키보드 상태 재설정", "reset_keyboard_done": "원격 키보드 상태가 재설정되었습니다", "keyboard_mode": "키보드 모드", - "keyboard_mode_auto": "자동(Windows에서 Map)", + "keyboard_mode_auto": "자동(Map, RustDesk와 동일)", "keyboard_mode_legacy": "Legacy(문자)", "keyboard_mode_map": "Map(물리 스캔코드)", "display_settings": "디스플레이 설정", diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index d1e0b811..8ece5af0 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Tilbakestill tastaturtilstand", "reset_keyboard_done": "Tastaturtilstand tilbakestilt på ekstern maskin", "keyboard_mode": "Tastaturmodus", - "keyboard_mode_auto": "Auto (Map på Windows)", + "keyboard_mode_auto": "Auto (Map, som RustDesk)", "keyboard_mode_legacy": "Legacy (tegn)", "keyboard_mode_map": "Map (fysisk scancode)", "display_settings": "Skjerminnstillinger", diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 99157fcf..f10fd5da 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Toetsenbordstatus resetten", "reset_keyboard_done": "Toetsenbordstatus op remote gereset", "keyboard_mode": "Toetsenbordmodus", - "keyboard_mode_auto": "Auto (Map op Windows)", + "keyboard_mode_auto": "Auto (Map, zoals RustDesk)", "keyboard_mode_legacy": "Legacy (teken)", "keyboard_mode_map": "Map (fysieke scancode)", "display_settings": "Scherminstellingen", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 2fb8748f..21c899ce 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -1109,7 +1109,7 @@ "reset_keyboard": "Resetuj stan klawiatury", "reset_keyboard_done": "Stan klawiatury zresetowany na hoście zdalnym", "keyboard_mode": "Tryb klawiatury", - "keyboard_mode_auto": "Auto (Map w Windows)", + "keyboard_mode_auto": "Auto (Map, jak RustDesk)", "keyboard_mode_legacy": "Legacy (znak)", "keyboard_mode_map": "Map (skan fizyczny)", "display_settings": "Ustawienia wyświetlania", diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index 8092d902..7b6aad74 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -1092,7 +1092,7 @@ "reset_keyboard": "Repor estado do teclado", "reset_keyboard_done": "Estado do teclado reposto no remoto", "keyboard_mode": "Modo de teclado", - "keyboard_mode_auto": "Auto (Map no Windows)", + "keyboard_mode_auto": "Auto (Map, como RustDesk)", "keyboard_mode_legacy": "Legacy (carácter)", "keyboard_mode_map": "Map (scancode físico)", "display_settings": "Definições do ecrã", diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 978cf20f..eab7bc65 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Resetează starea tastaturii", "reset_keyboard_done": "Starea tastaturii a fost resetată la distanță", "keyboard_mode": "Mod tastatură", - "keyboard_mode_auto": "Auto (Map pe Windows)", + "keyboard_mode_auto": "Auto (Map, ca RustDesk)", "keyboard_mode_legacy": "Legacy (caracter)", "keyboard_mode_map": "Map (scancode fizic)", "display_settings": "Setări de afișare", diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index f5258ae3..06f0cb26 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Återställ tangentbordsläge", "reset_keyboard_done": "Tangentbordsläge återställt på fjärrdatorn", "keyboard_mode": "Tangentbordsläge", - "keyboard_mode_auto": "Auto (Map i Windows)", + "keyboard_mode_auto": "Auto (Map, som RustDesk)", "keyboard_mode_legacy": "Legacy (tecken)", "keyboard_mode_map": "Map (fysisk scancode)", "display_settings": "Displayinställningar", diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 4102cf5c..3fa80bb2 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "รีเซ็ตสถานะคีย์บอร์ด", "reset_keyboard_done": "รีเซ็ตสถานะคีย์บอร์ดบนเครื่องระยะไกลแล้ว", "keyboard_mode": "โหมดคีย์บอร์ด", - "keyboard_mode_auto": "อัตโนมัติ (Map บน Windows)", + "keyboard_mode_auto": "อัตโนมัติ (Map เหมือน RustDesk)", "keyboard_mode_legacy": "Legacy (ตัวอักษร)", "keyboard_mode_map": "Map (scancode ทางกายภาพ)", "display_settings": "การตั้งค่าการแสดงผล", diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index a1908cca..0f2adf8c 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Klavye durumunu sıfırla", "reset_keyboard_done": "Uzak klavye durumu sıfırlandı", "keyboard_mode": "Klavye modu", - "keyboard_mode_auto": "Otomatik (Windows'ta Map)", + "keyboard_mode_auto": "Otomatik (Map, RustDesk gibi)", "keyboard_mode_legacy": "Legacy (karakter)", "keyboard_mode_map": "Map (fiziksel scancode)", "display_settings": "Ekran Ayarları", diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index 52fe6b71..d262f1a9 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Скинути стан клавіатури", "reset_keyboard_done": "Стан клавіатури на віддаленому ПК скинуто", "keyboard_mode": "Режим клавіатури", - "keyboard_mode_auto": "Авто (Map у Windows)", + "keyboard_mode_auto": "Авто (Map, як RustDesk)", "keyboard_mode_legacy": "Legacy (символ)", "keyboard_mode_map": "Map (фізичний scancode)", "display_settings": "Налаштування дисплея", diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 3ce6b256..3d6c9a6f 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -1098,7 +1098,7 @@ "reset_keyboard": "Đặt lại trạng thái bàn phím", "reset_keyboard_done": "Đã đặt lại bàn phím trên máy remote", "keyboard_mode": "Chế độ bàn phím", - "keyboard_mode_auto": "Tự động (Map trên Windows)", + "keyboard_mode_auto": "Tự động (Map, giống RustDesk)", "keyboard_mode_legacy": "Legacy (ký tự)", "keyboard_mode_map": "Map (scancode vật lý)", "display_settings": "Cài đặt hiển thị", diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index 0d30956d..4d3f77bf 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -1113,7 +1113,7 @@ "reset_keyboard": "重設鍵盤狀態", "reset_keyboard_done": "已在遠端重設鍵盤狀態", "keyboard_mode": "鍵盤模式", - "keyboard_mode_auto": "自動(Windows 使用 Map)", + "keyboard_mode_auto": "自動(Map,與 RustDesk 相同)", "keyboard_mode_legacy": "Legacy(字元)", "keyboard_mode_map": "Map(實體掃描碼)", "display_settings": "顯示設置", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index a6d77a70..2775653d 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -1094,7 +1094,7 @@ "reset_keyboard": "重置键盘状态", "reset_keyboard_done": "已在远程端重置键盘状态", "keyboard_mode": "键盘模式", - "keyboard_mode_auto": "自动(Windows 使用 Map)", + "keyboard_mode_auto": "自动(Map,与 RustDesk 相同)", "keyboard_mode_legacy": "Legacy(字符)", "keyboard_mode_map": "Map(物理扫描码)", "display_settings": "显示设置", diff --git a/web-nodejs/public/js/rdclient/input.js b/web-nodejs/public/js/rdclient/input.js index 7461449e..e2b37c5d 100644 --- a/web-nodejs/public/js/rdclient/input.js +++ b/web-nodejs/public/js/rdclient/input.js @@ -3,7 +3,7 @@ * Captures keyboard and mouse events and converts them to RustDesk protocol messages */ -/* global RDProtocol, RDKeyboardScancode */ +/* global RDProtocol, RDKeyboardScancode, RDKeyboardEncoder */ // eslint-disable-next-line no-unused-vars class RDInput { @@ -33,16 +33,14 @@ class RDInput { this.peerPlatform = ''; /** * Keyboard wire mode: 'Legacy' | 'Map' | 'Auto'. - * Auto uses Map for Windows peers (Hyper-V / VM console), Legacy otherwise. + * Auto resolves to Map (RustDesk native default). * @type {'Legacy'|'Map'|'Auto'} */ this.keyboardMode = 'Auto'; - // Mouse move throttling (~60 Hz for smoother remote control) this._lastMouseSendTime = 0; this._mouseThrottleMs = 16; - // Bound event handlers (for removal) this._onMouseMove = this._handleMouseMove.bind(this); this._onMouseDown = this._handleMouseDown.bind(this); this._onMouseUp = this._handleMouseUp.bind(this); @@ -77,9 +75,6 @@ class RDInput { this._releaseAllKeys(true); } - /** - * Start capturing input events - */ start() { if (this.enabled) return; @@ -90,31 +85,21 @@ class RDInput { c.addEventListener('wheel', this._onWheel, { passive: false }); c.addEventListener('contextmenu', this._onContextMenu); - // Keyboard events on document (canvas needs focus for key events) document.addEventListener('keydown', this._onKeyDown); document.addEventListener('keyup', this._onKeyUp); - - // Pointer Lock change detection document.addEventListener('pointerlockchange', this._onPointerLockChange); - - // Release remote keys when the operator loses focus (prevents sticky modifiers) window.addEventListener('blur', this._onWindowBlur); document.addEventListener('visibilitychange', this._onVisibilityChange); - // Make canvas focusable c.tabIndex = 0; c.focus(); this.enabled = true; } - /** - * Stop capturing input events - */ stop() { if (!this.enabled) return; - // Notify remote before detaching listeners (tracked keys only) this._releaseAllKeys(false); const c = this.canvas; @@ -139,48 +124,16 @@ class RDInput { this.enabled = false; } - /** - * Request pointer lock for better mouse capture - */ requestPointerLock() { this.canvas.requestPointerLock(); } - /** - * Release pointer lock - */ exitPointerLock() { if (this.pointerLocked) { document.exitPointerLock(); } } - // ---- Mouse Event Handlers ---- - - /** - * RustDesk mouse event mask encoding: - * mask = EVENT_TYPE | (BUTTON << 3) - * - * Event types (bits 0-2): - * 0 = move, 1 = down, 2 = up, 3 = wheel - * - * Button IDs (bits 3+): - * 1 = left, 2 = right, 4 = middle, 8 = back, 16 = forward - * - * Wheel events (type 3): x/y carry scroll delta, not cursor position. - * Match RustDesk Flutter client — normalized to -1 or 1 per axis. - * - * Examples: - * move = 0 - * left down = 1 | (1 << 3) = 9 - * left up = 2 | (1 << 3) = 10 - * right down = 1 | (2 << 3) = 17 - * right up = 2 | (2 << 3) = 18 - * middle down = 1 | (4 << 3) = 33 - * middle up = 2 | (4 << 3) = 34 - * scroll up = 3, x=0, y=1 - * scroll down = 3, x=0, y=-1 - */ static MOUSE_TYPE_DOWN = 1; static MOUSE_TYPE_UP = 2; static MOUSE_TYPE_WHEEL = 3; @@ -189,28 +142,25 @@ class RDInput { static MOUSE_BUTTON_RIGHT = 2; static MOUSE_BUTTON_MIDDLE = 4; - /** ControlKey enum values from message.proto (lock + modifier sync). */ - static MOD_ALT = 1; - static MOD_CAPS_LOCK = 3; - static MOD_CTRL = 4; - static MOD_META = 23; - static MOD_SHIFT = 29; - static MOD_NUM_LOCK = 63; - - /** @param {string} code */ - static isLetterCode(code) { - return /^Key[A-Z]$/.test(code); + _encoder() { + if (typeof RDKeyboardEncoder !== 'undefined') return RDKeyboardEncoder; + if (typeof window !== 'undefined' && window.RDKeyboardEncoder) { + return window.RDKeyboardEncoder; + } + return null; } - /** @param {string} code */ - static isNumpadCode(code) { - return /^Numpad/.test(code); + _getScancodeLib() { + if (typeof RDKeyboardScancode !== 'undefined') return RDKeyboardScancode; + if (typeof window !== 'undefined' && window.RDKeyboardScancode) { + return window.RDKeyboardScancode; + } + return null; } _handleMouseMove(e) { if (!this.enabled) return; - // Throttle mouse moves to reduce bandwidth and improve responsiveness const now = performance.now(); if (now - this._lastMouseSendTime < this._mouseThrottleMs) return; this._lastMouseSendTime = now; @@ -218,13 +168,12 @@ class RDInput { const pos = this._getRemotePosition(e); if (!pos) return; - // Mouse move = mask 0 this.sendMessage({ mouseEvent: { mask: 0, x: pos.x, y: pos.y, - modifiers: this._getModifiers(e) + modifiers: this._getMouseModifiers(e) } }); } @@ -232,8 +181,6 @@ class RDInput { _handleMouseDown(e) { if (!this.enabled) return; e.preventDefault(); - - // Focus canvas for keyboard events this.canvas.focus(); const pos = this._getRemotePosition(e); @@ -254,7 +201,7 @@ class RDInput { mask: mask, x: pos.x, y: pos.y, - modifiers: this._getModifiers(e) + modifiers: this._getMouseModifiers(e) } }); } @@ -282,7 +229,7 @@ class RDInput { mask: mask, x: pos.x, y: pos.y, - modifiers: this._getModifiers(e) + modifiers: this._getMouseModifiers(e) } }); } @@ -300,75 +247,19 @@ class RDInput { mask: RDInput.MOUSE_TYPE_WHEEL, x: delta.dx, y: delta.dy, - modifiers: this._getModifiers(e) + modifiers: this._getMouseModifiers(e) } }); } - // ---- Keyboard Event Handlers ---- - - /** - * Map browser key code to RustDesk ControlKey enum value - */ - static KEY_MAP = { - 'Escape': 'Escape', - 'Backspace': 'Backspace', - 'Tab': 'Tab', - 'Enter': 'Return', - 'ShiftLeft': 'Shift', - 'ShiftRight': 'RShift', - 'ControlLeft': 'Control', - 'ControlRight': 'RControl', - 'AltLeft': 'Alt', - 'AltRight': 'RAlt', - 'MetaLeft': 'Meta', - 'MetaRight': 'RWin', - 'Pause': 'Pause', - 'CapsLock': 'CapsLock', - 'Space': 'Space', - 'PageUp': 'PageUp', - 'PageDown': 'PageDown', - 'End': 'End', - 'Home': 'Home', - 'ArrowLeft': 'LeftArrow', - 'ArrowUp': 'UpArrow', - 'ArrowRight': 'RightArrow', - 'ArrowDown': 'DownArrow', - 'PrintScreen': 'Snapshot', - 'Insert': 'Insert', - 'Delete': 'Delete', - 'ScrollLock': 'Scroll', - 'NumLock': 'NumLock', - 'F1': 'F1', 'F2': 'F2', 'F3': 'F3', 'F4': 'F4', - 'F5': 'F5', 'F6': 'F6', 'F7': 'F7', 'F8': 'F8', - 'F9': 'F9', 'F10': 'F10', 'F11': 'F11', 'F12': 'F12', - 'Numpad0': 'Numpad0', 'Numpad1': 'Numpad1', 'Numpad2': 'Numpad2', - 'Numpad3': 'Numpad3', 'Numpad4': 'Numpad4', 'Numpad5': 'Numpad5', - 'Numpad6': 'Numpad6', 'Numpad7': 'Numpad7', 'Numpad8': 'Numpad8', - 'Numpad9': 'Numpad9', - 'NumpadMultiply': 'Multiply', - 'NumpadAdd': 'Add', - 'NumpadSubtract': 'Subtract', - 'NumpadDecimal': 'Decimal', - 'NumpadDivide': 'Divide', - 'NumpadEnter': 'NumpadEnter', - 'ContextMenu': 'Apps', - 'AudioVolumeMute': 'VolumeMute', - 'AudioVolumeDown': 'VolumeDown', - 'AudioVolumeUp': 'VolumeUp' - }; - _handleKeyDown(e) { if (!this.enabled) return; - // Don't capture if focus is on an input element if (this._isInputFocused()) return; e.preventDefault(); e.stopPropagation(); const keyCode = e.code; - // RustDesk legacy mode: keydown => down, key repeat => press (click). - // Repeat is reported either via e.repeat or our own pressedKeys guard. const isRepeat = e.repeat || this.pressedKeys.has(keyCode); this.pressedKeys.set(keyCode, { key: e.key }); @@ -403,15 +294,11 @@ class RDInput { } } - /** - * Send keyup for every locally tracked key. - * @param {boolean} [forceAllModifiers=false] - release all modifiers (toolbar recovery only) - */ _releaseAllKeys(forceAllModifiers) { const codes = [...this.pressedKeys.keys()]; for (const code of codes) { const meta = this.pressedKeys.get(code); - this._sendKeyForCode(code, meta?.key || '', false, false, []); + this._sendKeyForCode(code, meta?.key || '', false, false, null); } this.pressedKeys.clear(); @@ -420,188 +307,51 @@ class RDInput { } } - /** Release Shift/Ctrl/Alt/Meta on the remote side even if keyup was missed. */ _releaseAllModifiers() { - const mode = this._resolveKeyboardMode(); - const modCodes = this._getScancodeLib()?.MODIFIER_CODES || - ['ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', - 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight']; - - for (const code of modCodes) { - if (mode === 'Map') { - const scLib = this._getScancodeLib(); - const sc = scLib?.codeToScancode(code, this.peerPlatform); - if (sc != null) { - this.sendMessage({ - keyEvent: { - chr: sc, - down: false, - press: false, - modifiers: [], - mode: 'Map' - } - }); - continue; - } - } - const controlKey = RDInput.KEY_MAP[code]; - if (controlKey) { - this.sendMessage({ - keyEvent: { - controlKey: controlKey, - down: false, - press: false, - modifiers: [], - mode: 'Legacy' - } - }); - } + const enc = this._encoder(); + if (!enc) return; + const events = enc.buildModifierReleaseEvents(this._getScancodeLib()); + for (const keyEvent of events) { + this.sendMessage({ keyEvent }); } } - /** - * @returns {typeof RDKeyboardScancode|null} - */ - _getScancodeLib() { - if (typeof RDKeyboardScancode !== 'undefined') return RDKeyboardScancode; - if (typeof window !== 'undefined' && window.RDKeyboardScancode) { - return window.RDKeyboardScancode; - } - return null; - } - - /** - * Map (scancode) is only used for letter keys — digits and punctuation use - * Legacy chr so Shift+7, AltGr, PL layout symbols resolve correctly. - * @param {string} code - * @returns {boolean} - */ - _shouldUseMapScancode(code) { - return this._resolveKeyboardMode() === 'Map' && RDInput.isLetterCode(code); - } - - /** - * Legacy chr already encodes Shift/Caps; strip them unless Ctrl/Alt/Meta hotkey. - * @param {number[]} mods - * @param {KeyboardEvent} e - * @returns {number[]} - */ - _stripResolvedCharModifiers(mods, e) { - if (e.ctrlKey || e.altKey || e.metaKey) return mods; - return mods.filter((m) => - m !== RDInput.MOD_SHIFT && m !== RDInput.MOD_CAPS_LOCK - ); - } - - /** - * @returns {'Legacy'|'Map'} - */ - _resolveKeyboardMode() { - if (this.keyboardMode === 'Map') return 'Map'; - if (this.keyboardMode === 'Legacy') return 'Legacy'; - // Auto: Map for Windows peers (Hyper-V / VM console scancode path) - const platform = (this.peerPlatform || '').toLowerCase(); - if (platform === 'windows') return 'Map'; - return 'Legacy'; - } - - /** - * Build and dispatch a single RustDesk key event from a KeyboardEvent. - * @param {KeyboardEvent} e - * @param {boolean} down - key is pressed down - * @param {boolean} press - key click (down+up), used for repeats - */ _sendKey(e, down, press) { - const isChar = !RDInput.KEY_MAP[e.code]; - let mods = this._getKeyModifiers(e, isChar); - let key = e.key; - const useMap = isChar && this._shouldUseMapScancode(e.code); - - if (isChar && !useMap) { - if (RDInput.isLetterCode(e.code)) { - key = this._resolveLegacyLetterCase(e); - } - mods = this._stripResolvedCharModifiers(mods, e); - } - - this._sendKeyForCode(e.code, key, down, press, mods, useMap); + this._sendKeyForCode(e.code, e.key, down, press, e); } /** - * Build and dispatch a key event from code/key strings (used for synthetic keyup). * @param {string} code * @param {string} key * @param {boolean} down * @param {boolean} press - * @param {number[]} modifiers - * @param {boolean} [useMap=false] + * @param {KeyboardEvent|null} e */ - _sendKeyForCode(code, key, down, press, modifiers, useMap) { - const controlKey = RDInput.KEY_MAP[code]; + _sendKeyForCode(code, key, down, press, e) { + const enc = this._encoder(); + if (!enc) return; - // Navigation, modifiers, and lock keys always use Legacy controlKey — - // Map scancode path breaks reliable Shift/Caps/NumLock handling on Windows. - if (controlKey) { - this.sendMessage({ - keyEvent: { - controlKey: controlKey, - down: down, - press: press, - modifiers: modifiers, - mode: 'Legacy' - } - }); - return; - } - - if (useMap) { - const scLib = this._getScancodeLib(); - const sc = scLib?.codeToScancode(code, this.peerPlatform); - if (sc != null) { - this.sendMessage({ - keyEvent: { - chr: sc, - down: down, - press: press, - modifiers: modifiers, - mode: 'Map' - } - }); - return; - } - } - - // Character key — forward the produced character so layout-specific and - // accented glyphs (ą, ę, ü, etc.) resolve correctly on the remote side. - if (!key || key.length === 0 || key === 'Unidentified' || key === 'Dead') return; - - const codePoint = key.codePointAt(0); - if (codePoint === undefined) return; - - this.sendMessage({ - keyEvent: { - chr: codePoint, - down: down, - press: press, - modifiers: modifiers, - mode: 'Legacy' - } + const keyEvent = enc.encodeKeyEvent({ + code, + key, + down, + press, + e, + keyboardMode: this.keyboardMode, + peerPlatform: this.peerPlatform, + pressedCodes: this.pressedKeys.keys(), + scancodeLib: this._getScancodeLib(), }); + + if (keyEvent) { + this.sendMessage({ keyEvent }); + } } - // ---- Helpers ---- - - /** - * Convert a browser WheelEvent into RustDesk scroll deltas. - * Peers interpret x/y as scroll amount when mask type is wheel (3). - * @param {WheelEvent} e - * @returns {{ dx: number, dy: number }|null} - */ _normalizeWheelDelta(e) { let rawDx = e.deltaX; let rawDy = e.deltaY; - // Shift+wheel often maps vertical motion to horizontal scrolling. if (e.shiftKey && rawDy !== 0 && rawDx === 0) { rawDx = rawDy; rawDy = 0; @@ -625,11 +375,6 @@ class RDInput { this.pointerLocked = document.pointerLockElement === this.canvas; } - /** - * Get remote coordinates from mouse event - * @param {MouseEvent} e - * @returns {{ x: number, y: number }|null} - */ _getRemotePosition(e) { const rect = this.canvas.getBoundingClientRect(); const canvasX = e.clientX - rect.left; @@ -637,7 +382,6 @@ class RDInput { const pos = this.renderer.canvasToRemote(canvasX, canvasY); - // Clamp to remote display bounds if (pos.x < 0 || pos.y < 0 || pos.x > this.renderer.remoteWidth || pos.y > this.renderer.remoteHeight) { @@ -647,99 +391,26 @@ class RDInput { return pos; } - /** - * Get mouse modifier flags - * @param {MouseEvent} e - * @returns {number[]} - */ - _getModifiers(e) { + _getMouseModifiers(e) { + const enc = this._encoder(); + if (!enc) return []; const mods = []; - if (e.shiftKey) mods.push(RDInput.MOD_SHIFT); - if (e.ctrlKey) mods.push(RDInput.MOD_CTRL); - if (e.altKey) mods.push(RDInput.MOD_ALT); - if (e.metaKey) mods.push(RDInput.MOD_META); + if (e.shiftKey) mods.push(enc.MOD_SHIFT); + if (e.ctrlKey) mods.push(enc.MOD_CTRL); + if (e.altKey) mods.push(enc.MOD_ALT); + if (e.metaKey) mods.push(enc.MOD_META); return mods; } - /** - * Apply Shift XOR CapsLock case for Legacy ASCII letters (matches RustDesk client). - * @param {KeyboardEvent} e - * @returns {string} - */ - _resolveLegacyLetterCase(e) { - const key = e.key; - if (!key || key.length !== 1 || !RDInput.isLetterCode(e.code)) return key; - const cp = key.codePointAt(0); - if (cp === undefined || cp < 0x41 || cp > 0x7A) return key; - if (cp > 0x5A && cp < 0x61) return key; - - const caps = typeof e.getModifierState === 'function' && - e.getModifierState('CapsLock'); - const upper = e.shiftKey !== caps; - if (upper) { - return String.fromCodePoint(cp <= 0x5A ? cp : cp - 32); - } - return String.fromCodePoint(cp >= 0x61 ? cp : cp + 32); - } - - /** - * Get keyboard modifier flags - * @param {KeyboardEvent} e - * @param {boolean} [isChar=false] - true for printable-character events - * @returns {number[]} - */ - _getKeyModifiers(e, isChar) { - // Values must match ControlKey enum in message.proto: - // Alt=1, CapsLock=3, Control=4, Meta=23, Shift=29, NumLock=63 - const mods = []; - if (e.shiftKey) mods.push(RDInput.MOD_SHIFT); - - // AltGr (Right Alt) is reported on Windows as a phantom Ctrl+Alt - // combination. When AltGr produced a printable character the produced - // glyph already encodes it, so forwarding Ctrl+Alt makes the remote - // side treat it as a hotkey instead of typing the character. - const altGraph = typeof e.getModifierState === 'function' && - e.getModifierState('AltGraph'); - if (isChar && altGraph) { - return mods; - } - - if (e.ctrlKey) mods.push(RDInput.MOD_CTRL); - if (e.altKey) mods.push(RDInput.MOD_ALT); - if (e.metaKey) mods.push(RDInput.MOD_META); - - // RustDesk LockModesHandler: sync remote Caps/Num lock from modifier flags - // on letter / numpad events (required for Map scancode path and chr fallback). - if (typeof e.getModifierState === 'function') { - if (isChar && RDInput.isLetterCode(e.code) && - e.getModifierState('CapsLock')) { - mods.push(RDInput.MOD_CAPS_LOCK); - } - if (RDInput.isNumpadCode(e.code) && e.getModifierState('NumLock')) { - mods.push(RDInput.MOD_NUM_LOCK); - } - } - - return mods; - } - - /** - * Check if a visible input-like element has focus (not the remote canvas) - * @returns {boolean} - */ _isInputFocused() { const el = document.activeElement; if (!el) return false; const tag = el.tagName?.toLowerCase(); if (tag !== 'input' && tag !== 'textarea' && tag !== 'select') return false; - // Ignore hidden inputs (e.g. password field after login) if (el.type === 'hidden' || el.offsetParent === null) return false; return true; } - /** - * Close and release all resources - */ close() { this.stop(); } diff --git a/web-nodejs/public/js/rdclient/keyboard-encoder.js b/web-nodejs/public/js/rdclient/keyboard-encoder.js new file mode 100644 index 00000000..a05e782f --- /dev/null +++ b/web-nodejs/public/js/rdclient/keyboard-encoder.js @@ -0,0 +1,302 @@ +/** + * RustDesk-compatible KeyEvent encoder (mirrors upstream keyboard.rs client path). + * + * Legacy: lowercase chr + legacy_modifiers + lock_modes on chr/control events. + * Map: scancode in chr; modifiers on chr events are lock_modes only (Caps/Num). + * Modifier / navigation keys always use Legacy controlKey. + */ +/* eslint-disable no-unused-vars */ +/* global RDKeyboardScancode */ + +const RDKeyboardEncoder = { + MOD_ALT: 1, + MOD_CAPS_LOCK: 3, + MOD_CTRL: 4, + MOD_META: 23, + MOD_SHIFT: 29, + MOD_NUM_LOCK: 63, + + /** Upstream legacy_keyboard_mode: no wire event for lock keys. */ + SILENT_LOCK_CODES: ['CapsLock', 'NumLock', 'ScrollLock'], + + /** DOM code → RustDesk ControlKey name (Legacy path). */ + KEY_MAP: { + Escape: 'Escape', + Backspace: 'Backspace', + Tab: 'Tab', + Enter: 'Return', + ShiftLeft: 'Shift', + ShiftRight: 'RShift', + ControlLeft: 'Control', + ControlRight: 'RControl', + AltLeft: 'Alt', + AltRight: 'RAlt', + MetaLeft: 'Meta', + MetaRight: 'RWin', + Pause: 'Pause', + CapsLock: 'CapsLock', + Space: 'Space', + PageUp: 'PageUp', + PageDown: 'PageDown', + End: 'End', + Home: 'Home', + ArrowLeft: 'LeftArrow', + ArrowUp: 'UpArrow', + ArrowRight: 'RightArrow', + ArrowDown: 'DownArrow', + PrintScreen: 'Snapshot', + Insert: 'Insert', + Delete: 'Delete', + ScrollLock: 'Scroll', + NumLock: 'NumLock', + F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', + F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8', + F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12', + Numpad0: 'Numpad0', Numpad1: 'Numpad1', Numpad2: 'Numpad2', + Numpad3: 'Numpad3', Numpad4: 'Numpad4', Numpad5: 'Numpad5', + Numpad6: 'Numpad6', Numpad7: 'Numpad7', Numpad8: 'Numpad8', + Numpad9: 'Numpad9', + NumpadMultiply: 'Multiply', + NumpadAdd: 'Add', + NumpadSubtract: 'Subtract', + NumpadDecimal: 'Decimal', + NumpadDivide: 'Divide', + NumpadEnter: 'NumpadEnter', + ContextMenu: 'Apps', + AudioVolumeMute: 'VolumeMute', + AudioVolumeDown: 'VolumeDown', + AudioVolumeUp: 'VolumeUp', + }, + + /** Physical key → Legacy chr (lowercase), upstream legacy_keyboard_mode. */ + LEGACY_CHAR_MAP: { + Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5', + Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0', + KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e', KeyF: 'f', + KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j', KeyK: 'k', KeyL: 'l', + KeyM: 'm', KeyN: 'n', KeyO: 'o', KeyP: 'p', KeyQ: 'q', KeyR: 'r', + KeyS: 's', KeyT: 't', KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', + KeyY: 'y', KeyZ: 'z', + Comma: ',', Period: '.', Semicolon: ';', Quote: '\'', + BracketLeft: '[', BracketRight: ']', Slash: '/', Backslash: '\\', + Minus: '-', Equal: '=', Backquote: '`', + }, + + isLetterCode(code) { + return /^Key[A-Z]$/.test(code); + }, + + isNumpadCode(code) { + return /^Numpad/.test(code); + }, + + /** + * Resolve UI keyboard mode to wire mode (Auto → Map, matching native default). + * @param {'Legacy'|'Map'|'Auto'} keyboardMode + * @returns {'Legacy'|'Map'} + */ + resolveWireMode(keyboardMode) { + if (keyboardMode === 'Legacy') return 'Legacy'; + return 'Map'; + }, + + /** + * @param {Iterable} pressedCodes + * @returns {{ shift: boolean, ctrl: boolean, alt: boolean, meta: boolean }} + */ + modifierStateFromPressed(pressedCodes) { + const set = pressedCodes instanceof Set ? pressedCodes : new Set(pressedCodes); + return { + shift: set.has('ShiftLeft') || set.has('ShiftRight'), + ctrl: set.has('ControlLeft') || set.has('ControlRight'), + alt: set.has('AltLeft') || set.has('AltRight'), + meta: set.has('MetaLeft') || set.has('MetaRight'), + }; + }, + + /** + * Port client::legacy_modifiers — exclude self-referential control keys. + * @param {string} code + * @param {{ shift: boolean, ctrl: boolean, alt: boolean, meta: boolean }} modState + * @returns {number[]} + */ + legacyModifiers(code, modState) { + const mods = []; + if (modState.alt && code !== 'AltLeft' && code !== 'AltRight') { + mods.push(this.MOD_ALT); + } + if (modState.shift && code !== 'ShiftLeft' && code !== 'ShiftRight') { + mods.push(this.MOD_SHIFT); + } + if (modState.ctrl && code !== 'ControlLeft' && code !== 'ControlRight') { + mods.push(this.MOD_CTRL); + } + if (modState.meta && code !== 'MetaLeft' && code !== 'MetaRight') { + mods.push(this.MOD_META); + } + return mods; + }, + + /** + * Port add_lock_modes_modifiers (Caps/Num only). + * @param {string} code + * @param {KeyboardEvent|null} e + * @returns {number[]} + */ + lockModeModifiers(code, e) { + const mods = []; + if (!e || typeof e.getModifierState !== 'function') return mods; + if (this.isLetterCode(code) && e.getModifierState('CapsLock')) { + mods.push(this.MOD_CAPS_LOCK); + } + if (this.isNumpadCode(code) && e.getModifierState('NumLock')) { + mods.push(this.MOD_NUM_LOCK); + } + return mods; + }, + + /** + * @param {string} code + * @param {string} key + * @returns {number|null} + */ + legacyCharCodePoint(code, key) { + const mapped = this.LEGACY_CHAR_MAP[code]; + if (mapped) return mapped.codePointAt(0); + if (!key || key.length === 0 || key === 'Unidentified' || key === 'Dead') { + return null; + } + return key.codePointAt(0) ?? null; + }, + + /** + * AltGr on Windows appears as Ctrl+Alt; for printable Legacy chr omit Ctrl+Alt. + * @param {string} code + * @param {KeyboardEvent|null} e + * @param {number[]} mods + * @returns {number[]} + */ + applyAltGrLegacyFilter(code, e, mods) { + if (this.KEY_MAP[code]) return mods; + const altGraph = e && typeof e.getModifierState === 'function' && + e.getModifierState('AltGraph'); + if (!altGraph) return mods; + return mods.filter((m) => m !== this.MOD_CTRL && m !== this.MOD_ALT); + }, + + _getScancodeLib(scancodeLib) { + if (scancodeLib) return scancodeLib; + if (typeof RDKeyboardScancode !== 'undefined') return RDKeyboardScancode; + if (typeof window !== 'undefined' && window.RDKeyboardScancode) { + return window.RDKeyboardScancode; + } + return null; + }, + + /** + * Build zero or one RustDesk keyEvent payload. + * @param {object} opts + * @param {string} opts.code + * @param {string} opts.key + * @param {boolean} opts.down + * @param {boolean} opts.press + * @param {KeyboardEvent|null} [opts.e] + * @param {'Legacy'|'Map'|'Auto'} opts.keyboardMode + * @param {string} [opts.peerPlatform] + * @param {Set|Iterable} opts.pressedCodes + * @param {object|null} [opts.scancodeLib] + * @returns {object|null} + */ + encodeKeyEvent(opts) { + const { + code, + key, + down, + press, + e = null, + keyboardMode, + peerPlatform = '', + pressedCodes, + scancodeLib = null, + } = opts; + + const wireMode = this.resolveWireMode(keyboardMode); + const modState = this.modifierStateFromPressed(pressedCodes); + const controlKey = this.KEY_MAP[code]; + + if (this.SILENT_LOCK_CODES.includes(code)) { + return null; + } + + if (controlKey) { + let modifiers = this.legacyModifiers(code, modState); + modifiers = modifiers.concat(this.lockModeModifiers(code, e)); + return { + controlKey, + down, + press, + modifiers, + mode: 'Legacy', + }; + } + + const scLib = this._getScancodeLib(scancodeLib); + + if (wireMode === 'Map') { + const sc = scLib?.codeToScancode(code, peerPlatform); + if (sc != null) { + return { + chr: sc, + down, + press, + modifiers: this.lockModeModifiers(code, e), + mode: 'Map', + }; + } + } + + const chr = this.legacyCharCodePoint(code, key); + if (chr == null) return null; + + let modifiers = this.legacyModifiers(code, modState); + modifiers = modifiers.concat(this.lockModeModifiers(code, e)); + modifiers = this.applyAltGrLegacyFilter(code, e, modifiers); + + return { + chr, + down, + press, + modifiers, + mode: 'Legacy', + }; + }, + + /** + * Build modifier keyup events for toolbar recovery (always Legacy controlKey). + * @param {object|null} scancodeLib + * @returns {object[]} + */ + buildModifierReleaseEvents(scancodeLib) { + const lib = this._getScancodeLib(scancodeLib); + const codes = lib?.MODIFIER_CODES || [ + 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', + 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight', + ]; + const events = []; + for (const code of codes) { + const controlKey = this.KEY_MAP[code]; + if (controlKey) { + events.push({ + controlKey, + down: false, + press: false, + modifiers: [], + mode: 'Legacy', + }); + } + } + return events; + }, +}; + +window.RDKeyboardEncoder = RDKeyboardEncoder; diff --git a/web-nodejs/tests/rdclient.input.test.js b/web-nodejs/tests/rdclient.input.test.js index 1107440d..f36a5769 100644 --- a/web-nodejs/tests/rdclient.input.test.js +++ b/web-nodejs/tests/rdclient.input.test.js @@ -65,6 +65,7 @@ function makeInputHarness(extraGlobals) { const sandbox = loadBrowserScripts([ 'public/js/rdclient/keyboard-scancode.js', + 'public/js/rdclient/keyboard-encoder.js', 'public/js/rdclient/input.js', ], { document, @@ -92,6 +93,20 @@ function makeInputHarness(extraGlobals) { return { sandbox, document, win, makeInput }; } +function keyEvt(base) { + return { + repeat: false, + preventDefault() {}, + stopPropagation() {}, + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + getModifierState() { return false; }, + ...base, + }; +} + describe('RDKeyboardScancode', () => { let RDKeyboardScancode; @@ -106,14 +121,91 @@ describe('RDKeyboardScancode', () => { }); }); -describe('RDInput keyboard release sync', () => { - let makeInput; - let RDInput; +describe('RDKeyboardEncoder parity', () => { + let RDKeyboardEncoder; beforeAll(() => { - const harness = makeInputHarness({}); - makeInput = harness.makeInput; - RDInput = harness.sandbox.window.RDInput; + const sandbox = loadBrowserScripts([ + 'public/js/rdclient/keyboard-scancode.js', + 'public/js/rdclient/keyboard-encoder.js', + ], {}); + RDKeyboardEncoder = sandbox.RDKeyboardEncoder; + }); + + it('Auto resolves to Map wire mode', () => { + expect(RDKeyboardEncoder.resolveWireMode('Auto')).toBe('Map'); + }); + + it('legacyModifiers excludes self on ShiftLeft', () => { + const mods = RDKeyboardEncoder.legacyModifiers('ShiftLeft', { + shift: true, ctrl: false, alt: false, meta: false, + }); + expect(mods).not.toContain(29); + }); + + it('Legacy Shift+A → chr 97 + Shift modifier', () => { + const evt = RDKeyboardEncoder.encodeKeyEvent({ + code: 'KeyA', + key: 'A', + down: true, + press: false, + keyboardMode: 'Legacy', + pressedCodes: new Set(['ShiftLeft']), + }); + expect(evt.mode).toBe('Legacy'); + expect(evt.chr).toBe(97); + expect(evt.modifiers).toContain(29); + }); + + it('Map Shift+7 → scancode 0x08, no Shift in modifiers', () => { + const evt = RDKeyboardEncoder.encodeKeyEvent({ + code: 'Digit7', + key: '&', + down: true, + press: false, + keyboardMode: 'Map', + peerPlatform: 'Windows', + pressedCodes: new Set(['ShiftLeft']), + scancodeLib: RDKeyboardEncoder._getScancodeLib(), + }); + expect(evt.mode).toBe('Map'); + expect(evt.chr).toBe(0x08); + expect(evt.modifiers).not.toContain(29); + }); + + it('CapsLock key alone produces no event', () => { + const evt = RDKeyboardEncoder.encodeKeyEvent({ + code: 'CapsLock', + key: 'CapsLock', + down: true, + press: false, + keyboardMode: 'Legacy', + pressedCodes: new Set(), + }); + expect(evt).toBeNull(); + }); + + it('Legacy Caps+A → chr 97 + CapsLock modifier', () => { + const evt = RDKeyboardEncoder.encodeKeyEvent({ + code: 'KeyA', + key: 'a', + down: true, + press: false, + e: { getModifierState(s) { return s === 'CapsLock'; } }, + keyboardMode: 'Legacy', + pressedCodes: new Set(), + }); + expect(evt.chr).toBe(97); + expect(evt.modifiers).toContain(3); + expect(evt.modifiers).not.toContain(29); + }); +}); + +describe('RDInput keyboard release sync', () => { + let makeInput; + + beforeAll(() => { + ({ makeInput } = makeInputHarness({})); }); it('sends keyup for held keys when stop() is called', () => { @@ -121,28 +213,16 @@ describe('RDInput keyboard release sync', () => { const input = makeInput((msg) => sent.push(msg)); input.start(); - input._handleKeyDown({ + input._handleKeyDown(keyEvt({ code: 'ShiftLeft', key: 'Shift', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, shiftKey: true, - }); - input._handleKeyDown({ + })); + input._handleKeyDown(keyEvt({ code: 'KeyA', key: 'a', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, shiftKey: true, - }); + })); sent.length = 0; input.stop(); @@ -157,17 +237,11 @@ describe('RDInput keyboard release sync', () => { const harness = makeInputHarness({}); const input = harness.makeInput((msg) => sent.push(msg)); input.start(); - input._handleKeyDown({ + input._handleKeyDown(keyEvt({ code: 'ControlLeft', key: 'Control', - repeat: false, - preventDefault() {}, - stopPropagation() {}, ctrlKey: true, - altKey: false, - metaKey: false, - shiftKey: false, - }); + })); sent.length = 0; harness.win._dispatch('blur', new Event('blur')); @@ -205,13 +279,102 @@ describe('RDInput keyboard release sync', () => { }); }); -describe('RDInput KeyboardMode.Map', () => { +describe('RDInput keyboard parity (RustDesk contract)', () => { let makeInput; beforeAll(() => { ({ makeInput } = makeInputHarness({})); }); + it('ShiftLeft down (real DOM shiftKey) has no Shift in modifiers', () => { + const sent = []; + const input = makeInput((msg) => sent.push(msg)); + input.setPeerPlatform('Windows'); + input.setKeyboardMode('Auto'); + input.start(); + + input._handleKeyDown(keyEvt({ + code: 'ShiftLeft', + key: 'Shift', + shiftKey: true, + })); + + expect(sent[0].keyEvent.mode).toBe('Legacy'); + expect(sent[0].keyEvent.controlKey).toBe('Shift'); + expect(sent[0].keyEvent.modifiers).not.toContain(29); + }); + + it('Auto/Windows Shift+A → Map scancode without Shift modifier', () => { + const sent = []; + const input = makeInput((msg) => sent.push(msg)); + input.setPeerPlatform('Windows'); + input.setKeyboardMode('Auto'); + input.start(); + + input._handleKeyDown(keyEvt({ + code: 'ShiftLeft', + key: 'Shift', + shiftKey: true, + })); + input._handleKeyDown(keyEvt({ + code: 'KeyA', + key: 'A', + shiftKey: true, + })); + + const letter = sent.find((m) => m.keyEvent && m.keyEvent.chr === 0x1E); + expect(letter).toBeDefined(); + expect(letter.keyEvent.mode).toBe('Map'); + expect(letter.keyEvent.modifiers).not.toContain(29); + }); + + it('Legacy Shift+A → lowercase chr + Shift modifier', () => { + const sent = []; + const input = makeInput((msg) => sent.push(msg)); + input.setKeyboardMode('Legacy'); + input.start(); + + input._handleKeyDown(keyEvt({ + code: 'ShiftLeft', + key: 'Shift', + shiftKey: true, + })); + input._handleKeyDown(keyEvt({ + code: 'KeyA', + key: 'A', + shiftKey: true, + })); + + const letter = sent.find((m) => m.keyEvent && m.keyEvent.chr === 97); + expect(letter).toBeDefined(); + expect(letter.keyEvent.mode).toBe('Legacy'); + expect(letter.keyEvent.modifiers).toContain(29); + }); + + it('Auto/Map Shift+7 uses Map scancode not Legacy chr', () => { + const sent = []; + const input = makeInput((msg) => sent.push(msg)); + input.setPeerPlatform('Windows'); + input.setKeyboardMode('Auto'); + input.start(); + + input._handleKeyDown(keyEvt({ + code: 'ShiftLeft', + key: 'Shift', + shiftKey: true, + })); + input._handleKeyDown(keyEvt({ + code: 'Digit7', + key: '&', + shiftKey: true, + })); + + const digit = sent.find((m) => m.keyEvent && m.keyEvent.chr === 0x08); + expect(digit).toBeDefined(); + expect(digit.keyEvent.mode).toBe('Map'); + expect(digit.keyEvent.modifiers).not.toContain(29); + }); + it('Auto mode uses Map scancode for letters on Windows peers', () => { const sent = []; const input = makeInput((msg) => sent.push(msg)); @@ -219,90 +382,53 @@ describe('RDInput KeyboardMode.Map', () => { input.setKeyboardMode('Auto'); input.start(); - input._handleKeyDown({ - code: 'KeyA', - key: 'a', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: false, - }); + input._handleKeyDown(keyEvt({ code: 'KeyA', key: 'a' })); expect(sent[0].keyEvent.mode).toBe('Map'); expect(sent[0].keyEvent.chr).toBe(0x1E); }); - it('Auto mode uses Legacy chr for Shift+digit symbols on Windows', () => { + it('Legacy mode sends lowercase chr for letters', () => { const sent = []; const input = makeInput((msg) => sent.push(msg)); - input.setPeerPlatform('Windows'); - input.setKeyboardMode('Auto'); + input.setKeyboardMode('Legacy'); input.start(); - input._handleKeyDown({ - code: 'Digit7', - key: '&', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: true, - }); + input._handleKeyDown(keyEvt({ + code: 'KeyA', + key: 'a', + getModifierState(state) { + return state === 'CapsLock'; + }, + })); expect(sent[0].keyEvent.mode).toBe('Legacy'); - expect(sent[0].keyEvent.chr).toBe('&'.codePointAt(0)); - expect(sent[0].keyEvent.modifiers).not.toContain(29); + expect(sent[0].keyEvent.chr).toBe(97); + expect(sent[0].keyEvent.modifiers).toContain(3); }); - it('Legacy mode sends character chr for printable keys', () => { + it('Legacy mode sends digit chr for Digit1', () => { const sent = []; const input = makeInput((msg) => sent.push(msg)); input.setPeerPlatform('Linux'); input.setKeyboardMode('Legacy'); input.start(); - input._handleKeyDown({ - code: 'Digit1', - key: '1', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: false, - }); + input._handleKeyDown(keyEvt({ code: 'Digit1', key: '1' })); expect(sent[0].keyEvent.mode).toBe('Legacy'); expect(sent[0].keyEvent.chr).toBe(49); }); - it('Auto mode sends Legacy controlKey for Shift', () => { + it('CapsLock key alone sends no wire event', () => { const sent = []; const input = makeInput((msg) => sent.push(msg)); - input.setPeerPlatform('Windows'); input.setKeyboardMode('Auto'); input.start(); - input._handleKeyDown({ - code: 'ShiftLeft', - key: 'Shift', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: false, - }); + input._handleKeyDown(keyEvt({ code: 'CapsLock', key: 'CapsLock' })); - expect(sent[0].keyEvent.mode).toBe('Legacy'); - expect(sent[0].keyEvent.controlKey).toBe('Shift'); + expect(sent.length).toBe(0); }); it('includes CapsLock modifier on Map letter keys when Caps is on', () => { @@ -312,46 +438,16 @@ describe('RDInput KeyboardMode.Map', () => { input.setKeyboardMode('Auto'); input.start(); - input._handleKeyDown({ + input._handleKeyDown(keyEvt({ code: 'KeyA', key: 'a', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: false, getModifierState(state) { return state === 'CapsLock'; }, - }); + })); expect(sent[0].keyEvent.mode).toBe('Map'); expect(sent[0].keyEvent.modifiers).toContain(3); - }); - - it('Legacy mode uppercases letters when CapsLock is on', () => { - const sent = []; - const input = makeInput((msg) => sent.push(msg)); - input.setKeyboardMode('Legacy'); - input.start(); - - input._handleKeyDown({ - code: 'KeyA', - key: 'a', - repeat: false, - preventDefault() {}, - stopPropagation() {}, - ctrlKey: false, - altKey: false, - metaKey: false, - shiftKey: false, - getModifierState(state) { - return state === 'CapsLock'; - }, - }); - - expect(sent[0].keyEvent.chr).toBe(65); + expect(sent[0].keyEvent.modifiers).not.toContain(29); }); }); diff --git a/web-nodejs/tests/rdclient.sessionIsolation.test.js b/web-nodejs/tests/rdclient.sessionIsolation.test.js index aa61e219..eb23b7c0 100644 --- a/web-nodejs/tests/rdclient.sessionIsolation.test.js +++ b/web-nodejs/tests/rdclient.sessionIsolation.test.js @@ -21,6 +21,24 @@ function loadBrowserScript(relativePath, extraGlobals) { return sandbox; } +function loadBrowserScripts(relativePaths, extraGlobals) { + const sandbox = { + console, + window: {}, + globalThis: {}, + ...extraGlobals, + }; + if (!extraGlobals || extraGlobals.window === undefined) { + sandbox.window = sandbox; + } + sandbox.globalThis = sandbox.window; + for (const rel of relativePaths) { + const filename = path.join(__dirname, '..', rel); + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), sandbox, { filename }); + } + return sandbox; +} + describe('syncSessionMediaCapture', () => { it('activates only the active streaming session', () => { const clientA = { setSessionActive: jest.fn() }; @@ -155,15 +173,14 @@ describe('RDInput multi-session keyboard isolation', () => { removeEventListener() {}, }; - const sandbox = loadBrowserScript('public/js/rdclient/input.js', { + const sandbox = loadBrowserScripts([ + 'public/js/rdclient/keyboard-scancode.js', + 'public/js/rdclient/keyboard-encoder.js', + 'public/js/rdclient/input.js', + ], { document, window: windowStub, RDProtocol: {}, - RDKeyboardScancode: { - MODIFIER_CODES: ['ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', - 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight'], - codeToScancode: () => null, - }, makeCanvas, }); RDInput = sandbox.window.RDInput; diff --git a/web-nodejs/views/layouts/viewer.ejs b/web-nodejs/views/layouts/viewer.ejs index e4fc9bb3..0c72c447 100644 --- a/web-nodejs/views/layouts/viewer.ejs +++ b/web-nodejs/views/layouts/viewer.ejs @@ -58,6 +58,7 @@ +