fix(rdclient): align web keyboard encoding with RustDesk native contract

Add keyboard-encoder.js for Legacy chr+modifiers and full Map scancode path so Shift, Caps Lock, and symbol keys work reliably on relay sessions including Hyper-V.
This commit is contained in:
UNITRONIX
2026-07-05 00:39:48 +02:00
parent 3608594e48
commit 135342a384
32 changed files with 635 additions and 542 deletions
+19 -13
View File
@@ -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
+1 -1
View File
@@ -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 (رمز مسح فعلي)"
},
+1 -1
View File
@@ -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í",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "प्रदर्शन सेटिंग्स",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "表示設定",
+1 -1
View File
@@ -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": "디스플레이 설정",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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ã",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "การตั้งค่าการแสดงผล",
+1 -1
View File
@@ -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ı",
+1 -1
View File
@@ -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": "Налаштування дисплея",
+1 -1
View File
@@ -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ị",
+1 -1
View File
@@ -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": "顯示設置",
+1 -1
View File
@@ -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": "显示设置",
+50 -379
View File
@@ -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();
}
@@ -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<string>} 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<string>|Iterable<string>} 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;
+214 -118
View File
@@ -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);
});
});
@@ -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;
+1
View File
@@ -58,6 +58,7 @@
<script src="/js/rdclient/audio.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/renderer.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/keyboard-scancode.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/keyboard-encoder.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/input.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/touch.js?v=<%= cacheVersion %>"></script>
<script src="/js/rdclient/file-connection.js?v=<%= cacheVersion %>"></script>