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

View File

@@ -9,7 +9,7 @@
//! //!
//! Keyboard event (type 0x01): //! Keyboard event (type 0x01):
//! - Byte 1: Event type (0x00 = down, 0x01 = up) //! - 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 //! - Byte 3: Modifiers bitmask
//! - Bit 0: Left Ctrl //! - Bit 0: Left Ctrl
//! - Bit 1: Left Shift //! - Bit 1: Left Shift
@@ -38,7 +38,8 @@ use tracing::warn;
use super::types::ConsumerEvent; use super::types::ConsumerEvent;
use super::{ use super::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType, CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
MouseEventType,
}; };
/// Message types /// 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_byte = data[2];
let modifiers = KeyboardModifiers { let modifiers = KeyboardModifiers {
@@ -119,7 +126,6 @@ fn parse_keyboard_message(data: &[u8]) -> Option<HidChannelEvent> {
event_type, event_type,
key, key,
modifiers, 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(); 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) /// Encode a mouse event to binary format (for sending to client if needed)
@@ -242,10 +248,9 @@ mod tests {
match event { match event {
HidChannelEvent::Keyboard(kb) => { HidChannelEvent::Keyboard(kb) => {
assert!(matches!(kb.event_type, KeyEventType::Down)); 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_ctrl);
assert!(!kb.modifiers.left_shift); assert!(!kb.modifiers.left_shift);
assert!(kb.is_usb_hid);
} }
_ => panic!("Expected keyboard event"), _ => panic!("Expected keyboard event"),
} }
@@ -270,7 +275,7 @@ mod tests {
fn test_encode_keyboard() { fn test_encode_keyboard() {
let event = KeyboardEvent { let event = KeyboardEvent {
event_type: KeyEventType::Down, event_type: KeyEventType::Down,
key: 0x04, key: CanonicalKey::KeyA,
modifiers: KeyboardModifiers { modifiers: KeyboardModifiers {
left_ctrl: true, left_ctrl: true,
left_shift: false, left_shift: false,
@@ -281,7 +286,6 @@ mod tests {
right_alt: false, right_alt: false,
right_meta: false, right_meta: false,
}, },
is_usb_hid: true,
}; };
let encoded = encode_keyboard_event(&event); 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 ch9329;
pub mod consumer; pub mod consumer;
pub mod datachannel; pub mod datachannel;
pub mod keymap; pub mod keyboard;
pub mod otg; pub mod otg;
pub mod types; pub mod types;
pub mod websocket; pub mod websocket;
pub use backend::{HidBackend, HidBackendStatus, HidBackendType}; pub use backend::{HidBackend, HidBackendStatus, HidBackendType};
pub use keyboard::CanonicalKey;
pub use otg::LedState; pub use otg::LedState;
pub use types::{ pub use types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, 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 tracing::{debug, info, trace, warn};
use super::backend::{HidBackend, HidBackendStatus}; use super::backend::{HidBackend, HidBackendStatus};
use super::keymap;
use super::types::{ use super::types::{
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType, ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
}; };
@@ -873,18 +872,13 @@ impl HidBackend for OtgBackend {
} }
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> { async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
// Convert JS keycode to USB HID if needed (skip if already USB HID) let usb_key = event.key.to_hid_usage();
let usb_key = if event.is_usb_hid {
event.key
} else {
keymap::js_to_usb(event.key).unwrap_or(event.key)
};
// Handle modifier keys separately // Handle modifier keys separately
if keymap::is_modifier_key(usb_key) { if event.key.is_modifier() {
let mut state = self.keyboard_state.lock(); 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 { match event.event_type {
KeyEventType::Down => state.modifiers |= bit, KeyEventType::Down => state.modifiers |= bit,
KeyEventType::Up => state.modifiers &= !bit, KeyEventType::Up => state.modifiers &= !bit,

View File

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

View File

@@ -22,7 +22,7 @@ use tokio::sync::{broadcast, mpsc, Mutex};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::audio::AudioController; use crate::audio::AudioController;
use crate::hid::{HidController, KeyEventType, KeyboardEvent, KeyboardModifiers}; use crate::hid::{CanonicalKey, HidController, KeyEventType, KeyboardEvent, KeyboardModifiers};
use crate::video::codec_constraints::{ use crate::video::codec_constraints::{
encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec, encoder_codec_to_id, encoder_codec_to_video_codec, video_codec_to_encoder_codec,
}; };
@@ -1328,15 +1328,13 @@ impl Connection {
); );
let caps_down = KeyboardEvent { let caps_down = KeyboardEvent {
event_type: KeyEventType::Down, event_type: KeyEventType::Down,
key: 0x39, // USB HID CapsLock key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(), modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
}; };
let caps_up = KeyboardEvent { let caps_up = KeyboardEvent {
event_type: KeyEventType::Up, event_type: KeyEventType::Up,
key: 0x39, key: CanonicalKey::CapsLock,
modifiers: KeyboardModifiers::default(), modifiers: KeyboardModifiers::default(),
is_usb_hid: true,
}; };
if let Err(e) = hid.send_keyboard(caps_down).await { if let Err(e) = hid.send_keyboard(caps_down).await {
warn!("Failed to send CapsLock down: {}", e); warn!("Failed to send CapsLock down: {}", e);
@@ -1351,7 +1349,7 @@ impl Connection {
if let Some(kb_event) = convert_key_event(ke) { if let Some(kb_event) = convert_key_event(ke) {
debug!( debug!(
"Converted to HID: key=0x{:02X}, event_type={:?}, modifiers={:02X}", "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.event_type,
kb_event.modifiers.to_hid_byte() 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::hbb::message::key_event as ke_union;
use super::protocol::{ControlKey, KeyEvent, MouseEvent}; use super::protocol::{ControlKey, KeyEvent, MouseEvent};
use crate::hid::{ use crate::hid::{
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent as OneKvmMouseEvent, CanonicalKey, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton,
MouseEventType, MouseEvent as OneKvmMouseEvent, MouseEventType,
}; };
use protobuf::Enum; use protobuf::Enum;
@@ -217,11 +217,11 @@ pub fn convert_key_event(event: &KeyEvent) -> Option<KeyboardEvent> {
// Handle control keys // Handle control keys
if let Some(ke_union::Union::ControlKey(ck)) = &event.union { if let Some(ke_union::Union::ControlKey(ck)) = &event.union {
if let Some(key) = control_key_to_hid(ck.value()) { if let Some(key) = control_key_to_hid(ck.value()) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent { return Some(KeyboardEvent {
event_type, event_type,
key, key,
modifiers, 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 { if let Some(ke_union::Union::Chr(chr)) = &event.union {
// chr contains USB HID scancode on Windows, X11 keycode on Linux // chr contains USB HID scancode on Windows, X11 keycode on Linux
if let Some(key) = keycode_to_hid(*chr) { if let Some(key) = keycode_to_hid(*chr) {
let key = CanonicalKey::from_hid_usage(key)?;
return Some(KeyboardEvent { return Some(KeyboardEvent {
event_type, event_type,
key, key,
modifiers, modifiers,
is_usb_hid: true, // Already converted to USB HID code
}); });
} }
} }
@@ -608,6 +608,6 @@ mod tests {
let kb_event = result.unwrap(); let kb_event = result.unwrap();
assert_eq!(kb_event.event_type, KeyEventType::Down); 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 // API client for One-KVM backend
import { request, ApiError } from './request' import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
const API_BASE = '/api' const API_BASE = '/api'
@@ -357,7 +358,7 @@ export const hidApi = {
}> }>
}>('/hid/otg/self-check'), }>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => { keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
await ensureHidConnection() await ensureHidConnection()
const event: HidKeyboardEvent = { const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup', type: type === 'down' ? 'keydown' : 'keyup',

View File

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

View File

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

View File

@@ -4,12 +4,13 @@ import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard' import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css' import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api' import { hidApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import { import {
keys, keys,
consumerKeys, consumerKeys,
latchingKeys, latchingKeys,
modifiers, modifiers,
updateModifierMaskForHidKey, updateModifierMaskForKey,
type KeyName, type KeyName,
type ConsumerKeyName, type ConsumerKeyName,
} from '@/lib/keyboardMappings' } from '@/lib/keyboardMappings'
@@ -23,13 +24,15 @@ import {
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
attached?: boolean attached?: boolean
capsLock?: boolean
pressedKeys?: CanonicalKey[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:visible', value: boolean): void (e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void (e: 'update:attached', value: boolean): void
(e: 'keyDown', key: string): void (e: 'keyDown', key: CanonicalKey): void
(e: 'keyUp', key: string): void (e: 'keyUp', key: CanonicalKey): void
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -45,13 +48,17 @@ const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking // Pressed keys tracking
const pressedModifiers = ref<number>(0) const pressedModifiers = ref<number>(0)
const keysDown = ref<string[]>([]) const keysDown = ref<CanonicalKey[]>([])
// Shift state for display // Shift state for display
const isShiftActive = computed(() => { const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0 return (pressedModifiers.value & 0x22) !== 0
}) })
const areLettersUppercase = computed(() => {
return Boolean(props.capsLock) !== isShiftActive.value
})
const layoutName = computed(() => { const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default' return isShiftActive.value ? 'shift' : 'default'
}) })
@@ -63,7 +70,12 @@ const keyNamesForDownKeys = computed(() => {
.filter(([_, mask]) => (activeModifierMask & mask) !== 0) .filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name) .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) // 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', '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', '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', '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: [ shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
@@ -97,7 +109,7 @@ const keyboardLayout = {
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)', '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', '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', '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: { control: {
@@ -148,11 +160,10 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
ShiftLeft: 'Shift', ShiftLeft: 'Shift',
ShiftRight: 'Shift', ShiftRight: 'Shift',
AltLeft: 'Alt', AltLeft: 'Alt',
AltRight: 'Alt', AltRight: 'AltGr',
AltGr: 'AltGr',
MetaLeft: metaLabel, MetaLeft: metaLabel,
MetaRight: metaLabel, MetaRight: metaLabel,
Menu: 'Menu', ContextMenu: 'Menu',
// Special keys // Special keys
Escape: 'Esc', Escape: 'Esc',
@@ -187,20 +198,60 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters // Letters
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e', KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j', KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o', KeyC: areLettersUppercase.value ? 'C' : 'c',
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't', KeyD: areLettersUppercase.value ? 'D' : 'd',
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y', KeyE: areLettersUppercase.value ? 'E' : 'e',
KeyZ: 'z', 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 // Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E', '(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J', '(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O', '(KeyC)': areLettersUppercase.value ? 'C' : 'c',
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T', '(KeyD)': areLettersUppercase.value ? 'D' : 'd',
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y', '(KeyE)': areLettersUppercase.value ? 'E' : 'e',
'(KeyZ)': 'Z', '(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 // Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5', 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] const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.) // Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) { if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', cleanKey) emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask) await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => { setTimeout(() => {
sendKeyPress(keyCode, false, currentMask) sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
}, 100) }, 100)
return return
} }
// Handle modifier keys (toggle) // Handle modifier keys (toggle)
if (cleanKey in modifiers) { const mask = modifiers[keyCode] ?? 0
const mask = modifiers[cleanKey as keyof typeof modifiers] if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0 const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) { if (isCurrentlyDown) {
const nextMask = pressedModifiers.value & ~mask const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask) await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
} else { } else {
const nextMask = pressedModifiers.value | mask const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask) await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', cleanKey) emit('keyDown', keyCode)
} }
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
return return
} }
// Regular key: press and release // Regular key: press and release
keysDown.value.push(cleanKey) keysDown.value.push(keyCode)
emit('keyDown', cleanKey) emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask) await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
setTimeout(async () => { setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey) keysDown.value = keysDown.value.filter(k => k !== keyCode)
await sendKeyPress(keyCode, false, currentMask) await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey) emit('keyUp', keyCode)
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
}, 50) }, 50)
} }
@@ -352,7 +403,7 @@ async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout // 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 { try {
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff) await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) { } catch (err) {
@@ -372,7 +423,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) { for (const mod of step.modifiers) {
if (mod in keys) { if (mod in keys) {
const modHid = keys[mod as KeyName] const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true) macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
await sendKeyPress(modHid, true, macroModifierMask) await sendKeyPress(modHid, true, macroModifierMask)
} }
} }
@@ -394,7 +445,7 @@ async function executeMacro(steps: MacroStep[]) {
for (const mod of step.modifiers) { for (const mod of step.modifiers) {
if (mod in keys) { if (mod in keys) {
const modHid = keys[mod as KeyName] const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false) macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
await sendKeyPress(modHid, false, macroModifierMask) await sendKeyPress(modHid, false, macroModifierMask)
} }
} }
@@ -421,8 +472,12 @@ function updateKeyboardButtonTheme() {
} }
// Update layout when shift state changes // Update layout when shift state changes
watch(layoutName, (name) => { watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({ layoutName: name }) mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
}) })
// Initialize keyboards with unique selectors // Initialize keyboards with unique selectors
@@ -835,12 +890,12 @@ onUnmounted(() => {
min-width: 55px; min-width: 55px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] { .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
flex-grow: 1.25; flex-grow: 1.25;
min-width: 55px; min-width: 55px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] { .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
flex-grow: 1.25; flex-grow: 1.25;
min-width: 55px; 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="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"], .vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"], .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] { .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px; 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. // Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics. // The table follows US QWERTY layout semantics.
import { type CanonicalKey } from '@/types/generated'
import { keys } from '@/lib/keyboardMappings' import { keys } from '@/lib/keyboardMappings'
export interface CharKeyMapping { export interface CharKeyMapping {
hidCode: number // USB HID usage code key: CanonicalKey
shift: boolean // Whether Shift modifier is needed shift: boolean // Whether Shift modifier is needed
} }
const charToKeyMap: Record<string, CharKeyMapping> = { const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters // Lowercase letters
a: { hidCode: keys.KeyA, shift: false }, a: { key: keys.KeyA, shift: false },
b: { hidCode: keys.KeyB, shift: false }, b: { key: keys.KeyB, shift: false },
c: { hidCode: keys.KeyC, shift: false }, c: { key: keys.KeyC, shift: false },
d: { hidCode: keys.KeyD, shift: false }, d: { key: keys.KeyD, shift: false },
e: { hidCode: keys.KeyE, shift: false }, e: { key: keys.KeyE, shift: false },
f: { hidCode: keys.KeyF, shift: false }, f: { key: keys.KeyF, shift: false },
g: { hidCode: keys.KeyG, shift: false }, g: { key: keys.KeyG, shift: false },
h: { hidCode: keys.KeyH, shift: false }, h: { key: keys.KeyH, shift: false },
i: { hidCode: keys.KeyI, shift: false }, i: { key: keys.KeyI, shift: false },
j: { hidCode: keys.KeyJ, shift: false }, j: { key: keys.KeyJ, shift: false },
k: { hidCode: keys.KeyK, shift: false }, k: { key: keys.KeyK, shift: false },
l: { hidCode: keys.KeyL, shift: false }, l: { key: keys.KeyL, shift: false },
m: { hidCode: keys.KeyM, shift: false }, m: { key: keys.KeyM, shift: false },
n: { hidCode: keys.KeyN, shift: false }, n: { key: keys.KeyN, shift: false },
o: { hidCode: keys.KeyO, shift: false }, o: { key: keys.KeyO, shift: false },
p: { hidCode: keys.KeyP, shift: false }, p: { key: keys.KeyP, shift: false },
q: { hidCode: keys.KeyQ, shift: false }, q: { key: keys.KeyQ, shift: false },
r: { hidCode: keys.KeyR, shift: false }, r: { key: keys.KeyR, shift: false },
s: { hidCode: keys.KeyS, shift: false }, s: { key: keys.KeyS, shift: false },
t: { hidCode: keys.KeyT, shift: false }, t: { key: keys.KeyT, shift: false },
u: { hidCode: keys.KeyU, shift: false }, u: { key: keys.KeyU, shift: false },
v: { hidCode: keys.KeyV, shift: false }, v: { key: keys.KeyV, shift: false },
w: { hidCode: keys.KeyW, shift: false }, w: { key: keys.KeyW, shift: false },
x: { hidCode: keys.KeyX, shift: false }, x: { key: keys.KeyX, shift: false },
y: { hidCode: keys.KeyY, shift: false }, y: { key: keys.KeyY, shift: false },
z: { hidCode: keys.KeyZ, shift: false }, z: { key: keys.KeyZ, shift: false },
// Uppercase letters // Uppercase letters
A: { hidCode: keys.KeyA, shift: true }, A: { key: keys.KeyA, shift: true },
B: { hidCode: keys.KeyB, shift: true }, B: { key: keys.KeyB, shift: true },
C: { hidCode: keys.KeyC, shift: true }, C: { key: keys.KeyC, shift: true },
D: { hidCode: keys.KeyD, shift: true }, D: { key: keys.KeyD, shift: true },
E: { hidCode: keys.KeyE, shift: true }, E: { key: keys.KeyE, shift: true },
F: { hidCode: keys.KeyF, shift: true }, F: { key: keys.KeyF, shift: true },
G: { hidCode: keys.KeyG, shift: true }, G: { key: keys.KeyG, shift: true },
H: { hidCode: keys.KeyH, shift: true }, H: { key: keys.KeyH, shift: true },
I: { hidCode: keys.KeyI, shift: true }, I: { key: keys.KeyI, shift: true },
J: { hidCode: keys.KeyJ, shift: true }, J: { key: keys.KeyJ, shift: true },
K: { hidCode: keys.KeyK, shift: true }, K: { key: keys.KeyK, shift: true },
L: { hidCode: keys.KeyL, shift: true }, L: { key: keys.KeyL, shift: true },
M: { hidCode: keys.KeyM, shift: true }, M: { key: keys.KeyM, shift: true },
N: { hidCode: keys.KeyN, shift: true }, N: { key: keys.KeyN, shift: true },
O: { hidCode: keys.KeyO, shift: true }, O: { key: keys.KeyO, shift: true },
P: { hidCode: keys.KeyP, shift: true }, P: { key: keys.KeyP, shift: true },
Q: { hidCode: keys.KeyQ, shift: true }, Q: { key: keys.KeyQ, shift: true },
R: { hidCode: keys.KeyR, shift: true }, R: { key: keys.KeyR, shift: true },
S: { hidCode: keys.KeyS, shift: true }, S: { key: keys.KeyS, shift: true },
T: { hidCode: keys.KeyT, shift: true }, T: { key: keys.KeyT, shift: true },
U: { hidCode: keys.KeyU, shift: true }, U: { key: keys.KeyU, shift: true },
V: { hidCode: keys.KeyV, shift: true }, V: { key: keys.KeyV, shift: true },
W: { hidCode: keys.KeyW, shift: true }, W: { key: keys.KeyW, shift: true },
X: { hidCode: keys.KeyX, shift: true }, X: { key: keys.KeyX, shift: true },
Y: { hidCode: keys.KeyY, shift: true }, Y: { key: keys.KeyY, shift: true },
Z: { hidCode: keys.KeyZ, shift: true }, Z: { key: keys.KeyZ, shift: true },
// Number row // Number row
'0': { hidCode: keys.Digit0, shift: false }, '0': { key: keys.Digit0, shift: false },
'1': { hidCode: keys.Digit1, shift: false }, '1': { key: keys.Digit1, shift: false },
'2': { hidCode: keys.Digit2, shift: false }, '2': { key: keys.Digit2, shift: false },
'3': { hidCode: keys.Digit3, shift: false }, '3': { key: keys.Digit3, shift: false },
'4': { hidCode: keys.Digit4, shift: false }, '4': { key: keys.Digit4, shift: false },
'5': { hidCode: keys.Digit5, shift: false }, '5': { key: keys.Digit5, shift: false },
'6': { hidCode: keys.Digit6, shift: false }, '6': { key: keys.Digit6, shift: false },
'7': { hidCode: keys.Digit7, shift: false }, '7': { key: keys.Digit7, shift: false },
'8': { hidCode: keys.Digit8, shift: false }, '8': { key: keys.Digit8, shift: false },
'9': { hidCode: keys.Digit9, shift: false }, '9': { key: keys.Digit9, shift: false },
// Shifted number row symbols // Shifted number row symbols
')': { hidCode: keys.Digit0, shift: true }, ')': { key: keys.Digit0, shift: true },
'!': { hidCode: keys.Digit1, shift: true }, '!': { key: keys.Digit1, shift: true },
'@': { hidCode: keys.Digit2, shift: true }, '@': { key: keys.Digit2, shift: true },
'#': { hidCode: keys.Digit3, shift: true }, '#': { key: keys.Digit3, shift: true },
'$': { hidCode: keys.Digit4, shift: true }, '$': { key: keys.Digit4, shift: true },
'%': { hidCode: keys.Digit5, shift: true }, '%': { key: keys.Digit5, shift: true },
'^': { hidCode: keys.Digit6, shift: true }, '^': { key: keys.Digit6, shift: true },
'&': { hidCode: keys.Digit7, shift: true }, '&': { key: keys.Digit7, shift: true },
'*': { hidCode: keys.Digit8, shift: true }, '*': { key: keys.Digit8, shift: true },
'(': { hidCode: keys.Digit9, shift: true }, '(': { key: keys.Digit9, shift: true },
// Punctuation and symbols // Punctuation and symbols
'-': { hidCode: keys.Minus, shift: false }, '-': { key: keys.Minus, shift: false },
'=': { hidCode: keys.Equal, shift: false }, '=': { key: keys.Equal, shift: false },
'[': { hidCode: keys.BracketLeft, shift: false }, '[': { key: keys.BracketLeft, shift: false },
']': { hidCode: keys.BracketRight, shift: false }, ']': { key: keys.BracketRight, shift: false },
'\\': { hidCode: keys.Backslash, shift: false }, '\\': { key: keys.Backslash, shift: false },
';': { hidCode: keys.Semicolon, shift: false }, ';': { key: keys.Semicolon, shift: false },
"'": { hidCode: keys.Quote, shift: false }, "'": { key: keys.Quote, shift: false },
'`': { hidCode: keys.Backquote, shift: false }, '`': { key: keys.Backquote, shift: false },
',': { hidCode: keys.Comma, shift: false }, ',': { key: keys.Comma, shift: false },
'.': { hidCode: keys.Period, shift: false }, '.': { key: keys.Period, shift: false },
'/': { hidCode: keys.Slash, shift: false }, '/': { key: keys.Slash, shift: false },
// Shifted punctuation and symbols // Shifted punctuation and symbols
_: { hidCode: keys.Minus, shift: true }, _: { key: keys.Minus, shift: true },
'+': { hidCode: keys.Equal, shift: true }, '+': { key: keys.Equal, shift: true },
'{': { hidCode: keys.BracketLeft, shift: true }, '{': { key: keys.BracketLeft, shift: true },
'}': { hidCode: keys.BracketRight, shift: true }, '}': { key: keys.BracketRight, shift: true },
'|': { hidCode: keys.Backslash, shift: true }, '|': { key: keys.Backslash, shift: true },
':': { hidCode: keys.Semicolon, shift: true }, ':': { key: keys.Semicolon, shift: true },
'"': { hidCode: keys.Quote, shift: true }, '"': { key: keys.Quote, shift: true },
'~': { hidCode: keys.Backquote, shift: true }, '~': { key: keys.Backquote, shift: true },
'<': { hidCode: keys.Comma, shift: true }, '<': { key: keys.Comma, shift: true },
'>': { hidCode: keys.Period, shift: true }, '>': { key: keys.Period, shift: true },
'?': { hidCode: keys.Slash, shift: true }, '?': { key: keys.Slash, shift: true },
// Whitespace and control // Whitespace and control
' ': { hidCode: keys.Space, shift: false }, ' ': { key: keys.Space, shift: false },
'\t': { hidCode: keys.Tab, shift: false }, '\t': { key: keys.Tab, shift: false },
'\n': { hidCode: keys.Enter, shift: false }, '\n': { key: keys.Enter, shift: false },
'\r': { hidCode: 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 * @param char - Single character to convert
* @returns CharKeyMapping or null if character is not mappable * @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' export type KeyboardOsType = 'windows' | 'mac' | 'android'
// Bottom row layouts for different OS
export const osBottomRows: Record<KeyboardOsType, string[]> = { export const osBottomRows: Record<KeyboardOsType, string[]> = {
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'], mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
// Android: simplified layout
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'], 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'] export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
// Media key display names
export const mediaKeyLabels: Record<string, string> = { export const mediaKeyLabels: Record<string, string> = {
PlayPause: '⏯', PlayPause: '⏯',
Stop: '⏹', Stop: '⏹',
@@ -81,158 +19,3 @@ export const mediaKeyLabels: Record<string, string> = {
VolumeUp: '🔊', VolumeUp: '🔊',
VolumeDown: '🔉', 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 import { CanonicalKey } from '@/types/generated'
// [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)
export const keys = { export const keys = {
// Letters KeyA: CanonicalKey.KeyA,
KeyA: 0x04, KeyB: CanonicalKey.KeyB,
KeyB: 0x05, KeyC: CanonicalKey.KeyC,
KeyC: 0x06, KeyD: CanonicalKey.KeyD,
KeyD: 0x07, KeyE: CanonicalKey.KeyE,
KeyE: 0x08, KeyF: CanonicalKey.KeyF,
KeyF: 0x09, KeyG: CanonicalKey.KeyG,
KeyG: 0x0a, KeyH: CanonicalKey.KeyH,
KeyH: 0x0b, KeyI: CanonicalKey.KeyI,
KeyI: 0x0c, KeyJ: CanonicalKey.KeyJ,
KeyJ: 0x0d, KeyK: CanonicalKey.KeyK,
KeyK: 0x0e, KeyL: CanonicalKey.KeyL,
KeyL: 0x0f, KeyM: CanonicalKey.KeyM,
KeyM: 0x10, KeyN: CanonicalKey.KeyN,
KeyN: 0x11, KeyO: CanonicalKey.KeyO,
KeyO: 0x12, KeyP: CanonicalKey.KeyP,
KeyP: 0x13, KeyQ: CanonicalKey.KeyQ,
KeyQ: 0x14, KeyR: CanonicalKey.KeyR,
KeyR: 0x15, KeyS: CanonicalKey.KeyS,
KeyS: 0x16, KeyT: CanonicalKey.KeyT,
KeyT: 0x17, KeyU: CanonicalKey.KeyU,
KeyU: 0x18, KeyV: CanonicalKey.KeyV,
KeyV: 0x19, KeyW: CanonicalKey.KeyW,
KeyW: 0x1a, KeyX: CanonicalKey.KeyX,
KeyX: 0x1b, KeyY: CanonicalKey.KeyY,
KeyY: 0x1c, KeyZ: CanonicalKey.KeyZ,
KeyZ: 0x1d, Digit1: CanonicalKey.Digit1,
Digit2: CanonicalKey.Digit2,
// Numbers Digit3: CanonicalKey.Digit3,
Digit1: 0x1e, Digit4: CanonicalKey.Digit4,
Digit2: 0x1f, Digit5: CanonicalKey.Digit5,
Digit3: 0x20, Digit6: CanonicalKey.Digit6,
Digit4: 0x21, Digit7: CanonicalKey.Digit7,
Digit5: 0x22, Digit8: CanonicalKey.Digit8,
Digit6: 0x23, Digit9: CanonicalKey.Digit9,
Digit7: 0x24, Digit0: CanonicalKey.Digit0,
Digit8: 0x25, Enter: CanonicalKey.Enter,
Digit9: 0x26, Escape: CanonicalKey.Escape,
Digit0: 0x27, Backspace: CanonicalKey.Backspace,
Tab: CanonicalKey.Tab,
// Control keys Space: CanonicalKey.Space,
Enter: 0x28, Minus: CanonicalKey.Minus,
Escape: 0x29, Equal: CanonicalKey.Equal,
Backspace: 0x2a, BracketLeft: CanonicalKey.BracketLeft,
Tab: 0x2b, BracketRight: CanonicalKey.BracketRight,
Space: 0x2c, Backslash: CanonicalKey.Backslash,
Semicolon: CanonicalKey.Semicolon,
// Symbols Quote: CanonicalKey.Quote,
Minus: 0x2d, Backquote: CanonicalKey.Backquote,
Equal: 0x2e, Comma: CanonicalKey.Comma,
BracketLeft: 0x2f, Period: CanonicalKey.Period,
BracketRight: 0x30, Slash: CanonicalKey.Slash,
Backslash: 0x31, CapsLock: CanonicalKey.CapsLock,
Semicolon: 0x33, F1: CanonicalKey.F1,
Quote: 0x34, F2: CanonicalKey.F2,
Backquote: 0x35, F3: CanonicalKey.F3,
Comma: 0x36, F4: CanonicalKey.F4,
Period: 0x37, F5: CanonicalKey.F5,
Slash: 0x38, F6: CanonicalKey.F6,
F7: CanonicalKey.F7,
// Lock keys F8: CanonicalKey.F8,
CapsLock: 0x39, F9: CanonicalKey.F9,
F10: CanonicalKey.F10,
// Function keys F11: CanonicalKey.F11,
F1: 0x3a, F12: CanonicalKey.F12,
F2: 0x3b, PrintScreen: CanonicalKey.PrintScreen,
F3: 0x3c, ScrollLock: CanonicalKey.ScrollLock,
F4: 0x3d, Pause: CanonicalKey.Pause,
F5: 0x3e, Insert: CanonicalKey.Insert,
F6: 0x3f, Home: CanonicalKey.Home,
F7: 0x40, PageUp: CanonicalKey.PageUp,
F8: 0x41, Delete: CanonicalKey.Delete,
F9: 0x42, End: CanonicalKey.End,
F10: 0x43, PageDown: CanonicalKey.PageDown,
F11: 0x44, ArrowRight: CanonicalKey.ArrowRight,
F12: 0x45, ArrowLeft: CanonicalKey.ArrowLeft,
ArrowDown: CanonicalKey.ArrowDown,
// Control cluster ArrowUp: CanonicalKey.ArrowUp,
PrintScreen: 0x46, NumLock: CanonicalKey.NumLock,
ScrollLock: 0x47, NumpadDivide: CanonicalKey.NumpadDivide,
Pause: 0x48, NumpadMultiply: CanonicalKey.NumpadMultiply,
Insert: 0x49, NumpadSubtract: CanonicalKey.NumpadSubtract,
Home: 0x4a, NumpadAdd: CanonicalKey.NumpadAdd,
PageUp: 0x4b, NumpadEnter: CanonicalKey.NumpadEnter,
Delete: 0x4c, Numpad1: CanonicalKey.Numpad1,
End: 0x4d, Numpad2: CanonicalKey.Numpad2,
PageDown: 0x4e, Numpad3: CanonicalKey.Numpad3,
Numpad4: CanonicalKey.Numpad4,
// Arrow keys Numpad5: CanonicalKey.Numpad5,
ArrowRight: 0x4f, Numpad6: CanonicalKey.Numpad6,
ArrowLeft: 0x50, Numpad7: CanonicalKey.Numpad7,
ArrowDown: 0x51, Numpad8: CanonicalKey.Numpad8,
ArrowUp: 0x52, Numpad9: CanonicalKey.Numpad9,
Numpad0: CanonicalKey.Numpad0,
// Numpad NumpadDecimal: CanonicalKey.NumpadDecimal,
NumLock: 0x53, IntlBackslash: CanonicalKey.IntlBackslash,
NumpadDivide: 0x54, ContextMenu: CanonicalKey.ContextMenu,
NumpadMultiply: 0x55, F13: CanonicalKey.F13,
NumpadSubtract: 0x56, F14: CanonicalKey.F14,
NumpadAdd: 0x57, F15: CanonicalKey.F15,
NumpadEnter: 0x58, F16: CanonicalKey.F16,
Numpad1: 0x59, F17: CanonicalKey.F17,
Numpad2: 0x5a, F18: CanonicalKey.F18,
Numpad3: 0x5b, F19: CanonicalKey.F19,
Numpad4: 0x5c, F20: CanonicalKey.F20,
Numpad5: 0x5d, F21: CanonicalKey.F21,
Numpad6: 0x5e, F22: CanonicalKey.F22,
Numpad7: 0x5f, F23: CanonicalKey.F23,
Numpad8: 0x60, F24: CanonicalKey.F24,
Numpad9: 0x61, ControlLeft: CanonicalKey.ControlLeft,
Numpad0: 0x62, ShiftLeft: CanonicalKey.ShiftLeft,
NumpadDecimal: 0x63, AltLeft: CanonicalKey.AltLeft,
MetaLeft: CanonicalKey.MetaLeft,
// Non-US keys ControlRight: CanonicalKey.ControlRight,
IntlBackslash: 0x64, ShiftRight: CanonicalKey.ShiftRight,
ContextMenu: 0x65, AltRight: CanonicalKey.AltRight,
Menu: 0x65, MetaRight: CanonicalKey.MetaRight,
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,
} as const } as const
export type KeyName = keyof typeof keys export type KeyName = keyof typeof keys
// Consumer Control Usage codes (for multimedia keys) // Consumer Control Usage codes (for multimedia keys)
// These are sent via a separate Consumer Control HID report
export const consumerKeys = { export const consumerKeys = {
PlayPause: 0x00cd, PlayPause: 0x00cd,
Stop: 0x00b7, Stop: 0x00b7,
@@ -164,69 +135,153 @@ export const consumerKeys = {
export type ConsumerKeyName = keyof typeof consumerKeys export type ConsumerKeyName = keyof typeof consumerKeys
// Modifier bitmasks for HID report byte 0 export const modifiers: Partial<Record<CanonicalKey, number>> = {
export const modifiers = { [CanonicalKey.ControlLeft]: 0x01,
ControlLeft: 0x01, [CanonicalKey.ShiftLeft]: 0x02,
ShiftLeft: 0x02, [CanonicalKey.AltLeft]: 0x04,
AltLeft: 0x04, [CanonicalKey.MetaLeft]: 0x08,
MetaLeft: 0x08, [CanonicalKey.ControlRight]: 0x10,
ControlRight: 0x10, [CanonicalKey.ShiftRight]: 0x20,
ShiftRight: 0x20, [CanonicalKey.AltRight]: 0x40,
AltRight: 0x40, [CanonicalKey.MetaRight]: 0x80,
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
} }
// Update modifier mask when a HID modifier key is pressed/released. export const keyToHidUsage = {
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number { [CanonicalKey.KeyA]: 0x04,
const bit = hidKeyToModifierMask[hidKey] ?? 0 [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 if (bit === 0) return mask
return press ? (mask | bit) : (mask & ~bit) return press ? (mask | bit) : (mask & ~bit)
} }
// Keys that latch (toggle state) instead of being held export const latchingKeys = [
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const CanonicalKey.CapsLock,
CanonicalKey.ScrollLock,
// Modifier key names CanonicalKey.NumLock,
export const modifierKeyNames = [
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'AltGr',
'MetaLeft',
'MetaRight',
] as const ] 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. // Normalize browser-specific KeyboardEvent.code variants.
export function normalizeKeyboardCode(code: string, key: string): string { export function normalizeKeyboardCode(code: string, key: string): string {
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote' if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
@@ -238,18 +293,10 @@ export function normalizeKeyboardCode(code: string, key: string): string {
return code return code
} }
// Convert KeyboardEvent.code/key to USB HID usage code. export function keyboardEventToCanonicalKey(code: string, key: string): CanonicalKey | undefined {
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
const normalizedCode = normalizeKeyboardCode(code, key) const normalizedCode = normalizeKeyboardCode(code, key)
if (normalizedCode in keys) {
return keys[normalizedCode as KeyName] 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", Gpio = "gpio",
/** USB HID relay module */ /** USB HID relay module */
UsbRelay = "usbrelay", UsbRelay = "usbrelay",
/** Serial/COM port relay (LCUS type) */ /** Serial/COM port relay (taobao LCUS type) */
Serial = "serial", Serial = "serial",
/** Disabled / Not configured */ /** Disabled / Not configured */
None = "none", None = "none",
@@ -149,6 +149,7 @@ export interface AtxKeyConfig {
* Pin or channel number: * Pin or channel number:
* - For GPIO: GPIO pin number * - For GPIO: GPIO pin number
* - For USB Relay: relay channel (0-based) * - For USB Relay: relay channel (0-based)
* - For Serial Relay (LCUS): relay channel (1-based)
*/ */
pin: number; pin: number;
/** Active level (only applicable to GPIO, ignored for USB Relay) */ /** Active level (only applicable to GPIO, ignored for USB Relay) */
@@ -444,11 +445,11 @@ export interface AtxConfigUpdate {
/** Available ATX devices for discovery */ /** Available ATX devices for discovery */
export interface AtxDevices { export interface AtxDevices {
/** Available GPIO chips (/dev/gpiochip*) */ /** Available GPIO chips (/dev/gpiochip*) */
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
gpio_chips: string[]; gpio_chips: string[];
/** Available USB HID relay devices (/dev/hidraw*) */ /** Available USB HID relay devices (/dev/hidraw*) */
usb_relays: string[]; usb_relays: string[];
/** Available Serial ports (/dev/ttyUSB*) */
serial_ports: string[];
} }
export interface AudioConfigUpdate { export interface AudioConfigUpdate {
@@ -623,19 +624,19 @@ export interface RustDeskConfigUpdate {
device_password?: string; device_password?: string;
} }
/** Stream 配置响应(包含 has_turn_password 字段) */ /** Stream configuration response (includes has_turn_password) */
export interface StreamConfigResponse { export interface StreamConfigResponse {
mode: StreamMode; mode: StreamMode;
encoder: EncoderType; encoder: EncoderType;
bitrate_preset: BitratePreset; bitrate_preset: BitratePreset;
/** 是否有公共 ICE 服务器可用(编译时确定) */ /** Whether public ICE servers are available (compile-time decision) */
has_public_ice_servers: boolean; 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; using_public_ice_servers: boolean;
stun_server?: string; stun_server?: string;
turn_server?: string; turn_server?: string;
turn_username?: string; turn_username?: string;
/** 指示是否已设置 TURN 密码(实际密码不返回) */ /** Indicates whether TURN password has been configured (password is not returned) */
has_turn_password: boolean; has_turn_password: boolean;
} }
@@ -688,3 +689,130 @@ export interface WebConfigUpdate {
bind_address?: string; bind_address?: string;
https_enabled?: boolean; 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 // HID (Human Interface Device) type definitions
// Shared between WebRTC DataChannel and WebSocket HID channels // Shared between WebRTC DataChannel and WebSocket HID channels
import { type CanonicalKey } from '@/types/generated'
import { canonicalKeyToHidUsage } from '@/lib/keyboardMappings'
/** Keyboard event for HID input */ /** Keyboard event for HID input */
export interface HidKeyboardEvent { export interface HidKeyboardEvent {
type: 'keydown' | 'keyup' type: 'keydown' | 'keyup'
key: number key: CanonicalKey
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */ /** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
modifier?: number modifier?: number
} }
@@ -51,7 +54,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
view.setUint8(0, MSG_KEYBOARD) view.setUint8(0, MSG_KEYBOARD)
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP) 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.setUint8(3, (event.modifier ?? 0) & 0xff)

View File

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