refactor(hid): 统一 HID 键盘 CanonicalKey 语义并清理前端布局与输入链路冗余代码

This commit is contained in:
mofeng-git
2026-03-26 22:51:29 +08:00
parent 95bf1a852e
commit e09a906f93
20 changed files with 1083 additions and 1438 deletions

View File

@@ -28,7 +28,6 @@ use std::time::{Duration, Instant};
use tracing::{info, trace, warn};
use super::backend::{HidBackend, HidBackendStatus};
use super::keymap;
use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType};
use crate::error::{AppError, Result};
@@ -1095,18 +1094,13 @@ impl HidBackend for Ch9329Backend {
}
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID)
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
let usb_key = event.key.to_hid_usage();
// Handle modifier keys separately
if keymap::is_modifier_key(usb_key) {
if event.key.is_modifier() {
let mut state = self.keyboard_state.lock();
if let Some(bit) = keymap::modifier_bit(usb_key) {
if let Some(bit) = event.key.modifier_bit() {
match event.event_type {
KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit,

View File

@@ -9,7 +9,7 @@
//!
//! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up)
//! - Byte 2: Key code (USB HID usage code)
//! - Byte 2: Canonical key code (stable One-KVM key id aligned with HID usage)
//! - Byte 3: Modifiers bitmask
//! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift
@@ -38,7 +38,8 @@ use tracing::warn;
use super::types::ConsumerEvent;
use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
};
/// Message types
@@ -101,7 +102,13 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
}
};
let key = data[1];
let key = match CanonicalKey::from_hid_usage(data[1]) {
Some(key) => key,
None => {
warn!("Unknown canonical keyboard key code: 0x{:02X}", data[1]);
return None;
}
};
let modifiers_byte = data[2];
let modifiers = KeyboardModifiers {
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type,
key,
modifiers,
is_usb_hid: true, // WebRTC/WebSocket HID channel sends USB HID usages
}))
}
@@ -193,7 +199,7 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
let modifiers = event.modifiers.to_hid_byte();
vec![MSG_KEYBOARD, event_type, event.key, modifiers]
vec![MSG_KEYBOARD, event_type, event.key.to_hid_usage(), modifiers]
}
/// Encode a mouse event to binary format (for sending to client if needed)
@@ -242,10 +248,9 @@ mod tests {
match event {
HidChannelEvent::Keyboard(kb) => {
assert!(matches!(kb.event_type, KeyEventType::Down));
assert_eq!(kb.key, 0x04);
assert_eq!(kb.key, CanonicalKey::KeyA);
assert!(kb.modifiers.left_ctrl);
assert!(!kb.modifiers.left_shift);
assert!(kb.is_usb_hid);
}
_ => panic!("Expected keyboard event"),
}
@@ -270,7 +275,7 @@ mod tests {
fn test_encode_keyboard() {
let event = KeyboardEvent {
event_type: KeyEventType::Down,
key: 0x04,
key: CanonicalKey::KeyA,
modifiers: KeyboardModifiers {
left_ctrl: true,
left_shift: false,
@@ -281,7 +286,6 @@ mod tests {
right_alt: false,
right_meta: false,
},
is_usb_hid: true,
};
let encoded = encode_keyboard_event(&event);

409
src/hid/keyboard.rs Normal file
View File

@@ -0,0 +1,409 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Shared canonical keyboard key identifiers used across frontend and backend.
///
/// The enum names intentionally mirror `KeyboardEvent.code` style values so the
/// browser, virtual keyboard, and HID backend can all speak the same language.
#[typeshare]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CanonicalKey {
KeyA,
KeyB,
KeyC,
KeyD,
KeyE,
KeyF,
KeyG,
KeyH,
KeyI,
KeyJ,
KeyK,
KeyL,
KeyM,
KeyN,
KeyO,
KeyP,
KeyQ,
KeyR,
KeyS,
KeyT,
KeyU,
KeyV,
KeyW,
KeyX,
KeyY,
KeyZ,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
Digit0,
Enter,
Escape,
Backspace,
Tab,
Space,
Minus,
Equal,
BracketLeft,
BracketRight,
Backslash,
Semicolon,
Quote,
Backquote,
Comma,
Period,
Slash,
CapsLock,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
PrintScreen,
ScrollLock,
Pause,
Insert,
Home,
PageUp,
Delete,
End,
PageDown,
ArrowRight,
ArrowLeft,
ArrowDown,
ArrowUp,
NumLock,
NumpadDivide,
NumpadMultiply,
NumpadSubtract,
NumpadAdd,
NumpadEnter,
Numpad1,
Numpad2,
Numpad3,
Numpad4,
Numpad5,
Numpad6,
Numpad7,
Numpad8,
Numpad9,
Numpad0,
NumpadDecimal,
IntlBackslash,
ContextMenu,
F13,
F14,
F15,
F16,
F17,
F18,
F19,
F20,
F21,
F22,
F23,
F24,
ControlLeft,
ShiftLeft,
AltLeft,
MetaLeft,
ControlRight,
ShiftRight,
AltRight,
MetaRight,
}
impl CanonicalKey {
/// Convert the canonical key to a stable wire code.
///
/// The wire code intentionally matches the USB HID usage for keyboard page
/// keys so existing low-level behavior stays intact while the semantic type
/// becomes explicit.
pub const fn to_hid_usage(self) -> u8 {
match self {
Self::KeyA => 0x04,
Self::KeyB => 0x05,
Self::KeyC => 0x06,
Self::KeyD => 0x07,
Self::KeyE => 0x08,
Self::KeyF => 0x09,
Self::KeyG => 0x0A,
Self::KeyH => 0x0B,
Self::KeyI => 0x0C,
Self::KeyJ => 0x0D,
Self::KeyK => 0x0E,
Self::KeyL => 0x0F,
Self::KeyM => 0x10,
Self::KeyN => 0x11,
Self::KeyO => 0x12,
Self::KeyP => 0x13,
Self::KeyQ => 0x14,
Self::KeyR => 0x15,
Self::KeyS => 0x16,
Self::KeyT => 0x17,
Self::KeyU => 0x18,
Self::KeyV => 0x19,
Self::KeyW => 0x1A,
Self::KeyX => 0x1B,
Self::KeyY => 0x1C,
Self::KeyZ => 0x1D,
Self::Digit1 => 0x1E,
Self::Digit2 => 0x1F,
Self::Digit3 => 0x20,
Self::Digit4 => 0x21,
Self::Digit5 => 0x22,
Self::Digit6 => 0x23,
Self::Digit7 => 0x24,
Self::Digit8 => 0x25,
Self::Digit9 => 0x26,
Self::Digit0 => 0x27,
Self::Enter => 0x28,
Self::Escape => 0x29,
Self::Backspace => 0x2A,
Self::Tab => 0x2B,
Self::Space => 0x2C,
Self::Minus => 0x2D,
Self::Equal => 0x2E,
Self::BracketLeft => 0x2F,
Self::BracketRight => 0x30,
Self::Backslash => 0x31,
Self::Semicolon => 0x33,
Self::Quote => 0x34,
Self::Backquote => 0x35,
Self::Comma => 0x36,
Self::Period => 0x37,
Self::Slash => 0x38,
Self::CapsLock => 0x39,
Self::F1 => 0x3A,
Self::F2 => 0x3B,
Self::F3 => 0x3C,
Self::F4 => 0x3D,
Self::F5 => 0x3E,
Self::F6 => 0x3F,
Self::F7 => 0x40,
Self::F8 => 0x41,
Self::F9 => 0x42,
Self::F10 => 0x43,
Self::F11 => 0x44,
Self::F12 => 0x45,
Self::PrintScreen => 0x46,
Self::ScrollLock => 0x47,
Self::Pause => 0x48,
Self::Insert => 0x49,
Self::Home => 0x4A,
Self::PageUp => 0x4B,
Self::Delete => 0x4C,
Self::End => 0x4D,
Self::PageDown => 0x4E,
Self::ArrowRight => 0x4F,
Self::ArrowLeft => 0x50,
Self::ArrowDown => 0x51,
Self::ArrowUp => 0x52,
Self::NumLock => 0x53,
Self::NumpadDivide => 0x54,
Self::NumpadMultiply => 0x55,
Self::NumpadSubtract => 0x56,
Self::NumpadAdd => 0x57,
Self::NumpadEnter => 0x58,
Self::Numpad1 => 0x59,
Self::Numpad2 => 0x5A,
Self::Numpad3 => 0x5B,
Self::Numpad4 => 0x5C,
Self::Numpad5 => 0x5D,
Self::Numpad6 => 0x5E,
Self::Numpad7 => 0x5F,
Self::Numpad8 => 0x60,
Self::Numpad9 => 0x61,
Self::Numpad0 => 0x62,
Self::NumpadDecimal => 0x63,
Self::IntlBackslash => 0x64,
Self::ContextMenu => 0x65,
Self::F13 => 0x68,
Self::F14 => 0x69,
Self::F15 => 0x6A,
Self::F16 => 0x6B,
Self::F17 => 0x6C,
Self::F18 => 0x6D,
Self::F19 => 0x6E,
Self::F20 => 0x6F,
Self::F21 => 0x70,
Self::F22 => 0x71,
Self::F23 => 0x72,
Self::F24 => 0x73,
Self::ControlLeft => 0xE0,
Self::ShiftLeft => 0xE1,
Self::AltLeft => 0xE2,
Self::MetaLeft => 0xE3,
Self::ControlRight => 0xE4,
Self::ShiftRight => 0xE5,
Self::AltRight => 0xE6,
Self::MetaRight => 0xE7,
}
}
/// Convert a wire code / USB HID usage to its canonical key.
pub const fn from_hid_usage(usage: u8) -> Option<Self> {
match usage {
0x04 => Some(Self::KeyA),
0x05 => Some(Self::KeyB),
0x06 => Some(Self::KeyC),
0x07 => Some(Self::KeyD),
0x08 => Some(Self::KeyE),
0x09 => Some(Self::KeyF),
0x0A => Some(Self::KeyG),
0x0B => Some(Self::KeyH),
0x0C => Some(Self::KeyI),
0x0D => Some(Self::KeyJ),
0x0E => Some(Self::KeyK),
0x0F => Some(Self::KeyL),
0x10 => Some(Self::KeyM),
0x11 => Some(Self::KeyN),
0x12 => Some(Self::KeyO),
0x13 => Some(Self::KeyP),
0x14 => Some(Self::KeyQ),
0x15 => Some(Self::KeyR),
0x16 => Some(Self::KeyS),
0x17 => Some(Self::KeyT),
0x18 => Some(Self::KeyU),
0x19 => Some(Self::KeyV),
0x1A => Some(Self::KeyW),
0x1B => Some(Self::KeyX),
0x1C => Some(Self::KeyY),
0x1D => Some(Self::KeyZ),
0x1E => Some(Self::Digit1),
0x1F => Some(Self::Digit2),
0x20 => Some(Self::Digit3),
0x21 => Some(Self::Digit4),
0x22 => Some(Self::Digit5),
0x23 => Some(Self::Digit6),
0x24 => Some(Self::Digit7),
0x25 => Some(Self::Digit8),
0x26 => Some(Self::Digit9),
0x27 => Some(Self::Digit0),
0x28 => Some(Self::Enter),
0x29 => Some(Self::Escape),
0x2A => Some(Self::Backspace),
0x2B => Some(Self::Tab),
0x2C => Some(Self::Space),
0x2D => Some(Self::Minus),
0x2E => Some(Self::Equal),
0x2F => Some(Self::BracketLeft),
0x30 => Some(Self::BracketRight),
0x31 => Some(Self::Backslash),
0x33 => Some(Self::Semicolon),
0x34 => Some(Self::Quote),
0x35 => Some(Self::Backquote),
0x36 => Some(Self::Comma),
0x37 => Some(Self::Period),
0x38 => Some(Self::Slash),
0x39 => Some(Self::CapsLock),
0x3A => Some(Self::F1),
0x3B => Some(Self::F2),
0x3C => Some(Self::F3),
0x3D => Some(Self::F4),
0x3E => Some(Self::F5),
0x3F => Some(Self::F6),
0x40 => Some(Self::F7),
0x41 => Some(Self::F8),
0x42 => Some(Self::F9),
0x43 => Some(Self::F10),
0x44 => Some(Self::F11),
0x45 => Some(Self::F12),
0x46 => Some(Self::PrintScreen),
0x47 => Some(Self::ScrollLock),
0x48 => Some(Self::Pause),
0x49 => Some(Self::Insert),
0x4A => Some(Self::Home),
0x4B => Some(Self::PageUp),
0x4C => Some(Self::Delete),
0x4D => Some(Self::End),
0x4E => Some(Self::PageDown),
0x4F => Some(Self::ArrowRight),
0x50 => Some(Self::ArrowLeft),
0x51 => Some(Self::ArrowDown),
0x52 => Some(Self::ArrowUp),
0x53 => Some(Self::NumLock),
0x54 => Some(Self::NumpadDivide),
0x55 => Some(Self::NumpadMultiply),
0x56 => Some(Self::NumpadSubtract),
0x57 => Some(Self::NumpadAdd),
0x58 => Some(Self::NumpadEnter),
0x59 => Some(Self::Numpad1),
0x5A => Some(Self::Numpad2),
0x5B => Some(Self::Numpad3),
0x5C => Some(Self::Numpad4),
0x5D => Some(Self::Numpad5),
0x5E => Some(Self::Numpad6),
0x5F => Some(Self::Numpad7),
0x60 => Some(Self::Numpad8),
0x61 => Some(Self::Numpad9),
0x62 => Some(Self::Numpad0),
0x63 => Some(Self::NumpadDecimal),
0x64 => Some(Self::IntlBackslash),
0x65 => Some(Self::ContextMenu),
0x68 => Some(Self::F13),
0x69 => Some(Self::F14),
0x6A => Some(Self::F15),
0x6B => Some(Self::F16),
0x6C => Some(Self::F17),
0x6D => Some(Self::F18),
0x6E => Some(Self::F19),
0x6F => Some(Self::F20),
0x70 => Some(Self::F21),
0x71 => Some(Self::F22),
0x72 => Some(Self::F23),
0x73 => Some(Self::F24),
0xE0 => Some(Self::ControlLeft),
0xE1 => Some(Self::ShiftLeft),
0xE2 => Some(Self::AltLeft),
0xE3 => Some(Self::MetaLeft),
0xE4 => Some(Self::ControlRight),
0xE5 => Some(Self::ShiftRight),
0xE6 => Some(Self::AltRight),
0xE7 => Some(Self::MetaRight),
_ => None,
}
}
pub const fn is_modifier(self) -> bool {
matches!(
self,
Self::ControlLeft
| Self::ShiftLeft
| Self::AltLeft
| Self::MetaLeft
| Self::ControlRight
| Self::ShiftRight
| Self::AltRight
| Self::MetaRight
)
}
pub const fn modifier_bit(self) -> Option<u8> {
match self {
Self::ControlLeft => Some(0x01),
Self::ShiftLeft => Some(0x02),
Self::AltLeft => Some(0x04),
Self::MetaLeft => Some(0x08),
Self::ControlRight => Some(0x10),
Self::ShiftRight => Some(0x20),
Self::AltRight => Some(0x40),
Self::MetaRight => Some(0x80),
_ => None,
}
}
}

View File

@@ -1,430 +0,0 @@
//! USB HID keyboard key codes mapping
//!
//! This module provides mapping between JavaScript key codes and USB HID usage codes.
//! Reference: USB HID Usage Tables 1.12, Section 10 (Keyboard/Keypad Page)
/// USB HID key codes (Usage Page 0x07)
#[allow(dead_code)]
pub mod usb {
// Letters A-Z (0x04 - 0x1D)
pub const KEY_A: u8 = 0x04;
pub const KEY_B: u8 = 0x05;
pub const KEY_C: u8 = 0x06;
pub const KEY_D: u8 = 0x07;
pub const KEY_E: u8 = 0x08;
pub const KEY_F: u8 = 0x09;
pub const KEY_G: u8 = 0x0A;
pub const KEY_H: u8 = 0x0B;
pub const KEY_I: u8 = 0x0C;
pub const KEY_J: u8 = 0x0D;
pub const KEY_K: u8 = 0x0E;
pub const KEY_L: u8 = 0x0F;
pub const KEY_M: u8 = 0x10;
pub const KEY_N: u8 = 0x11;
pub const KEY_O: u8 = 0x12;
pub const KEY_P: u8 = 0x13;
pub const KEY_Q: u8 = 0x14;
pub const KEY_R: u8 = 0x15;
pub const KEY_S: u8 = 0x16;
pub const KEY_T: u8 = 0x17;
pub const KEY_U: u8 = 0x18;
pub const KEY_V: u8 = 0x19;
pub const KEY_W: u8 = 0x1A;
pub const KEY_X: u8 = 0x1B;
pub const KEY_Y: u8 = 0x1C;
pub const KEY_Z: u8 = 0x1D;
// Numbers 1-9, 0 (0x1E - 0x27)
pub const KEY_1: u8 = 0x1E;
pub const KEY_2: u8 = 0x1F;
pub const KEY_3: u8 = 0x20;
pub const KEY_4: u8 = 0x21;
pub const KEY_5: u8 = 0x22;
pub const KEY_6: u8 = 0x23;
pub const KEY_7: u8 = 0x24;
pub const KEY_8: u8 = 0x25;
pub const KEY_9: u8 = 0x26;
pub const KEY_0: u8 = 0x27;
// Control keys
pub const KEY_ENTER: u8 = 0x28;
pub const KEY_ESCAPE: u8 = 0x29;
pub const KEY_BACKSPACE: u8 = 0x2A;
pub const KEY_TAB: u8 = 0x2B;
pub const KEY_SPACE: u8 = 0x2C;
pub const KEY_MINUS: u8 = 0x2D;
pub const KEY_EQUAL: u8 = 0x2E;
pub const KEY_LEFT_BRACKET: u8 = 0x2F;
pub const KEY_RIGHT_BRACKET: u8 = 0x30;
pub const KEY_BACKSLASH: u8 = 0x31;
pub const KEY_HASH: u8 = 0x32; // Non-US # and ~
pub const KEY_SEMICOLON: u8 = 0x33;
pub const KEY_APOSTROPHE: u8 = 0x34;
pub const KEY_GRAVE: u8 = 0x35;
pub const KEY_COMMA: u8 = 0x36;
pub const KEY_PERIOD: u8 = 0x37;
pub const KEY_SLASH: u8 = 0x38;
pub const KEY_CAPS_LOCK: u8 = 0x39;
// Function keys F1-F12
pub const KEY_F1: u8 = 0x3A;
pub const KEY_F2: u8 = 0x3B;
pub const KEY_F3: u8 = 0x3C;
pub const KEY_F4: u8 = 0x3D;
pub const KEY_F5: u8 = 0x3E;
pub const KEY_F6: u8 = 0x3F;
pub const KEY_F7: u8 = 0x40;
pub const KEY_F8: u8 = 0x41;
pub const KEY_F9: u8 = 0x42;
pub const KEY_F10: u8 = 0x43;
pub const KEY_F11: u8 = 0x44;
pub const KEY_F12: u8 = 0x45;
// Special keys
pub const KEY_PRINT_SCREEN: u8 = 0x46;
pub const KEY_SCROLL_LOCK: u8 = 0x47;
pub const KEY_PAUSE: u8 = 0x48;
pub const KEY_INSERT: u8 = 0x49;
pub const KEY_HOME: u8 = 0x4A;
pub const KEY_PAGE_UP: u8 = 0x4B;
pub const KEY_DELETE: u8 = 0x4C;
pub const KEY_END: u8 = 0x4D;
pub const KEY_PAGE_DOWN: u8 = 0x4E;
pub const KEY_RIGHT_ARROW: u8 = 0x4F;
pub const KEY_LEFT_ARROW: u8 = 0x50;
pub const KEY_DOWN_ARROW: u8 = 0x51;
pub const KEY_UP_ARROW: u8 = 0x52;
// Numpad
pub const KEY_NUM_LOCK: u8 = 0x53;
pub const KEY_NUMPAD_DIVIDE: u8 = 0x54;
pub const KEY_NUMPAD_MULTIPLY: u8 = 0x55;
pub const KEY_NUMPAD_MINUS: u8 = 0x56;
pub const KEY_NUMPAD_PLUS: u8 = 0x57;
pub const KEY_NUMPAD_ENTER: u8 = 0x58;
pub const KEY_NUMPAD_1: u8 = 0x59;
pub const KEY_NUMPAD_2: u8 = 0x5A;
pub const KEY_NUMPAD_3: u8 = 0x5B;
pub const KEY_NUMPAD_4: u8 = 0x5C;
pub const KEY_NUMPAD_5: u8 = 0x5D;
pub const KEY_NUMPAD_6: u8 = 0x5E;
pub const KEY_NUMPAD_7: u8 = 0x5F;
pub const KEY_NUMPAD_8: u8 = 0x60;
pub const KEY_NUMPAD_9: u8 = 0x61;
pub const KEY_NUMPAD_0: u8 = 0x62;
pub const KEY_NUMPAD_DECIMAL: u8 = 0x63;
// Additional keys
pub const KEY_NON_US_BACKSLASH: u8 = 0x64;
pub const KEY_APPLICATION: u8 = 0x65; // Context menu
pub const KEY_POWER: u8 = 0x66;
pub const KEY_NUMPAD_EQUAL: u8 = 0x67;
// F13-F24
pub const KEY_F13: u8 = 0x68;
pub const KEY_F14: u8 = 0x69;
pub const KEY_F15: u8 = 0x6A;
pub const KEY_F16: u8 = 0x6B;
pub const KEY_F17: u8 = 0x6C;
pub const KEY_F18: u8 = 0x6D;
pub const KEY_F19: u8 = 0x6E;
pub const KEY_F20: u8 = 0x6F;
pub const KEY_F21: u8 = 0x70;
pub const KEY_F22: u8 = 0x71;
pub const KEY_F23: u8 = 0x72;
pub const KEY_F24: u8 = 0x73;
// Modifier keys (these are handled separately in the modifier byte)
pub const KEY_LEFT_CTRL: u8 = 0xE0;
pub const KEY_LEFT_SHIFT: u8 = 0xE1;
pub const KEY_LEFT_ALT: u8 = 0xE2;
pub const KEY_LEFT_META: u8 = 0xE3;
pub const KEY_RIGHT_CTRL: u8 = 0xE4;
pub const KEY_RIGHT_SHIFT: u8 = 0xE5;
pub const KEY_RIGHT_ALT: u8 = 0xE6;
pub const KEY_RIGHT_META: u8 = 0xE7;
}
/// JavaScript key codes (event.keyCode / event.code)
#[allow(dead_code)]
pub mod js {
// Letters
pub const KEY_A: u8 = 65;
pub const KEY_B: u8 = 66;
pub const KEY_C: u8 = 67;
pub const KEY_D: u8 = 68;
pub const KEY_E: u8 = 69;
pub const KEY_F: u8 = 70;
pub const KEY_G: u8 = 71;
pub const KEY_H: u8 = 72;
pub const KEY_I: u8 = 73;
pub const KEY_J: u8 = 74;
pub const KEY_K: u8 = 75;
pub const KEY_L: u8 = 76;
pub const KEY_M: u8 = 77;
pub const KEY_N: u8 = 78;
pub const KEY_O: u8 = 79;
pub const KEY_P: u8 = 80;
pub const KEY_Q: u8 = 81;
pub const KEY_R: u8 = 82;
pub const KEY_S: u8 = 83;
pub const KEY_T: u8 = 84;
pub const KEY_U: u8 = 85;
pub const KEY_V: u8 = 86;
pub const KEY_W: u8 = 87;
pub const KEY_X: u8 = 88;
pub const KEY_Y: u8 = 89;
pub const KEY_Z: u8 = 90;
// Numbers (top row)
pub const KEY_0: u8 = 48;
pub const KEY_1: u8 = 49;
pub const KEY_2: u8 = 50;
pub const KEY_3: u8 = 51;
pub const KEY_4: u8 = 52;
pub const KEY_5: u8 = 53;
pub const KEY_6: u8 = 54;
pub const KEY_7: u8 = 55;
pub const KEY_8: u8 = 56;
pub const KEY_9: u8 = 57;
// Function keys
pub const KEY_F1: u8 = 112;
pub const KEY_F2: u8 = 113;
pub const KEY_F3: u8 = 114;
pub const KEY_F4: u8 = 115;
pub const KEY_F5: u8 = 116;
pub const KEY_F6: u8 = 117;
pub const KEY_F7: u8 = 118;
pub const KEY_F8: u8 = 119;
pub const KEY_F9: u8 = 120;
pub const KEY_F10: u8 = 121;
pub const KEY_F11: u8 = 122;
pub const KEY_F12: u8 = 123;
// Control keys
pub const KEY_BACKSPACE: u8 = 8;
pub const KEY_TAB: u8 = 9;
pub const KEY_ENTER: u8 = 13;
pub const KEY_SHIFT: u8 = 16;
pub const KEY_CTRL: u8 = 17;
pub const KEY_ALT: u8 = 18;
pub const KEY_PAUSE: u8 = 19;
pub const KEY_CAPS_LOCK: u8 = 20;
pub const KEY_ESCAPE: u8 = 27;
pub const KEY_SPACE: u8 = 32;
pub const KEY_PAGE_UP: u8 = 33;
pub const KEY_PAGE_DOWN: u8 = 34;
pub const KEY_END: u8 = 35;
pub const KEY_HOME: u8 = 36;
pub const KEY_LEFT: u8 = 37;
pub const KEY_UP: u8 = 38;
pub const KEY_RIGHT: u8 = 39;
pub const KEY_DOWN: u8 = 40;
pub const KEY_INSERT: u8 = 45;
pub const KEY_DELETE: u8 = 46;
// Punctuation
pub const KEY_SEMICOLON: u8 = 186;
pub const KEY_EQUAL: u8 = 187;
pub const KEY_COMMA: u8 = 188;
pub const KEY_MINUS: u8 = 189;
pub const KEY_PERIOD: u8 = 190;
pub const KEY_SLASH: u8 = 191;
pub const KEY_GRAVE: u8 = 192;
pub const KEY_LEFT_BRACKET: u8 = 219;
pub const KEY_BACKSLASH: u8 = 220;
pub const KEY_RIGHT_BRACKET: u8 = 221;
pub const KEY_APOSTROPHE: u8 = 222;
// Numpad
pub const KEY_NUMPAD_0: u8 = 96;
pub const KEY_NUMPAD_1: u8 = 97;
pub const KEY_NUMPAD_2: u8 = 98;
pub const KEY_NUMPAD_3: u8 = 99;
pub const KEY_NUMPAD_4: u8 = 100;
pub const KEY_NUMPAD_5: u8 = 101;
pub const KEY_NUMPAD_6: u8 = 102;
pub const KEY_NUMPAD_7: u8 = 103;
pub const KEY_NUMPAD_8: u8 = 104;
pub const KEY_NUMPAD_9: u8 = 105;
pub const KEY_NUMPAD_MULTIPLY: u8 = 106;
pub const KEY_NUMPAD_ADD: u8 = 107;
pub const KEY_NUMPAD_SUBTRACT: u8 = 109;
pub const KEY_NUMPAD_DECIMAL: u8 = 110;
pub const KEY_NUMPAD_DIVIDE: u8 = 111;
// Lock keys
pub const KEY_NUM_LOCK: u8 = 144;
pub const KEY_SCROLL_LOCK: u8 = 145;
// Windows keys
pub const KEY_META_LEFT: u8 = 91;
pub const KEY_META_RIGHT: u8 = 92;
pub const KEY_CONTEXT_MENU: u8 = 93;
}
/// JavaScript keyCode to USB HID keyCode mapping table
/// Using a fixed-size array for O(1) lookup instead of HashMap
/// Index = JavaScript keyCode, Value = USB HID keyCode (0 means unmapped)
static JS_TO_USB_TABLE: [u8; 256] = {
let mut table = [0u8; 256];
// Letters A-Z (JS 65-90 -> USB 0x04-0x1D)
let mut i = 0u8;
while i < 26 {
table[(65 + i) as usize] = usb::KEY_A + i;
i += 1;
}
// Numbers 1-9, 0 (JS 49-57, 48 -> USB 0x1E-0x27)
table[49] = usb::KEY_1; // 1
table[50] = usb::KEY_2; // 2
table[51] = usb::KEY_3; // 3
table[52] = usb::KEY_4; // 4
table[53] = usb::KEY_5; // 5
table[54] = usb::KEY_6; // 6
table[55] = usb::KEY_7; // 7
table[56] = usb::KEY_8; // 8
table[57] = usb::KEY_9; // 9
table[48] = usb::KEY_0; // 0
// Function keys F1-F12 (JS 112-123 -> USB 0x3A-0x45)
table[112] = usb::KEY_F1;
table[113] = usb::KEY_F2;
table[114] = usb::KEY_F3;
table[115] = usb::KEY_F4;
table[116] = usb::KEY_F5;
table[117] = usb::KEY_F6;
table[118] = usb::KEY_F7;
table[119] = usb::KEY_F8;
table[120] = usb::KEY_F9;
table[121] = usb::KEY_F10;
table[122] = usb::KEY_F11;
table[123] = usb::KEY_F12;
// Control keys
table[13] = usb::KEY_ENTER; // Enter
table[27] = usb::KEY_ESCAPE; // Escape
table[8] = usb::KEY_BACKSPACE; // Backspace
table[9] = usb::KEY_TAB; // Tab
table[32] = usb::KEY_SPACE; // Space
table[20] = usb::KEY_CAPS_LOCK; // Caps Lock
// Punctuation (JS codes vary by browser/layout)
table[189] = usb::KEY_MINUS; // -
table[187] = usb::KEY_EQUAL; // =
table[219] = usb::KEY_LEFT_BRACKET; // [
table[221] = usb::KEY_RIGHT_BRACKET; // ]
table[220] = usb::KEY_BACKSLASH; // \
table[186] = usb::KEY_SEMICOLON; // ;
table[222] = usb::KEY_APOSTROPHE; // '
table[192] = usb::KEY_GRAVE; // `
table[188] = usb::KEY_COMMA; // ,
table[190] = usb::KEY_PERIOD; // .
table[191] = usb::KEY_SLASH; // /
// Navigation keys
table[45] = usb::KEY_INSERT;
table[46] = usb::KEY_DELETE;
table[36] = usb::KEY_HOME;
table[35] = usb::KEY_END;
table[33] = usb::KEY_PAGE_UP;
table[34] = usb::KEY_PAGE_DOWN;
// Arrow keys
table[39] = usb::KEY_RIGHT_ARROW;
table[37] = usb::KEY_LEFT_ARROW;
table[40] = usb::KEY_DOWN_ARROW;
table[38] = usb::KEY_UP_ARROW;
// Numpad
table[144] = usb::KEY_NUM_LOCK;
table[111] = usb::KEY_NUMPAD_DIVIDE;
table[106] = usb::KEY_NUMPAD_MULTIPLY;
table[109] = usb::KEY_NUMPAD_MINUS;
table[107] = usb::KEY_NUMPAD_PLUS;
table[96] = usb::KEY_NUMPAD_0;
table[97] = usb::KEY_NUMPAD_1;
table[98] = usb::KEY_NUMPAD_2;
table[99] = usb::KEY_NUMPAD_3;
table[100] = usb::KEY_NUMPAD_4;
table[101] = usb::KEY_NUMPAD_5;
table[102] = usb::KEY_NUMPAD_6;
table[103] = usb::KEY_NUMPAD_7;
table[104] = usb::KEY_NUMPAD_8;
table[105] = usb::KEY_NUMPAD_9;
table[110] = usb::KEY_NUMPAD_DECIMAL;
// Special keys
table[19] = usb::KEY_PAUSE;
table[145] = usb::KEY_SCROLL_LOCK;
table[93] = usb::KEY_APPLICATION; // Context menu
// Modifier keys
table[17] = usb::KEY_LEFT_CTRL;
table[16] = usb::KEY_LEFT_SHIFT;
table[18] = usb::KEY_LEFT_ALT;
table[91] = usb::KEY_LEFT_META; // Left Windows/Command
table[92] = usb::KEY_RIGHT_META; // Right Windows/Command
table
};
/// Convert JavaScript keyCode to USB HID keyCode
///
/// Uses a fixed-size lookup table for O(1) performance.
/// Returns None if the key code is not mapped.
#[inline]
pub fn js_to_usb(js_code: u8) -> Option<u8> {
let usb_code = JS_TO_USB_TABLE[js_code as usize];
if usb_code != 0 {
Some(usb_code)
} else {
None
}
}
/// Check if a key code is a modifier key
pub fn is_modifier_key(usb_code: u8) -> bool {
(0xE0..=0xE7).contains(&usb_code)
}
/// Get modifier bit for a modifier key
pub fn modifier_bit(usb_code: u8) -> Option<u8> {
match usb_code {
usb::KEY_LEFT_CTRL => Some(0x01),
usb::KEY_LEFT_SHIFT => Some(0x02),
usb::KEY_LEFT_ALT => Some(0x04),
usb::KEY_LEFT_META => Some(0x08),
usb::KEY_RIGHT_CTRL => Some(0x10),
usb::KEY_RIGHT_SHIFT => Some(0x20),
usb::KEY_RIGHT_ALT => Some(0x40),
usb::KEY_RIGHT_META => Some(0x80),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_letter_mapping() {
assert_eq!(js_to_usb(65), Some(usb::KEY_A)); // A
assert_eq!(js_to_usb(90), Some(usb::KEY_Z)); // Z
}
#[test]
fn test_number_mapping() {
assert_eq!(js_to_usb(48), Some(usb::KEY_0));
assert_eq!(js_to_usb(49), Some(usb::KEY_1));
}
#[test]
fn test_modifier_key() {
assert!(is_modifier_key(usb::KEY_LEFT_CTRL));
assert!(is_modifier_key(usb::KEY_RIGHT_SHIFT));
assert!(!is_modifier_key(usb::KEY_A));
}
}

View File

@@ -15,12 +15,13 @@ pub mod backend;
pub mod ch9329;
pub mod consumer;
pub mod datachannel;
pub mod keymap;
pub mod keyboard;
pub mod otg;
pub mod types;
pub mod websocket;
pub use backend::{HidBackend, HidBackendStatus, HidBackendType};
pub use keyboard::CanonicalKey;
pub use otg::LedState;
pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,

View File

@@ -29,7 +29,6 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use tracing::{debug, info, trace, warn};
use super::backend::{HidBackend, HidBackendStatus};
use super::keymap;
use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
};
@@ -873,18 +872,13 @@ impl HidBackend for OtgBackend {
}
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID)
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
let usb_key = event.key.to_hid_usage();
// Handle modifier keys separately
if keymap::is_modifier_key(usb_key) {
if event.key.is_modifier() {
let mut state = self.keyboard_state.lock();
if let Some(bit) = keymap::modifier_bit(usb_key) {
if let Some(bit) = event.key.modifier_bit() {
match event.event_type {
KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit,

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
use super::keyboard::CanonicalKey;
/// Keyboard event type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -105,34 +107,29 @@ pub struct KeyboardEvent {
/// Event type (down/up)
#[serde(rename = "type")]
pub event_type: KeyEventType,
/// Key code (USB HID usage code or JavaScript key code)
pub key: u8,
/// Canonical keyboard key identifier shared across frontend and backend
pub key: CanonicalKey,
/// Modifier keys state
#[serde(default)]
pub modifiers: KeyboardModifiers,
/// If true, key is already USB HID code (skip js_to_usb conversion)
#[serde(default)]
pub is_usb_hid: bool,
}
impl KeyboardEvent {
/// Create a key down event (JS keycode, needs conversion)
pub fn key_down(key: u8, modifiers: KeyboardModifiers) -> Self {
/// Create a key down event
pub fn key_down(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self {
event_type: KeyEventType::Down,
key,
modifiers,
is_usb_hid: false,
}
}
/// Create a key up event (JS keycode, needs conversion)
pub fn key_up(key: u8, modifiers: KeyboardModifiers) -> Self {
/// Create a key up event
pub fn key_up(key: CanonicalKey, modifiers: KeyboardModifiers) -> Self {
Self {
event_type: KeyEventType::Up,
key,
modifiers,
is_usb_hid: false,
}
}
}

View File

@@ -22,7 +22,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
use tracing::{debug, error, info, warn};
use crate::audio::AudioController;
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::video::codec_constraints::{
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
};
@@ -1328,15 +1328,13 @@ impl Connection {
);
let caps_down = KeyboardEvent {
event_type: KeyEventType::Down,
key: 0x39, // USB HID CapsLock
key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
};
let caps_up = KeyboardEvent {
event_type: KeyEventType::Up,
key: 0x39,
key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
};
if let Err(e) = hid.send_keyboard(caps_down).await {
warn!("Failed to send CapsLock down: {}", e);
@@ -1351,7 +1349,7 @@ impl Connection {
if let Some(kb_event) = convert_key_event(ke) {
debug!(
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}",
kb_event.key,
kb_event.key.to_hid_usage(),
kb_event.event_type,
kb_event.modifiers.to_hid_byte()
);

View File

@@ -5,8 +5,8 @@
use super::protocol::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, MouseEvent};
use crate::hid::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent,
MouseEventType,
CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEvent as OneKvmMouseEvent, MouseEventType,
};
use protobuf::Enum;
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
// Handle control keys
if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
if let Some(key) = control_key_to_hid(ck.value()) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
modifiers,
is_usb_hid: true, // Already converted to USB HID code
});
}
}
@@ -230,11 +230,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
if let Some(ke_union::Union::Chr(chr)) = &event.union {
// chr contains USB HID scancode on Windows, X11 keycode on Linux
if let Some(key) = keycode_to_hid(*chr) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent {
event_type,
key,
modifiers,
is_usb_hid: true, // Already converted to USB HID code
});
}
}
@@ -608,6 +608,6 @@ mod tests {
let kb_event = result.unwrap();
assert_eq!(kb_event.event_type, KeyEventType::Down);
assert_eq!(kb_event.key, 0x28); // Return key USB HID code
assert_eq!(kb_event.key, CanonicalKey::Enter);
}
}

View File

@@ -1,6 +1,7 @@
// API client for One-KVM backend
import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
const API_BASE = '/api'
@@ -357,7 +358,7 @@ export const hidApi = {
}>
}>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => {
keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CanonicalKey } from '@/types/generated'
import { cn } from '@/lib/utils'
const props = defineProps<{
pressedKeys?: string[]
pressedKeys?: CanonicalKey[]
capsLock?: boolean
mousePosition?: { x: number; y: number }
debugMode?: boolean
@@ -18,13 +19,14 @@ const keyNameMap: Record<string, string> = {
MetaLeft: 'Win', MetaRight: 'Win',
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
ShiftLeft: 'Shift', ShiftRight: 'Shift',
AltLeft: 'Alt', AltRight: 'Alt',
AltLeft: 'Alt', AltRight: 'AltGr',
CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll',
Backspace: 'Back', Delete: 'Del',
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space',
PageUp: 'PgUp', PageDown: 'PgDn',
Insert: 'Ins', Home: 'Home', End: 'End',
ContextMenu: 'Menu',
}
const keysDisplay = computed(() => {

View File

@@ -69,24 +69,24 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
return true
}
const { hidCode, shift } = mapping
const { key, shift } = mapping
const modifier = shift ? 0x02 : 0
try {
// Send keydown
await hidApi.keyboard('down', hidCode, modifier)
await hidApi.keyboard('down', key, modifier)
// Small delay between down and up to ensure key is registered
await sleep(5)
if (signal.aborted) {
// Even if aborted, still send keyup to release the key
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
return false
}
// Send keyup
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
// Additional small delay after keyup to ensure it's processed
await sleep(2)
@@ -96,7 +96,7 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
console.error('[Paste] Failed to type character:', char, error)
// Try to release the key even on error
try {
await hidApi.keyboard('up', hidCode, modifier)
await hidApi.keyboard('up', key, modifier)
} catch {
// Ignore cleanup errors
}

View File

@@ -4,12 +4,13 @@ import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import {
keys,
consumerKeys,
latchingKeys,
modifiers,
updateModifierMaskForHidKey,
updateModifierMaskForKey,
type KeyName,
type ConsumerKeyName,
} from '@/lib/keyboardMappings'
@@ -23,13 +24,15 @@ import {
const props = defineProps<{
visible: boolean
attached?: boolean
capsLock?: boolean
pressedKeys?: CanonicalKey[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void
(e: 'keyDown', key: string): void
(e: 'keyUp', key: string): void
(e: 'keyDown', key: CanonicalKey): void
(e: 'keyUp', key: CanonicalKey): void
}>()
const { t } = useI18n()
@@ -45,13 +48,17 @@ const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking
const pressedModifiers = ref<number>(0)
const keysDown = ref<string[]>([])
const keysDown = ref<CanonicalKey[]>([])
// Shift state for display
const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0
})
const areLettersUppercase = computed(() => {
return Boolean(props.capsLock) !== isShiftActive.value
})
const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default'
})
@@ -63,7 +70,12 @@ const keyNamesForDownKeys = computed(() => {
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name)
return [...modifierNames, ...keysDown.value, ' ']
return Array.from(new Set([
...modifierNames,
...(props.pressedKeys ?? []),
...keysDown.value,
...(props.capsLock ? ['CapsLock'] : []),
]))
})
// Dragging state (for floating mode)
@@ -88,7 +100,7 @@ const keyboardLayout = {
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
@@ -97,7 +109,7 @@ const keyboardLayout = {
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
},
control: {
@@ -148,11 +160,10 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
AltRight: 'AltGr',
MetaLeft: metaLabel,
MetaRight: metaLabel,
Menu: 'Menu',
ContextMenu: 'Menu',
// Special keys
Escape: 'Esc',
@@ -187,20 +198,60 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters
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',
KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyC: areLettersUppercase.value ? 'C' : 'c',
KeyD: areLettersUppercase.value ? 'D' : 'd',
KeyE: areLettersUppercase.value ? 'E' : 'e',
KeyF: areLettersUppercase.value ? 'F' : 'f',
KeyG: areLettersUppercase.value ? 'G' : 'g',
KeyH: areLettersUppercase.value ? 'H' : 'h',
KeyI: areLettersUppercase.value ? 'I' : 'i',
KeyJ: areLettersUppercase.value ? 'J' : 'j',
KeyK: areLettersUppercase.value ? 'K' : 'k',
KeyL: areLettersUppercase.value ? 'L' : 'l',
KeyM: areLettersUppercase.value ? 'M' : 'm',
KeyN: areLettersUppercase.value ? 'N' : 'n',
KeyO: areLettersUppercase.value ? 'O' : 'o',
KeyP: areLettersUppercase.value ? 'P' : 'p',
KeyQ: areLettersUppercase.value ? 'Q' : 'q',
KeyR: areLettersUppercase.value ? 'R' : 'r',
KeyS: areLettersUppercase.value ? 'S' : 's',
KeyT: areLettersUppercase.value ? 'T' : 't',
KeyU: areLettersUppercase.value ? 'U' : 'u',
KeyV: areLettersUppercase.value ? 'V' : 'v',
KeyW: areLettersUppercase.value ? 'W' : 'w',
KeyX: areLettersUppercase.value ? 'X' : 'x',
KeyY: areLettersUppercase.value ? 'Y' : 'y',
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
// Capital letters
'(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',
// Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
'(KeyD)': areLettersUppercase.value ? 'D' : 'd',
'(KeyE)': areLettersUppercase.value ? 'E' : 'e',
'(KeyF)': areLettersUppercase.value ? 'F' : 'f',
'(KeyG)': areLettersUppercase.value ? 'G' : 'g',
'(KeyH)': areLettersUppercase.value ? 'H' : 'h',
'(KeyI)': areLettersUppercase.value ? 'I' : 'i',
'(KeyJ)': areLettersUppercase.value ? 'J' : 'j',
'(KeyK)': areLettersUppercase.value ? 'K' : 'k',
'(KeyL)': areLettersUppercase.value ? 'L' : 'l',
'(KeyM)': areLettersUppercase.value ? 'M' : 'm',
'(KeyN)': areLettersUppercase.value ? 'N' : 'n',
'(KeyO)': areLettersUppercase.value ? 'O' : 'o',
'(KeyP)': areLettersUppercase.value ? 'P' : 'p',
'(KeyQ)': areLettersUppercase.value ? 'Q' : 'q',
'(KeyR)': areLettersUppercase.value ? 'R' : 'r',
'(KeyS)': areLettersUppercase.value ? 'S' : 's',
'(KeyT)': areLettersUppercase.value ? 'T' : 't',
'(KeyU)': areLettersUppercase.value ? 'U' : 'u',
'(KeyV)': areLettersUppercase.value ? 'V' : 'v',
'(KeyW)': areLettersUppercase.value ? 'W' : 'w',
'(KeyX)': areLettersUppercase.value ? 'X' : 'x',
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
@@ -303,47 +354,47 @@ async function onKeyDown(key: string) {
const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) {
emit('keyDown', cleanKey)
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => {
sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
}, 100)
return
}
// Handle modifier keys (toggle)
if (cleanKey in modifiers) {
const mask = modifiers[cleanKey as keyof typeof modifiers]
const mask = modifiers[keyCode] ?? 0
if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) {
const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
} else {
const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', cleanKey)
emit('keyDown', keyCode)
}
updateKeyboardButtonTheme()
return
}
// Regular key: press and release
keysDown.value.push(cleanKey)
emit('keyDown', cleanKey)
keysDown.value.push(keyCode)
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme()
setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey)
keysDown.value = keysDown.value.filter(k => k !== keyCode)
await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
emit('keyUp', keyCode)
updateKeyboardButtonTheme()
}, 50)
}
@@ -352,7 +403,7 @@ async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout
}
async function sendKeyPress(keyCode: number, press: boolean, modifierMask: number) {
async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
try {
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) {
@@ -372,7 +423,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) {
if (mod in keys) {
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true)
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
await sendKeyPress(modHid, true, macroModifierMask)
}
}
@@ -394,7 +445,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) {
if (mod in keys) {
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false)
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
await sendKeyPress(modHid, false, macroModifierMask)
}
}
@@ -421,8 +472,12 @@ function updateKeyboardButtonTheme() {
}
// Update layout when shift state changes
watch(layoutName, (name) => {
mainKeyboard.value?.setOptions({ layoutName: name })
watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
})
// Initialize keyboards with unique selectors
@@ -835,12 +890,12 @@ onUnmounted(() => {
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] {
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
flex-grow: 1.25;
min-width: 55px;
}
@@ -1194,8 +1249,8 @@ html.dark .hg-theme-default .hg-button.down-key,
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px;
}

View File

@@ -1,343 +0,0 @@
// HID input composable - manages keyboard and mouse input
// Extracted from ConsoleView.vue for better separation of concerns
import { ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { hidApi } from '@/api'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
export interface HidInputState {
mouseMode: Ref<'absolute' | 'relative'>
pressedKeys: Ref<string[]>
keyboardLed: Ref<{ capsLock: boolean; numLock: boolean; scrollLock: boolean }>
mousePosition: Ref<{ x: number; y: number }>
isPointerLocked: Ref<boolean>
cursorVisible: Ref<boolean>
}
export interface UseHidInputOptions {
videoContainerRef: Ref<HTMLDivElement | null>
getVideoElement: () => HTMLElement | null
isFullscreen: Ref<boolean>
}
export function useHidInput(options: UseHidInputOptions) {
const { t } = useI18n()
// State
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<string[]>([])
const keyboardLed = ref({
capsLock: false,
numLock: false,
scrollLock: false,
})
const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 })
const isPointerLocked = ref(false)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
const pressedMouseButton = ref<'left' | 'right' | 'middle' | null>(null)
// Error handling - silently handle all HID errors
function handleHidError(_error: unknown, _operation: string) {
// All HID errors are silently ignored
}
// Check if a key should be blocked
function shouldBlockKey(e: KeyboardEvent): boolean {
if (options.isFullscreen.value) return true
const key = e.key.toUpperCase()
if (e.ctrlKey && ['W', 'T', 'N'].includes(key)) return false
if (key === 'F11') return false
if (e.altKey && key === 'TAB') return false
return true
}
// Keyboard handlers
function handleKeyDown(e: KeyboardEvent) {
const container = options.videoContainerRef.value
if (!container) return
if (!options.isFullscreen.value && !container.contains(document.activeElement)) return
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
if (!options.isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
toast.info(t('console.metaKeyHint'), {
description: t('console.metaKeyHintDesc'),
duration: 3000,
})
}
const keyName = e.key === ' ' ? 'Space' : e.key
if (!pressedKeys.value.includes(keyName)) {
pressedKeys.value = [...pressedKeys.value, keyName]
}
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
keyboardLed.value.numLock = e.getModifierState('NumLock')
keyboardLed.value.scrollLock = e.getModifierState('ScrollLock')
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
activeModifierMask.value = modifierMask
hidApi.keyboard('down', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard down'))
}
function handleKeyUp(e: KeyboardEvent) {
const container = options.videoContainerRef.value
if (!container) return
if (!options.isFullscreen.value && !container.contains(document.activeElement)) return
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
const keyName = e.key === ' ' ? 'Space' : e.key
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
activeModifierMask.value = modifierMask
hidApi.keyboard('up', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard up'))
}
// Mouse handlers
function handleMouseMove(e: MouseEvent) {
const videoElement = options.getVideoElement()
if (!videoElement) return
if (mouseMode.value === 'absolute') {
const rect = videoElement.getBoundingClientRect()
const x = Math.round((e.clientX - rect.left) / rect.width * 32767)
const y = Math.round((e.clientY - rect.top) / rect.height * 32767)
mousePosition.value = { x, y }
hidApi.mouse({ type: 'move_abs', x, y }).catch(err => handleHidError(err, 'mouse move'))
} else {
if (isPointerLocked.value) {
const dx = e.movementX
const dy = e.movementY
if (dx !== 0 || dy !== 0) {
const clampedDx = Math.max(-127, Math.min(127, dx))
const clampedDy = Math.max(-127, Math.min(127, dy))
hidApi.mouse({ type: 'move', x: clampedDx, y: clampedDy }).catch(err => handleHidError(err, 'mouse move'))
}
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
}
}
}
}
function handleMouseDown(e: MouseEvent) {
e.preventDefault()
const container = options.videoContainerRef.value
if (container && document.activeElement !== container) {
if (typeof container.focus === 'function') {
container.focus()
}
}
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
requestPointerLock()
return
}
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
pressedMouseButton.value = button
hidApi.mouse({ type: 'down', button }).catch(err => handleHidError(err, 'mouse down'))
}
function handleMouseUp(e: MouseEvent) {
e.preventDefault()
handleMouseUpInternal(e.button)
}
function handleWindowMouseUp(e: MouseEvent) {
if (pressedMouseButton.value !== null) {
handleMouseUpInternal(e.button)
}
}
function handleMouseUpInternal(rawButton: number) {
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
pressedMouseButton.value = null
return
}
const button = rawButton === 0 ? 'left' : rawButton === 2 ? 'right' : 'middle'
if (pressedMouseButton.value !== button) return
pressedMouseButton.value = null
hidApi.mouse({ type: 'up', button }).catch(err => handleHidError(err, 'mouse up'))
}
function handleWheel(e: WheelEvent) {
e.preventDefault()
const scroll = e.deltaY > 0 ? -1 : 1
hidApi.mouse({ type: 'scroll', scroll }).catch(err => handleHidError(err, 'mouse scroll'))
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
}
// Pointer lock
function requestPointerLock() {
const container = options.videoContainerRef.value
if (!container) return
container.requestPointerLock().catch((err: Error) => {
toast.error(t('console.pointerLockFailed'), {
description: err.message,
})
})
}
function exitPointerLock() {
if (document.pointerLockElement) {
document.exitPointerLock()
}
}
function handlePointerLockChange() {
const container = options.videoContainerRef.value
isPointerLocked.value = document.pointerLockElement === container
if (isPointerLocked.value) {
mousePosition.value = { x: 0, y: 0 }
toast.info(t('console.pointerLocked'), {
description: t('console.pointerLockedDesc'),
duration: 3000,
})
}
}
function handlePointerLockError() {
isPointerLocked.value = false
}
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
pressedMouseButton.value = null
hidApi.mouse({ type: 'up', button }).catch(err => handleHidError(err, 'mouse up (blur)'))
}
}
// Mode toggle
function toggleMouseMode() {
if (mouseMode.value === 'relative' && isPointerLocked.value) {
exitPointerLock()
}
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 }
if (mouseMode.value === 'relative') {
toast.info(t('console.relativeModeHint'), {
description: t('console.relativeModeHintDesc'),
duration: 5000,
})
}
}
// Virtual keyboard handlers
function handleVirtualKeyDown(key: string) {
if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key]
}
}
function handleVirtualKeyUp(key: string) {
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
// Cursor visibility
function handleCursorVisibilityChange(e: Event) {
const customEvent = e as CustomEvent<{ visible: boolean }>
cursorVisible.value = customEvent.detail.visible
}
// Setup event listeners
function setupEventListeners() {
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
window.addEventListener('cursor-visibility-change', handleCursorVisibilityChange)
}
function cleanupEventListeners() {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('cursor-visibility-change', handleCursorVisibilityChange)
}
return {
// State
mouseMode,
pressedKeys,
keyboardLed,
mousePosition,
isPointerLocked,
cursorVisible,
// Keyboard handlers
handleKeyDown,
handleKeyUp,
// Mouse handlers
handleMouseMove,
handleMouseDown,
handleMouseUp,
handleWheel,
handleContextMenu,
// Pointer lock
requestPointerLock,
exitPointerLock,
// Mode toggle
toggleMouseMode,
// Virtual keyboard
handleVirtualKeyDown,
handleVirtualKeyUp,
// Cursor visibility
handleCursorVisibilityChange,
// Lifecycle
setupEventListeners,
cleanupEventListeners,
}
}

View File

@@ -1,129 +1,130 @@
// Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics.
import { type CanonicalKey } from '@/types/generated'
import { keys } from '@/lib/keyboardMappings'
export interface CharKeyMapping {
hidCode: number // USB HID usage code
key: CanonicalKey
shift: boolean // Whether Shift modifier is needed
}
const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters
a: { hidCode: keys.KeyA, shift: false },
b: { hidCode: keys.KeyB, shift: false },
c: { hidCode: keys.KeyC, shift: false },
d: { hidCode: keys.KeyD, shift: false },
e: { hidCode: keys.KeyE, shift: false },
f: { hidCode: keys.KeyF, shift: false },
g: { hidCode: keys.KeyG, shift: false },
h: { hidCode: keys.KeyH, shift: false },
i: { hidCode: keys.KeyI, shift: false },
j: { hidCode: keys.KeyJ, shift: false },
k: { hidCode: keys.KeyK, shift: false },
l: { hidCode: keys.KeyL, shift: false },
m: { hidCode: keys.KeyM, shift: false },
n: { hidCode: keys.KeyN, shift: false },
o: { hidCode: keys.KeyO, shift: false },
p: { hidCode: keys.KeyP, shift: false },
q: { hidCode: keys.KeyQ, shift: false },
r: { hidCode: keys.KeyR, shift: false },
s: { hidCode: keys.KeyS, shift: false },
t: { hidCode: keys.KeyT, shift: false },
u: { hidCode: keys.KeyU, shift: false },
v: { hidCode: keys.KeyV, shift: false },
w: { hidCode: keys.KeyW, shift: false },
x: { hidCode: keys.KeyX, shift: false },
y: { hidCode: keys.KeyY, shift: false },
z: { hidCode: keys.KeyZ, shift: false },
a: { key: keys.KeyA, shift: false },
b: { key: keys.KeyB, shift: false },
c: { key: keys.KeyC, shift: false },
d: { key: keys.KeyD, shift: false },
e: { key: keys.KeyE, shift: false },
f: { key: keys.KeyF, shift: false },
g: { key: keys.KeyG, shift: false },
h: { key: keys.KeyH, shift: false },
i: { key: keys.KeyI, shift: false },
j: { key: keys.KeyJ, shift: false },
k: { key: keys.KeyK, shift: false },
l: { key: keys.KeyL, shift: false },
m: { key: keys.KeyM, shift: false },
n: { key: keys.KeyN, shift: false },
o: { key: keys.KeyO, shift: false },
p: { key: keys.KeyP, shift: false },
q: { key: keys.KeyQ, shift: false },
r: { key: keys.KeyR, shift: false },
s: { key: keys.KeyS, shift: false },
t: { key: keys.KeyT, shift: false },
u: { key: keys.KeyU, shift: false },
v: { key: keys.KeyV, shift: false },
w: { key: keys.KeyW, shift: false },
x: { key: keys.KeyX, shift: false },
y: { key: keys.KeyY, shift: false },
z: { key: keys.KeyZ, shift: false },
// Uppercase letters
A: { hidCode: keys.KeyA, shift: true },
B: { hidCode: keys.KeyB, shift: true },
C: { hidCode: keys.KeyC, shift: true },
D: { hidCode: keys.KeyD, shift: true },
E: { hidCode: keys.KeyE, shift: true },
F: { hidCode: keys.KeyF, shift: true },
G: { hidCode: keys.KeyG, shift: true },
H: { hidCode: keys.KeyH, shift: true },
I: { hidCode: keys.KeyI, shift: true },
J: { hidCode: keys.KeyJ, shift: true },
K: { hidCode: keys.KeyK, shift: true },
L: { hidCode: keys.KeyL, shift: true },
M: { hidCode: keys.KeyM, shift: true },
N: { hidCode: keys.KeyN, shift: true },
O: { hidCode: keys.KeyO, shift: true },
P: { hidCode: keys.KeyP, shift: true },
Q: { hidCode: keys.KeyQ, shift: true },
R: { hidCode: keys.KeyR, shift: true },
S: { hidCode: keys.KeyS, shift: true },
T: { hidCode: keys.KeyT, shift: true },
U: { hidCode: keys.KeyU, shift: true },
V: { hidCode: keys.KeyV, shift: true },
W: { hidCode: keys.KeyW, shift: true },
X: { hidCode: keys.KeyX, shift: true },
Y: { hidCode: keys.KeyY, shift: true },
Z: { hidCode: keys.KeyZ, shift: true },
A: { key: keys.KeyA, shift: true },
B: { key: keys.KeyB, shift: true },
C: { key: keys.KeyC, shift: true },
D: { key: keys.KeyD, shift: true },
E: { key: keys.KeyE, shift: true },
F: { key: keys.KeyF, shift: true },
G: { key: keys.KeyG, shift: true },
H: { key: keys.KeyH, shift: true },
I: { key: keys.KeyI, shift: true },
J: { key: keys.KeyJ, shift: true },
K: { key: keys.KeyK, shift: true },
L: { key: keys.KeyL, shift: true },
M: { key: keys.KeyM, shift: true },
N: { key: keys.KeyN, shift: true },
O: { key: keys.KeyO, shift: true },
P: { key: keys.KeyP, shift: true },
Q: { key: keys.KeyQ, shift: true },
R: { key: keys.KeyR, shift: true },
S: { key: keys.KeyS, shift: true },
T: { key: keys.KeyT, shift: true },
U: { key: keys.KeyU, shift: true },
V: { key: keys.KeyV, shift: true },
W: { key: keys.KeyW, shift: true },
X: { key: keys.KeyX, shift: true },
Y: { key: keys.KeyY, shift: true },
Z: { key: keys.KeyZ, shift: true },
// Number row
'0': { hidCode: keys.Digit0, shift: false },
'1': { hidCode: keys.Digit1, shift: false },
'2': { hidCode: keys.Digit2, shift: false },
'3': { hidCode: keys.Digit3, shift: false },
'4': { hidCode: keys.Digit4, shift: false },
'5': { hidCode: keys.Digit5, shift: false },
'6': { hidCode: keys.Digit6, shift: false },
'7': { hidCode: keys.Digit7, shift: false },
'8': { hidCode: keys.Digit8, shift: false },
'9': { hidCode: keys.Digit9, shift: false },
'0': { key: keys.Digit0, shift: false },
'1': { key: keys.Digit1, shift: false },
'2': { key: keys.Digit2, shift: false },
'3': { key: keys.Digit3, shift: false },
'4': { key: keys.Digit4, shift: false },
'5': { key: keys.Digit5, shift: false },
'6': { key: keys.Digit6, shift: false },
'7': { key: keys.Digit7, shift: false },
'8': { key: keys.Digit8, shift: false },
'9': { key: keys.Digit9, shift: false },
// Shifted number row symbols
')': { hidCode: keys.Digit0, shift: true },
'!': { hidCode: keys.Digit1, shift: true },
'@': { hidCode: keys.Digit2, shift: true },
'#': { hidCode: keys.Digit3, shift: true },
'$': { hidCode: keys.Digit4, shift: true },
'%': { hidCode: keys.Digit5, shift: true },
'^': { hidCode: keys.Digit6, shift: true },
'&': { hidCode: keys.Digit7, shift: true },
'*': { hidCode: keys.Digit8, shift: true },
'(': { hidCode: keys.Digit9, shift: true },
')': { key: keys.Digit0, shift: true },
'!': { key: keys.Digit1, shift: true },
'@': { key: keys.Digit2, shift: true },
'#': { key: keys.Digit3, shift: true },
'$': { key: keys.Digit4, shift: true },
'%': { key: keys.Digit5, shift: true },
'^': { key: keys.Digit6, shift: true },
'&': { key: keys.Digit7, shift: true },
'*': { key: keys.Digit8, shift: true },
'(': { key: keys.Digit9, shift: true },
// Punctuation and symbols
'-': { hidCode: keys.Minus, shift: false },
'=': { hidCode: keys.Equal, shift: false },
'[': { hidCode: keys.BracketLeft, shift: false },
']': { hidCode: keys.BracketRight, shift: false },
'\\': { hidCode: keys.Backslash, shift: false },
';': { hidCode: keys.Semicolon, shift: false },
"'": { hidCode: keys.Quote, shift: false },
'`': { hidCode: keys.Backquote, shift: false },
',': { hidCode: keys.Comma, shift: false },
'.': { hidCode: keys.Period, shift: false },
'/': { hidCode: keys.Slash, shift: false },
'-': { key: keys.Minus, shift: false },
'=': { key: keys.Equal, shift: false },
'[': { key: keys.BracketLeft, shift: false },
']': { key: keys.BracketRight, shift: false },
'\\': { key: keys.Backslash, shift: false },
';': { key: keys.Semicolon, shift: false },
"'": { key: keys.Quote, shift: false },
'`': { key: keys.Backquote, shift: false },
',': { key: keys.Comma, shift: false },
'.': { key: keys.Period, shift: false },
'/': { key: keys.Slash, shift: false },
// Shifted punctuation and symbols
_: { hidCode: keys.Minus, shift: true },
'+': { hidCode: keys.Equal, shift: true },
'{': { hidCode: keys.BracketLeft, shift: true },
'}': { hidCode: keys.BracketRight, shift: true },
'|': { hidCode: keys.Backslash, shift: true },
':': { hidCode: keys.Semicolon, shift: true },
'"': { hidCode: keys.Quote, shift: true },
'~': { hidCode: keys.Backquote, shift: true },
'<': { hidCode: keys.Comma, shift: true },
'>': { hidCode: keys.Period, shift: true },
'?': { hidCode: keys.Slash, shift: true },
_: { key: keys.Minus, shift: true },
'+': { key: keys.Equal, shift: true },
'{': { key: keys.BracketLeft, shift: true },
'}': { key: keys.BracketRight, shift: true },
'|': { key: keys.Backslash, shift: true },
':': { key: keys.Semicolon, shift: true },
'"': { key: keys.Quote, shift: true },
'~': { key: keys.Backquote, shift: true },
'<': { key: keys.Comma, shift: true },
'>': { key: keys.Period, shift: true },
'?': { key: keys.Slash, shift: true },
// Whitespace and control
' ': { hidCode: keys.Space, shift: false },
'\t': { hidCode: keys.Tab, shift: false },
'\n': { hidCode: keys.Enter, shift: false },
'\r': { hidCode: keys.Enter, shift: false },
' ': { key: keys.Space, shift: false },
'\t': { key: keys.Tab, shift: false },
'\n': { key: keys.Enter, shift: false },
'\r': { key: keys.Enter, shift: false },
}
/**
* Get HID usage code and modifier state for a character
* Get canonical key and modifier state for a character
* @param char - Single character to convert
* @returns CharKeyMapping or null if character is not mappable
*/

View File

@@ -1,77 +1,15 @@
// Keyboard layout definitions for virtual keyboard
// Virtual keyboard layout data shared by the on-screen keyboard.
export interface KeyboardLayout {
id: string
name: string
// Key display labels
keyLabels: Record<string, string>
// Shift variant labels (key in parentheses)
shiftLabels: Record<string, string>
// Virtual keyboard layout rows
layout: {
main: {
macros: string[]
functionRow: string[]
default: string[][]
shift: string[][]
}
control: string[][]
arrows: string[][]
media: string[] // Media keys row
}
}
// OS-specific keyboard layout type
export type KeyboardOsType = 'windows' | 'mac' | 'android'
// Bottom row layouts for different OS
export const osBottomRows: Record<KeyboardOsType, string[]> = {
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
// Android: simplified layout
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
}
// OS-specific modifier display names
export const osModifierLabels: Record<KeyboardOsType, Record<string, string>> = {
windows: {
ControlLeft: '^Ctrl',
ControlRight: 'Ctrl^',
MetaLeft: '⊞Win',
MetaRight: 'Win⊞',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
Menu: 'Menu',
},
mac: {
ControlLeft: '^Ctrl',
ControlRight: 'Ctrl^',
MetaLeft: '⌘Cmd',
MetaRight: 'Cmd⌘',
AltLeft: '⌥Opt',
AltRight: 'Opt⌥',
AltGr: '⌥Opt',
Menu: 'Menu',
},
android: {
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
MetaLeft: 'Meta',
MetaRight: 'Meta',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'Alt',
Menu: 'Menu',
},
}
// Media keys (Consumer Control)
export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
// Media key display names
export const mediaKeyLabels: Record<string, string> = {
PlayPause: '⏯',
Stop: '⏹',
@@ -81,158 +19,3 @@ export const mediaKeyLabels: Record<string, string> = {
VolumeUp: '🔊',
VolumeDown: '🔉',
}
// English US Layout
export const enUSLayout: KeyboardLayout = {
id: 'en-US',
name: 'English (US)',
keyLabels: {
// Macros
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Back',
// Modifiers
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
AltGr: 'AltGr',
MetaLeft: 'Meta',
MetaRight: 'Meta',
// Special keys
Escape: 'Esc',
Backspace: 'Back',
Tab: 'Tab',
CapsLock: 'Caps',
Enter: 'Enter',
Space: ' ',
Menu: 'Menu',
// Navigation
Insert: 'Ins',
Delete: 'Del',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn',
// Arrows
ArrowUp: '\u2191',
ArrowDown: '\u2193',
ArrowLeft: '\u2190',
ArrowRight: '\u2192',
// Control cluster
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
// Function keys
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters
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',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
// Symbols
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Backquote: '`',
Comma: ',',
Period: '.',
Slash: '/',
},
shiftLabels: {
// Capital letters
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',
// Shifted numbers
Digit1: '!', Digit2: '@', Digit3: '#', Digit4: '$', Digit5: '%',
Digit6: '^', Digit7: '&', Digit8: '*', Digit9: '(', Digit0: ')',
// Shifted symbols
Minus: '_',
Equal: '+',
BracketLeft: '{',
BracketRight: '}',
Backslash: '|',
Semicolon: ':',
Quote: '"',
Backquote: '~',
Comma: '<',
Period: '>',
Slash: '?',
},
layout: {
main: {
macros: ['CtrlAltDelete', 'AltMetaEscape', 'CtrlAltBackspace'],
functionRow: ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'],
default: [
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
],
shift: [
['Backquote', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', 'Minus', 'Equal', 'Backspace'],
['Tab', 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', 'BracketLeft', 'BracketRight', 'Backslash'],
['CapsLock', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon', 'Quote', 'Enter'],
['ShiftLeft', 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', 'Comma', 'Period', 'Slash', 'ShiftRight'],
['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
],
},
control: [
['PrintScreen', 'ScrollLock', 'Pause'],
['Insert', 'Home', 'PageUp'],
['Delete', 'End', 'PageDown'],
],
arrows: [
['ArrowUp'],
['ArrowLeft', 'ArrowDown', 'ArrowRight'],
],
media: ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp'],
},
}
// All available layouts
export const keyboardLayouts: Record<string, KeyboardLayout> = {
'en-US': enUSLayout,
}
// Get layout by ID or return default
export function getKeyboardLayout(id: string): KeyboardLayout {
return keyboardLayouts[id] || enUSLayout
}
// Get key label for display
export function getKeyLabel(layout: KeyboardLayout, keyName: string, isShift: boolean): string {
if (isShift && layout.shiftLabels[keyName]) {
return layout.shiftLabels[keyName]
}
return layout.keyLabels[keyName] || keyName
}

View File

@@ -1,157 +1,128 @@
// Key codes and modifiers correspond to definitions in the
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf)
import { CanonicalKey } from '@/types/generated'
export const keys = {
// Letters
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
// Numbers
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
// Control keys
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
// Symbols
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
// Lock keys
CapsLock: 0x39,
// Function keys
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
// Control cluster
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
// Arrow keys
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
// Numpad
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
// Non-US keys
IntlBackslash: 0x64,
ContextMenu: 0x65,
Menu: 0x65,
Application: 0x65,
// Extended function keys
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
F17: 0x6c,
F18: 0x6d,
F19: 0x6e,
F20: 0x6f,
F21: 0x70,
F22: 0x71,
F23: 0x72,
F24: 0x73,
// Modifiers (these are special - HID codes 0xE0-0xE7)
ControlLeft: 0xe0,
ShiftLeft: 0xe1,
AltLeft: 0xe2,
MetaLeft: 0xe3,
ControlRight: 0xe4,
ShiftRight: 0xe5,
AltRight: 0xe6,
AltGr: 0xe6,
MetaRight: 0xe7,
KeyA: CanonicalKey.KeyA,
KeyB: CanonicalKey.KeyB,
KeyC: CanonicalKey.KeyC,
KeyD: CanonicalKey.KeyD,
KeyE: CanonicalKey.KeyE,
KeyF: CanonicalKey.KeyF,
KeyG: CanonicalKey.KeyG,
KeyH: CanonicalKey.KeyH,
KeyI: CanonicalKey.KeyI,
KeyJ: CanonicalKey.KeyJ,
KeyK: CanonicalKey.KeyK,
KeyL: CanonicalKey.KeyL,
KeyM: CanonicalKey.KeyM,
KeyN: CanonicalKey.KeyN,
KeyO: CanonicalKey.KeyO,
KeyP: CanonicalKey.KeyP,
KeyQ: CanonicalKey.KeyQ,
KeyR: CanonicalKey.KeyR,
KeyS: CanonicalKey.KeyS,
KeyT: CanonicalKey.KeyT,
KeyU: CanonicalKey.KeyU,
KeyV: CanonicalKey.KeyV,
KeyW: CanonicalKey.KeyW,
KeyX: CanonicalKey.KeyX,
KeyY: CanonicalKey.KeyY,
KeyZ: CanonicalKey.KeyZ,
Digit1: CanonicalKey.Digit1,
Digit2: CanonicalKey.Digit2,
Digit3: CanonicalKey.Digit3,
Digit4: CanonicalKey.Digit4,
Digit5: CanonicalKey.Digit5,
Digit6: CanonicalKey.Digit6,
Digit7: CanonicalKey.Digit7,
Digit8: CanonicalKey.Digit8,
Digit9: CanonicalKey.Digit9,
Digit0: CanonicalKey.Digit0,
Enter: CanonicalKey.Enter,
Escape: CanonicalKey.Escape,
Backspace: CanonicalKey.Backspace,
Tab: CanonicalKey.Tab,
Space: CanonicalKey.Space,
Minus: CanonicalKey.Minus,
Equal: CanonicalKey.Equal,
BracketLeft: CanonicalKey.BracketLeft,
BracketRight: CanonicalKey.BracketRight,
Backslash: CanonicalKey.Backslash,
Semicolon: CanonicalKey.Semicolon,
Quote: CanonicalKey.Quote,
Backquote: CanonicalKey.Backquote,
Comma: CanonicalKey.Comma,
Period: CanonicalKey.Period,
Slash: CanonicalKey.Slash,
CapsLock: CanonicalKey.CapsLock,
F1: CanonicalKey.F1,
F2: CanonicalKey.F2,
F3: CanonicalKey.F3,
F4: CanonicalKey.F4,
F5: CanonicalKey.F5,
F6: CanonicalKey.F6,
F7: CanonicalKey.F7,
F8: CanonicalKey.F8,
F9: CanonicalKey.F9,
F10: CanonicalKey.F10,
F11: CanonicalKey.F11,
F12: CanonicalKey.F12,
PrintScreen: CanonicalKey.PrintScreen,
ScrollLock: CanonicalKey.ScrollLock,
Pause: CanonicalKey.Pause,
Insert: CanonicalKey.Insert,
Home: CanonicalKey.Home,
PageUp: CanonicalKey.PageUp,
Delete: CanonicalKey.Delete,
End: CanonicalKey.End,
PageDown: CanonicalKey.PageDown,
ArrowRight: CanonicalKey.ArrowRight,
ArrowLeft: CanonicalKey.ArrowLeft,
ArrowDown: CanonicalKey.ArrowDown,
ArrowUp: CanonicalKey.ArrowUp,
NumLock: CanonicalKey.NumLock,
NumpadDivide: CanonicalKey.NumpadDivide,
NumpadMultiply: CanonicalKey.NumpadMultiply,
NumpadSubtract: CanonicalKey.NumpadSubtract,
NumpadAdd: CanonicalKey.NumpadAdd,
NumpadEnter: CanonicalKey.NumpadEnter,
Numpad1: CanonicalKey.Numpad1,
Numpad2: CanonicalKey.Numpad2,
Numpad3: CanonicalKey.Numpad3,
Numpad4: CanonicalKey.Numpad4,
Numpad5: CanonicalKey.Numpad5,
Numpad6: CanonicalKey.Numpad6,
Numpad7: CanonicalKey.Numpad7,
Numpad8: CanonicalKey.Numpad8,
Numpad9: CanonicalKey.Numpad9,
Numpad0: CanonicalKey.Numpad0,
NumpadDecimal: CanonicalKey.NumpadDecimal,
IntlBackslash: CanonicalKey.IntlBackslash,
ContextMenu: CanonicalKey.ContextMenu,
F13: CanonicalKey.F13,
F14: CanonicalKey.F14,
F15: CanonicalKey.F15,
F16: CanonicalKey.F16,
F17: CanonicalKey.F17,
F18: CanonicalKey.F18,
F19: CanonicalKey.F19,
F20: CanonicalKey.F20,
F21: CanonicalKey.F21,
F22: CanonicalKey.F22,
F23: CanonicalKey.F23,
F24: CanonicalKey.F24,
ControlLeft: CanonicalKey.ControlLeft,
ShiftLeft: CanonicalKey.ShiftLeft,
AltLeft: CanonicalKey.AltLeft,
MetaLeft: CanonicalKey.MetaLeft,
ControlRight: CanonicalKey.ControlRight,
ShiftRight: CanonicalKey.ShiftRight,
AltRight: CanonicalKey.AltRight,
MetaRight: CanonicalKey.MetaRight,
} as const
export type KeyName = keyof typeof keys
// Consumer Control Usage codes (for multimedia keys)
// These are sent via a separate Consumer Control HID report
export const consumerKeys = {
PlayPause: 0x00cd,
Stop: 0x00b7,
@@ -164,69 +135,153 @@ export const consumerKeys = {
export type ConsumerKeyName = keyof typeof consumerKeys
// Modifier bitmasks for HID report byte 0
export const modifiers = {
ControlLeft: 0x01,
ShiftLeft: 0x02,
AltLeft: 0x04,
MetaLeft: 0x08,
ControlRight: 0x10,
ShiftRight: 0x20,
AltRight: 0x40,
AltGr: 0x40,
MetaRight: 0x80,
} as const
export type ModifierName = keyof typeof modifiers
// Map HID key codes to modifier bitmasks
export const hidKeyToModifierMask: Record<number, number> = {
0xe0: 0x01, // ControlLeft
0xe1: 0x02, // ShiftLeft
0xe2: 0x04, // AltLeft
0xe3: 0x08, // MetaLeft
0xe4: 0x10, // ControlRight
0xe5: 0x20, // ShiftRight
0xe6: 0x40, // AltRight
0xe7: 0x80, // MetaRight
export const modifiers: Partial<Record<CanonicalKey, number>> = {
[CanonicalKey.ControlLeft]: 0x01,
[CanonicalKey.ShiftLeft]: 0x02,
[CanonicalKey.AltLeft]: 0x04,
[CanonicalKey.MetaLeft]: 0x08,
[CanonicalKey.ControlRight]: 0x10,
[CanonicalKey.ShiftRight]: 0x20,
[CanonicalKey.AltRight]: 0x40,
[CanonicalKey.MetaRight]: 0x80,
}
// Update modifier mask when a HID modifier key is pressed/released.
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number {
const bit = hidKeyToModifierMask[hidKey] ?? 0
export const keyToHidUsage = {
[CanonicalKey.KeyA]: 0x04,
[CanonicalKey.KeyB]: 0x05,
[CanonicalKey.KeyC]: 0x06,
[CanonicalKey.KeyD]: 0x07,
[CanonicalKey.KeyE]: 0x08,
[CanonicalKey.KeyF]: 0x09,
[CanonicalKey.KeyG]: 0x0a,
[CanonicalKey.KeyH]: 0x0b,
[CanonicalKey.KeyI]: 0x0c,
[CanonicalKey.KeyJ]: 0x0d,
[CanonicalKey.KeyK]: 0x0e,
[CanonicalKey.KeyL]: 0x0f,
[CanonicalKey.KeyM]: 0x10,
[CanonicalKey.KeyN]: 0x11,
[CanonicalKey.KeyO]: 0x12,
[CanonicalKey.KeyP]: 0x13,
[CanonicalKey.KeyQ]: 0x14,
[CanonicalKey.KeyR]: 0x15,
[CanonicalKey.KeyS]: 0x16,
[CanonicalKey.KeyT]: 0x17,
[CanonicalKey.KeyU]: 0x18,
[CanonicalKey.KeyV]: 0x19,
[CanonicalKey.KeyW]: 0x1a,
[CanonicalKey.KeyX]: 0x1b,
[CanonicalKey.KeyY]: 0x1c,
[CanonicalKey.KeyZ]: 0x1d,
[CanonicalKey.Digit1]: 0x1e,
[CanonicalKey.Digit2]: 0x1f,
[CanonicalKey.Digit3]: 0x20,
[CanonicalKey.Digit4]: 0x21,
[CanonicalKey.Digit5]: 0x22,
[CanonicalKey.Digit6]: 0x23,
[CanonicalKey.Digit7]: 0x24,
[CanonicalKey.Digit8]: 0x25,
[CanonicalKey.Digit9]: 0x26,
[CanonicalKey.Digit0]: 0x27,
[CanonicalKey.Enter]: 0x28,
[CanonicalKey.Escape]: 0x29,
[CanonicalKey.Backspace]: 0x2a,
[CanonicalKey.Tab]: 0x2b,
[CanonicalKey.Space]: 0x2c,
[CanonicalKey.Minus]: 0x2d,
[CanonicalKey.Equal]: 0x2e,
[CanonicalKey.BracketLeft]: 0x2f,
[CanonicalKey.BracketRight]: 0x30,
[CanonicalKey.Backslash]: 0x31,
[CanonicalKey.Semicolon]: 0x33,
[CanonicalKey.Quote]: 0x34,
[CanonicalKey.Backquote]: 0x35,
[CanonicalKey.Comma]: 0x36,
[CanonicalKey.Period]: 0x37,
[CanonicalKey.Slash]: 0x38,
[CanonicalKey.CapsLock]: 0x39,
[CanonicalKey.F1]: 0x3a,
[CanonicalKey.F2]: 0x3b,
[CanonicalKey.F3]: 0x3c,
[CanonicalKey.F4]: 0x3d,
[CanonicalKey.F5]: 0x3e,
[CanonicalKey.F6]: 0x3f,
[CanonicalKey.F7]: 0x40,
[CanonicalKey.F8]: 0x41,
[CanonicalKey.F9]: 0x42,
[CanonicalKey.F10]: 0x43,
[CanonicalKey.F11]: 0x44,
[CanonicalKey.F12]: 0x45,
[CanonicalKey.PrintScreen]: 0x46,
[CanonicalKey.ScrollLock]: 0x47,
[CanonicalKey.Pause]: 0x48,
[CanonicalKey.Insert]: 0x49,
[CanonicalKey.Home]: 0x4a,
[CanonicalKey.PageUp]: 0x4b,
[CanonicalKey.Delete]: 0x4c,
[CanonicalKey.End]: 0x4d,
[CanonicalKey.PageDown]: 0x4e,
[CanonicalKey.ArrowRight]: 0x4f,
[CanonicalKey.ArrowLeft]: 0x50,
[CanonicalKey.ArrowDown]: 0x51,
[CanonicalKey.ArrowUp]: 0x52,
[CanonicalKey.NumLock]: 0x53,
[CanonicalKey.NumpadDivide]: 0x54,
[CanonicalKey.NumpadMultiply]: 0x55,
[CanonicalKey.NumpadSubtract]: 0x56,
[CanonicalKey.NumpadAdd]: 0x57,
[CanonicalKey.NumpadEnter]: 0x58,
[CanonicalKey.Numpad1]: 0x59,
[CanonicalKey.Numpad2]: 0x5a,
[CanonicalKey.Numpad3]: 0x5b,
[CanonicalKey.Numpad4]: 0x5c,
[CanonicalKey.Numpad5]: 0x5d,
[CanonicalKey.Numpad6]: 0x5e,
[CanonicalKey.Numpad7]: 0x5f,
[CanonicalKey.Numpad8]: 0x60,
[CanonicalKey.Numpad9]: 0x61,
[CanonicalKey.Numpad0]: 0x62,
[CanonicalKey.NumpadDecimal]: 0x63,
[CanonicalKey.IntlBackslash]: 0x64,
[CanonicalKey.ContextMenu]: 0x65,
[CanonicalKey.F13]: 0x68,
[CanonicalKey.F14]: 0x69,
[CanonicalKey.F15]: 0x6a,
[CanonicalKey.F16]: 0x6b,
[CanonicalKey.F17]: 0x6c,
[CanonicalKey.F18]: 0x6d,
[CanonicalKey.F19]: 0x6e,
[CanonicalKey.F20]: 0x6f,
[CanonicalKey.F21]: 0x70,
[CanonicalKey.F22]: 0x71,
[CanonicalKey.F23]: 0x72,
[CanonicalKey.F24]: 0x73,
[CanonicalKey.ControlLeft]: 0xe0,
[CanonicalKey.ShiftLeft]: 0xe1,
[CanonicalKey.AltLeft]: 0xe2,
[CanonicalKey.MetaLeft]: 0xe3,
[CanonicalKey.ControlRight]: 0xe4,
[CanonicalKey.ShiftRight]: 0xe5,
[CanonicalKey.AltRight]: 0xe6,
[CanonicalKey.MetaRight]: 0xe7,
} as const satisfies Record<CanonicalKey, number>
export function canonicalKeyToHidUsage(key: CanonicalKey): number {
return keyToHidUsage[key]
}
export function updateModifierMaskForKey(mask: number, key: CanonicalKey, press: boolean): number {
const bit = modifiers[key] ?? 0
if (bit === 0) return mask
return press ? (mask | bit) : (mask & ~bit)
}
// Keys that latch (toggle state) instead of being held
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const
// Modifier key names
export const modifierKeyNames = [
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'AltGr',
'MetaLeft',
'MetaRight',
export const latchingKeys = [
CanonicalKey.CapsLock,
CanonicalKey.ScrollLock,
CanonicalKey.NumLock,
] as const
// Check if a key is a modifier
export function isModifierKey(keyName: string): keyName is ModifierName {
return keyName in modifiers
}
// Get modifier bitmask for a key name
export function getModifierMask(keyName: string): number {
if (keyName in modifiers) {
return modifiers[keyName as ModifierName]
}
return 0
}
// Normalize browser-specific KeyboardEvent.code variants.
export function normalizeKeyboardCode(code: string, key: string): string {
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
@@ -238,18 +293,10 @@ export function normalizeKeyboardCode(code: string, key: string): string {
return code
}
// Convert KeyboardEvent.code/key to USB HID usage code.
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
export function keyboardEventToCanonicalKey(code: string, key: string): CanonicalKey | undefined {
const normalizedCode = normalizeKeyboardCode(code, key)
if (normalizedCode in keys) {
return keys[normalizedCode as KeyName]
}
// Decode modifier byte into individual states
export function decodeModifiers(modifier: number) {
return {
isShiftActive: (modifier & 0x22) !== 0, // ShiftLeft | ShiftRight
isControlActive: (modifier & 0x11) !== 0, // ControlLeft | ControlRight
isAltActive: (modifier & 0x44) !== 0, // AltLeft | AltRight
isMetaActive: (modifier & 0x88) !== 0, // MetaLeft | MetaRight
}
return undefined
}

View File

@@ -118,7 +118,7 @@ export enum AtxDriverType {
Gpio = "gpio",
/** USB HID relay module */
UsbRelay = "usbrelay",
/** Serial/COM port relay (LCUS type) */
/** Serial/COM port relay (taobao LCUS type) */
Serial = "serial",
/** Disabled / Not configured */
None = "none",
@@ -149,6 +149,7 @@ export interface AtxKeyConfig {
* Pin or channel number:
* - For GPIO: GPIO pin number
* - For USB Relay: relay channel (0-based)
* - For Serial Relay (LCUS): relay channel (1-based)
*/
pin: number;
/** Active level (only applicable to GPIO, ignored for USB Relay) */
@@ -444,11 +445,11 @@ export interface AtxConfigUpdate {
/** Available ATX devices for discovery */
export interface AtxDevices {
/** Available GPIO chips (/dev/gpiochip*) */
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
gpio_chips: string[];
/** Available USB HID relay devices (/dev/hidraw*) */
usb_relays: string[];
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
}
export interface AudioConfigUpdate {
@@ -623,19 +624,19 @@ export interface RustDeskConfigUpdate {
device_password?: string;
}
/** Stream 配置响应(包含 has_turn_password 字段) */
/** Stream configuration response (includes has_turn_password) */
export interface StreamConfigResponse {
mode: StreamMode;
encoder: EncoderType;
bitrate_preset: BitratePreset;
/** 是否有公共 ICE 服务器可用(编译时确定) */
/** Whether public ICE servers are available (compile-time decision) */
has_public_ice_servers: boolean;
/** 当前是否正在使用公共 ICE 服务器STUN/TURN 都为空时) */
/** Whether public ICE servers are currently in use (when STUN/TURN are unset) */
using_public_ice_servers: boolean;
stun_server?: string;
turn_server?: string;
turn_username?: string;
/** 指示是否已设置 TURN 密码(实际密码不返回) */
/** Indicates whether TURN password has been configured (password is not returned) */
has_turn_password: boolean;
}
@@ -688,3 +689,130 @@ export interface WebConfigUpdate {
bind_address?: string;
https_enabled?: boolean;
}
/**
* Shared canonical keyboard key identifiers used across frontend and backend.
*
* The enum names intentionally mirror `KeyboardEvent.code` style values so the
* browser, virtual keyboard, and HID backend can all speak the same language.
*/
export enum CanonicalKey {
KeyA = "KeyA",
KeyB = "KeyB",
KeyC = "KeyC",
KeyD = "KeyD",
KeyE = "KeyE",
KeyF = "KeyF",
KeyG = "KeyG",
KeyH = "KeyH",
KeyI = "KeyI",
KeyJ = "KeyJ",
KeyK = "KeyK",
KeyL = "KeyL",
KeyM = "KeyM",
KeyN = "KeyN",
KeyO = "KeyO",
KeyP = "KeyP",
KeyQ = "KeyQ",
KeyR = "KeyR",
KeyS = "KeyS",
KeyT = "KeyT",
KeyU = "KeyU",
KeyV = "KeyV",
KeyW = "KeyW",
KeyX = "KeyX",
KeyY = "KeyY",
KeyZ = "KeyZ",
Digit1 = "Digit1",
Digit2 = "Digit2",
Digit3 = "Digit3",
Digit4 = "Digit4",
Digit5 = "Digit5",
Digit6 = "Digit6",
Digit7 = "Digit7",
Digit8 = "Digit8",
Digit9 = "Digit9",
Digit0 = "Digit0",
Enter = "Enter",
Escape = "Escape",
Backspace = "Backspace",
Tab = "Tab",
Space = "Space",
Minus = "Minus",
Equal = "Equal",
BracketLeft = "BracketLeft",
BracketRight = "BracketRight",
Backslash = "Backslash",
Semicolon = "Semicolon",
Quote = "Quote",
Backquote = "Backquote",
Comma = "Comma",
Period = "Period",
Slash = "Slash",
CapsLock = "CapsLock",
F1 = "F1",
F2 = "F2",
F3 = "F3",
F4 = "F4",
F5 = "F5",
F6 = "F6",
F7 = "F7",
F8 = "F8",
F9 = "F9",
F10 = "F10",
F11 = "F11",
F12 = "F12",
PrintScreen = "PrintScreen",
ScrollLock = "ScrollLock",
Pause = "Pause",
Insert = "Insert",
Home = "Home",
PageUp = "PageUp",
Delete = "Delete",
End = "End",
PageDown = "PageDown",
ArrowRight = "ArrowRight",
ArrowLeft = "ArrowLeft",
ArrowDown = "ArrowDown",
ArrowUp = "ArrowUp",
NumLock = "NumLock",
NumpadDivide = "NumpadDivide",
NumpadMultiply = "NumpadMultiply",
NumpadSubtract = "NumpadSubtract",
NumpadAdd = "NumpadAdd",
NumpadEnter = "NumpadEnter",
Numpad1 = "Numpad1",
Numpad2 = "Numpad2",
Numpad3 = "Numpad3",
Numpad4 = "Numpad4",
Numpad5 = "Numpad5",
Numpad6 = "Numpad6",
Numpad7 = "Numpad7",
Numpad8 = "Numpad8",
Numpad9 = "Numpad9",
Numpad0 = "Numpad0",
NumpadDecimal = "NumpadDecimal",
IntlBackslash = "IntlBackslash",
ContextMenu = "ContextMenu",
F13 = "F13",
F14 = "F14",
F15 = "F15",
F16 = "F16",
F17 = "F17",
F18 = "F18",
F19 = "F19",
F20 = "F20",
F21 = "F21",
F22 = "F22",
F23 = "F23",
F24 = "F24",
ControlLeft = "ControlLeft",
ShiftLeft = "ShiftLeft",
AltLeft = "AltLeft",
MetaLeft = "MetaLeft",
ControlRight = "ControlRight",
ShiftRight = "ShiftRight",
AltRight = "AltRight",
MetaRight = "MetaRight",
}

View File

@@ -1,10 +1,13 @@
// HID (Human Interface Device) type definitions
// Shared between WebRTC DataChannel and WebSocket HID channels
import { type CanonicalKey } from '@/types/generated'
import { canonicalKeyToHidUsage } from '@/lib/keyboardMappings'
/** Keyboard event for HID input */
export interface HidKeyboardEvent {
type: 'keydown' | 'keyup'
key: number
key: CanonicalKey
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
modifier?: number
}
@@ -51,7 +54,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
view.setUint8(0, MSG_KEYBOARD)
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
view.setUint8(2, event.key & 0xff)
view.setUint8(2, canonicalKeyToHidUsage(event.key) & 0xff)
view.setUint8(3, (event.modifier ?? 0) & 0xff)

View File

@@ -12,8 +12,9 @@ import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
@@ -117,7 +118,7 @@ const myClientId = generateUUID()
// HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<string[]>([])
const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = ref({
capsLock: false,
})
@@ -1539,7 +1540,7 @@ function handleHidError(_error: any, _operation: string) {
}
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
function sendKeyboardEvent(type: 'down' | 'up', key: number, modifier?: number) {
function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: number) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidKeyboardEvent = {
@@ -1618,22 +1619,21 @@ function handleKeyDown(e: KeyboardEvent) {
})
}
const keyName = e.key === ' ' ? 'Space' : e.key
if (!pressedKeys.value.includes(keyName)) {
pressedKeys.value = [...pressedKeys.value, keyName]
}
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
if (!pressedKeys.value.includes(canonicalKey)) {
pressedKeys.value = [...pressedKeys.value, canonicalKey]
}
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, true)
activeModifierMask.value = modifierMask
sendKeyboardEvent('down', hidKey, modifierMask)
sendKeyboardEvent('down', canonicalKey, modifierMask)
}
function handleKeyUp(e: KeyboardEvent) {
@@ -1649,18 +1649,17 @@ function handleKeyUp(e: KeyboardEvent) {
e.stopPropagation()
}
const keyName = e.key === ' ' ? 'Space' : e.key
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
activeModifierMask.value = modifierMask
sendKeyboardEvent('up', hidKey, modifierMask)
sendKeyboardEvent('up', canonicalKey, modifierMask)
}
function getActiveVideoElement(): HTMLImageElement | HTMLVideoElement | null {
@@ -2016,18 +2015,18 @@ function handleToggleVirtualKeyboard() {
}
// Virtual keyboard key event handlers
function handleVirtualKeyDown(key: string) {
function handleVirtualKeyDown(key: CanonicalKey) {
// Add to pressedKeys for InfoBar display
if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key]
}
// Toggle CapsLock state when virtual keyboard presses CapsLock
if (key === 'CapsLock') {
if (key === CanonicalKey.CapsLock) {
keyboardLed.value.capsLock = !keyboardLed.value.capsLock
}
}
function handleVirtualKeyUp(key: string) {
function handleVirtualKeyUp(key: CanonicalKey) {
// Remove from pressedKeys
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
@@ -2485,6 +2484,8 @@ onUnmounted(() => {
v-if="virtualKeyboardVisible"
v-model:visible="virtualKeyboardVisible"
v-model:attached="virtualKeyboardAttached"
:caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys"
@key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp"
/>