From 35db6343174f2cfa307f02699d1e4e1f47970303 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 7 May 2026 12:13:57 +0300 Subject: [PATCH] fix: hotkey-based port switching and HTML entity unescaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for KVM port management: 1. HTML unescape scraped values — the > character in hotkey strings (e.g., PrintScreen>0>1) gets entity-encoded to > in the device's HTML. Added html_unescape() to the scraper so hotkeys round-trip correctly. 2. Send hotkeys over WebSocket — port switching via the Belkin web form only changes the active port number, it doesn't send the hotkey sequence to the downstream KVM. Now when switching ports from the console dropdown, blekin parses the Belkin hotkey syntax and sends the key press/release sequence over the existing WebSocket connection. 3. New hotkey.ts parser — converts Belkin hotkey syntax to scancode sequences: - > and -> = sequential (tap each key) - + = simultaneous (hold all, release in reverse) - Supports all key names: PrintScreen, Ctrl, Alt, Shift, F1-F12, letters, digits, navigation keys, numpad Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ericrfb-frontend/src/hotkey.ts | 92 ++++++++++++++++++++ crates/ericrfb-frontend/src/pages/console.ts | 32 +++++-- crates/ericrfb-proxy/src/kvm.rs | 9 +- 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 crates/ericrfb-frontend/src/hotkey.ts diff --git a/crates/ericrfb-frontend/src/hotkey.ts b/crates/ericrfb-frontend/src/hotkey.ts new file mode 100644 index 0000000..c24ca0d --- /dev/null +++ b/crates/ericrfb-frontend/src/hotkey.ts @@ -0,0 +1,92 @@ +// Parse Belkin hotkey syntax and convert to scancode sequences. +// +// Syntax: [confirm] ((+|->|>))* +// + = press simultaneously (hold first, press second) +// > = press sequentially (release first, then press second) +// -> = same as > +// +// Examples: +// PrintScreen>0>1 → tap PrintScreen, tap 0, tap 1 +// Ctrl+Alt+Del → hold Ctrl+Alt, tap Del, release all +// PrintScreen>1>7 → tap PrintScreen, tap 1, tap 7 + +import { makeKeyPress, makeKeyRelease } from './protocol' + +// Belkin key names → e-RIC scancodes (from KeyTranslator.java) +const KEY_NAMES: Record = { + // Letters + A: 29, B: 47, C: 45, D: 31, E: 17, F: 32, G: 33, H: 34, I: 22, + J: 35, K: 36, L: 37, M: 49, N: 48, O: 23, P: 24, Q: 15, R: 18, + S: 30, T: 19, U: 21, V: 46, W: 16, X: 44, Y: 20, Z: 43, + + // Digits + '0': 10, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, + + // Modifiers + Ctrl: 54, Alt: 55, Shift: 41, Win: 105, + CtrlLeft: 54, CtrlRight: 58, AltLeft: 55, AltRight: 57, + ShiftLeft: 41, ShiftRight: 53, + + // Special keys + Esc: 59, Escape: 59, Tab: 14, CapsLock: 28, Space: 56, + Enter: 27, Return: 27, Backspace: 13, Del: 78, Delete: 78, + Insert: 75, Home: 76, End: 79, PageUp: 77, PageDown: 80, + PrintScreen: 72, Print: 72, SysRq: 72, + ScrollLock: 73, Pause: 74, Break: 74, + NumLock: 85, + + // Arrow keys + Up: 81, Down: 83, Left: 82, Right: 84, + + // F-keys + F1: 60, F2: 61, F3: 62, F4: 63, F5: 64, F6: 65, + F7: 66, F8: 67, F9: 68, F10: 69, F11: 70, F12: 71, + + // Numpad + NumPlus: 89, 'Num+': 89, NumMinus: 99, 'Num-': 99, + NumMultiply: 94, 'Num*': 94, NumDivide: 90, 'Num/': 90, + NumEnter: 98, +} + +/** + * Parse a Belkin hotkey string into a sequence of WS messages to send. + * Returns an array of ArrayBuffer messages (press/release pairs). + */ +export function parseHotkey(hotkey: string): ArrayBuffer[] { + if (!hotkey.trim()) return [] + + const messages: ArrayBuffer[] = [] + // Split on > (sequential) first, preserving + groups + const groups = hotkey.split(/->|>/).map(g => g.trim()).filter(Boolean) + + for (const group of groups) { + // Each group may contain + (simultaneous keys) + const keys = group.split('+').map(k => k.trim()).filter(Boolean) + const scancodes: number[] = [] + + for (const key of keys) { + const sc = KEY_NAMES[key] + if (sc !== undefined) { + scancodes.push(sc) + } + } + + if (scancodes.length === 0) continue + + if (scancodes.length === 1) { + // Single key: press then release + messages.push(makeKeyPress(scancodes[0])) + messages.push(makeKeyRelease(scancodes[0])) + } else { + // Combo: press all in order, release in reverse + for (const sc of scancodes) { + messages.push(makeKeyPress(sc)) + } + for (const sc of scancodes.reverse()) { + messages.push(makeKeyRelease(sc)) + } + } + } + + return messages +} diff --git a/crates/ericrfb-frontend/src/pages/console.ts b/crates/ericrfb-frontend/src/pages/console.ts index 870d924..cc28a18 100644 --- a/crates/ericrfb-frontend/src/pages/console.ts +++ b/crates/ericrfb-frontend/src/pages/console.ts @@ -1,4 +1,5 @@ import { codeToScancode } from '../input' +import { parseHotkey } from '../hotkey' import { TAG_BLIT, TAG_RESIZE, makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel, @@ -153,6 +154,8 @@ function handleMessage(ev: MessageEvent) { // Port switcher // --------------------------------------------------------------------------- +let portHotkeys: Record = {} + async function loadPortList() { try { const resp = await fetch('/api/kvm/ports') @@ -161,26 +164,39 @@ async function loadPortList() { const select = document.getElementById('port-select') as HTMLSelectElement if (!select) return select.innerHTML = '' + portHotkeys = {} for (const p of data.ports) { const opt = document.createElement('option') opt.value = String(p.index) opt.textContent = p.name || `Port ${p.index + 1}` opt.selected = p.index === data.active_port select.appendChild(opt) + if (p.hotkey) portHotkeys[p.index] = p.hotkey } - select.addEventListener('change', async () => { + select.addEventListener('change', () => { const port = parseInt(select.value) - await fetch('/api/kvm/switch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ port }), - }) - // Reconnect to pick up new video stream - ws?.close() + switchToPort(port) }) } catch { /* port list optional */ } } +function switchToPort(port: number) { + const hotkey = portHotkeys[port] + if (hotkey && ws?.readyState === WebSocket.OPEN) { + // Send the hotkey sequence as key events over WS to the KVM + const messages = parseHotkey(hotkey) + for (const msg of messages) { + ws.send(msg) + } + } + // Also update the Belkin's active port tracking + fetch('/api/kvm/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ port }), + }).catch(() => {}) +} + // --------------------------------------------------------------------------- // Input handlers // --------------------------------------------------------------------------- diff --git a/crates/ericrfb-proxy/src/kvm.rs b/crates/ericrfb-proxy/src/kvm.rs index 05d886a..9313fdb 100644 --- a/crates/ericrfb-proxy/src/kvm.rs +++ b/crates/ericrfb-proxy/src/kvm.rs @@ -83,7 +83,14 @@ fn extract_input_value(html: &str, name: &str) -> Option { let val_needle = "value=\""; let val_pos = after.find(val_needle)? + val_needle.len(); let end = after[val_pos..].find('"')? + val_pos; - Some(after[val_pos..end].to_string()) + Some(html_unescape(&after[val_pos..end])) +} + +fn html_unescape(s: &str) -> String { + s.replace(">", ">") + .replace("<", "<") + .replace("&", "&") + .replace(""", "\"") } fn extract_selected_option(html: &str, name: &str) -> Option {