fix: hotkey-based port switching and HTML entity unescaping
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) <noreply@anthropic.com>
This commit is contained in:
92
crates/ericrfb-frontend/src/hotkey.ts
Normal file
92
crates/ericrfb-frontend/src/hotkey.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Parse Belkin hotkey syntax and convert to scancode sequences.
|
||||
//
|
||||
// Syntax: [confirm] <keyname>((+|->|>)<keyname>)*
|
||||
// + = 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<string, number> = {
|
||||
// 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
|
||||
}
|
||||
@@ -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<number, string> = {}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,14 @@ fn extract_input_value(html: &str, name: &str) -> Option<String> {
|
||||
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<String> {
|
||||
|
||||
Reference in New Issue
Block a user