From 93c5887153c2c891c84c06a4ed639c8694860a8d Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Thu, 7 Aug 2025 06:23:35 +0000 Subject: [PATCH 1/9] Add ability to track modifier state on the device Remove LED sync source and add keypress reporting We return the modifiers as the valid bitmask so that the VirtualKeyboard can represent the correct keys as down. This is important when we have strokes like Left-Control + Right-Control + Keypad-1 (used in switching KVMs and such) --- internal/usbgadget/hid_keyboard.go | 196 ++++++-- internal/usbgadget/usbgadget.go | 13 +- jsonrpc.go | 2 + ui/eslint.config.cjs | 4 + ui/package-lock.json | 426 +++++++++--------- ui/src/components/InfoBar.tsx | 21 +- ui/src/components/VirtualKeyboard.tsx | 34 +- ui/src/components/WebRTCVideo.tsx | 134 +++--- ui/src/hooks/stores.ts | 51 +-- ui/src/hooks/useKeyboard.ts | 28 +- ui/src/keyboardMappings.ts | 1 + .../routes/devices.$id.settings.keyboard.tsx | 28 +- ui/src/routes/devices.$id.tsx | 38 +- usb.go | 20 +- webrtc.go | 8 +- 15 files changed, 570 insertions(+), 434 deletions(-) diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 6ad3b6a..7be48d4 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -1,10 +1,10 @@ package usbgadget import ( + "bytes" "context" "fmt" "os" - "reflect" "time" ) @@ -61,6 +61,8 @@ var keyboardReportDesc = []byte{ const ( hidReadBufferSize = 8 + hidKeyBufferSize = 6 + hidErrorRollOver = 0x01 // https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf KeyboardLedMaskNumLock = 1 << 0 @@ -68,7 +70,9 @@ const ( KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskKana = 1 << 4 - ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana + // power on/off LED is 5 + KeyboardLedMaskShift = 1 << 6 + ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift ) // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK, @@ -81,6 +85,7 @@ type KeyboardState struct { ScrollLock bool `json:"scroll_lock"` Compose bool `json:"compose"` Kana bool `json:"kana"` + Shift bool `json:"shift"` // This is not part of the main USB HID spec } func getKeyboardState(b byte) KeyboardState { @@ -91,27 +96,27 @@ func getKeyboardState(b byte) KeyboardState { ScrollLock: b&KeyboardLedMaskScrollLock != 0, Compose: b&KeyboardLedMaskCompose != 0, Kana: b&KeyboardLedMaskKana != 0, + Shift: b&KeyboardLedMaskShift != 0, } } -func (u *UsbGadget) updateKeyboardState(b byte) { +func (u *UsbGadget) updateKeyboardState(state byte) { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() - if b&^ValidKeyboardLedMasks != 0 { - u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring") + if state&^ValidKeyboardLedMasks != 0 { + u.log.Error().Uint8("state", state).Msg("ignoring invalid bits") return } - newState := getKeyboardState(b) - if reflect.DeepEqual(u.keyboardState, newState) { + if u.keyboardState == state { return } - u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated") - u.keyboardState = newState + u.log.Trace().Interface("old", u.keyboardState).Interface("new", state).Msg("keyboardState updated") + u.keyboardState = state if u.onKeyboardStateChange != nil { - (*u.onKeyboardStateChange)(newState) + (*u.onKeyboardStateChange)(getKeyboardState(state)) } } @@ -123,7 +128,52 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() - return u.keyboardState + return getKeyboardState(u.keyboardState) +} + +const ( + // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C + ModifierMaskLeftControl = 0x01 + ModifierMaskRightControl = 0x10 + ModifierMaskLeftShift = 0x02 + ModifierMaskRightShift = 0x20 + ModifierMaskLeftAlt = 0x04 + ModifierMaskRightAlt = 0x40 + ModifierMaskLeftSuper = 0x08 + ModifierMaskRightSuper = 0x80 + + EitherShiftMask = ModifierMaskLeftShift | ModifierMaskRightShift + EitherControlMask = ModifierMaskLeftControl | ModifierMaskRightControl + EitherAltMask = ModifierMaskLeftAlt | ModifierMaskRightAlt + EitherSuperMask = ModifierMaskLeftSuper | ModifierMaskRightSuper +) + +func (u *UsbGadget) GetKeysDownState() KeysDownState { + u.keyboardStateLock.Lock() + defer u.keyboardStateLock.Unlock() + + return u.keysDownState +} + +func (u *UsbGadget) updateKeyDownState(state KeysDownState) { + u.keyboardStateLock.Lock() + defer u.keyboardStateLock.Unlock() + + if u.keysDownState.Modifier == state.Modifier && + bytes.Equal(u.keysDownState.Keys, state.Keys) { + return // No change in key down state + } + + u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated") + u.keysDownState = state + + if u.onKeysDownChange != nil { + (*u.onKeysDownChange)(state) + } +} + +func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) { + u.onKeysDownChange = &f } func (u *UsbGadget) listenKeyboardEvents() { @@ -142,7 +192,7 @@ func (u *UsbGadget) listenKeyboardEvents() { l.Info().Msg("context done") return default: - l.Trace().Msg("reading from keyboard") + l.Trace().Msg("reading from keyboard for LED state changes") if u.keyboardHidFile == nil { u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") // show the error every 100 times to avoid spamming the logs @@ -195,12 +245,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error { return u.openKeyboardHidFile() } -func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { +func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { if err := u.openKeyboardHidFile(); err != nil { return err } - _, err := u.keyboardHidFile.Write(data) + _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:]...)) if err != nil { u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.keyboardHidFile.Close() @@ -211,22 +261,116 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error { +func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { u.keyboardLock.Lock() defer u.keyboardLock.Unlock() + defer u.resetUserInputTime() - if len(keys) > 6 { - keys = keys[:6] + if len(keys) > hidKeyBufferSize { + keys = keys[:hidKeyBufferSize] } - if len(keys) < 6 { - keys = append(keys, make([]uint8, 6-len(keys))...) + if len(keys) < hidKeyBufferSize { + keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) } - err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}) - if err != nil { - return err - } - - u.resetUserInputTime() - return nil + return u.keyboardWriteHidFile(modifier, keys) +} + +const ( + // https://www.usb.org/sites/default/files/documents/hut1_2.pdf + // Dynamic Flags (DV) + LeftControl = 0xE0 + LeftShift = 0xE1 + LeftAlt = 0xE2 + LeftSuper = 0xE3 // Left GUI (e.g. Windows key, Apple Command key) + RightControl = 0xE4 + RightShift = 0xE5 + RightAlt = 0xE6 + RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key) +) + +// KeyCodeMask maps a key code to its corresponding bit mask +type KeyCodeMask struct { + KeyCode byte + Mask byte +} + +// KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup +var KeyCodeToMaskMap = map[uint8]uint8{ + LeftControl: ModifierMaskLeftControl, + LeftShift: ModifierMaskLeftShift, + LeftAlt: ModifierMaskLeftAlt, + LeftSuper: ModifierMaskLeftSuper, + RightControl: ModifierMaskRightControl, + RightShift: ModifierMaskRightShift, + RightAlt: ModifierMaskRightAlt, + RightSuper: ModifierMaskRightSuper, +} + +func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) { + u.keyboardLock.Lock() + defer u.keyboardLock.Unlock() + defer u.resetUserInputTime() + + var state = u.keysDownState + modifier := state.Modifier + keys := state.Keys[:] + + if mask, exists := KeyCodeToMaskMap[key]; exists { + // If the key is a modifier key, we update the keyboardModifier state + // by setting or clearing the corresponding bit in the modifier byte. + // This allows us to track the state of modifier keys like Shift, Control, Alt, and Super. + if press { + modifier |= mask + } else { + modifier &^= mask + } + } else { + // handle other keys that are not modifier keys by placing or removing them + // from the key buffer since the buffer tracks currently pressed keys + overrun := true + for i := range hidKeyBufferSize { + // If we find the key in the buffer the buffer, we either remove it (if press is false) + // or do nothing (if down is true) because the buffer tracks currently pressed keys + // and if we find a zero byte, we can place the key there (if press is true) + if keys[i] == key || keys[i] == 0 { + if press { + keys[i] = key // overwrites the zero byte or the same key if already pressed + } else { + // we are releasing the key, remove it from the buffer + if keys[i] != 0 { + copy(keys[i:], keys[i+1:]) + keys[hidKeyBufferSize-1] = 0 // Clear the last byte + } + } + overrun = false // We found a slot for the key + break + } + } + + // If we reach here it means we didn't find an empty slot or the key in the buffer + if overrun { + if press { + u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added") + // Fill all key slots with ErrorRollOver (0x01) to indicate overflow + for i := range keys { + keys[i] = hidErrorRollOver + } + } else { + // If we are releasing a key, and we didn't find it in a slot, who cares? + u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release") + } + } + } + + if err := u.keyboardWriteHidFile(modifier, keys); err != nil { + u.log.Warn().Uint8("modifier", modifier).Bytes("keys", keys).Msg("Could not write keypress report to hidg0") + } + + state.Modifier = modifier + state.Keys = keys + + u.updateKeyDownState(state) + + return state, nil } diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index cb70655..21e37cd 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{ MassStorage: true, } +type KeysDownState struct { + Modifier byte `json:"modifier"` + Keys []byte `json:"keys"` +} + // UsbGadget is a struct that represents a USB gadget. type UsbGadget struct { name string @@ -60,7 +65,9 @@ type UsbGadget struct { relMouseHidFile *os.File relMouseLock sync.Mutex - keyboardState KeyboardState + keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) + keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys) + keyboardStateLock sync.Mutex keyboardStateCtx context.Context keyboardStateCancel context.CancelFunc @@ -77,6 +84,7 @@ type UsbGadget struct { txLock sync.Mutex onKeyboardStateChange *func(state KeyboardState) + onKeysDownChange *func(state KeysDownState) log *zerolog.Logger @@ -122,7 +130,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev txLock: sync.Mutex{}, keyboardStateCtx: keyboardCtx, keyboardStateCancel: keyboardCancel, - keyboardState: KeyboardState{}, + keyboardState: 0, + keysDownState: KeysDownState{Modifier: 0, Keys: make([]byte, hidKeyBufferSize)}, enabledDevices: *enabledDevices, lastUserInput: time.Now(), log: logger, diff --git a/jsonrpc.go b/jsonrpc.go index a0264b8..81b6a65 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1047,6 +1047,8 @@ var rpcHandlers = map[string]RPCHandler{ "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState}, + "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, + "getKeyDownState": {Func: rpcGetKeysDownState}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, diff --git a/ui/eslint.config.cjs b/ui/eslint.config.cjs index a6c0c1f..6e97258 100644 --- a/ui/eslint.config.cjs +++ b/ui/eslint.config.cjs @@ -66,6 +66,10 @@ module.exports = defineConfig([{ groups: ["builtin", "external", "internal", "parent", "sibling"], "newlines-between": "always", }], + + "@typescript-eslint/no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" + }], }, settings: { diff --git a/ui/package-lock.json b/ui/package-lock.json index 72a4849..bc05d41 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -112,9 +112,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -128,9 +128,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -144,9 +144,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -160,9 +160,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -192,9 +192,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -208,9 +208,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -224,9 +224,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -240,9 +240,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -256,9 +256,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -272,9 +272,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -288,9 +288,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -336,9 +336,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -352,9 +352,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -368,9 +368,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -400,9 +400,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -416,9 +416,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -448,9 +448,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "cpu": [ "arm64" ], @@ -464,9 +464,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -480,9 +480,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -496,9 +496,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -512,9 +512,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -555,9 +555,9 @@ } }, "node_modules/@eslint/compat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", - "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.2.tgz", + "integrity": "sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -587,18 +587,18 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -643,9 +643,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", - "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -664,12 +664,12 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -845,9 +845,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -866,16 +866,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1964,9 +1964,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", - "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "version": "19.1.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", + "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1996,17 +1996,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", - "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", + "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/type-utils": "8.39.0", - "@typescript-eslint/utils": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/type-utils": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2020,7 +2020,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2036,16 +2036,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", - "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", + "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "engines": { @@ -2061,14 +2061,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", - "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", + "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.0", - "@typescript-eslint/types": "^8.39.0", + "@typescript-eslint/tsconfig-utils": "^8.39.1", + "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "engines": { @@ -2083,14 +2083,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", - "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0" + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2101,9 +2101,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", - "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", + "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", "dev": true, "license": "MIT", "engines": { @@ -2118,15 +2118,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", - "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", + "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0", - "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2143,9 +2143,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", - "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", "dev": true, "license": "MIT", "engines": { @@ -2157,16 +2157,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", - "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.0", - "@typescript-eslint/tsconfig-utils": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2212,16 +2212,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", - "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", + "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0" + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2236,13 +2236,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", - "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2650,9 +2650,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", "dev": true, "funding": [ { @@ -2670,8 +2670,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", + "caniuse-lite": "^1.0.30001733", + "electron-to-chromium": "^1.5.199", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -2739,9 +2739,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001734", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", + "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", "dev": true, "funding": [ { @@ -3159,9 +3159,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.198", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz", - "integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", + "version": "1.5.200", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", + "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", "dev": true, "license": "ISC" }, @@ -3350,9 +3350,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3362,32 +3362,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/escalade": { @@ -3413,19 +3413,19 @@ } }, "node_modules/eslint": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", - "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4747,9 +4747,9 @@ } }, "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, "node_modules/js-tokens": { @@ -5851,9 +5851,9 @@ } }, "node_modules/react-simple-keyboard": { - "version": "3.8.106", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.106.tgz", - "integrity": "sha512-ItCHCdhVCzn9huhenuyuHQMOGsl3UMLu5xAO1bkjj4AAgVoktFC1DQ4HWkOS6BGPvUJejFM3Q5hVM8Bl2oX9pA==", + "version": "3.8.108", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.108.tgz", + "integrity": "sha512-Q3JK/qnaDjTMaE6EcNOG4MJV8jHqug2K1YIz9j+QXgmAE/nUuHXuyAnLIZuU3uvqv2n/556l2hLkqr9jW1LgUw==", "license": "MIT", "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 7ce67a4..02ff170 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -38,9 +38,6 @@ export default function InfoBar() { }, [rpcDataChannel]); const keyboardLedState = useHidStore(state => state.keyboardLedState); - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const usbState = useHidStore(state => state.usbState); @@ -122,19 +119,6 @@ export default function InfoBar() { )} - {keyboardLedStateSyncAvailable ? ( -
- {keyboardLedSync === "browser" ? "Browser" : "Host"} -
- ) : null}
) : null} + {keyboardLedState?.shift ? ( +
+ Shift +
+ ) : null}
diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 4ff04a9..18c5fbe 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -1,7 +1,7 @@ import { useShallow } from "zustand/react/shallow"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Keyboard from "react-simple-keyboard"; import Card from "@components/Card"; @@ -13,7 +13,7 @@ import "react-simple-keyboard/build/css/index.css"; import AttachIconRaw from "@/assets/attach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; +import { useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; @@ -44,15 +44,19 @@ function KeyboardWrapper() { const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock)); - // HID related states - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const isKeyboardLedManagedByHost = useMemo(() => - keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable, - [keyboardLedSync, keyboardLedStateSyncAvailable], - ); + /* + // These will be used to display the currently pressed keys and modifiers on the virtual keyboard - const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); + // used to show the modifier keys that are in the "down state" on the virtual keyboard + const keyNamesFromModifierMask = (activeModifiers: number): string[] => { + return Object.entries(modifiers).filter(m => (activeModifiers & m[1]) !== 0).map(m => m[0]); + } + + // used to show the regular keys that are in the "down state" on the virtual keyboard + const keyNamesFromDownKeys = (downKeys: number[]) => { + return Object.entries(keys).filter(([_, code]) => downKeys.includes(code)).map(([name, _]) => name); + } + */ const startDrag = useCallback((e: MouseEvent | TouchEvent) => { if (!keyboardRef.current) return; @@ -168,19 +172,11 @@ function KeyboardWrapper() { toggleLayout(); if (isCapsLockActive) { - if (!isKeyboardLedManagedByHost) { - setIsCapsLockActive(false); - } sendKeyboardEvent([keys["CapsLock"]], []); return; } } - // Handle caps lock state change - if (isKeyCaps && !isKeyboardLedManagedByHost) { - setIsCapsLockActive(!isCapsLockActive); - } - // Collect new active keys and modifiers const newKeys = keys[cleanKey] ? [keys[cleanKey]] : []; const newModifiers = @@ -196,7 +192,7 @@ function KeyboardWrapper() { setTimeout(resetKeyboardState, 100); }, - [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], + [isCapsLockActive, sendKeyboardEvent, resetKeyboardState], ); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 4312c91..3878284 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -27,6 +27,7 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states + const oldSchool = false;// useSettingsStore(state => state.oldSchoolKeyboard); const videoElm = useRef(null); const mediaStream = useRTCStore(state => state.mediaStream); const [isPlaying, setIsPlaying] = useState(false); @@ -34,7 +35,7 @@ export default function WebRTCVideo() { const [isPointerLockActive, setIsPointerLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); - const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + const { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState } = useKeyboard(); const setMousePosition = useMouseStore(state => state.setMousePosition); const setMouseMove = useMouseStore(state => state.setMouseMove); const { @@ -51,18 +52,6 @@ export default function WebRTCVideo() { const videoBrightness = useSettingsStore(state => state.videoBrightness); const videoContrast = useSettingsStore(state => state.videoContrast); - // HID related states - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const isKeyboardLedManagedByHost = useMemo(() => - keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable, - [keyboardLedSync, keyboardLedStateSyncAvailable], - ); - - const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive); - const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); - const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive); - // RTC related states const peerConnection = useRTCStore(state => state.peerConnection); @@ -414,83 +403,66 @@ export default function WebRTCVideo() { async (e: KeyboardEvent) => { e.preventDefault(); const prev = useHidStore.getState(); - let code = e.code; - const key = e.key; - - if (!isKeyboardLedManagedByHost) { - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); - } - - if (code == "IntlBackslash" && ["`", "~"].includes(key)) { - code = "Backquote"; - } else if (code == "Backquote" && ["§", "±"].includes(key)) { - code = "IntlBackslash"; - } - - // Add the key to the active keys - const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean); - - // Add the modifier to the active modifiers - const newModifiers = handleModifierKeys(e, [ - ...prev.activeModifiers, - modifiers[code], - ]); + const code = getAdjustedKeyCode(e); + const hidKey = keys[code]; // When pressing the meta key + another key, the key will never trigger a keyup // event, so we need to clear the keys after a short delay // https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 - if (e.metaKey) { - setTimeout(() => { - const prev = useHidStore.getState(); - sendKeyboardEvent([], newModifiers || prev.activeModifiers); - }, 10); - } + + if (oldSchool) { + // Add the modifier to the active modifiers + const newModifiers = handleModifierKeys(e, [ + ...prev.activeModifiers, + modifiers[code], + ]); - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); + // Add the key to the active keys + const newKeys = [...prev.activeKeys, hidKey].filter(Boolean); + + if (e.metaKey) { + setTimeout((activeModifiers: number[]) => { + // TODO this should probably be passing prev.activeKeys not an empty array + sendKeyboardEvent([], newModifiers || activeModifiers); + }, 10, prev.activeModifiers); + } + sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); + } + else { + if (e.metaKey) { + setTimeout(() => { + sendKeypressEvent(hidKey, false); + }, 10); + } + sendKeypressEvent(hidKey, true); + } }, - [ - handleModifierKeys, - sendKeyboardEvent, - isKeyboardLedManagedByHost, - setIsNumLockActive, - setIsCapsLockActive, - setIsScrollLockActive, - ], + [handleModifierKeys, oldSchool, sendKeyboardEvent, sendKeypressEvent], ); const keyUpHandler = useCallback( - (e: KeyboardEvent) => { + async (e: KeyboardEvent) => { e.preventDefault(); const prev = useHidStore.getState(); + const code = getAdjustedKeyCode(e); + const hidKey = keys[code]; - if (!isKeyboardLedManagedByHost) { - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); + if (oldSchool) { + // Filter out the modifier that was just released + const newModifiers = handleModifierKeys( + e, + prev.activeModifiers.filter(k => k !== modifiers[code]), + ); + + // Filtering out the key that was just released (keys[e.code]) + const newKeys = prev.activeKeys.filter(k => k !== hidKey).filter(Boolean); + sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); + } else { + sendKeypressEvent(hidKey, false); } - - // Filtering out the key that was just released (keys[e.code]) - const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean); - - // Filter out the modifier that was just released - const newModifiers = handleModifierKeys( - e, - prev.activeModifiers.filter(k => k !== modifiers[e.code]), - ); - - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); }, - [ - handleModifierKeys, - sendKeyboardEvent, - isKeyboardLedManagedByHost, - setIsNumLockActive, - setIsCapsLockActive, - setIsScrollLockActive, - ], + [handleModifierKeys, oldSchool, sendKeyboardEvent, sendKeypressEvent], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -667,6 +639,18 @@ export default function WebRTCVideo() { }; }, [videoSaturation, videoBrightness, videoContrast]); + function getAdjustedKeyCode(e: KeyboardEvent) { + const key = e.key; + let code = e.code; + + if (code == "IntlBackslash" && ["`", "~"].includes(key)) { + code = "Backquote"; + } else if (code == "Backquote" && ["§", "±"].includes(key)) { + code = "IntlBackslash"; + } + return code; + } + return (
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index aa29528..e788faa 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -283,8 +283,6 @@ export const useVideoStore = create(set => ({ }, })); -export type KeyboardLedSync = "auto" | "browser" | "host"; - interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; @@ -308,9 +306,6 @@ interface SettingsState { keyboardLayout: string; setKeyboardLayout: (layout: string) => void; - keyboardLedSync: KeyboardLedSync; - setKeyboardLedSync: (sync: KeyboardLedSync) => void; - scrollThrottling: number; setScrollThrottling: (value: number) => void; @@ -356,9 +351,6 @@ export const useSettingsStore = create( keyboardLayout: "en-US", setKeyboardLayout: layout => set({ keyboardLayout: layout }), - keyboardLedSync: "auto", - setKeyboardLedSync: sync => set({ keyboardLedSync: sync }), - scrollThrottling: 0, setScrollThrottling: value => set({ scrollThrottling: value }), @@ -436,14 +428,13 @@ export interface KeyboardLedState { scroll_lock: boolean; compose: boolean; kana: boolean; + shift: boolean; // Optional, as not all keyboards have a shift LED }; -const defaultKeyboardLedState: KeyboardLedState = { - num_lock: false, - caps_lock: false, - scroll_lock: false, - compose: false, - kana: false, -}; + +export interface KeysDownState { + modifier: number; + keys: number[]; +} export interface HidState { activeKeys: number[]; @@ -465,12 +456,9 @@ export interface HidState { keyboardLedState?: KeyboardLedState; setKeyboardLedState: (state: KeyboardLedState) => void; - setIsNumLockActive: (active: boolean) => void; - setIsCapsLockActive: (active: boolean) => void; - setIsScrollLockActive: (active: boolean) => void; - keyboardLedStateSyncAvailable: boolean; - setKeyboardLedStateSyncAvailable: (available: boolean) => void; + keysDownState?: KeysDownState; + setKeysDownState: (state: KeysDownState) => void; isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; @@ -482,9 +470,10 @@ export interface HidState { setUsbState: (state: HidState["usbState"]) => void; } -export const useHidStore = create((set, get) => ({ +export const useHidStore = create((set) => ({ activeKeys: [], activeModifiers: [], + updateActiveKeysAndModifiers: ({ keys, modifiers }) => { return set({ activeKeys: keys, activeModifiers: modifiers }); }, @@ -498,25 +487,11 @@ export const useHidStore = create((set, get) => ({ altGrCtrlTime: 0, setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), + keyboardLedState: undefined, setKeyboardLedState: ledState => set({ keyboardLedState: ledState }), - setIsNumLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.num_lock = active; - set({ keyboardLedState }); - }, - setIsCapsLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.caps_lock = active; - set({ keyboardLedState }); - }, - setIsScrollLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.scroll_lock = active; - set({ keyboardLedState }); - }, - keyboardLedStateSyncAvailable: false, - setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), + keysDownState: undefined, + setKeysDownState: state => set({ keysDownState: state }), isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 0ce1eef..7861dd9 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { useHidStore, useRTCStore } from "@/hooks/stores"; +import { KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { keys, modifiers } from "@/keyboardMappings"; @@ -25,6 +25,30 @@ export default function useKeyboard() { [rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], ); + const modifiersFromModifierMask = (activeModifiers: number): number[] => { + return Object.values(modifiers).filter(m => (activeModifiers & m) !== 0); + } + + const sendKeypressEvent = useCallback( + (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + + send("keypressReport", { key, press }, resp => { + if ("error" in resp) { + console.error("Failed to send keypress:", resp.error); + } else { + const keyDownState = resp.result as KeysDownState; + const keysDown = keyDownState.keys; + const activeModifiers = modifiersFromModifierMask(keyDownState.modifier) + + // We do this for the info bar to display the currently pressed keys for the user + updateActiveKeysAndModifiers({ keys: keysDown, modifiers: activeModifiers }); + } + }); + }, + [rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], + ); + const resetKeyboardState = useCallback(() => { sendKeyboardEvent([], []); }, [sendKeyboardEvent]); @@ -52,5 +76,5 @@ export default function useKeyboard() { } }; - return { sendKeyboardEvent, resetKeyboardState, executeMacro }; + return { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro }; } diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index bb24fbb..1395a59 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -42,6 +42,7 @@ export const keys = { F10: 0x43, F11: 0x44, F12: 0x45, + F13: 0x68, F14: 0x69, F15: 0x6a, F16: 0x6b, diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index 57119ba..40c7c6f 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; -import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores"; +import { useSettingsStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { SettingsPageHeader } from "@components/SettingsPageheader"; @@ -13,14 +13,10 @@ import { SettingsItem } from "./devices.$id.settings"; export default function SettingsKeyboardRoute() { const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); const showPressedKeys = useSettingsStore(state => state.showPressedKeys); const setKeyboardLayout = useSettingsStore( state => state.setKeyboardLayout, ); - const setKeyboardLedSync = useSettingsStore( - state => state.setKeyboardLedSync, - ); const setShowPressedKeys = useSettingsStore( state => state.setShowPressedKeys, ); @@ -33,11 +29,6 @@ export default function SettingsKeyboardRoute() { }, [keyboardLayout]); const layoutOptions = keyboardOptions(); - const ledSyncOptions = [ - { value: "auto", label: "Automatic" }, - { value: "browser", label: "Browser Only" }, - { value: "host", label: "Host Only" }, - ]; const [send] = useJsonRpc(); @@ -91,23 +82,6 @@ export default function SettingsKeyboardRoute() {

-
- { /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ } - - setKeyboardLedSync(e.target.value as KeyboardLedSync)} - options={ledSyncOptions} - /> - -
-
state.keyboardLedState); const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState); - const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable); + const keysDownState = useHidStore(state => state.keysDownState); + const setKeysDownState = useHidStore(state => state.setKeysDownState); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -617,7 +619,12 @@ export default function KvmIdRoute() { const ledState = resp.params as KeyboardLedState; console.log("Setting keyboard led state", ledState); setKeyboardLedState(ledState); - setKeyboardLedStateSyncAvailable(true); + } + + if (resp.method === "keysDownState") { + const downState = resp.params as KeysDownState; + console.log("Setting key down state", downState); + setKeysDownState(downState); } if (resp.method === "otaState") { @@ -664,20 +671,29 @@ export default function KvmIdRoute() { send("getKeyboardLedState", {}, resp => { if ("error" in resp) { - // -32601 means the method is not supported - if (resp.error.code === -32601) { - setKeyboardLedStateSyncAvailable(false); - console.error("Failed to get keyboard led state, disabling sync", resp.error); - } else { - console.error("Failed to get keyboard led state", resp.error); - } + console.error("Failed to get keyboard led state", resp.error); return; } console.log("Keyboard led state", resp.result); setKeyboardLedState(resp.result as KeyboardLedState); - setKeyboardLedStateSyncAvailable(true); }); - }, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]); + }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]); + + // request keyboard key down state from the device + useEffect(() => { + if (rpcDataChannel?.readyState !== "open") return; + if (keysDownState !== undefined) return; + console.log("Requesting keys down state"); + + send("getKeyDownState", {}, resp => { + if ("error" in resp) { + console.error("Failed to get key down state", resp.error); + return; + } + console.log("Keyboard key down state", resp.result); + setKeysDownState(resp.result as KeysDownState); + }); + }, [keysDownState, rpcDataChannel?.readyState, send, setKeysDownState]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { diff --git a/usb.go b/usb.go index f777f89..3cf6908 100644 --- a/usb.go +++ b/usb.go @@ -31,16 +31,26 @@ func initUsbGadget() { } }) + gadget.SetOnKeysDownChange(func(state usbgadget.KeysDownState) { + if currentSession != nil { + writeJSONRPCEvent("keysDownState", state, currentSession) + } + }) + // open the keyboard hid file to listen for keyboard events if err := gadget.OpenKeyboardHidFile(); err != nil { usbLogger.Error().Err(err).Msg("failed to open keyboard hid file") } } -func rpcKeyboardReport(modifier uint8, keys []uint8) error { +func rpcKeyboardReport(modifier byte, keys []byte) error { return gadget.KeyboardReport(modifier, keys) } +func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) { + return gadget.KeypressReport(key, press) +} + func rpcAbsMouseReport(x, y int, buttons uint8) error { return gadget.AbsMouseReport(x, y, buttons) } @@ -57,6 +67,10 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) { return gadget.GetKeyboardState() } +func rpcGetKeysDownState() (state usbgadget.KeysDownState) { + return gadget.GetKeysDownState() +} + var usbState = "unknown" func rpcGetUSBState() (state string) { @@ -66,7 +80,7 @@ func rpcGetUSBState() (state string) { func triggerUSBStateUpdate() { go func() { if currentSession == nil { - usbLogger.Info().Msg("No active RPC session, skipping update state update") + usbLogger.Info().Msg("No active RPC session, skipping USB state update") return } writeJSONRPCEvent("usbState", usbState, currentSession) @@ -78,9 +92,9 @@ func checkUSBState() { if newState == usbState { return } + usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed") usbState = newState - usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed") requestDisplayUpdate(true) triggerUSBStateUpdate() } diff --git a/webrtc.go b/webrtc.go index f6c8529..f62a0f2 100644 --- a/webrtc.go +++ b/webrtc.go @@ -102,6 +102,7 @@ func newSession(config SessionConfig) (*Session, error) { ICEServers: []webrtc.ICEServer{iceServer}, }) if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to create PeerConnection") return nil, err } session := &Session{peerConnection: peerConnection} @@ -133,11 +134,13 @@ func newSession(config SessionConfig) (*Session, error) { session.VideoTrack, err = webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "kvm") if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to create VideoTrack") return nil, err } rtpSender, err := peerConnection.AddTrack(session.VideoTrack) if err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to add VideoTrack to PeerConnection") return nil, err } @@ -187,8 +190,9 @@ func newSession(config SessionConfig) (*Session, error) { currentSession = nil } if session.shouldUmountVirtualMedia { - err := rpcUnmountImage() - scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") + if err := rpcUnmountImage(); err != nil { + scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") + } } if isConnected { isConnected = false From 0c766343e4cd175c760cf3e77b28a992f6bf9151 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 13 Aug 2025 01:32:20 -0500 Subject: [PATCH 2/9] Remove all the old school code --- ui/src/components/WebRTCVideo.tsx | 125 +++--------------------------- 1 file changed, 11 insertions(+), 114 deletions(-) diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 3878284..ec86149 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -9,9 +9,8 @@ import notifications from "@/notifications"; import useKeyboard from "@/hooks/useKeyboard"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { cx } from "@/cva.config"; -import { keys, modifiers } from "@/keyboardMappings"; +import { keys } from "@/keyboardMappings"; import { - useHidStore, useMouseStore, useRTCStore, useSettingsStore, @@ -27,7 +26,6 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states - const oldSchool = false;// useSettingsStore(state => state.oldSchoolKeyboard); const videoElm = useRef(null); const mediaStream = useRTCStore(state => state.mediaStream); const [isPlaying, setIsPlaying] = useState(false); @@ -35,7 +33,7 @@ export default function WebRTCVideo() { const [isPointerLockActive, setIsPointerLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); - const { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState } = useKeyboard(); + const { sendKeypressEvent, resetKeyboardState } = useKeyboard(); const setMousePosition = useMouseStore(state => state.setMousePosition); const setMouseMove = useMouseStore(state => state.setMouseMove); const { @@ -333,76 +331,9 @@ export default function WebRTCVideo() { sendAbsMouseMovement(0, 0, 0); }, [sendAbsMouseMovement]); - // Keyboard-related - const handleModifierKeys = useCallback( - (e: KeyboardEvent, activeModifiers: number[]) => { - const { shiftKey, ctrlKey, altKey, metaKey } = e; - - const filteredModifiers = activeModifiers.filter(Boolean); - - // Example: activeModifiers = [0x01, 0x02, 0x04, 0x08] - // Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft - return ( - filteredModifiers - // Shift: Keep if Shift is pressed or if the key isn't a Shift key - // Example: If shiftKey is true, keep all modifiers - // If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight) - .filter( - modifier => - shiftKey || - (modifier !== modifiers["ShiftLeft"] && - modifier !== modifiers["ShiftRight"]), - ) - // Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key - // Example: If ctrlKey is true, keep all modifiers - // If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight) - .filter( - modifier => - ctrlKey || - (modifier !== modifiers["ControlLeft"] && - modifier !== modifiers["ControlRight"]), - ) - // Alt: Keep if Alt is pressed or if the key isn't an Alt key - // Example: If altKey is true, keep all modifiers - // If altKey is false, filter out 0x04 (AltLeft) - // - // But intentionally do not filter out 0x40 (AltRight) to accomodate - // Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare - // itself to be an altKey. For example, the KeyboardEvent for - // Alt Gr + 2 has the following structure: - // - altKey: false - // - code: "Digit2" - // - type: [ "keydown" | "keyup" ] - // - // For context, filteredModifiers aims to keep track which modifiers - // are being pressed on the physical keyboard at any point in time. - // There is logic in the keyUpHandler and keyDownHandler to add and - // remove 0x40 (AltRight) from the list of new modifiers. - // - // But relying on the two handlers alone to track the state of the - // modifier bears the risk that the key up event for Alt Gr could - // get lost while the browser window is temporarily out of focus, - // which means the Alt Gr key state would then be "stuck". At this - // point, we would need to rely on the user to press Alt Gr again - // to properly release the state of that modifier. - .filter(modifier => altKey || modifier !== modifiers["AltLeft"]) - // Meta: Keep if Meta is pressed or if the key isn't a Meta key - // Example: If metaKey is true, keep all modifiers - // If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight) - .filter( - modifier => - metaKey || - (modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]), - ) - ); - }, - [], - ); - const keyDownHandler = useCallback( async (e: KeyboardEvent) => { e.preventDefault(); - const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; @@ -410,59 +341,25 @@ export default function WebRTCVideo() { // event, so we need to clear the keys after a short delay // https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 - - if (oldSchool) { - // Add the modifier to the active modifiers - const newModifiers = handleModifierKeys(e, [ - ...prev.activeModifiers, - modifiers[code], - ]); - - // Add the key to the active keys - const newKeys = [...prev.activeKeys, hidKey].filter(Boolean); - - if (e.metaKey) { - setTimeout((activeModifiers: number[]) => { - // TODO this should probably be passing prev.activeKeys not an empty array - sendKeyboardEvent([], newModifiers || activeModifiers); - }, 10, prev.activeModifiers); - } - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); - } - else { - if (e.metaKey) { - setTimeout(() => { - sendKeypressEvent(hidKey, false); - }, 10); - } - sendKeypressEvent(hidKey, true); + if (e.metaKey) { + setTimeout(() => { + sendKeypressEvent(hidKey, false); + }, 10); } + sendKeypressEvent(hidKey, true); + }, - [handleModifierKeys, oldSchool, sendKeyboardEvent, sendKeypressEvent], + [sendKeypressEvent], ); const keyUpHandler = useCallback( async (e: KeyboardEvent) => { e.preventDefault(); - const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; - - if (oldSchool) { - // Filter out the modifier that was just released - const newModifiers = handleModifierKeys( - e, - prev.activeModifiers.filter(k => k !== modifiers[code]), - ); - - // Filtering out the key that was just released (keys[e.code]) - const newKeys = prev.activeKeys.filter(k => k !== hidKey).filter(Boolean); - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); - } else { - sendKeypressEvent(hidKey, false); - } + sendKeypressEvent(hidKey, false); }, - [handleModifierKeys, oldSchool, sendKeyboardEvent, sendKeypressEvent], + [sendKeypressEvent], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { From 0ef60189c07c4d48066e18486077534f61e7f745 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 13 Aug 2025 16:39:55 -0500 Subject: [PATCH 3/9] Fix handling of meta keys in client --- jsonrpc.go | 63 +++++++++++++++++++------------ ui/src/components/WebRTCVideo.tsx | 14 ++++++- ui/src/keyboardMappings.ts | 10 ++++- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index 81b6a65..dedf586 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -13,6 +13,7 @@ import ( "time" "github.com/pion/webrtc/v4" + "github.com/rs/zerolog" "go.bug.st/serial" "github.com/jetkvm/kvm/internal/usbgadget" @@ -134,7 +135,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { } scopedLogger.Trace().Msg("Calling RPC handler") - result, err := callRPCHandler(handler, request.Params) + result, err := callRPCHandler(scopedLogger, handler, request.Params) if err != nil { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") errorResponse := JSONRPCResponse{ @@ -472,7 +473,7 @@ type RPCHandler struct { } // call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls -func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) { +func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]interface{}) (result interface{}, err error) { // Use defer to recover from a panic defer func() { if r := recover(); r != nil { @@ -486,11 +487,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i }() // Call the handler - result, err = riskyCallRPCHandler(handler, params) - return result, err + result, err = riskyCallRPCHandler(logger, handler, params) + return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err } -func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { +func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]interface{}) (interface{}, error) { handlerValue := reflect.ValueOf(handler.Func) handlerType := handlerValue.Type() @@ -499,20 +500,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } numParams := handlerType.NumIn() - args := make([]reflect.Value, numParams) - // Get the parameter names from the RPCHandler - paramNames := handler.Params + paramNames := handler.Params // Get the parameter names from the RPCHandler if len(paramNames) != numParams { - return nil, errors.New("mismatch between handler parameters and defined parameter names") + err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames)) + logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler") + return nil, err } + args := make([]reflect.Value, numParams) + for i := 0; i < numParams; i++ { paramType := handlerType.In(i) paramName := paramNames[i] paramValue, ok := params[paramName] if !ok { - return nil, errors.New("missing parameter: " + paramName) + err := fmt.Errorf("missing parameter: %s", paramName) + logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler") + return nil, err } convertedValue := reflect.ValueOf(paramValue) @@ -529,7 +534,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { intValue := int(elemValue.Float()) if intValue < 0 || intValue > 255 { - return nil, fmt.Errorf("value out of range for uint8: %v", intValue) + return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName) } newSlice.Index(j).SetUint(uint64(intValue)) } else { @@ -545,12 +550,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { jsonData, err := json.Marshal(convertedValue.Interface()) if err != nil { - return nil, fmt.Errorf("failed to marshal map to JSON: %v", err) + return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName) } newStruct := reflect.New(paramType).Interface() if err := json.Unmarshal(jsonData, newStruct); err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err) + return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName) } args[i] = reflect.ValueOf(newStruct).Elem() } else { @@ -561,6 +566,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } } + logger.Trace().Interface("args", args).Msg("Calling RPC handler") results := handlerValue.Call(args) if len(results) == 0 { @@ -568,23 +574,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } if len(results) == 1 { - if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[0].IsNil() { - return nil, results[0].Interface().(error) + if ok, err := asError(results[0]); ok { + return nil, err + } + return results[0].Interface(), nil + } + + if len(results) == 2 { + if ok, err := asError(results[1]); ok { + if err != nil { + return nil, err } - return nil, nil } return results[0].Interface(), nil } - if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[1].IsNil() { - return nil, results[1].Interface().(error) - } - return results[0].Interface(), nil - } + return nil, fmt.Errorf("too many return values from handler: %d", len(results)) +} - return nil, errors.New("unexpected return values from handler") +func asError(value reflect.Value) (bool, error) { + if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if value.IsNil() { + return true, nil + } + return true, value.Interface().(error) + } + return false, nil } func rpcSetMassStorageMode(mode string) (string, error) { diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index ec86149..c377154 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -337,17 +337,21 @@ export default function WebRTCVideo() { const code = getAdjustedKeyCode(e); const hidKey = keys[code]; + if (hidKey === undefined) { + console.warn(`Key down not mapped: ${code}`); + return; + } + // When pressing the meta key + another key, the key will never trigger a keyup // event, so we need to clear the keys after a short delay // https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 - if (e.metaKey) { + if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { sendKeypressEvent(hidKey, false); }, 10); } sendKeypressEvent(hidKey, true); - }, [sendKeypressEvent], ); @@ -357,6 +361,12 @@ export default function WebRTCVideo() { e.preventDefault(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; + + if (hidKey === undefined) { + console.warn(`Key up not mapped: ${code}`); + return; + } + sendKeypressEvent(hidKey, false); }, [sendKeypressEvent], diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index 1395a59..7122254 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -14,7 +14,7 @@ export const keys = { CapsLock: 0x39, Comma: 0x36, Compose: 0x65, - ContextMenu: 0, + ContextMenu: 0x65, // same as Compose Delete: 0x4c, Digit0: 0x27, Digit1: 0x1e, @@ -121,6 +121,14 @@ export const keys = { Space: 0x2c, SystemRequest: 0x9a, Tab: 0x2b, + ControlLeft: 0xe0, + ControlRight: 0xe4, + ShiftLeft: 0xe1, + ShiftRight: 0xe5, + AltLeft: 0xe2, + AltRight: 0xe6, + MetaLeft: 0xe3, + MetaRight: 0xe7, } as Record; export const modifiers = { From b3d8c3b77deb329244f311b6ee2d1031791f5bec Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 13 Aug 2025 18:09:18 -0500 Subject: [PATCH 4/9] Ran go modernize Morphs Interface{} to any Ranges over SplitSeq and FieldSeq for iterating splits Used min for end calculation remote_mount.Read Used range 16 in wol.createMagicPacket DID NOT apply the Omitempty cleanup. --- display.go | 20 +++++------ internal/confparser/confparser.go | 20 +++++------ internal/confparser/utils.go | 2 +- internal/logging/logger.go | 6 ++-- internal/logging/pion.go | 10 +++--- internal/logging/utils.go | 2 +- internal/network/hostname.go | 2 +- internal/network/utils.go | 2 +- internal/udhcpc/parser.go | 4 +-- internal/udhcpc/udhcpc.go | 2 +- internal/usbgadget/usbgadget.go | 2 +- internal/usbgadget/utils.go | 2 +- jsonrpc.go | 58 +++++++++++++++---------------- log.go | 2 +- native.go | 22 ++++++------ remote_mount.go | 5 +-- ui/package-lock.json | 20 +++++------ ui/package.json | 14 ++++---- wol.go | 2 +- 19 files changed, 97 insertions(+), 100 deletions(-) diff --git a/display.go b/display.go index 274bb8b..aab19fb 100644 --- a/display.go +++ b/display.go @@ -30,7 +30,7 @@ const ( // do not call this function directly, use switchToScreenIfDifferent instead // this function is not thread safe func switchToScreen(screen string) { - _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) + _, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen}) if err != nil { displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") return @@ -39,15 +39,15 @@ func switchToScreen(screen string) { } func lvObjSetState(objName string, state string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state}) + return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state}) } func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag}) + return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag}) } func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag}) + return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag}) } func lvObjHide(objName string) (*CtrlResponse, error) { @@ -59,27 +59,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) { } func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused - return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity}) + return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity}) } func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration}) } func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration}) } func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { - return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text}) + return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text}) } func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) { - return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src}) + return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src}) } func lvDispSetRotation(rotation string) (*CtrlResponse, error) { - return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation}) + return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation}) } func updateLabelIfChanged(objName string, newText string) { diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 5ccd1cb..aaa3968 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -16,22 +16,22 @@ import ( type FieldConfig struct { Name string Required bool - RequiredIf map[string]interface{} + RequiredIf map[string]any OneOf []string ValidateTypes []string - Defaults interface{} + Defaults any IsEmpty bool - CurrentValue interface{} + CurrentValue any TypeString string Delegated bool shouldUpdateValue bool } -func SetDefaultsAndValidate(config interface{}) error { +func SetDefaultsAndValidate(config any) error { return setDefaultsAndValidate(config, true) } -func setDefaultsAndValidate(config interface{}, isRoot bool) error { +func setDefaultsAndValidate(config any, isRoot bool) error { // first we need to check if the config is a pointer if reflect.TypeOf(config).Kind() != reflect.Ptr { return fmt.Errorf("config is not a pointer") @@ -55,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { Name: field.Name, OneOf: splitString(field.Tag.Get("one_of")), ValidateTypes: splitString(field.Tag.Get("validate_type")), - RequiredIf: make(map[string]interface{}), + RequiredIf: make(map[string]any), CurrentValue: fieldValue.Interface(), IsEmpty: false, TypeString: fieldType, @@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { // now check if the field has required_if requiredIf := field.Tag.Get("required_if") if requiredIf != "" { - requiredIfParts := strings.Split(requiredIf, ",") - for _, part := range requiredIfParts { + requiredIfParts := strings.SplitSeq(requiredIf, ",") + for part := range requiredIfParts { partVal := strings.SplitN(part, "=", 2) if len(partVal) != 2 { return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) @@ -168,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { return nil } -func validateFields(config interface{}, fields map[string]FieldConfig) error { +func validateFields(config any, fields map[string]FieldConfig) error { // now we can start to validate the fields for _, fieldConfig := range fields { if err := fieldConfig.validate(fields); err != nil { @@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error { return nil } -func (f *FieldConfig) populate(config interface{}) { +func (f *FieldConfig) populate(config any) { // update the field if it's not empty if !f.shouldUpdateValue { return diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go index a46871e..36ee28b 100644 --- a/internal/confparser/utils.go +++ b/internal/confparser/utils.go @@ -16,7 +16,7 @@ func splitString(s string) []string { return strings.Split(s, ",") } -func toString(v interface{}) (string, error) { +func toString(v any) (string, error) { switch v := v.(type) { case string: return v, nil diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 39156ec..3a8274c 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -50,7 +50,7 @@ var ( TimeFormat: time.RFC3339, PartsOrder: []string{"time", "level", "scope", "component", "message"}, FieldsExclude: []string{"scope", "component"}, - FormatPartValueByName: func(value interface{}, name string) string { + FormatPartValueByName: func(value any, name string) string { val := fmt.Sprintf("%s", value) if name == "component" { if value == nil { @@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() { continue } - scopes := strings.Split(strings.ToLower(env), ",") - for _, scope := range scopes { + scopes := strings.SplitSeq(strings.ToLower(env), ",") + for scope := range scopes { l.scopeLevels[scope] = level } } diff --git a/internal/logging/pion.go b/internal/logging/pion.go index 453b8bc..2676caf 100644 --- a/internal/logging/pion.go +++ b/internal/logging/pion.go @@ -13,32 +13,32 @@ type pionLogger struct { func (c pionLogger) Trace(msg string) { c.logger.Trace().Msg(msg) } -func (c pionLogger) Tracef(format string, args ...interface{}) { +func (c pionLogger) Tracef(format string, args ...any) { c.logger.Trace().Msgf(format, args...) } func (c pionLogger) Debug(msg string) { c.logger.Debug().Msg(msg) } -func (c pionLogger) Debugf(format string, args ...interface{}) { +func (c pionLogger) Debugf(format string, args ...any) { c.logger.Debug().Msgf(format, args...) } func (c pionLogger) Info(msg string) { c.logger.Info().Msg(msg) } -func (c pionLogger) Infof(format string, args ...interface{}) { +func (c pionLogger) Infof(format string, args ...any) { c.logger.Info().Msgf(format, args...) } func (c pionLogger) Warn(msg string) { c.logger.Warn().Msg(msg) } -func (c pionLogger) Warnf(format string, args ...interface{}) { +func (c pionLogger) Warnf(format string, args ...any) { c.logger.Warn().Msgf(format, args...) } func (c pionLogger) Error(msg string) { c.logger.Error().Msg(msg) } -func (c pionLogger) Errorf(format string, args ...interface{}) { +func (c pionLogger) Errorf(format string, args ...any) { c.logger.Error().Msgf(format, args...) } diff --git a/internal/logging/utils.go b/internal/logging/utils.go index e622d96..73ae37a 100644 --- a/internal/logging/utils.go +++ b/internal/logging/utils.go @@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger { return &defaultLogger } -func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { +func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { // TODO: move rootLogger to logging package if l == nil { l = &defaultLogger diff --git a/internal/network/hostname.go b/internal/network/hostname.go index d75255c..09d3996 100644 --- a/internal/network/hostname.go +++ b/internal/network/hostname.go @@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error { hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) hostLineExists := false - for _, line := range strings.Split(string(lines), "\n") { + for line := range strings.SplitSeq(string(lines), "\n") { if strings.HasPrefix(line, "127.0.1.1") { hostLineExists = true line = hostLine diff --git a/internal/network/utils.go b/internal/network/utils.go index 6d64332..797fd72 100644 --- a/internal/network/utils.go +++ b/internal/network/utils.go @@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time { return &t } -func IsSame(a, b interface{}) bool { +func IsSame(a, b any) bool { aJSON, err := json.Marshal(a) if err != nil { return false diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go index 66c3ba2..d75857c 100644 --- a/internal/udhcpc/parser.go +++ b/internal/udhcpc/parser.go @@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) { func UnmarshalDHCPCLease(lease *Lease, str string) error { // parse the lease file as a map data := make(map[string]string) - for _, line := range strings.Split(str, "\n") { + for line := range strings.SplitSeq(str, "\n") { line = strings.TrimSpace(line) // skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { @@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error { field.Set(reflect.ValueOf(ip)) case []net.IP: val := make([]net.IP, 0) - for _, ipStr := range strings.Fields(value) { + for ipStr := range strings.FieldsSeq(value) { ip := net.ParseIP(ipStr) if ip == nil { continue diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 128ea66..7b4d6e4 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { } func (c *DHCPClient) getWatchPaths() []string { - watchPaths := make(map[string]interface{}) + watchPaths := make(map[string]any) watchPaths[filepath.Dir(c.leaseFile)] = nil if c.pidFile != "" { diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index 21e37cd..0e0604f 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -131,7 +131,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev keyboardStateCtx: keyboardCtx, keyboardStateCancel: keyboardCancel, keyboardState: 0, - keysDownState: KeysDownState{Modifier: 0, Keys: make([]byte, hidKeyBufferSize)}, + keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes enabledDevices: *enabledDevices, lastUserInput: time.Now(), log: logger, diff --git a/internal/usbgadget/utils.go b/internal/usbgadget/utils.go index 8654924..f3d22ed 100644 --- a/internal/usbgadget/utils.go +++ b/internal/usbgadget/utils.go @@ -81,7 +81,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) return false } -func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) { +func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) { u.logSuppressionLock.Lock() defer u.logSuppressionLock.Unlock() diff --git a/jsonrpc.go b/jsonrpc.go index dedf586..378c6a0 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -20,23 +20,23 @@ import ( ) type JSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]interface{} `json:"params,omitempty"` - ID interface{} `json:"id,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` + ID any `json:"id,omitempty"` } type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result interface{} `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` - ID interface{} `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` + ID any `json:"id"` } type JSONRPCEvent struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params,omitempty"` } type DisplayRotationSettings struct { @@ -62,7 +62,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { } } -func writeJSONRPCEvent(event string, params interface{}, session *Session) { +func writeJSONRPCEvent(event string, params any, session *Session) { request := JSONRPCEvent{ JSONRPC: "2.0", Method: event, @@ -103,7 +103,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32700, "message": "Parse error", }, @@ -124,7 +124,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { if !ok { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32601, "message": "Method not found", }, @@ -140,7 +140,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32603, "message": "Internal error", "data": err.Error(), @@ -201,7 +201,7 @@ func rpcGetStreamQualityFactor() (float64, error) { func rpcSetStreamQualityFactor(factor float64) error { logger.Info().Float64("factor", factor).Msg("Setting stream quality factor") - var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor}) + var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor}) if err != nil { return err } @@ -241,7 +241,7 @@ func rpcSetEDID(edid string) error { } else { logger.Info().Str("edid", edid).Msg("Setting EDID") } - _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid}) + _, err := CallCtrlAction("set_edid", map[string]any{"edid": edid}) if err != nil { return err } @@ -468,12 +468,12 @@ func rpcSetTLSState(state TLSState) error { } type RPCHandler struct { - Func interface{} + Func any Params []string } // call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls -func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]interface{}) (result interface{}, err error) { +func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) { // Use defer to recover from a panic defer func() { if r := recover(); r != nil { @@ -491,7 +491,7 @@ func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err } -func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]interface{}) (interface{}, error) { +func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) { handlerValue := reflect.ValueOf(handler.Func) handlerType := handlerValue.Type() @@ -510,7 +510,7 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s args := make([]reflect.Value, numParams) - for i := 0; i < numParams; i++ { + for i := range numParams { paramType := handlerType.In(i) paramName := paramNames[i] paramValue, ok := params[paramName] @@ -938,7 +938,7 @@ func rpcSetKeyboardLayout(layout string) error { return nil } -func getKeyboardMacros() (interface{}, error) { +func getKeyboardMacros() (any, error) { macros := make([]KeyboardMacro, len(config.KeyboardMacros)) copy(macros, config.KeyboardMacros) @@ -946,10 +946,10 @@ func getKeyboardMacros() (interface{}, error) { } type KeyboardMacrosParams struct { - Macros []interface{} `json:"macros"` + Macros []any `json:"macros"` } -func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { +func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { if params.Macros == nil { return nil, fmt.Errorf("missing or invalid macros parameter") } @@ -957,7 +957,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { newMacros := make([]KeyboardMacro, 0, len(params.Macros)) for i, item := range params.Macros { - macroMap, ok := item.(map[string]interface{}) + macroMap, ok := item.(map[string]any) if !ok { return nil, fmt.Errorf("invalid macro at index %d", i) } @@ -975,16 +975,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { } steps := []KeyboardMacroStep{} - if stepsArray, ok := macroMap["steps"].([]interface{}); ok { + if stepsArray, ok := macroMap["steps"].([]any); ok { for _, stepItem := range stepsArray { - stepMap, ok := stepItem.(map[string]interface{}) + stepMap, ok := stepItem.(map[string]any) if !ok { continue } step := KeyboardMacroStep{} - if keysArray, ok := stepMap["keys"].([]interface{}); ok { + if keysArray, ok := stepMap["keys"].([]any); ok { for _, k := range keysArray { if keyStr, ok := k.(string); ok { step.Keys = append(step.Keys, keyStr) @@ -992,7 +992,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { } } - if modsArray, ok := stepMap["modifiers"].([]interface{}); ok { + if modsArray, ok := stepMap["modifiers"].([]any); ok { for _, m := range modsArray { if modStr, ok := m.(string); ok { step.Modifiers = append(step.Modifiers, modStr) diff --git a/log.go b/log.go index b353a2c..1a091b1 100644 --- a/log.go +++ b/log.go @@ -5,7 +5,7 @@ import ( "github.com/rs/zerolog" ) -func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { +func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { return logging.ErrorfL(l, format, err, args...) } diff --git a/native.go b/native.go index 9807206..67f423a 100644 --- a/native.go +++ b/native.go @@ -21,18 +21,18 @@ import ( var ctrlSocketConn net.Conn type CtrlAction struct { - Action string `json:"action"` - Seq int32 `json:"seq,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` + Action string `json:"action"` + Seq int32 `json:"seq,omitempty"` + Params map[string]any `json:"params,omitempty"` } type CtrlResponse struct { - Seq int32 `json:"seq,omitempty"` - Error string `json:"error,omitempty"` - Errno int32 `json:"errno,omitempty"` - Result map[string]interface{} `json:"result,omitempty"` - Event string `json:"event,omitempty"` - Data json.RawMessage `json:"data,omitempty"` + Seq int32 `json:"seq,omitempty"` + Error string `json:"error,omitempty"` + Errno int32 `json:"errno,omitempty"` + Result map[string]any `json:"result,omitempty"` + Event string `json:"event,omitempty"` + Data json.RawMessage `json:"data,omitempty"` } type EventHandler func(event CtrlResponse) @@ -48,7 +48,7 @@ var ( nativeCmdLock = &sync.Mutex{} ) -func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { +func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) { lock.Lock() defer lock.Unlock() ctrlAction := CtrlAction{ @@ -429,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error { func restoreHdmiEdid() { if config.EdidString != "" { nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID") - _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) + _, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString}) if err != nil { nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID") } diff --git a/remote_mount.go b/remote_mount.go index befffcb..32a0fd2 100644 --- a/remote_mount.go +++ b/remote_mount.go @@ -27,10 +27,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ( } mountedImageSize := currentVirtualMediaState.Size virtualMediaStateMutex.RUnlock() - end := offset + size - if end > mountedImageSize { - end = mountedImageSize - } + end := min(offset+size, mountedImageSize) req := DiskReadRequest{ Start: uint64(offset), End: uint64(end), diff --git a/ui/package-lock.json b/ui/package-lock.json index bc05d41..ab6f459 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -31,7 +31,7 @@ "react-hot-toast": "^2.5.2", "react-icons": "^5.5.0", "react-router-dom": "^6.22.3", - "react-simple-keyboard": "^3.8.106", + "react-simple-keyboard": "^3.8.109", "react-use-websocket": "^4.13.0", "react-xtermjs": "^1.0.10", "recharts": "^2.15.3", @@ -41,22 +41,22 @@ "zustand": "^4.5.2" }, "devDependencies": { - "@eslint/compat": "^1.3.1", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.32.0", + "@eslint/js": "^9.33.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4.1.11", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.11", - "@types/react": "^19.1.9", + "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", "@types/validator": "^13.15.2", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/eslint-plugin": "^8.39.1", + "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.32.0", + "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", @@ -5851,9 +5851,9 @@ } }, "node_modules/react-simple-keyboard": { - "version": "3.8.108", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.108.tgz", - "integrity": "sha512-Q3JK/qnaDjTMaE6EcNOG4MJV8jHqug2K1YIz9j+QXgmAE/nUuHXuyAnLIZuU3uvqv2n/556l2hLkqr9jW1LgUw==", + "version": "3.8.109", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.109.tgz", + "integrity": "sha512-FLlivKL4tb5G2cWOo2slOrMEkzzFX0Yg8P7k5qzisN8+TnqUPq+8G7N8D2+0oVkSmfeqZn6PyLCurGSitK4QIQ==", "license": "MIT", "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/ui/package.json b/ui/package.json index 9f0c298..4c929f0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -42,7 +42,7 @@ "react-hot-toast": "^2.5.2", "react-icons": "^5.5.0", "react-router-dom": "^6.22.3", - "react-simple-keyboard": "^3.8.106", + "react-simple-keyboard": "^3.8.109", "react-use-websocket": "^4.13.0", "react-xtermjs": "^1.0.10", "recharts": "^2.15.3", @@ -52,22 +52,22 @@ "zustand": "^4.5.2" }, "devDependencies": { - "@eslint/compat": "^1.3.1", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.32.0", + "@eslint/js": "^9.33.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4.1.11", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.11", - "@types/react": "^19.1.9", + "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", "@types/validator": "^13.15.2", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/eslint-plugin": "^8.39.1", + "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.32.0", + "eslint": "^9.33.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", diff --git a/wol.go b/wol.go index 02b5c96..c3d0de2 100644 --- a/wol.go +++ b/wol.go @@ -65,7 +65,7 @@ func createMagicPacket(mac net.HardwareAddr) []byte { buf.Write(bytes.Repeat([]byte{0xFF}, 6)) // Write the target MAC address 16 times - for i := 0; i < 16; i++ { + for range 16 { _ = binary.Write(&buf, binary.BigEndian, mac) } From 1e57f4bf4fedf17aa59326998e85b2dc48a9d020 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 13 Aug 2025 18:10:31 -0500 Subject: [PATCH 5/9] Use the KeysDownState for the infobar Strong typed in the typescript realm. --- internal/usbgadget/hid_keyboard.go | 26 ++-- internal/usbgadget/hid_mouse_absolute.go | 14 +- internal/usbgadget/hid_mouse_relative.go | 10 +- internal/usbgadget/usbgadget.go | 4 +- internal/usbgadget/utils.go | 26 ++++ jsonrpc.go | 2 +- ui/src/components/InfoBar.tsx | 57 +++++--- ui/src/components/WebRTCVideo.tsx | 22 +-- ui/src/hooks/stores.ts | 170 +++++++++++------------ ui/src/hooks/useJsonRpc.ts | 4 +- ui/src/hooks/useKeyboard.ts | 29 ++-- ui/src/routes/devices.$id.tsx | 70 +++++----- usb.go | 4 +- 13 files changed, 230 insertions(+), 208 deletions(-) diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 7be48d4..2c4a456 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -209,7 +209,7 @@ func (u *UsbGadget) listenKeyboardEvents() { } u.resetLogSuppressionCounter("keyboardHidFileRead") - l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard") + l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard") if n != 1 { l.Trace().Int("n", n).Msg("expected 1 byte, got") continue @@ -250,7 +250,7 @@ func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { return err } - _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:]...)) + _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) if err != nil { u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.keyboardHidFile.Close() @@ -289,14 +289,8 @@ const ( RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key) ) -// KeyCodeMask maps a key code to its corresponding bit mask -type KeyCodeMask struct { - KeyCode byte - Mask byte -} - // KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup -var KeyCodeToMaskMap = map[uint8]uint8{ +var KeyCodeToMaskMap = map[byte]byte{ LeftControl: ModifierMaskLeftControl, LeftShift: ModifierMaskLeftShift, LeftAlt: ModifierMaskLeftAlt, @@ -314,7 +308,7 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) var state = u.keysDownState modifier := state.Modifier - keys := state.Keys[:] + keys := append([]byte(nil), state.Keys...) if mask, exists := KeyCodeToMaskMap[key]; exists { // If the key is a modifier key, we update the keyboardModifier state @@ -364,13 +358,15 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) } if err := u.keyboardWriteHidFile(modifier, keys); err != nil { - u.log.Warn().Uint8("modifier", modifier).Bytes("keys", keys).Msg("Could not write keypress report to hidg0") + u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0") } - state.Modifier = modifier - state.Keys = keys + var result = KeysDownState{ + Modifier: modifier, + Keys: []byte(keys[:]), + } - u.updateKeyDownState(state) + u.updateKeyDownState(result) - return state, nil + return result, nil } diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index 2718f20..c083b60 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -85,17 +85,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error { +func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error { u.absMouseLock.Lock() defer u.absMouseLock.Unlock() err := u.absMouseWriteHidFile([]byte{ - 1, // Report ID 1 - buttons, // Buttons - uint8(x), // X Low Byte - uint8(x >> 8), // X High Byte - uint8(y), // Y Low Byte - uint8(y >> 8), // Y High Byte + 1, // Report ID 1 + buttons, // Buttons + byte(x), // X Low Byte + byte(x >> 8), // X High Byte + byte(y), // Y Low Byte + byte(y >> 8), // Y High Byte }) if err != nil { return err diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 786f265..70cb72c 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -75,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error { +func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { u.relMouseLock.Lock() defer u.relMouseLock.Unlock() err := u.relMouseWriteHidFile([]byte{ - buttons, // Buttons - uint8(mx), // X - uint8(my), // Y - 0, // Wheel + buttons, // Buttons + byte(mx), // X + byte(my), // Y + 0, // Wheel }) if err != nil { return err diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index 0e0604f..3a01a44 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -42,8 +42,8 @@ var defaultUsbGadgetDevices = Devices{ } type KeysDownState struct { - Modifier byte `json:"modifier"` - Keys []byte `json:"keys"` + Modifier byte `json:"modifier"` + Keys ByteSlice `json:"keys"` } // UsbGadget is a struct that represents a USB gadget. diff --git a/internal/usbgadget/utils.go b/internal/usbgadget/utils.go index f3d22ed..05fcd3a 100644 --- a/internal/usbgadget/utils.go +++ b/internal/usbgadget/utils.go @@ -2,6 +2,7 @@ package usbgadget import ( "bytes" + "encoding/json" "fmt" "path/filepath" "strconv" @@ -10,6 +11,31 @@ import ( "github.com/rs/zerolog" ) +type ByteSlice []byte + +func (s ByteSlice) MarshalJSON() ([]byte, error) { + vals := make([]int, len(s)) + for i, v := range s { + vals[i] = int(v) + } + return json.Marshal(vals) +} + +func (s *ByteSlice) UnmarshalJSON(data []byte) error { + var vals []int + if err := json.Unmarshal(data, &vals); err != nil { + return err + } + *s = make([]byte, len(vals)) + for i, v := range vals { + if v < 0 || v > 255 { + return fmt.Errorf("value %d out of byte range", v) + } + (*s)[i] = byte(v) + } + return nil +} + func joinPath(basePath string, paths []string) string { pathArr := append([]string{basePath}, paths...) return filepath.Join(pathArr...) diff --git a/jsonrpc.go b/jsonrpc.go index 378c6a0..7d05933 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -566,7 +566,7 @@ func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[s } } - logger.Trace().Interface("args", args).Msg("Calling RPC handler") + logger.Trace().Msg("Calling RPC handler") results := handlerValue.Call(args) if len(results) == 0 { diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 02ff170..8d3b40e 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -1,47 +1,65 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { cx } from "@/cva.config"; import { + HidState, + KeysDownState, + MouseState, + RTCState, + SettingsState, useHidStore, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, + VideoState, } from "@/hooks/stores"; import { keys, modifiers } from "@/keyboardMappings"; export default function InfoBar() { - const activeKeys = useHidStore(state => state.activeKeys); - const activeModifiers = useHidStore(state => state.activeModifiers); - const mouseX = useMouseStore(state => state.mouseX); - const mouseY = useMouseStore(state => state.mouseY); - const mouseMove = useMouseStore(state => state.mouseMove); + const keysDownState = useHidStore((state: HidState) => state.keysDownState); + const mouseX = useMouseStore((state: MouseState) => state.mouseX); + const mouseY = useMouseStore((state: MouseState) => state.mouseY); + const mouseMove = useMouseStore((state: MouseState) => state.mouseMove); const videoClientSize = useVideoStore( - state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, + (state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, ); const videoSize = useVideoStore( - state => `${Math.round(state.width)}x${Math.round(state.height)}`, + (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`, ); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); const settings = useSettingsStore(); - const showPressedKeys = useSettingsStore(state => state.showPressedKeys); + const showPressedKeys = useSettingsStore((state: SettingsState) => state.showPressedKeys); useEffect(() => { if (!rpcDataChannel) return; rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); - rpcDataChannel.onerror = e => + rpcDataChannel.onerror = (e: Event) => console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); }, [rpcDataChannel]); - const keyboardLedState = useHidStore(state => state.keyboardLedState); - const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); + const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); + const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); - const usbState = useHidStore(state => state.usbState); - const hdmiState = useVideoStore(state => state.hdmiState); + const usbState = useHidStore((state: HidState) => state.usbState); + const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); + + const displayKeys = useMemo(() => { + if (!showPressedKeys || !keysDownState) + return ""; + + const state = keysDownState as KeysDownState; + const activeModifierMask = state.modifier || 0; + const keysDown = state.keys || []; + const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name); + const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name); + + return [...modifierNames,...keyNames].join(", "); + }, [keysDownState, showPressedKeys]); return (
@@ -99,14 +117,7 @@ export default function InfoBar() {
Keys:

- {[ - ...activeKeys.map( - x => Object.entries(keys).filter(y => y[1] === x)[0][0], - ), - activeModifiers.map( - x => Object.entries(modifiers).filter(y => y[1] === x)[0][0], - ), - ].join(", ")} + {displayKeys}

)} diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index c377154..a938a6b 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -11,10 +11,14 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { cx } from "@/cva.config"; import { keys } from "@/keyboardMappings"; import { + MouseState, + RTCState, + SettingsState, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, + VideoState, } from "@/hooks/stores"; import { @@ -27,15 +31,15 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states const videoElm = useRef(null); - const mediaStream = useRTCStore(state => state.mediaStream); + const mediaStream = useRTCStore((state: RTCState) => state.mediaStream); const [isPlaying, setIsPlaying] = useState(false); - const peerConnectionState = useRTCStore(state => state.peerConnectionState); + const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); const [isPointerLockActive, setIsPointerLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); const { sendKeypressEvent, resetKeyboardState } = useKeyboard(); - const setMousePosition = useMouseStore(state => state.setMousePosition); - const setMouseMove = useMouseStore(state => state.setMouseMove); + const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition); + const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -46,15 +50,15 @@ export default function WebRTCVideo() { } = useVideoStore(); // Video enhancement settings - const videoSaturation = useSettingsStore(state => state.videoSaturation); - const videoBrightness = useSettingsStore(state => state.videoBrightness); - const videoContrast = useSettingsStore(state => state.videoContrast); + const videoSaturation = useSettingsStore((state: SettingsState) => state.videoSaturation); + const videoBrightness = useSettingsStore((state: SettingsState) => state.videoBrightness); + const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast); // RTC related states - const peerConnection = useRTCStore(state => state.peerConnection); + const peerConnection = useRTCStore((state: RTCState ) => state.peerConnection); // HDMI and UI states - const hdmiState = useVideoStore(state => state.hdmiState); + const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index e788faa..223d994 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -47,12 +47,12 @@ export interface User { picture?: string; } -interface UserState { +export interface UserState { user: User | null; setUser: (user: User | null) => void; } -interface UIState { +export interface UIState { sidebarView: AvailableSidebarViews | null; setSidebarView: (view: AvailableSidebarViews | null) => void; @@ -68,21 +68,21 @@ interface UIState { setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; terminalType: AvailableTerminalTypes; - setTerminalType: (enabled: UIState["terminalType"]) => void; + setTerminalType: (type: UIState["terminalType"]) => void; } export const useUiStore = create(set => ({ terminalType: "none", - setTerminalType: type => set({ terminalType: type }), + setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }), sidebarView: null, - setSidebarView: view => set({ sidebarView: view }), + setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }), disableVideoFocusTrap: false, - setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }), + setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }), isWakeOnLanModalVisible: false, - setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }), + setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }), toggleSidebarView: view => set(state => { @@ -94,11 +94,11 @@ export const useUiStore = create(set => ({ }), isAttachedVirtualKeyboardVisible: true, - setAttachedVirtualKeyboardVisibility: enabled => + setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), })); -interface RTCState { +export interface RTCState { peerConnection: RTCPeerConnection | null; setPeerConnection: (pc: RTCState["peerConnection"]) => void; @@ -118,18 +118,18 @@ interface RTCState { setMediaStream: (stream: MediaStream) => void; videoStreamStats: RTCInboundRtpStreamStats | null; - appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void; + appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void; videoStreamStatsHistory: Map; isTurnServerInUse: boolean; setTurnServerInUse: (inUse: boolean) => void; inboundRtpStats: Map; - appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void; + appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void; clearInboundRtpStats: () => void; candidatePairStats: Map; - appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void; + appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void; clearCandidatePairStats: () => void; // Remote ICE candidates stat type doesn't exist as of today @@ -141,7 +141,7 @@ interface RTCState { // Disk data channel stats type doesn't exist as of today diskDataChannelStats: Map; - appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void; + appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void; terminalChannel: RTCDataChannel | null; setTerminalChannel: (channel: RTCDataChannel) => void; @@ -149,78 +149,78 @@ interface RTCState { export const useRTCStore = create(set => ({ peerConnection: null, - setPeerConnection: pc => set({ peerConnection: pc }), + setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), rpcDataChannel: null, - setRpcDataChannel: channel => set({ rpcDataChannel: channel }), + setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), transceiver: null, - setTransceiver: transceiver => set({ transceiver }), + setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), peerConnectionState: null, - setPeerConnectionState: state => set({ peerConnectionState: state }), + setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), diskChannel: null, - setDiskChannel: channel => set({ diskChannel: channel }), + setDiskChannel: (channel: RTCDataChannel) => set({ diskChannel: channel }), mediaStream: null, - setMediaStream: stream => set({ mediaStream: stream }), + setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), videoStreamStats: null, - appendVideoStreamStats: stats => set({ videoStreamStats: stats }), + appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), videoStreamStatsHistory: new Map(), isTurnServerInUse: false, - setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), + setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), inboundRtpStats: new Map(), - appendInboundRtpStats: newStat => { + appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { set(prevState => ({ - inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats), + inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), })); }, clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), candidatePairStats: new Map(), - appendCandidatePairStats: newStat => { + appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { set(prevState => ({ - candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats), + candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), })); }, clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), localCandidateStats: new Map(), - appendLocalCandidateStats: newStat => { + appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { set(prevState => ({ - localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats), + localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), })); }, remoteCandidateStats: new Map(), - appendRemoteCandidateStats: newStat => { + appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { set(prevState => ({ - remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats), + remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), })); }, diskDataChannelStats: new Map(), - appendDiskDataChannelStats: newStat => { + appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { set(prevState => ({ - diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats), + diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), })); }, // Add these new properties to the store implementation terminalChannel: null, - setTerminalChannel: channel => set({ terminalChannel: channel }), + setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), })); -interface MouseMove { +export interface MouseMove { x: number; y: number; buttons: number; } -interface MouseState { +export interface MouseState { mouseX: number; mouseY: number; mouseMove?: MouseMove; @@ -232,9 +232,14 @@ export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), - setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), + setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), })); +export interface HdmiState { + ready: boolean; + error?: Extract; +} + export interface VideoState { width: number; height: number; @@ -263,13 +268,13 @@ export const useVideoStore = create(set => ({ clientHeight: 0, // The video element's client size - setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }), + setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), // Resolution - setSize: (width, height) => set({ width, height }), + setSize: (width: number, height: number) => set({ width, height }), hdmiState: "connecting", - setHdmiState: state => { + setHdmiState: (state: HdmiState) => { if (!state) return; const { ready, error } = state; @@ -283,7 +288,7 @@ export const useVideoStore = create(set => ({ }, })); -interface SettingsState { +export interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; @@ -325,17 +330,17 @@ export const useSettingsStore = create( persist( set => ({ isCursorHidden: false, - setCursorVisibility: enabled => set({ isCursorHidden: enabled }), + setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }), mouseMode: "absolute", - setMouseMode: mode => set({ mouseMode: mode }), + setMouseMode: (mode: string) => set({ mouseMode: mode }), debugMode: import.meta.env.DEV, - setDebugMode: enabled => set({ debugMode: enabled }), + setDebugMode: (enabled: boolean) => set({ debugMode: enabled }), // Add developer mode with default value developerMode: false, - setDeveloperMode: enabled => set({ developerMode: enabled }), + setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }), displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), @@ -349,21 +354,21 @@ export const useSettingsStore = create( set({ backlightSettings: settings }), keyboardLayout: "en-US", - setKeyboardLayout: layout => set({ keyboardLayout: layout }), + setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }), scrollThrottling: 0, - setScrollThrottling: value => set({ scrollThrottling: value }), + setScrollThrottling: (value: number) => set({ scrollThrottling: value }), showPressedKeys: true, - setShowPressedKeys: show => set({ showPressedKeys: show }), + setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }), // Video enhancement settings with default values (1.0 = normal) videoSaturation: 1.0, - setVideoSaturation: value => set({ videoSaturation: value }), + setVideoSaturation: (value: number) => set({ videoSaturation: value }), videoBrightness: 1.0, - setVideoBrightness: value => set({ videoBrightness: value }), + setVideoBrightness: (value: number) => set({ videoBrightness: value }), videoContrast: 1.0, - setVideoContrast: value => set({ videoContrast: value }), + setVideoContrast: (value: number) => set({ videoContrast: value }), }), { name: "settings", @@ -403,23 +408,23 @@ export interface MountMediaState { export const useMountMediaStore = create(set => ({ localFile: null, - setLocalFile: file => set({ localFile: file }), + setLocalFile: (file: MountMediaState["localFile"]) => set({ localFile: file }), remoteVirtualMediaState: null, - setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }), + setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), modalView: "mode", - setModalView: view => set({ modalView: view }), + setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), isMountMediaDialogOpen: false, - setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }), + setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), uploadedFiles: [], - addUploadedFile: file => + addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })), errorMessage: null, - setErrorMessage: message => set({ errorMessage: message }), + setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); export interface KeyboardLedState { @@ -437,14 +442,6 @@ export interface KeysDownState { } export interface HidState { - activeKeys: number[]; - activeModifiers: number[]; - - updateActiveKeysAndModifiers: (keysAndModifiers: { - keys: number[]; - modifiers: number[]; - }) => void; - altGrArmed: boolean; setAltGrArmed: (armed: boolean) => void; @@ -470,38 +467,31 @@ export interface HidState { setUsbState: (state: HidState["usbState"]) => void; } -export const useHidStore = create((set) => ({ - activeKeys: [], - activeModifiers: [], - - updateActiveKeysAndModifiers: ({ keys, modifiers }) => { - return set({ activeKeys: keys, activeModifiers: modifiers }); - }, - +export const useHidStore = create(set => ({ altGrArmed: false, - setAltGrArmed: armed => set({ altGrArmed: armed }), + setAltGrArmed: (armed: boolean): void => set({ altGrArmed: armed }), altGrTimer: 0, - setAltGrTimer: timeout => set({ altGrTimer: timeout }), + setAltGrTimer: (timeout: number | null): void => set({ altGrTimer: timeout }), altGrCtrlTime: 0, - setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), + setAltGrCtrlTime: (time: number): void => set({ altGrCtrlTime: time }), keyboardLedState: undefined, - setKeyboardLedState: ledState => set({ keyboardLedState: ledState }), + setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), keysDownState: undefined, - setKeysDownState: state => set({ keysDownState: state }), + setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), isVirtualKeyboardEnabled: false, - setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), + setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), isPasteModeEnabled: false, - setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), + setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }), // Add these new properties for USB state usbState: "not attached", - setUsbState: state => set({ usbState: state }), + setUsbState: (state: HidState["usbState"]) => set({ usbState: state }), })); export const useUserStore = create(set => ({ @@ -559,7 +549,7 @@ export interface UpdateState { export const useUpdateStore = create(set => ({ isUpdatePending: false, - setIsUpdatePending: isPending => set({ isUpdatePending: isPending }), + setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }), setOtaState: state => set({ otaState: state }), otaState: { @@ -583,12 +573,12 @@ export const useUpdateStore = create(set => ({ }, updateDialogHasBeenMinimized: false, - setUpdateDialogHasBeenMinimized: hasBeenMinimized => + setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => set({ updateDialogHasBeenMinimized: hasBeenMinimized }), modalView: "loading", - setModalView: view => set({ modalView: view }), + setModalView: (view: UpdateState["modalView"]) => set({ modalView: view }), updateErrorMessage: null, - setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), + setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), })); interface UsbConfigModalState { @@ -609,8 +599,8 @@ export interface UsbConfigState { export const useUsbConfigModalStore = create(set => ({ modalView: "updateUsbConfig", errorMessage: null, - setModalView: view => set({ modalView: view }), - setErrorMessage: message => set({ errorMessage: message }), + setModalView: (view: UsbConfigModalState["modalView"]) => set({ modalView: view }), + setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); interface LocalAuthModalState { @@ -626,7 +616,7 @@ interface LocalAuthModalState { export const useLocalAuthModalStore = create(set => ({ modalView: "createPassword", - setModalView: view => set({ modalView: view }), + setModalView: (view: LocalAuthModalState["modalView"]) => set({ modalView: view }), })); export interface DeviceState { @@ -641,8 +631,8 @@ export const useDeviceStore = create(set => ({ appVersion: null, systemVersion: null, - setAppVersion: version => set({ appVersion: version }), - setSystemVersion: version => set({ systemVersion: version }), + setAppVersion: (version: string) => set({ appVersion: version }), + setSystemVersion: (version: string) => set({ systemVersion: version }), })); export interface DhcpLease { @@ -808,7 +798,7 @@ export const useMacrosStore = create((set, get) => ({ try { await new Promise((resolve, reject) => { - sendFn("getKeyboardMacros", {}, response => { + sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => { if (response.error) { console.error("Error loading macros:", response.error); reject(new Error(response.error.message)); @@ -888,7 +878,7 @@ export const useMacrosStore = create((set, get) => ({ sendFn( "setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, - response => { + (response: JsonRpcResponse) => { resolve(response); }, ); diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 92b56ff..5f088dc 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { useRTCStore } from "@/hooks/stores"; +import { RTCState, useRTCStore } from "@/hooks/stores"; export interface JsonRpcRequest { jsonrpc: string; @@ -33,7 +33,7 @@ const callbackStore = new Map void>( let requestCounter = 0; export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); const send = useCallback( (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 7861dd9..a77946c 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,16 +1,14 @@ import { useCallback } from "react"; -import { KeysDownState, useHidStore, useRTCStore } from "@/hooks/stores"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore } from "@/hooks/stores"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { const [send] = useJsonRpc(); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - const updateActiveKeysAndModifiers = useHidStore( - state => state.updateActiveKeysAndModifiers, - ); + const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); const sendKeyboardEvent = useCallback( (keys: number[], modifiers: number[]) => { @@ -18,35 +16,28 @@ export default function useKeyboard() { const accModifier = modifiers.reduce((acc, val) => acc + val, 0); send("keyboardReport", { keys, modifier: accModifier }); - + //TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does // We do this for the info bar to display the currently pressed keys for the user - updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers }); + setKeysDownState({ keys: keys, modifier: accModifier }); }, - [rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], + [rpcDataChannel?.readyState, send, setKeysDownState], ); - const modifiersFromModifierMask = (activeModifiers: number): number[] => { - return Object.values(modifiers).filter(m => (activeModifiers & m) !== 0); - } - const sendKeypressEvent = useCallback( (key: number, press: boolean) => { if (rpcDataChannel?.readyState !== "open") return; - send("keypressReport", { key, press }, resp => { + send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { if ("error" in resp) { console.error("Failed to send keypress:", resp.error); } else { const keyDownState = resp.result as KeysDownState; - const keysDown = keyDownState.keys; - const activeModifiers = modifiersFromModifierMask(keyDownState.modifier) - // We do this for the info bar to display the currently pressed keys for the user - updateActiveKeysAndModifiers({ keys: keysDown, modifiers: activeModifiers }); + setKeysDownState(keyDownState); } }); }, - [rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], + [rpcDataChannel?.readyState, send, setKeysDownState], ); const resetKeyboardState = useCallback(() => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index d5753ab..53177c5 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -18,10 +18,14 @@ import useWebSocket from "react-use-websocket"; import { cx } from "@/cva.config"; import { + DeviceState, HidState, KeyboardLedState, KeysDownState, + MountMediaState, NetworkState, + RTCState, + UIState, UpdateState, useDeviceStore, useHidStore, @@ -38,7 +42,7 @@ import WebRTCVideo from "@components/WebRTCVideo"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; import DashboardNavbar from "@components/Header"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; -import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; +import { JsonRpcRequest, JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import Terminal from "@components/Terminal"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; @@ -128,18 +132,18 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const sidebarView = useUiStore(state => state.sidebarView); + const sidebarView = useUiStore((state: UIState) => state.sidebarView); const [queryParams, setQueryParams] = useSearchParams(); - const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); - const peerConnection = useRTCStore(state => state.peerConnection); - const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); - const peerConnectionState = useRTCStore(state => state.peerConnectionState); - const setMediaMediaStream = useRTCStore(state => state.setMediaStream); - const setPeerConnection = useRTCStore(state => state.setPeerConnection); - const setDiskChannel = useRTCStore(state => state.setDiskChannel); - const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); - const setTransceiver = useRTCStore(state => state.setTransceiver); + const setIsTurnServerInUse = useRTCStore((state: RTCState) => state.setTurnServerInUse); + const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); + const setPeerConnectionState = useRTCStore((state: RTCState) => state.setPeerConnectionState); + const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); + const setMediaMediaStream = useRTCStore((state: RTCState) => state.setMediaStream); + const setPeerConnection = useRTCStore((state: RTCState) => state.setPeerConnection); + const setDiskChannel = useRTCStore((state: RTCState) => state.setDiskChannel); + const setRpcDataChannel = useRTCStore((state: RTCState) => state.setRpcDataChannel); + const setTransceiver = useRTCStore((state: RTCState) => state.setTransceiver); const location = useLocation(); const isLegacySignalingEnabled = useRef(false); @@ -513,9 +517,9 @@ export default function KvmIdRoute() { }, [peerConnectionState, cleanupAndStopReconnecting]); // Cleanup effect - const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); - const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats); - const setSidebarView = useUiStore(state => state.setSidebarView); + const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats); + const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats); + const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); useEffect(() => { return () => { @@ -550,7 +554,7 @@ export default function KvmIdRoute() { }, [peerConnectionState, setIsTurnServerInUse]); // TURN server usage reporting - const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); + const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); const lastBytesReceived = useRef(0); const lastBytesSent = useRef(0); @@ -583,16 +587,16 @@ export default function KvmIdRoute() { }); }, 10000); - const setNetworkState = useNetworkStateStore(state => state.setNetworkState); + const setNetworkState = useNetworkStateStore((state: NetworkState) => state.setNetworkState); - const setUsbState = useHidStore(state => state.setUsbState); - const setHdmiState = useVideoStore(state => state.setHdmiState); + const setUsbState = useHidStore((state: HidState) => state.setUsbState); + const setHdmiState = useVideoStore((state: VideoState) => state.setHdmiState); - const keyboardLedState = useHidStore(state => state.keyboardLedState); - const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState); + const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); + const setKeyboardLedState = useHidStore((state: HidState) => state.setKeyboardLedState); - const keysDownState = useHidStore(state => state.keysDownState); - const setKeysDownState = useHidStore(state => state.setKeysDownState); + const keysDownState = useHidStore((state: HidState) => state.keysDownState); + const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -652,12 +656,12 @@ export default function KvmIdRoute() { } } - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); const [send] = useJsonRpc(onJsonRpcRequest); useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - send("getVideoState", {}, resp => { + send("getVideoState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; setHdmiState(resp.result as Parameters[0]); }); @@ -669,7 +673,7 @@ export default function KvmIdRoute() { if (keyboardLedState !== undefined) return; console.log("Requesting keyboard led state"); - send("getKeyboardLedState", {}, resp => { + send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { console.error("Failed to get keyboard led state", resp.error); return; @@ -685,7 +689,7 @@ export default function KvmIdRoute() { if (keysDownState !== undefined) return; console.log("Requesting keys down state"); - send("getKeyDownState", {}, resp => { + send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { console.error("Failed to get key down state", resp.error); return; @@ -702,8 +706,8 @@ export default function KvmIdRoute() { } }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); - const diskChannel = useRTCStore(state => state.diskChannel)!; - const file = useMountMediaStore(state => state.localFile)!; + const diskChannel = useRTCStore((state: RTCState) => state.diskChannel)!; + const file = useMountMediaStore((state: MountMediaState) => state.localFile)!; useEffect(() => { if (!diskChannel || !file) return; diskChannel.onmessage = async e => { @@ -723,7 +727,7 @@ export default function KvmIdRoute() { }, [diskChannel, file]); // System update - const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap); + const disableVideoFocusTrap = useUiStore((state: UIState) => state.disableVideoFocusTrap); const [kvmTerminal, setKvmTerminal] = useState(null); const [serialConsole, setSerialConsole] = useState(null); @@ -744,14 +748,14 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const appVersion = useDeviceStore(state => state.appVersion); - const setAppVersion = useDeviceStore(state => state.setAppVersion); - const setSystemVersion = useDeviceStore(state => state.setSystemVersion); + const appVersion = useDeviceStore((state: DeviceState) => state.appVersion); + const setAppVersion = useDeviceStore((state: DeviceState) => state.setAppVersion); + const setSystemVersion = useDeviceStore((state: DeviceState) => state.setSystemVersion); useEffect(() => { if (appVersion) return; - send("getUpdateStatus", {}, async resp => { + send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to get device version: ${resp.error}`); return diff --git a/usb.go b/usb.go index 3cf6908..5c8036c 100644 --- a/usb.go +++ b/usb.go @@ -51,11 +51,11 @@ func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) { return gadget.KeypressReport(key, press) } -func rpcAbsMouseReport(x, y int, buttons uint8) error { +func rpcAbsMouseReport(x int, y int, buttons uint8) error { return gadget.AbsMouseReport(x, y, buttons) } -func rpcRelMouseReport(dx, dy int8, buttons uint8) error { +func rpcRelMouseReport(dx int8, dy int8, buttons uint8) error { return gadget.RelMouseReport(dx, dy, buttons) } From 894e66efaa5afed58c8f50230f127c6fb776f976 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Thu, 14 Aug 2025 19:39:25 -0500 Subject: [PATCH 6/9] Enable still working with devices that haven't been upgraded --- ui/src/components/InfoBar.tsx | 12 +-- ui/src/components/VirtualKeyboard.tsx | 84 +++++++--------- ui/src/components/WebRTCVideo.tsx | 125 ++++++++++++------------ ui/src/hooks/stores.ts | 35 +++---- ui/src/hooks/useKeyboard.ts | 128 ++++++++++++++++++++----- ui/src/keyboardMappings.ts | 11 +++ ui/src/routes/devices.$id.settings.tsx | 8 +- ui/src/routes/devices.$id.tsx | 32 +++++-- 8 files changed, 258 insertions(+), 177 deletions(-) diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 8d3b40e..bc123ac 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -133,7 +133,7 @@ export default function InfoBar() {
Scroll Lock
- {keyboardLedState?.compose ? ( + {keyboardLedState.compose ? (
Compose
) : null} - {keyboardLedState?.kana ? ( + {keyboardLedState.kana ? (
Kana
) : null} - {keyboardLedState?.shift ? ( + {keyboardLedState.shift ? (
Shift
diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 18c5fbe..e08cee7 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -1,4 +1,3 @@ -import { useShallow } from "zustand/react/shallow"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -13,7 +12,7 @@ import "react-simple-keyboard/build/css/index.css"; import AttachIconRaw from "@/assets/attach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { useHidStore, useUiStore } from "@/hooks/stores"; +import { HidState, useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; @@ -26,7 +25,7 @@ const AttachIcon = ({ className }: { className?: string }) => { }; function KeyboardWrapper() { - const [layoutName, setLayoutName] = useState("default"); + const [layoutName] = useState("default"); const keyboardRef = useRef(null); const showAttachedVirtualKeyboard = useUiStore( @@ -36,14 +35,16 @@ function KeyboardWrapper() { state => state.setAttachedVirtualKeyboardVisibility, ); - const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); + const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); + + const keysDownState = useHidStore((state: HidState) => state.keysDownState); + const { handleKeyPress, sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const [isDragging, setIsDragging] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); - const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock)); - /* // These will be used to display the currently pressed keys and modifiers on the virtual keyboard @@ -129,74 +130,55 @@ function KeyboardWrapper() { const onKeyDown = useCallback( (key: string) => { - const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight"; - const isKeyCaps = key === "CapsLock"; - const cleanKey = key.replace(/[()]/g, ""); - const keyHasShiftModifier = key.includes("("); - - // Handle toggle of layout for shift or caps lock - const toggleLayout = () => { - setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default")); - }; + const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; + const dynamicKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight"]; if (key === "CtrlAltDelete") { - sendKeyboardEvent( - [keys["Delete"]], - [modifiers["ControlLeft"], modifiers["AltLeft"]], - ); + sendKeyboardEvent({ keys: [keys.Delete], modifier: modifiers.ControlLeft | modifiers.AltLeft }); setTimeout(resetKeyboardState, 100); return; } if (key === "AltMetaEscape") { - sendKeyboardEvent( - [keys["Escape"]], - [modifiers["MetaLeft"], modifiers["AltLeft"]], - ); - + sendKeyboardEvent({ keys: [keys.Escape], modifier: modifiers.AltLeft | modifiers.MetaLeft }); setTimeout(resetKeyboardState, 100); return; } if (key === "CtrlAltBackspace") { - sendKeyboardEvent( - [keys["Backspace"]], - [modifiers["ControlLeft"], modifiers["AltLeft"]], - ); - + sendKeyboardEvent({ keys: [keys.Backspace], modifier: modifiers.ControlLeft | modifiers.AltLeft }); setTimeout(resetKeyboardState, 100); return; } - if (isKeyShift || isKeyCaps) { - toggleLayout(); - - if (isCapsLockActive) { - sendKeyboardEvent([keys["CapsLock"]], []); - return; - } + // if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) + if (latchingKeys.includes(key)) { + handleKeyPress(keys[key], true) + setTimeout(() => handleKeyPress(keys[key], false), 100); + return; } - // Collect new active keys and modifiers - const newKeys = keys[cleanKey] ? [keys[cleanKey]] : []; - const newModifiers = - keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : []; - - // Update current keys and modifiers - sendKeyboardEvent(newKeys, newModifiers); - - // If shift was used as a modifier and caps lock is not active, revert to default layout - if (keyHasShiftModifier && !isCapsLockActive) { - setLayoutName("default"); + // if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again + if (dynamicKeys.includes(key)) { + const currentlyDown = keysDownState.keys.includes(keys[key]); + handleKeyPress(keys[key], !currentlyDown) + return; } - setTimeout(resetKeyboardState, 100); + // otherwise, just treat it as a down+up pair + const cleanKey = key.replace(/[()]/g, ""); + handleKeyPress(keys[cleanKey], true); + setTimeout(() => handleKeyPress(keys[cleanKey], false), 50); }, - [isCapsLockActive, sendKeyboardEvent, resetKeyboardState], + [handleKeyPress, sendKeyboardEvent, resetKeyboardState, keysDownState], ); - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); + // TODO handle the display of down keys and the layout change for shift/caps lock + // const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState.caps_lock)); + // // Handle toggle of layout for shift or caps lock + // const toggleLayout = () => { + // setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default")); + // }; return (
state.setMousePosition); const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove); const { @@ -55,13 +55,14 @@ export default function WebRTCVideo() { const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast); // RTC related states - const peerConnection = useRTCStore((state: RTCState ) => state.peerConnection); + const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); // HDMI and UI states const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; + // Mouse wheel states const [blockWheelEvent, setBlockWheelEvent] = useState(false); // Misc states and hooks @@ -104,7 +105,7 @@ export default function WebRTCVideo() { // Pointer lock and keyboard lock related const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isFullscreenEnabled = document.fullscreenEnabled; - + const checkNavigatorPermissions = useCallback(async (permissionName: string) => { if (!navigator.permissions || !navigator.permissions.query) { return false; // if can't query permissions, assume NOT granted @@ -140,11 +141,11 @@ export default function WebRTCVideo() { if (videoElm.current === null) return; const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); - + if (isKeyboardLockGranted && "keyboard" in navigator) { try { // @ts-expect-error - keyboard lock is not supported in all browsers - await navigator.keyboard.lock(); + await navigator.keyboard.lock(); } catch { // ignore errors } @@ -155,12 +156,12 @@ export default function WebRTCVideo() { if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; if ("keyboard" in navigator) { - try { - // @ts-expect-error - keyboard unlock is not supported in all browsers - await navigator.keyboard.unlock(); - } catch { - // ignore errors - } + try { + // @ts-expect-error - keyboard unlock is not supported in all browsers + await navigator.keyboard.unlock(); + } catch { + // ignore errors + } } }, []); @@ -188,7 +189,7 @@ export default function WebRTCVideo() { }, [isPointerLockPossible]); const requestFullscreen = useCallback(async () => { - if (!isFullscreenEnabled || !videoElm.current) return; + if (!isFullscreenEnabled || !videoElm.current) return; // per https://wicg.github.io/keyboard-lock/#system-key-press-handler // If keyboard lock is activated after fullscreen is already in effect, then the user my @@ -352,12 +353,12 @@ export default function WebRTCVideo() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { - sendKeypressEvent(hidKey, false); + handleKeyPress(hidKey, false); }, 10); } - sendKeypressEvent(hidKey, true); + handleKeyPress(hidKey, true); }, - [sendKeypressEvent], + [handleKeyPress], ); const keyUpHandler = useCallback( @@ -365,15 +366,15 @@ export default function WebRTCVideo() { e.preventDefault(); const code = getAdjustedKeyCode(e); const hidKey = keys[code]; - + if (hidKey === undefined) { console.warn(`Key up not mapped: ${code}`); return; } - sendKeypressEvent(hidKey, false); + handleKeyPress(hidKey, false); }, - [sendKeypressEvent], + [handleKeyPress], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -489,7 +490,7 @@ export default function WebRTCVideo() { videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, passive: true, @@ -546,8 +547,8 @@ export default function WebRTCVideo() { return isDefault ? {} // No filter if all settings are default (1.0) : { - filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, - }; + filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, + }; }, [videoSaturation, videoBrightness, videoContrast]); function getAdjustedKeyCode(e: KeyboardEvent) { @@ -594,48 +595,48 @@ export default function WebRTCVideo() {
-
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 223d994..c5d714c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -436,27 +436,24 @@ export interface KeyboardLedState { shift: boolean; // Optional, as not all keyboards have a shift LED }; +export const hidKeyBufferSize = 6; +export const hidErrorRollOver = 0x01; + export interface KeysDownState { modifier: number; keys: number[]; } export interface HidState { - altGrArmed: boolean; - setAltGrArmed: (armed: boolean) => void; - - altGrTimer: number | null; // _altGrCtrlTime - setAltGrTimer: (timeout: number | null) => void; - - altGrCtrlTime: number; // _altGrCtrlTime - setAltGrCtrlTime: (time: number) => void; - - keyboardLedState?: KeyboardLedState; + keyboardLedState: KeyboardLedState; setKeyboardLedState: (state: KeyboardLedState) => void; - keysDownState?: KeysDownState; + keysDownState: KeysDownState; setKeysDownState: (state: KeysDownState) => void; + keyPressAvailable: boolean; + setKeyPressAvailable: (available: boolean) => void; + isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; @@ -468,21 +465,15 @@ export interface HidState { } export const useHidStore = create(set => ({ - altGrArmed: false, - setAltGrArmed: (armed: boolean): void => set({ altGrArmed: armed }), - - altGrTimer: 0, - setAltGrTimer: (timeout: number | null): void => set({ altGrTimer: timeout }), - - altGrCtrlTime: 0, - setAltGrCtrlTime: (time: number): void => set({ altGrCtrlTime: time }), - - keyboardLedState: undefined, + keyboardLedState: {} as KeyboardLedState, setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), - keysDownState: undefined, + keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), + keyPressAvailable: true, + setKeyPressAvailable: (available: boolean) => set({ keyPressAvailable: available }), + isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index a77946c..f98066d 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,24 +1,29 @@ import { useCallback } from "react"; -import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore } from "@/hooks/stores"; +import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import { keys, modifiers } from "@/keyboardMappings"; +import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { const [send] = useJsonRpc(); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + + const keysDownState = useHidStore((state: HidState) => state.keysDownState); const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); - const sendKeyboardEvent = useCallback( - (keys: number[], modifiers: number[]) => { - if (rpcDataChannel?.readyState !== "open") return; - const accModifier = modifiers.reduce((acc, val) => acc + val, 0); + const keyPressAvailable = useHidStore((state: HidState) => state.keyPressAvailable); + const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); + + const sendKeyboardEvent = useCallback( + (state: KeysDownState) => { + if (rpcDataChannel?.readyState !== "open") return; - send("keyboardReport", { keys, modifier: accModifier }); //TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does + send("keyboardReport", { keys: state.keys, modifier: state.modifier }); + // We do this for the info bar to display the currently pressed keys for the user - setKeysDownState({ keys: keys, modifier: accModifier }); + setKeysDownState(state); }, [rpcDataChannel?.readyState, send, setKeysDownState], ); @@ -28,30 +33,37 @@ export default function useKeyboard() { if (rpcDataChannel?.readyState !== "open") return; send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - console.error("Failed to send keypress:", resp.error); - } else { - const keyDownState = resp.result as KeysDownState; - // We do this for the info bar to display the currently pressed keys for the user - setKeysDownState(keyDownState); - } - }); + if ("error" in resp) { + // -32601 means the method is not supported + if (resp.error.code === -32601) { + // if we don't support key press report, we need to disable all that handling + console.error("Failed calling keypressReport, switching to local handling", resp.error); + setKeyPressAvailable(false); + } else { + console.error(`Failed to send key ${key} press: ${press}`, resp.error); + } + } else { + const keyDownState = resp.result as KeysDownState; + // We do this for the info bar to display the currently pressed keys for the user + setKeysDownState(keyDownState); + } + }); }, - [rpcDataChannel?.readyState, send, setKeysDownState], + [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], ); const resetKeyboardState = useCallback(() => { - sendKeyboardEvent([], []); + sendKeyboardEvent({ keys: [], modifier: 0 }); }, [sendKeyboardEvent]); const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { for (const [index, step] of steps.entries()) { - const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || []; - const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || []; + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0); // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierValues.length > 0) { - sendKeyboardEvent(keyValues, modifierValues); + if (keyValues.length > 0 || modifierMask > 0) { + sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); await new Promise(resolve => setTimeout(resolve, step.delay || 50)); resetKeyboardState(); @@ -67,5 +79,75 @@ export default function useKeyboard() { } }; - return { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro }; + // this code exists because we have devices that don't support the keysPress api yet (not current) + // so we mirror the device-side code here to keep track of the keyboard state + function handleKeyLocally(state: KeysDownState, key: number, press: boolean): KeysDownState { + const keys = state.keys; + let modifiers = state.modifier; + const modifierMask = hidKeyToModifierMask[key] || 0; + + if (modifierMask !== 0) { + if (press) { + modifiers |= modifierMask; + } else { + modifiers &= ~modifierMask; + } + } else { + // handle other keys that are not modifier keys by placing or removing them + // from the key buffer since the buffer tracks currently pressed keys + let overrun = true; + for (let i = 0; i < hidKeyBufferSize && overrun; i++) { + // If we find the key in the buffer the buffer, we either remove it (if press is false) + // or do nothing (if down is true) because the buffer tracks currently pressed keys + // and if we find a zero byte, we can place the key there (if press is true) + if (keys[i] == key || keys[i] == 0) { + if (press) { + keys[i] = key // overwrites the zero byte or the same key if already pressed + } else { + // we are releasing the key, remove it from the buffer + if (keys[i] != 0) { + keys.splice(i, 1); + keys.push(0); // add a zero at the end + } + } + overrun = false // We found a slot for the key + } + + // If we reach here it means we didn't find an empty slot or the key in the buffer + if (overrun) { + if (press) { + console.warn(`keyboard buffer overflow, key: ${key} not added`); + // Fill all key slots with ErrorRollOver (0x01) to indicate overflow + keys.length = 6; + keys.fill(hidErrorRollOver); + } else { + // If we are releasing a key, and we didn't find it in a slot, who cares? + console.debug(`key ${key} not found in buffer, nothing to release`) + } + } + } + } + return { modifier: modifiers, keys }; + } + + const handleKeyPress = useCallback( + (key: number, press: boolean) => { + if (keyPressAvailable) { + // if the keyPress api is available, we can just send the key press event + sendKeypressEvent(key, press); + + // TODO handle the case where the keyPress api is not available and we need to handle the key locally now... + } else { + // if the keyPress api is not available, we need to handle the key locally + const newKeysDownState = handleKeyLocally(keysDownState, key, press); + setKeysDownState(newKeysDownState); + + // then we send the full state + sendKeyboardEvent(newKeysDownState); + } + }, + [keyPressAvailable, keysDownState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState], + ); + + return { handleKeyPress, sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro }; } diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index 7122254..c91729b 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -142,6 +142,17 @@ export const modifiers = { MetaRight: 0x80, } as Record; +export const hidKeyToModifierMask = { + 0xe0: modifiers.ControlLeft, + 0xe1: modifiers.ShiftLeft, + 0xe2: modifiers.AltLeft, + 0xe3: modifiers.MetaLeft, + 0xe4: modifiers.ControlRight, + 0xe5: modifiers.ShiftRight, + 0xe6: modifiers.AltRight, + 0xe7: modifiers.MetaRight, +} as Record; + export const modifierDisplayMap: Record = { ControlLeft: "Left Ctrl", ControlRight: "Right Ctrl", diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 5075ab5..09703c3 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -29,7 +29,7 @@ import { cx } from "../cva.config"; export default function SettingsRoute() { const location = useLocation(); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - const { sendKeyboardEvent } = useKeyboard(); + const { resetKeyboardState } = useKeyboard(); const scrollContainerRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); @@ -67,8 +67,8 @@ export default function SettingsRoute() { useEffect(() => { // disable focus trap setTimeout(() => { - // Reset keyboard state. Incase the user is pressing a key while enabling the sidebar - sendKeyboardEvent([], []); + // Reset keyboard state. In case the user is pressing a key while enabling the sidebar + resetKeyboardState(); setDisableVideoFocusTrap(true); // For some reason, the focus trap is not disabled immediately // so we need to blur the active element @@ -79,7 +79,7 @@ export default function SettingsRoute() { return () => { setDisableVideoFocusTrap(false); }; - }, [sendKeyboardEvent, setDisableVideoFocusTrap]); + }, [resetKeyboardState, setDisableVideoFocusTrap]); return (
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 53177c5..efc52df 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -519,7 +519,7 @@ export default function KvmIdRoute() { // Cleanup effect const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats); const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats); - const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); + const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); useEffect(() => { return () => { @@ -597,6 +597,7 @@ export default function KvmIdRoute() { const keysDownState = useHidStore((state: HidState) => state.keysDownState); const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); + const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -624,7 +625,7 @@ export default function KvmIdRoute() { console.log("Setting keyboard led state", ledState); setKeyboardLedState(ledState); } - + if (resp.method === "keysDownState") { const downState = resp.params as KeysDownState; console.log("Setting key down state", downState); @@ -667,10 +668,12 @@ export default function KvmIdRoute() { }); }, [rpcDataChannel?.readyState, send, setHdmiState]); + const [needLedState, setNeedLedState] = useState(true); + // request keyboard led state from the device useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - if (keyboardLedState !== undefined) return; + if (!needLedState) return; console.log("Requesting keyboard led state"); send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { @@ -680,24 +683,35 @@ export default function KvmIdRoute() { } console.log("Keyboard led state", resp.result); setKeyboardLedState(resp.result as KeyboardLedState); + setNeedLedState(false); }); - }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]); + }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); + + const [needKeyDownState, setNeedKeyDownState] = useState(true); // request keyboard key down state from the device useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - if (keysDownState !== undefined) return; + if (!needKeyDownState) return; console.log("Requesting keys down state"); send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { - console.error("Failed to get key down state", resp.error); + // -32601 means the method is not supported + if (resp.error.code === -32601) { + // if we don't support key down state, we know key press is also not available + console.error("Failed to get key down state, switching to old-school", resp.error); + setKeyPressAvailable(false); + } else { + console.error("Failed to get key down state", resp.error); + } return; } console.log("Keyboard key down state", resp.result); setKeysDownState(resp.result as KeysDownState); + setNeedKeyDownState(false); }); - }, [keysDownState, rpcDataChannel?.readyState, send, setKeysDownState]); + }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { @@ -758,7 +772,7 @@ export default function KvmIdRoute() { send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to get device version: ${resp.error}`); - return + return } const result = resp.result as SystemVersionInfo; @@ -895,7 +909,7 @@ interface SidebarContainerProps { } function SidebarContainer(props: SidebarContainerProps) { - const { sidebarView }= props; + const { sidebarView } = props; return (
Date: Thu, 14 Aug 2025 23:35:06 -0500 Subject: [PATCH 7/9] Return the KeysDownState from keyboardReport Also clear out the hidErrorRollOver once sent to reset the keyboard to nothing down. --- internal/usbgadget/hid_keyboard.go | 37 +++++++++++++++++++++--------- jsonrpc.go | 1 - usb.go | 2 +- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 2c4a456..3b9f6c2 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -261,7 +261,23 @@ func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { return nil } -func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { +func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { + // if we just reported an error roll over, we should clear the keys + if keys[0] == hidErrorRollOver { + for i := range keys { + keys[i] = 0 + } + } + + downState := KeysDownState{ + Modifier: modifier, + Keys: []byte(keys[:]), + } + u.updateKeyDownState(downState) + return downState +} + +func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) { u.keyboardLock.Lock() defer u.keyboardLock.Unlock() defer u.resetUserInputTime() @@ -273,7 +289,12 @@ func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) } - return u.keyboardWriteHidFile(modifier, keys) + err := u.keyboardWriteHidFile(modifier, keys) + if err != nil { + u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0") + } + + return u.UpdateKeysDown(modifier, keys), err } const ( @@ -357,16 +378,10 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) } } - if err := u.keyboardWriteHidFile(modifier, keys); err != nil { + err := u.keyboardWriteHidFile(modifier, keys) + if err != nil { u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0") } - var result = KeysDownState{ - Modifier: modifier, - Keys: []byte(keys[:]), - } - - u.updateKeyDownState(result) - - return result, nil + return u.UpdateKeysDown(modifier, keys), err } diff --git a/jsonrpc.go b/jsonrpc.go index 7d05933..321c1d3 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -134,7 +134,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { return } - scopedLogger.Trace().Msg("Calling RPC handler") result, err := callRPCHandler(scopedLogger, handler, request.Params) if err != nil { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") diff --git a/usb.go b/usb.go index 5c8036c..d29e01a 100644 --- a/usb.go +++ b/usb.go @@ -43,7 +43,7 @@ func initUsbGadget() { } } -func rpcKeyboardReport(modifier byte, keys []byte) error { +func rpcKeyboardReport(modifier byte, keys []byte) (usbgadget.KeysDownState, error) { return gadget.KeyboardReport(modifier, keys) } From a4851f980d283b09506807ae42dc90b2330ddc59 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Fri, 15 Aug 2025 00:31:18 -0500 Subject: [PATCH 8/9] Handles the returned KeysDownState from keyboardReport Now passes all logic through handleKeyPress. If we get a state back from a keyboardReport, use it and also enable keypressReport because we now know it's an upgraded device. --- ui/package-lock.json | 227 +++++++++--------- ui/package.json | 8 +- ui/src/components/ActionBar.tsx | 2 +- ui/src/components/InfoBar.tsx | 10 +- ui/src/components/USBStateStatus.tsx | 2 +- ui/src/components/UsbInfoSetting.tsx | 2 +- ui/src/components/VirtualKeyboard.tsx | 19 +- ui/src/components/WebRTCVideo.tsx | 49 ++-- ui/src/hooks/useKeyboard.ts | 65 +++-- .../routes/devices.$id.settings.network.tsx | 17 +- ui/src/routes/devices.$id.settings.tsx | 2 +- ui/src/routes/devices.$id.tsx | 39 ++- 12 files changed, 251 insertions(+), 191 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index ab6f459..2126943 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvm-ui", - "version": "2025.08.07.001", + "version": "2025.08.15.001", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvm-ui", - "version": "2025.08.07.001", + "version": "2025.08.15.001", "dependencies": { "@headlessui/react": "^2.2.7", "@headlessui/tailwindcss": "^0.2.2", @@ -45,9 +45,9 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.33.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.1.12", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", @@ -66,7 +66,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" @@ -88,24 +88,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -855,6 +841,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1549,25 +1546,25 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1579,24 +1576,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-x64": "4.1.11", - "@tailwindcss/oxide-freebsd-x64": "4.1.11", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-x64-musl": "4.1.11", - "@tailwindcss/oxide-wasm32-wasi": "4.1.11", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -1611,9 +1608,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -1628,9 +1625,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -1645,9 +1642,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -1662,9 +1659,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -1679,9 +1676,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -1696,9 +1693,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -1713,9 +1710,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -1730,9 +1727,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -1747,9 +1744,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1765,11 +1762,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "engines": { @@ -1777,9 +1774,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -1794,9 +1791,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -1811,17 +1808,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", - "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz", + "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/typography": { @@ -1841,15 +1838,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", - "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", - "tailwindcss": "4.1.11" + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -2739,9 +2736,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001734", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", - "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", "dev": true, "funding": [ { @@ -3159,9 +3156,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.200", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", - "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", + "version": "1.5.201", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.201.tgz", + "integrity": "sha512-ZG65vsrLClodGqywuigc+7m0gr4ISoTQttfVh7nfpLv0M7SIwF4WbFNEOywcqTiujs12AUeeXbFyQieDICAIxg==", "dev": true, "license": "ISC" }, @@ -6478,9 +6475,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "license": "MIT" }, "node_modules/tapable": { @@ -6534,10 +6531,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6939,10 +6939,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, diff --git a/ui/package.json b/ui/package.json index 4c929f0..f10341f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "kvm-ui", "private": true, - "version": "2025.08.07.001", + "version": "2025.08.15.001", "type": "module", "engines": { "node": "22.15.0" @@ -56,9 +56,9 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.33.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.11", + "@tailwindcss/vite": "^4.1.12", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", @@ -77,7 +77,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 801cc7a..c8a214b 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -48,7 +48,7 @@ export default function Actionbar({ if (!open) { setTimeout(() => { setDisableFocusTrap(false); - console.log("Popover is closing. Returning focus trap to video"); + console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index bc123ac..eafc380 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo } from "react"; import { cx } from "@/cva.config"; import { HidState, - KeysDownState, MouseState, RTCState, SettingsState, @@ -39,7 +38,7 @@ export default function InfoBar() { if (!rpcDataChannel) return; rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); rpcDataChannel.onerror = (e: Event) => - console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); + console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); }, [rpcDataChannel]); const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); @@ -49,12 +48,11 @@ export default function InfoBar() { const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const displayKeys = useMemo(() => { - if (!showPressedKeys || !keysDownState) + if (!showPressedKeys) return ""; - const state = keysDownState as KeysDownState; - const activeModifierMask = state.modifier || 0; - const keysDown = state.keys || []; + const activeModifierMask = keysDownState.modifier || 0; + const keysDown = keysDownState.keys || []; const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name); const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name); diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index f0b2cb2..618a9c3 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -67,7 +67,7 @@ export default function USBStateStatus({ }; const props = StatusCardProps[state]; if (!props) { - console.log("Unsupported USB state: ", state); + console.warn("Unsupported USB state: ", state); return; } diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index 198335c..4f63697 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -101,8 +101,8 @@ export function UsbInfoSetting() { `Failed to load USB Config: ${resp.error.data || "Unknown error"}`, ); } else { - console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result); const usbConfigState = resp.result as UsbConfigState; + console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState); const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) ? usbConfigState.product : "custom"; diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index e08cee7..fa7857d 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -14,7 +14,7 @@ import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; import { HidState, useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; -import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; +import { keyDisplayMap, keys } from "@/keyboardMappings"; export const DetachIcon = ({ className }: { className?: string }) => { return Detach Icon; @@ -39,7 +39,7 @@ function KeyboardWrapper() { const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const { handleKeyPress, sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + const { handleKeyPress, executeMacro } = useKeyboard(); const [isDragging, setIsDragging] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); @@ -133,26 +133,25 @@ function KeyboardWrapper() { const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; const dynamicKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight"]; + // handle the fake key-macros we have defined for common combinations if (key === "CtrlAltDelete") { - sendKeyboardEvent({ keys: [keys.Delete], modifier: modifiers.ControlLeft | modifiers.AltLeft }); - setTimeout(resetKeyboardState, 100); + executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } if (key === "AltMetaEscape") { - sendKeyboardEvent({ keys: [keys.Escape], modifier: modifiers.AltLeft | modifiers.MetaLeft }); - setTimeout(resetKeyboardState, 100); + executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); return; } if (key === "CtrlAltBackspace") { - sendKeyboardEvent({ keys: [keys.Backspace], modifier: modifiers.ControlLeft | modifiers.AltLeft }); - setTimeout(resetKeyboardState, 100); + executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } // if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) if (latchingKeys.includes(key)) { + console.debug(`Latching key pressed: ${key} sending down and delayed up pair`); handleKeyPress(keys[key], true) setTimeout(() => handleKeyPress(keys[key], false), 100); return; @@ -161,16 +160,18 @@ function KeyboardWrapper() { // if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again if (dynamicKeys.includes(key)) { const currentlyDown = keysDownState.keys.includes(keys[key]); + console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`); handleKeyPress(keys[key], !currentlyDown) return; } // otherwise, just treat it as a down+up pair const cleanKey = key.replace(/[()]/g, ""); + console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`); handleKeyPress(keys[cleanKey], true); setTimeout(() => handleKeyPress(keys[cleanKey], false), 50); }, - [handleKeyPress, sendKeyboardEvent, resetKeyboardState, keysDownState], + [executeMacro, handleKeyPress, keysDownState], ); // TODO handle the display of down keys and the layout change for shift/caps lock diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 33c3dfc..6e273f5 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -35,6 +35,7 @@ export default function WebRTCVideo() { const [isPlaying, setIsPlaying] = useState(false); const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); const [isPointerLockActive, setIsPointerLockActive] = useState(false); + const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); @@ -69,16 +70,19 @@ export default function WebRTCVideo() { const [send] = useJsonRpc(); // Video-related + const handleResize = useCallback( + ({ width, height }: { width: number; height: number }) => { + if (!videoElm.current) return; + // Do something with width and height, e.g.: + setVideoClientSize(width, height); + setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); + }, + [setVideoClientSize, setVideoSize] + ); + useResizeObserver({ ref: videoElm as React.RefObject, - onResize: ({ width, height }) => { - // This is actually client size, not videoSize - if (width && height) { - if (!videoElm.current) return; - setVideoClientSize(width, height); - setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); - } - }, + onResize: handleResize, }); const updateVideoSizeStore = useCallback( @@ -107,7 +111,7 @@ export default function WebRTCVideo() { const isFullscreenEnabled = document.fullscreenEnabled; const checkNavigatorPermissions = useCallback(async (permissionName: string) => { - if (!navigator.permissions || !navigator.permissions.query) { + if (!navigator || !navigator.permissions || !navigator.permissions.query) { return false; // if can't query permissions, assume NOT granted } @@ -142,28 +146,30 @@ export default function WebRTCVideo() { const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); - if (isKeyboardLockGranted && "keyboard" in navigator) { + if (isKeyboardLockGranted && navigator && "keyboard" in navigator) { try { // @ts-expect-error - keyboard lock is not supported in all browsers await navigator.keyboard.lock(); + setIsKeyboardLockActive(true); } catch { // ignore errors } } - }, [checkNavigatorPermissions]); + }, [checkNavigatorPermissions, setIsKeyboardLockActive]); const releaseKeyboardLock = useCallback(async () => { if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; - if ("keyboard" in navigator) { + if (navigator && "keyboard" in navigator) { try { // @ts-expect-error - keyboard unlock is not supported in all browsers await navigator.keyboard.unlock(); } catch { // ignore errors } + setIsKeyboardLockActive(false); } - }, []); + }, [setIsKeyboardLockActive]); useEffect(() => { if (!isPointerLockPossible || !videoElm.current) return; @@ -353,12 +359,24 @@ export default function WebRTCVideo() { // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { + console.debug(`Forcing the meta key release of associated key: ${hidKey}`); handleKeyPress(hidKey, false); }, 10); } + console.debug(`Key down: ${hidKey}`); handleKeyPress(hidKey, true); + + if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { + // If the left meta key was just pressed and we're not keyboard locked + // we'll never see the keyup event because the browser is going to lose + // focus so set a deferred keyup after a short delay + setTimeout(() => { + console.debug(`Forcing the left meta key release`); + handleKeyPress(hidKey, false); + }, 100); + } }, - [handleKeyPress], + [handleKeyPress, isKeyboardLockActive], ); const keyUpHandler = useCallback( @@ -372,6 +390,7 @@ export default function WebRTCVideo() { return; } + console.debug(`Key up: ${hidKey}`); handleKeyPress(hidKey, false); }, [handleKeyPress], @@ -385,7 +404,7 @@ export default function WebRTCVideo() { // Fix only works in chrome based browsers. if (e.code === "Space") { if (videoElm.current.paused) { - console.log("Force playing video"); + console.debug("Force playing video"); videoElm.current.play(); } } diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index f98066d..f9d9b04 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -19,19 +19,33 @@ export default function useKeyboard() { (state: KeysDownState) => { if (rpcDataChannel?.readyState !== "open") return; - //TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does - send("keyboardReport", { keys: state.keys, modifier: state.modifier }); + console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`); + send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error(`Failed to send keyboard report ${state}`, resp.error); + } else { + const keysDownState = resp.result as KeysDownState; - // We do this for the info bar to display the currently pressed keys for the user - setKeysDownState(state); + if (keysDownState) { + // new devices return the keyDownState, so we can use it to update the state + setKeysDownState(keysDownState); + setKeyPressAvailable(true); // if they returned a keysDownState, we know they also support keyPressReport + } else { + // old devices do not return the keyDownState, so we just pretend they accepted what we sent + setKeysDownState(state); + // and we shouldn't set keyPressAvailable here because we don't know if they support it + } + } + }); }, - [rpcDataChannel?.readyState, send, setKeysDownState], + [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], ); const sendKeypressEvent = useCallback( (key: number, press: boolean) => { if (rpcDataChannel?.readyState !== "open") return; + console.debug(`Send keypressEvent key: ${key}, press: ${press}`); send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { if ("error" in resp) { // -32601 means the method is not supported @@ -43,9 +57,12 @@ export default function useKeyboard() { console.error(`Failed to send key ${key} press: ${press}`, resp.error); } } else { - const keyDownState = resp.result as KeysDownState; - // We do this for the info bar to display the currently pressed keys for the user - setKeysDownState(keyDownState); + const keysDownState = resp.result as KeysDownState; + + if (keysDownState) { + setKeysDownState(keysDownState); + // we don't need to set keyPressAvailable here, because it's already true or we never landed here + } } }); }, @@ -53,8 +70,11 @@ export default function useKeyboard() { ); const resetKeyboardState = useCallback(() => { - sendKeyboardEvent({ keys: [], modifier: 0 }); - }, [sendKeyboardEvent]); + console.debug("Resetting keyboard state"); + keysDownState.keys.fill(0); // Reset the keys buffer to zeros + keysDownState.modifier = 0; // Reset the modifier state to zero + sendKeyboardEvent(keysDownState); + }, [keysDownState, sendKeyboardEvent]); const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { for (const [index, step] of steps.entries()) { @@ -87,6 +107,7 @@ export default function useKeyboard() { const modifierMask = hidKeyToModifierMask[key] || 0; if (modifierMask !== 0) { + console.debug(`Handling modifier key: ${key}, press: ${press}, current modifiers: ${modifiers}, modifier mask: ${modifierMask}`); if (press) { modifiers |= modifierMask; } else { @@ -132,22 +153,24 @@ export default function useKeyboard() { const handleKeyPress = useCallback( (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + if (keyPressAvailable) { // if the keyPress api is available, we can just send the key press event sendKeypressEvent(key, press); - - // TODO handle the case where the keyPress api is not available and we need to handle the key locally now... - } else { - // if the keyPress api is not available, we need to handle the key locally - const newKeysDownState = handleKeyLocally(keysDownState, key, press); - setKeysDownState(newKeysDownState); - - // then we send the full state - sendKeyboardEvent(newKeysDownState); + // if keyPress api is STILL available, we don't need to handle the key locally + if (keyPressAvailable) return; } + + // if the keyPress api is not available, we need to handle the key locally + const downState = handleKeyLocally(keysDownState, key, press); + setKeysDownState(downState); + + // then we send the full state + sendKeyboardEvent(downState); }, - [keyPressAvailable, keysDownState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState], + [keyPressAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState], ); - return { handleKeyPress, sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro }; + return { handleKeyPress, resetKeyboardState, executeMacro }; } diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 6fcd588..1df380f 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -106,11 +106,12 @@ export default function SettingsNetworkRoute() { setNetworkSettingsLoaded(false); send("getNetworkSettings", {}, resp => { if ("error" in resp) return; - console.log(resp.result); - setNetworkSettings(resp.result as NetworkSettings); + const networkSettings = resp.result as NetworkSettings; + console.debug("Network settings: ", networkSettings); + setNetworkSettings(networkSettings); if (!firstNetworkSettings.current) { - firstNetworkSettings.current = resp.result as NetworkSettings; + firstNetworkSettings.current = networkSettings; } setNetworkSettingsLoaded(true); }); @@ -119,8 +120,9 @@ export default function SettingsNetworkRoute() { const getNetworkState = useCallback(() => { send("getNetworkState", {}, resp => { if ("error" in resp) return; - console.log(resp.result); - setNetworkState(resp.result as NetworkState); + const networkState = resp.result as NetworkState; + console.debug("Network state:", networkState); + setNetworkState(networkState); }); }, [send, setNetworkState]); @@ -136,9 +138,10 @@ export default function SettingsNetworkRoute() { setNetworkSettingsLoaded(true); return; } + const networkSettings = resp.result as NetworkSettings; // We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed - firstNetworkSettings.current = resp.result as NetworkSettings; - setNetworkSettings(resp.result as NetworkSettings); + firstNetworkSettings.current = networkSettings; + setNetworkSettings(networkSettings); getNetworkState(); setNetworkSettingsLoaded(true); notifications.success("Network settings saved"); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 09703c3..2a7e190 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -73,7 +73,7 @@ export default function SettingsRoute() { // For some reason, the focus trap is not disabled immediately // so we need to blur the active element (document.activeElement as HTMLElement)?.blur(); - console.log("Just disabled focus trap"); + console.debug("Just disabled focus trap"); }, 300); return () => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index efc52df..81ad5fd 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -216,7 +216,7 @@ export default function KvmIdRoute() { clearInterval(checkInterval); setLoadingMessage("Connection established"); } else if (attempts >= 10) { - console.log( + console.warn( "[setRemoteSessionDescription] Failed to establish connection after 10 attempts", { connectionState: pc.connectionState, @@ -444,7 +444,7 @@ export default function KvmIdRoute() { if (isNewSignalingEnabled) { sendWebRTCSignal("offer", { sd: sd }); } else { - console.log("Legacy signanling. Waiting for ICE Gathering to complete..."); + console.log("Legacy signaling. Waiting for ICE Gathering to complete..."); } } catch (e) { console.error( @@ -511,7 +511,7 @@ export default function KvmIdRoute() { useEffect(() => { if (peerConnectionState === "failed") { - console.log("Connection failed, closing peer connection"); + console.warn("Connection failed, closing peer connection"); cleanupAndStopReconnecting(); } }, [peerConnectionState, cleanupAndStopReconnecting]); @@ -616,20 +616,24 @@ export default function KvmIdRoute() { } if (resp.method === "networkState") { - console.log("Setting network state", resp.params); + console.debug("Setting network state", resp.params); setNetworkState(resp.params as NetworkState); } if (resp.method === "keyboardLedState") { const ledState = resp.params as KeyboardLedState; - console.log("Setting keyboard led state", ledState); + console.debug("Setting keyboard led state", ledState); setKeyboardLedState(ledState); } if (resp.method === "keysDownState") { const downState = resp.params as KeysDownState; - console.log("Setting key down state", downState); - setKeysDownState(downState); + + if (downState) { + console.debug("Setting key down state:", downState); + setKeysDownState(downState); + setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + } } if (resp.method === "otaState") { @@ -681,8 +685,12 @@ export default function KvmIdRoute() { console.error("Failed to get keyboard led state", resp.error); return; } - console.log("Keyboard led state", resp.result); - setKeyboardLedState(resp.result as KeyboardLedState); + const ledState = resp.result as KeyboardLedState; + + if (ledState) { + console.debug("Keyboard led state: ", resp.result); + setKeyboardLedState(resp.result as KeyboardLedState); + } setNeedLedState(false); }); }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); @@ -705,10 +713,15 @@ export default function KvmIdRoute() { } else { console.error("Failed to get key down state", resp.error); } - return; + } else { + const downState = resp.result as KeysDownState; + + if (downState) { + console.debug("Keyboard key down state", downState); + setKeysDownState(downState); + setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + } } - console.log("Keyboard key down state", resp.result); - setKeysDownState(resp.result as KeysDownState); setNeedKeyDownState(false); }); }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState]); @@ -725,7 +738,7 @@ export default function KvmIdRoute() { useEffect(() => { if (!diskChannel || !file) return; diskChannel.onmessage = async e => { - console.log("Received", e.data); + console.debug("Received", e.data); const data = JSON.parse(e.data); const blob = file.slice(data.start, data.end); const buf = await blob.arrayBuffer(); From 39b39c7b7284b9a6c64a64cca310ea054abd48b8 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Fri, 15 Aug 2025 14:14:18 -0500 Subject: [PATCH 9/9] Add documentation on the legacy support. Cleanup react state management to enable upgrading Zustand --- config.go | 2 +- internal/usbgadget/hid_keyboard.go | 41 +++--- ui/src/components/ActionBar.tsx | 26 ++-- ui/src/components/Header.tsx | 2 +- ui/src/components/InfoBar.tsx | 38 ++---- ui/src/components/MacroBar.tsx | 2 +- ui/src/components/Terminal.tsx | 19 +-- ui/src/components/USBStateStatus.tsx | 4 +- ui/src/components/UsbDeviceSetting.tsx | 2 +- ui/src/components/UsbInfoSetting.tsx | 4 +- ui/src/components/VirtualKeyboard.tsx | 43 +++--- ui/src/components/WebRTCVideo.tsx | 34 ++--- .../components/extensions/ATXPowerControl.tsx | 2 +- .../components/extensions/DCPowerControl.tsx | 2 +- .../components/extensions/SerialConsole.tsx | 4 +- .../components/popovers/ExtensionPopover.tsx | 2 +- ui/src/components/popovers/MountPopover.tsx | 4 +- ui/src/components/popovers/PasteModal.tsx | 30 ++--- .../components/popovers/WakeOnLan/Index.tsx | 8 +- ui/src/components/sidebar/connectionStats.tsx | 33 ++--- ui/src/hooks/stores.ts | 112 +++++++++------- ui/src/hooks/useJsonRpc.ts | 11 +- ui/src/hooks/useKeyboard.ts | 123 +++++++++++------- ui/src/keyboardMappings.ts | 1 - ui/src/routes/devices.$id.mount.tsx | 6 +- .../devices.$id.settings.access._index.tsx | 6 +- .../routes/devices.$id.settings.advanced.tsx | 4 +- .../devices.$id.settings.general._index.tsx | 2 +- .../devices.$id.settings.general.reboot.tsx | 2 +- .../devices.$id.settings.general.update.tsx | 8 +- .../routes/devices.$id.settings.hardware.tsx | 7 +- .../routes/devices.$id.settings.keyboard.tsx | 19 +-- ui/src/routes/devices.$id.settings.mouse.tsx | 19 ++- .../routes/devices.$id.settings.network.tsx | 2 +- ui/src/routes/devices.$id.settings.tsx | 2 +- ui/src/routes/devices.$id.settings.video.tsx | 13 +- ui/src/routes/devices.$id.tsx | 95 ++++++-------- 37 files changed, 355 insertions(+), 379 deletions(-) diff --git a/config.go b/config.go index 46f83e6..513b655 100644 --- a/config.go +++ b/config.go @@ -114,7 +114,7 @@ var defaultConfig = &Config{ ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", - KeyboardLayout: "en_US", + KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 3b9f6c2..f4fbaa6 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -105,14 +105,14 @@ func (u *UsbGadget) updateKeyboardState(state byte) { defer u.keyboardStateLock.Unlock() if state&^ValidKeyboardLedMasks != 0 { - u.log.Error().Uint8("state", state).Msg("ignoring invalid bits") + u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") return } if u.keyboardState == state { return } - u.log.Trace().Interface("old", u.keyboardState).Interface("new", state).Msg("keyboardState updated") + u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") u.keyboardState = state if u.onKeyboardStateChange != nil { @@ -131,23 +131,6 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState { return getKeyboardState(u.keyboardState) } -const ( - // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C - ModifierMaskLeftControl = 0x01 - ModifierMaskRightControl = 0x10 - ModifierMaskLeftShift = 0x02 - ModifierMaskRightShift = 0x20 - ModifierMaskLeftAlt = 0x04 - ModifierMaskRightAlt = 0x40 - ModifierMaskLeftSuper = 0x08 - ModifierMaskRightSuper = 0x80 - - EitherShiftMask = ModifierMaskLeftShift | ModifierMaskRightShift - EitherControlMask = ModifierMaskLeftControl | ModifierMaskRightControl - EitherAltMask = ModifierMaskLeftAlt | ModifierMaskRightAlt - EitherSuperMask = ModifierMaskLeftSuper | ModifierMaskRightSuper -) - func (u *UsbGadget) GetKeysDownState() KeysDownState { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() @@ -310,6 +293,18 @@ const ( RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key) ) +const ( + // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C + ModifierMaskLeftControl = 0x01 + ModifierMaskRightControl = 0x10 + ModifierMaskLeftShift = 0x02 + ModifierMaskRightShift = 0x20 + ModifierMaskLeftAlt = 0x04 + ModifierMaskRightAlt = 0x40 + ModifierMaskLeftSuper = 0x08 + ModifierMaskRightSuper = 0x80 +) + // KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup var KeyCodeToMaskMap = map[byte]byte{ LeftControl: ModifierMaskLeftControl, @@ -327,6 +322,11 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) defer u.keyboardLock.Unlock() defer u.resetUserInputTime() + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the client/browser-side code in useKeyboard.ts so make sure to keep + // them in sync. var state = u.keysDownState modifier := state.Modifier keys := append([]byte(nil), state.Keys...) @@ -334,7 +334,8 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) if mask, exists := KeyCodeToMaskMap[key]; exists { // If the key is a modifier key, we update the keyboardModifier state // by setting or clearing the corresponding bit in the modifier byte. - // This allows us to track the state of modifier keys like Shift, Control, Alt, and Super. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. if press { modifier |= mask } else { diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index c8a214b..6a8e75e 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -26,17 +26,13 @@ export default function Actionbar({ requestFullscreen: () => Promise; }) { const { navigateTo } = useDeviceUiNavigation(); - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); + const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore(); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - const toggleSidebarView = useUiStore(state => state.toggleSidebarView); - const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - const terminalType = useUiStore(state => state.terminalType); - const setTerminalType = useUiStore(state => state.setTerminalType); const remoteVirtualMediaState = useMountMediaStore( state => state.remoteVirtualMediaState, ); - const developerMode = useSettingsStore(state => state.developerMode); + const { developerMode } = useSettingsStore(); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -47,13 +43,13 @@ export default function Actionbar({ isOpen.current = open; if (!open) { setTimeout(() => { - setDisableFocusTrap(false); + setDisableVideoFocusTrap(false); console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } }, - [setDisableFocusTrap], + [setDisableVideoFocusTrap], ); return ( @@ -81,7 +77,7 @@ export default function Actionbar({ text="Paste text" LeadingIcon={MdOutlineContentPasteGo} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -123,7 +119,7 @@ export default function Actionbar({ ); }} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -154,7 +150,7 @@ export default function Actionbar({ theme="light" text="Wake on LAN" onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} LeadingIcon={({ className }) => ( setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} />
@@ -218,7 +214,7 @@ export default function Actionbar({ text="Extension" LeadingIcon={LuCable} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -243,7 +239,7 @@ export default function Actionbar({ theme="light" text="Virtual Keyboard" LeadingIcon={FaKeyboard} - onClick={() => setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} />
diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 543634a..4bb7a97 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -48,7 +48,7 @@ export default function DashboardNavbar({ navigate("/"); }, [navigate, setUser]); - const usbState = useHidStore(state => state.usbState); + const { usbState } = useHidStore(); // for testing //userEmail = "user@example.org"; diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index eafc380..29f159d 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -2,24 +2,18 @@ import { useEffect, useMemo } from "react"; import { cx } from "@/cva.config"; import { - HidState, - MouseState, - RTCState, - SettingsState, useHidStore, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, - VideoState, + VideoState } from "@/hooks/stores"; import { keys, modifiers } from "@/keyboardMappings"; export default function InfoBar() { - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const mouseX = useMouseStore((state: MouseState) => state.mouseX); - const mouseY = useMouseStore((state: MouseState) => state.mouseY); - const mouseMove = useMouseStore((state: MouseState) => state.mouseMove); + const { keysDownState } = useHidStore(); + const { mouseX, mouseY, mouseMove } = useMouseStore(); const videoClientSize = useVideoStore( (state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, @@ -29,10 +23,8 @@ export default function InfoBar() { (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`, ); - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); - - const settings = useSettingsStore(); - const showPressedKeys = useSettingsStore((state: SettingsState) => state.showPressedKeys); + const { rpcDataChannel } = useRTCStore(); + const { debugMode, mouseMode, showPressedKeys } = useSettingsStore(); useEffect(() => { if (!rpcDataChannel) return; @@ -41,11 +33,9 @@ export default function InfoBar() { console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); }, [rpcDataChannel]); - const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); - const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); - - const usbState = useHidStore((state: HidState) => state.usbState); - const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); + const { keyboardLedState, usbState } = useHidStore(); + const { isTurnServerInUse } = useRTCStore(); + const { hdmiState } = useVideoStore(); const displayKeys = useMemo(() => { if (!showPressedKeys) @@ -64,21 +54,21 @@ export default function InfoBar() {
- {settings.debugMode ? ( + {debugMode ? (
Resolution:{" "} {videoSize}
) : null} - {settings.debugMode ? ( + {debugMode ? (
Video Size: {videoClientSize}
) : null} - {(settings.debugMode && settings.mouseMode == "absolute") ? ( + {(debugMode && mouseMode == "absolute") ? (
Pointer: @@ -87,7 +77,7 @@ export default function InfoBar() {
) : null} - {(settings.debugMode && settings.mouseMode == "relative") ? ( + {(debugMode && mouseMode == "relative") ? (
Last Move: @@ -98,13 +88,13 @@ export default function InfoBar() {
) : null} - {settings.debugMode && ( + {debugMode && (
USB State: {usbState}
)} - {settings.debugMode && ( + {debugMode && (
HDMI State: {hdmiState} diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index 066c21f..0ba8cf4 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; export default function MacroBar() { const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { executeMacro } = useKeyboard(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); useEffect(() => { setSendFn(send); diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index f5d662d..ba3e667 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -65,21 +65,22 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const enableTerminal = useUiStore(state => state.terminalType == type); - const setTerminalType = useUiStore(state => state.setTerminalType); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - + const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); + const isTerminalTypeEnabled = useMemo(() => { + return terminalType == type; + }, [terminalType, type]); + useEffect(() => { setTimeout(() => { - setDisableVideoFocusTrap(enableTerminal); + setDisableVideoFocusTrap(isTerminalTypeEnabled); }, 500); return () => { setDisableVideoFocusTrap(false); }; - }, [enableTerminal, setDisableVideoFocusTrap]); + }, [setDisableVideoFocusTrap, isTerminalTypeEnabled]); const readyState = dataChannel.readyState; useEffect(() => { @@ -175,9 +176,9 @@ function Terminal({ ], { "pointer-events-none translate-y-[500px] opacity-100 transition duration-300": - !enableTerminal, + !isTerminalTypeEnabled, "pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300": - enableTerminal, + isTerminalTypeEnabled, }, )} > diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index 618a9c3..9321a19 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -4,9 +4,7 @@ import { cx } from "@/cva.config"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import LoadingSpinner from "@components/LoadingSpinner"; import StatusCard from "@components/StatusCards"; -import { HidState } from "@/hooks/stores"; - -type USBStates = HidState["usbState"]; +import { USBStates } from "@/hooks/stores"; type StatusProps = Record< USBStates, diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 432ec3d..2a5193c 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -59,7 +59,7 @@ const usbPresets = [ ]; export function UsbDeviceSetting() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [loading, setLoading] = useState(false); const [usbDeviceConfig, setUsbDeviceConfig] = diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index 4f63697..dc6b474 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -54,7 +54,7 @@ const usbConfigs = [ type UsbConfigMap = Record; export function UsbInfoSetting() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [loading, setLoading] = useState(false); const [usbConfigProduct, setUsbConfigProduct] = useState(""); @@ -205,7 +205,7 @@ function USBConfigDialog({ product: "", }); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const syncUsbConfig = useCallback(() => { send("getUsbConfig", {}, resp => { diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index fa7857d..16ccb9b 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -12,7 +12,7 @@ import "react-simple-keyboard/build/css/index.css"; import AttachIconRaw from "@/assets/attach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { HidState, useHidStore, useUiStore } from "@/hooks/stores"; +import { useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import { keyDisplayMap, keys } from "@/keyboardMappings"; @@ -28,17 +28,8 @@ function KeyboardWrapper() { const [layoutName] = useState("default"); const keyboardRef = useRef(null); - const showAttachedVirtualKeyboard = useUiStore( - state => state.isAttachedVirtualKeyboardVisible, - ); - const setShowAttachedVirtualKeyboard = useUiStore( - state => state.setAttachedVirtualKeyboardVisibility, - ); - - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - - const keysDownState = useHidStore((state: HidState) => state.keysDownState); + const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); + const { keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); const { handleKeyPress, executeMacro } = useKeyboard(); const [isDragging, setIsDragging] = useState(false); @@ -129,23 +120,23 @@ function KeyboardWrapper() { }, [endDrag, onDrag, startDrag]); const onKeyDown = useCallback( - (key: string) => { + async (key: string) => { const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; const dynamicKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight"]; // handle the fake key-macros we have defined for common combinations if (key === "CtrlAltDelete") { - executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } if (key === "AltMetaEscape") { - executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); return; } if (key === "CtrlAltBackspace") { - executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } @@ -175,7 +166,7 @@ function KeyboardWrapper() { ); // TODO handle the display of down keys and the layout change for shift/caps lock - // const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState.caps_lock)); + // const { isCapsLockActive } = useShallow(useHidStore()); // // Handle toggle of layout for shift or caps lock // const toggleLayout = () => { // setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default")); @@ -185,11 +176,11 @@ function KeyboardWrapper() {
- {virtualKeyboard && ( + {isVirtualKeyboardEnabled && (
- {showAttachedVirtualKeyboard ? ( + {isAttachedVirtualKeyboardVisible ? (
@@ -245,7 +236,7 @@ function KeyboardWrapper() { theme="light" text="Hide" LeadingIcon={ChevronDownIcon} - onClick={() => setVirtualKeyboard(false)} + onClick={() => setVirtualKeyboardEnabled(false)} />
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 6e273f5..d4c7da4 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -11,14 +11,10 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { cx } from "@/cva.config"; import { keys } from "@/keyboardMappings"; import { - MouseState, - RTCState, - SettingsState, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, - VideoState, } from "@/hooks/stores"; import { @@ -31,16 +27,14 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states const videoElm = useRef(null); - const mediaStream = useRTCStore((state: RTCState) => state.mediaStream); + const { mediaStream, peerConnectionState } = useRTCStore(); const [isPlaying, setIsPlaying] = useState(false); - const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); - const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition); - const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove); + const { setMousePosition, setMouseMove } = useMouseStore(); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -48,18 +42,16 @@ export default function WebRTCVideo() { height: videoHeight, clientWidth: videoClientWidth, clientHeight: videoClientHeight, + hdmiState, } = useVideoStore(); // Video enhancement settings - const videoSaturation = useSettingsStore((state: SettingsState) => state.videoSaturation); - const videoBrightness = useSettingsStore((state: SettingsState) => state.videoBrightness); - const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast); + const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore(); // RTC related states - const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); + const { peerConnection } = useRTCStore(); // HDMI and UI states - const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; @@ -67,14 +59,14 @@ export default function WebRTCVideo() { const [blockWheelEvent, setBlockWheelEvent] = useState(false); // Misc states and hooks - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); // Video-related const handleResize = useCallback( - ({ width, height }: { width: number; height: number }) => { + ( { width, height }: { width: number | undefined; height: number | undefined }) => { if (!videoElm.current) return; // Do something with width and height, e.g.: - setVideoClientSize(width, height); + setVideoClientSize(width || 0, height || 0); setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); }, [setVideoClientSize, setVideoSize] @@ -103,7 +95,7 @@ export default function WebRTCVideo() { function updateVideoSizeOnMount() { if (videoElm.current) updateVideoSizeStore(videoElm.current); }, - [setVideoClientSize, updateVideoSizeStore, setVideoSize], + [updateVideoSizeStore], ); // Pointer lock and keyboard lock related @@ -447,13 +439,7 @@ export default function WebRTCVideo() { // We set the as early as possible addStreamToVideoElm(mediaStream); }, - [ - setVideoClientSize, - mediaStream, - updateVideoSizeStore, - peerConnection, - addStreamToVideoElm, - ], + [addStreamToVideoElm, mediaStream], ); // Setup Keyboard Events diff --git a/ui/src/components/extensions/ATXPowerControl.tsx b/ui/src/components/extensions/ATXPowerControl.tsx index 0334a18..e276da1 100644 --- a/ui/src/components/extensions/ATXPowerControl.tsx +++ b/ui/src/components/extensions/ATXPowerControl.tsx @@ -23,7 +23,7 @@ export function ATXPowerControl() { > | null>(null); const [atxState, setAtxState] = useState(null); - const [send] = useJsonRpc(function onRequest(resp) { + const { send } = useJsonRpc(function onRequest(resp) { if (resp.method === "atxState") { setAtxState(resp.params as ATXState); } diff --git a/ui/src/components/extensions/DCPowerControl.tsx b/ui/src/components/extensions/DCPowerControl.tsx index a13e4ea..13bf128 100644 --- a/ui/src/components/extensions/DCPowerControl.tsx +++ b/ui/src/components/extensions/DCPowerControl.tsx @@ -19,7 +19,7 @@ interface DCPowerState { } export function DCPowerControl() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [powerState, setPowerState] = useState(null); const getDCPowerState = useCallback(() => { diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index 544d3fd..d19f1f0 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -17,7 +17,7 @@ interface SerialSettings { } export function SerialConsole() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [settings, setSettings] = useState({ baudRate: "9600", dataBits: "8", @@ -49,7 +49,7 @@ export function SerialConsole() { setSettings(newSettings); }); }; - const setTerminalType = useUiStore(state => state.setTerminalType); + const { setTerminalType } = useUiStore(); return (
diff --git a/ui/src/components/popovers/ExtensionPopover.tsx b/ui/src/components/popovers/ExtensionPopover.tsx index 10ee2ea..f6ec1f1 100644 --- a/ui/src/components/popovers/ExtensionPopover.tsx +++ b/ui/src/components/popovers/ExtensionPopover.tsx @@ -39,7 +39,7 @@ const AVAILABLE_EXTENSIONS: Extension[] = [ ]; export default function ExtensionPopover() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [activeExtension, setActiveExtension] = useState(null); // Load active extension on component mount diff --git a/ui/src/components/popovers/MountPopover.tsx b/ui/src/components/popovers/MountPopover.tsx index 752398b..86ba623 100644 --- a/ui/src/components/popovers/MountPopover.tsx +++ b/ui/src/components/popovers/MountPopover.tsx @@ -21,8 +21,8 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import notifications from "@/notifications"; const MountPopopover = forwardRef((_props, ref) => { - const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); - const [send] = useJsonRpc(); + const { diskDataChannelStats } = useRTCStore(); + const { send } = useJsonRpc(); const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = useMountMediaStore(); diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 23a504a..0b69718 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -25,25 +25,23 @@ const noModifier = 0 export default function PasteModal() { const TextAreaRef = useRef(null); - const setPasteMode = useHidStore(state => state.setPasteModeEnabled); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + const { setPasteModeEnabled } = useHidStore(); + const { setDisableVideoFocusTrap } = useUiStore(); - const [send] = useJsonRpc(); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const { send } = useJsonRpc(); + const { rpcDataChannel } = useRTCStore(); const [invalidChars, setInvalidChars] = useState([]); const close = useClose(); - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - - // this ensures we always get the original en_US if it hasn't been set yet + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); + + // this ensures we always get the en-US if it hasn't been set yet + // and if we get en_US from the backend, we convert it to en-US const safeKeyboardLayout = useMemo(() => { if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; + return keyboardLayout.replace("en_US", "en-US"); + return "en-US"; }, [keyboardLayout]); useEffect(() => { @@ -54,13 +52,13 @@ export default function PasteModal() { }, [send, setKeyboardLayout]); const onCancelPasteMode = useCallback(() => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); setInvalidChars([]); - }, [setDisableVideoFocusTrap, setPasteMode]); + }, [setDisableVideoFocusTrap, setPasteModeEnabled]); const onConfirmPaste = useCallback(async () => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; @@ -111,7 +109,7 @@ export default function PasteModal() { ); }); } - }, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]); + }, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteModeEnabled]); useEffect(() => { if (TextAreaRef.current) { diff --git a/ui/src/components/popovers/WakeOnLan/Index.tsx b/ui/src/components/popovers/WakeOnLan/Index.tsx index 1cf7f18..e801052 100644 --- a/ui/src/components/popovers/WakeOnLan/Index.tsx +++ b/ui/src/components/popovers/WakeOnLan/Index.tsx @@ -14,11 +14,9 @@ import AddDeviceForm from "./AddDeviceForm"; export default function WakeOnLanModal() { const [storedDevices, setStoredDevices] = useState([]); const [showAddForm, setShowAddForm] = useState(false); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - - const [send] = useJsonRpc(); + const { setDisableVideoFocusTrap } = useUiStore(); + const { rpcDataChannel } = useRTCStore(); + const { send } = useJsonRpc(); const close = useClose(); const [errorMessage, setErrorMessage] = useState(null); const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState(null); diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index 404deb1..3faf81b 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -37,10 +37,18 @@ function createChartArray( } export default function ConnectionStatsSidebar() { - const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); - - const candidatePairStats = useRTCStore(state => state.candidatePairStats); - const setSidebarView = useUiStore(state => state.setSidebarView); + const { sidebarView, setSidebarView } = useUiStore(); + const { + mediaStream, + peerConnection, + inboundRtpStats, + appendInboundRtpStats, + candidatePairStats, + appendCandidatePairStats, + appendLocalCandidateStats, + appendRemoteCandidateStats, + appendDiskDataChannelStats, + } = useRTCStore(); function isMetricSupported( stream: Map, @@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() { return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); } - const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats); - const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats); - const appendDiskDataChannelStats = useRTCStore( - state => state.appendDiskDataChannelStats, - ); - const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats); - const appendRemoteCandidateStats = useRTCStore( - state => state.appendRemoteCandidateStats, - ); - - const peerConnection = useRTCStore(state => state.peerConnection); - const mediaStream = useRTCStore(state => state.mediaStream); - const sidebarView = useUiStore(state => state.sidebarView); - useInterval(function collectWebRTCStats() { (async () => { if (!mediaStream) return; @@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() { successfulLocalCandidateId = report.localCandidateId; successfulRemoteCandidateId = report.remoteCandidateId; } - - appendIceCandidatePair(report); + appendCandidatePairStats(report); } else if (report.type === "local-candidate") { // We only want to append the local candidate stats that were used in nominated candidate pair if (successfulLocalCandidateId === report.id) { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index c5d714c..f071825 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -235,9 +235,12 @@ export const useMouseStore = create(set => ({ setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), })); +export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; +export type HdmiErrorStates = Extract + export interface HdmiState { ready: boolean; - error?: Extract; + error?: HdmiErrorStates; } export interface VideoState { @@ -247,19 +250,13 @@ export interface VideoState { clientHeight: number; setClientSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void; - hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; + hdmiState: HdmiStates; setHdmiState: (state: { ready: boolean; - error?: Extract; + error?: HdmiErrorStates; }) => void; } -export interface BacklightSettings { - max_brightness: number; - dim_after: number; - off_after: number; -} - export const useVideoStore = create(set => ({ width: 0, height: 0, @@ -288,6 +285,12 @@ export const useVideoStore = create(set => ({ }, })); +export interface BacklightSettings { + max_brightness: number; + dim_after: number; + off_after: number; +} + export interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; @@ -444,6 +447,13 @@ export interface KeysDownState { keys: number[]; } +export type USBStates = + | "configured" + | "attached" + | "not attached" + | "suspended" + | "addressed"; + export interface HidState { keyboardLedState: KeyboardLedState; setKeyboardLedState: (state: KeyboardLedState) => void; @@ -451,8 +461,8 @@ export interface HidState { keysDownState: KeysDownState; setKeysDownState: (state: KeysDownState) => void; - keyPressAvailable: boolean; - setKeyPressAvailable: (available: boolean) => void; + keyPressReportApiAvailable: boolean; + setkeyPressReportApiAvailable: (available: boolean) => void; isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; @@ -460,8 +470,8 @@ export interface HidState { isPasteModeEnabled: boolean; setPasteModeEnabled: (enabled: boolean) => void; - usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed"; - setUsbState: (state: HidState["usbState"]) => void; + usbState: USBStates; + setUsbState: (state: USBStates) => void; } export const useHidStore = create(set => ({ @@ -471,8 +481,8 @@ export const useHidStore = create(set => ({ keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), - keyPressAvailable: true, - setKeyPressAvailable: (available: boolean) => set({ keyPressAvailable: available }), + keyPressReportApiAvailable: true, + setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }), isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), @@ -482,7 +492,7 @@ export const useHidStore = create(set => ({ // Add these new properties for USB state usbState: "not attached", - setUsbState: (state: HidState["usbState"]) => set({ usbState: state }), + setUsbState: (state: USBStates) => set({ usbState: state }), })); export const useUserStore = create(set => ({ @@ -490,11 +500,15 @@ export const useUserStore = create(set => ({ setUser: user => set({ user }), })); -export interface UpdateState { - isUpdatePending: boolean; - setIsUpdatePending: (isPending: boolean) => void; - updateDialogHasBeenMinimized: boolean; - otaState: { +export type UpdateModalViews = + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; + +export interface OtaState { updating: boolean; error: string | null; @@ -523,17 +537,17 @@ export interface UpdateState { systemUpdateProgress: number; systemUpdatedAt: string | null; - }; - setOtaState: (state: UpdateState["otaState"]) => void; +}; + +export interface UpdateState { + isUpdatePending: boolean; + setIsUpdatePending: (isPending: boolean) => void; + updateDialogHasBeenMinimized: boolean; + otaState: OtaState; + setOtaState: (state: OtaState) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; - modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; - setModalView: (view: UpdateState["modalView"]) => void; + modalView: UpdateModalViews + setModalView: (view: UpdateModalViews) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; } @@ -567,15 +581,19 @@ export const useUpdateStore = create(set => ({ setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => set({ updateDialogHasBeenMinimized: hasBeenMinimized }), modalView: "loading", - setModalView: (view: UpdateState["modalView"]) => set({ modalView: view }), + setModalView: (view: UpdateModalViews) => set({ modalView: view }), updateErrorMessage: null, setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), })); -interface UsbConfigModalState { - modalView: "updateUsbConfig" | "updateUsbConfigSuccess"; +export type UsbConfigModalViews = + | "updateUsbConfig" + | "updateUsbConfigSuccess"; + +export interface UsbConfigModalState { + modalView: UsbConfigModalViews ; errorMessage: string | null; - setModalView: (view: UsbConfigModalState["modalView"]) => void; + setModalView: (view: UsbConfigModalViews) => void; setErrorMessage: (message: string | null) => void; } @@ -590,24 +608,26 @@ export interface UsbConfigState { export const useUsbConfigModalStore = create(set => ({ modalView: "updateUsbConfig", errorMessage: null, - setModalView: (view: UsbConfigModalState["modalView"]) => set({ modalView: view }), + setModalView: (view: UsbConfigModalViews) => set({ modalView: view }), setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); -interface LocalAuthModalState { - modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; - setModalView: (view: LocalAuthModalState["modalView"]) => void; +export type LocalAuthModalViews = + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; + +export interface LocalAuthModalState { + modalView:LocalAuthModalViews; + setModalView: (view:LocalAuthModalViews) => void; } export const useLocalAuthModalStore = create(set => ({ modalView: "createPassword", - setModalView: (view: LocalAuthModalState["modalView"]) => set({ modalView: view }), + setModalView: (view: LocalAuthModalViews) => set({ modalView: view }), })); export interface DeviceState { diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5f088dc..a50c15b 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { RTCState, useRTCStore } from "@/hooks/stores"; +import { useRTCStore } from "@/hooks/stores"; export interface JsonRpcRequest { jsonrpc: string; @@ -33,7 +33,7 @@ const callbackStore = new Map void>( let requestCounter = 0; export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); const send = useCallback( (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { @@ -45,7 +45,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.send(JSON.stringify(payload)); }, - [rpcDataChannel], + [rpcDataChannel] ); useEffect(() => { @@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { return () => { rpcDataChannel.removeEventListener("message", messageHandler); }; - }, [rpcDataChannel, onRequest]); + }, + [rpcDataChannel, onRequest]); - return [send]; + return { send }; } diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index f9d9b04..a6a4582 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,20 +1,32 @@ import { useCallback } from "react"; -import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; +import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); + const { keysDownState, setKeysDownState } = useHidStore(); - const keyPressAvailable = useHidStore((state: HidState) => state.keyPressAvailable); - const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); + // INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state + // being tracked on the browser/client-side. When adding the keyPressReport API to the + // device-side code, we have to still support the situation where the browser/client-side code + // is running on the cloud against a device that has not been updated yet and thus does not + // support the keyPressReport API. In that case, we need to handle the key presses locally + // and send the full state to the device, so it can behave like a real USB HID keyboard. + // This flag indicates whether the keyPressReport API is available on the device which is + // dynamically set when the device responds to the first key press event or reports its + // keysDownState when queried since the keyPressReport was introduced together with the + // getKeysDownState API. + const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore(); + // sendKeyboardEvent is used to send the full keyboard state to the device for macro handling and resetting keyboard state. + // It sends the keys currently pressed and the modifier state. + // The device will respond with the keysDownState if it supports the keyPressReport API + // or just accept the state if it does not support (returning no result) const sendKeyboardEvent = useCallback( (state: KeysDownState) => { if (rpcDataChannel?.readyState !== "open") return; @@ -24,23 +36,30 @@ export default function useKeyboard() { if ("error" in resp) { console.error(`Failed to send keyboard report ${state}`, resp.error); } else { + // If the device supports keyPressReport API, it will (also) return the keysDownState when we send + // the keyboardReport const keysDownState = resp.result as KeysDownState; if (keysDownState) { - // new devices return the keyDownState, so we can use it to update the state - setKeysDownState(keysDownState); - setKeyPressAvailable(true); // if they returned a keysDownState, we know they also support keyPressReport + setKeysDownState(keysDownState); // treat the response as the canonical state + setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport } else { - // old devices do not return the keyDownState, so we just pretend they accepted what we sent - setKeysDownState(state); - // and we shouldn't set keyPressAvailable here because we don't know if they support it + // older devices versions do not return the keyDownState + setKeysDownState(state); // we just pretend they accepted what we sent + setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport } } }); }, - [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], + [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], ); + // sendKeypressEvent is used to send a single key press/release event to the device. + // It sends the key and whether it is pressed or released. + // Older device version will not understand this request and will respond with + // an error with code -32601, which means that the RPC method name was not recognized. + // In that case we will switch to local key handling and update the keysDownState + // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. const sendKeypressEvent = useCallback( (key: number, press: boolean) => { if (rpcDataChannel?.readyState !== "open") return; @@ -48,11 +67,10 @@ export default function useKeyboard() { console.debug(`Send keypressEvent key: ${key}, press: ${press}`); send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { if ("error" in resp) { - // -32601 means the method is not supported + // -32601 means the method is not supported because the device is running an older version if (resp.error.code === -32601) { - // if we don't support key press report, we need to disable all that handling - console.error("Failed calling keypressReport, switching to local handling", resp.error); - setKeyPressAvailable(false); + console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error); + setkeyPressReportApiAvailable(false); } else { console.error(`Failed to send key ${key} press: ${press}`, resp.error); } @@ -61,14 +79,17 @@ export default function useKeyboard() { if (keysDownState) { setKeysDownState(keysDownState); - // we don't need to set keyPressAvailable here, because it's already true or we never landed here + // we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here } } }); }, - [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], + [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], ); + // resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. + // This is useful for macros and when the browser loses focus to ensure that the keyboard state + // is clean. const resetKeyboardState = useCallback(() => { console.debug("Resetting keyboard state"); keysDownState.keys.fill(0); // Reset the keys buffer to zeros @@ -76,6 +97,12 @@ export default function useKeyboard() { sendKeyboardEvent(keysDownState); }, [keysDownState, sendKeyboardEvent]); + // executeMacro is used to execute a macro consisting of multiple steps. + // Each step can have multiple keys, multiple modifiers and a delay. + // The keys and modifiers are pressed together and held for the delay duration. + // After the delay, the keys and modifiers are released and the next step is executed. + // If a step has no keys or modifiers, it is treated as a delay-only step. + // A small pause is added between steps to ensure that the device can process the events. const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { for (const [index, step] of steps.entries()) { const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); @@ -99,14 +126,43 @@ export default function useKeyboard() { } }; - // this code exists because we have devices that don't support the keysPress api yet (not current) - // so we mirror the device-side code here to keep track of the keyboard state - function handleKeyLocally(state: KeysDownState, key: number, press: boolean): KeysDownState { + // handleKeyPress is used to handle a key press or release event. + // This function handle both key press and key release events. + // It checks if the keyPressReport API is available and sends the key press event. + // If the keyPressReport API is not available, it simulates the device-side key + // handling for legacy devices and updates the keysDownState accordingly. + // It then sends the full keyboard state to the device. + const handleKeyPress = useCallback( + (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + + if (keyPressReportApiAvailable) { + // if the keyPress api is available, we can just send the key press event + sendKeypressEvent(key, press); + } else { + // if the keyPress api is not available, we need to handle the key locally + const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press); + sendKeyboardEvent(downState); // then we send the full state + } + }, + [keyPressReportApiAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent], + ); + + // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists + function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState { + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the device-side code in hid_keyboard.go so make sure to keep them in sync. const keys = state.keys; let modifiers = state.modifier; const modifierMask = hidKeyToModifierMask[key] || 0; if (modifierMask !== 0) { + // If the key is a modifier key, we update the keyboardModifier state + // by setting or clearing the corresponding bit in the modifier byte. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. console.debug(`Handling modifier key: ${key}, press: ${press}, current modifiers: ${modifiers}, modifier mask: ${modifierMask}`); if (press) { modifiers |= modifierMask; @@ -151,26 +207,5 @@ export default function useKeyboard() { return { modifier: modifiers, keys }; } - const handleKeyPress = useCallback( - (key: number, press: boolean) => { - if (rpcDataChannel?.readyState !== "open") return; - - if (keyPressAvailable) { - // if the keyPress api is available, we can just send the key press event - sendKeypressEvent(key, press); - // if keyPress api is STILL available, we don't need to handle the key locally - if (keyPressAvailable) return; - } - - // if the keyPress api is not available, we need to handle the key locally - const downState = handleKeyLocally(keysDownState, key, press); - setKeysDownState(downState); - - // then we send the full state - sendKeyboardEvent(downState); - }, - [keyPressAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState], - ); - return { handleKeyPress, resetKeyboardState, executeMacro }; } diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index c91729b..7dddd88 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -86,7 +86,6 @@ export const keys = { KeyZ: 0x1d, KeypadExclamation: 0xcf, Minus: 0x2d, - None: 0x00, NumLock: 0x53, // and Clear Numpad0: 0x62, // and Insert Numpad1: 0x59, // and End diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 7ac519c..ae5d985 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -64,7 +64,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { setRemoteVirtualMediaState(null); } - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); async function syncRemoteVirtualMediaState() { return new Promise((resolve, reject) => { send("getVirtualMediaState", {}, resp => { @@ -684,7 +684,7 @@ function DeviceFileView({ const [currentPage, setCurrentPage] = useState(1); const filesPerPage = 5; - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); interface StorageSpace { bytesUsed: number; @@ -996,7 +996,7 @@ function UploadFileView({ const [fileError, setFileError] = useState(null); const [uploadError, setUploadError] = useState(null); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const rtcDataChannelRef = useRef(null); useEffect(() => { diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index e0543b8..951c07b 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -42,7 +42,7 @@ export default function SettingsAccessIndexRoute() { const { navigateTo } = useDeviceUiNavigation(); const navigate = useNavigate(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [isAdopted, setAdopted] = useState(false); const [deviceId, setDeviceId] = useState(null); @@ -166,9 +166,7 @@ export default function SettingsAccessIndexRoute() { notifications.success("TLS settings updated successfully"); }); - }, - [send], - ); + }, [send]); // Handle TLS mode change const handleTlsModeChange = (value: string) => { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index d1dab68..c453e79 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -15,10 +15,10 @@ import notifications from "../notifications"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsAdvancedRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [sshKey, setSSHKey] = useState(""); - const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); + const { setDeveloperMode } = useSettingsStore(); const [devChannel, setDevChannel] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index ecefdfa..8916af4 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -13,7 +13,7 @@ import { useDeviceStore } from "../hooks/stores"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsGeneralRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const [autoUpdate, setAutoUpdate] = useState(true); diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index c6889f6..0bf114c 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -6,7 +6,7 @@ import { Button } from "@components/Button"; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const onConfirmUpdate = useCallback(() => { // This is where we send the RPC to the golang binary diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 7c41449..f456d89 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -16,7 +16,7 @@ export default function SettingsGeneralUpdateRoute() { const { updateSuccess } = location.state || {}; const { setModalView, otaState } = useUpdateStore(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const onConfirmUpdate = useCallback(() => { send("tryUpdate", {}); @@ -134,10 +134,8 @@ function LoadingState({ }) { const [progressWidth, setProgressWidth] = useState("0%"); const abortControllerRef = useRef(null); - const [send] = useJsonRpc(); - - const setAppVersion = useDeviceStore(state => state.setAppVersion); - const setSystemVersion = useDeviceStore(state => state.setSystemVersion); + const { send } = useJsonRpc(); + const { setAppVersion, setSystemVersion } = useDeviceStore(); const getVersionInfo = useCallback(() => { return new Promise((resolve, reject) => { diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 82cc6a1..850126c 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -12,10 +12,9 @@ import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { FeatureFlag } from "../components/FeatureFlag"; export default function SettingsHardwareRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const settings = useSettingsStore(); - - const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation); + const { setDisplayRotation } = useSettingsStore(); const handleDisplayRotationChange = (rotation: string) => { setDisplayRotation(rotation); @@ -34,7 +33,7 @@ export default function SettingsHardwareRoute() { }); }; - const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); + const { setBacklightSettings } = useSettingsStore(); const handleBacklightSettingsChange = (settings: BacklightSettings) => { // If the user has set the display to dim after it turns off, set the dim_after diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index 40c7c6f..d740ffb 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -12,25 +12,20 @@ import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsKeyboardRoute() { - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const showPressedKeys = useSettingsStore(state => state.showPressedKeys); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - const setShowPressedKeys = useSettingsStore( - state => state.setShowPressedKeys, - ); + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); + const { showPressedKeys, setShowPressedKeys } = useSettingsStore(); - // this ensures we always get the original en_US if it hasn't been set yet + // this ensures we always get the en-US if it hasn't been set yet + // and if we get en_US from the backend, we convert it to en-US const safeKeyboardLayout = useMemo(() => { if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; + return keyboardLayout.replace("en_US", "en-US"); + return "en-US"; }, [keyboardLayout]); const layoutOptions = keyboardOptions(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); useEffect(() => { send("getKeyboardLayout", {}, resp => { diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index 26c4b5b..18016d3 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -66,14 +66,11 @@ const jigglerOptions = [ type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom"; export default function SettingsMouseRoute() { - const hideCursor = useSettingsStore(state => state.isCursorHidden); - const setHideCursor = useSettingsStore(state => state.setCursorVisibility); - - const mouseMode = useSettingsStore(state => state.mouseMode); - const setMouseMode = useSettingsStore(state => state.setMouseMode); - - const scrollThrottling = useSettingsStore(state => state.scrollThrottling); - const setScrollThrottling = useSettingsStore(state => state.setScrollThrottling); + const { + isCursorHidden, setCursorVisibility, + mouseMode, setMouseMode, + scrollThrottling, setScrollThrottling + } = useSettingsStore(); const [selectedJigglerOption, setSelectedJigglerOption] = useState(null); @@ -86,7 +83,7 @@ export default function SettingsMouseRoute() { { value: "100", label: "Very High" }, ]; - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const syncJigglerSettings = useCallback(() => { send("getJigglerState", {}, resp => { @@ -182,8 +179,8 @@ export default function SettingsMouseRoute() { description="Hide the cursor when sending mouse movements" > setHideCursor(e.target.checked)} + checked={isCursorHidden} + onChange={e => setCursorVisibility(e.target.checked)} /> diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 1df380f..ebcbc85 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -72,7 +72,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { } export default function SettingsNetworkRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [networkState, setNetworkState] = useNetworkStateStore(state => [ state, state.setNetworkState, diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 2a7e190..0309ce9 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -28,7 +28,7 @@ import { cx } from "../cva.config"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { const location = useLocation(); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + const { setDisableVideoFocusTrap } = useUiStore(); const { resetKeyboardState } = useKeyboard(); const scrollContainerRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 9e888ab..e6a39ea 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -41,18 +41,17 @@ const streamQualityOptions = [ ]; export default function SettingsVideoRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [streamQuality, setStreamQuality] = useState("1"); const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); // Video enhancement settings from store - const videoSaturation = useSettingsStore(state => state.videoSaturation); - const setVideoSaturation = useSettingsStore(state => state.setVideoSaturation); - const videoBrightness = useSettingsStore(state => state.videoBrightness); - const setVideoBrightness = useSettingsStore(state => state.setVideoBrightness); - const videoContrast = useSettingsStore(state => state.videoContrast); - const setVideoContrast = useSettingsStore(state => state.setVideoContrast); + const { + videoSaturation, setVideoSaturation, + videoBrightness, setVideoBrightness, + videoContrast, setVideoContrast + } = useSettingsStore(); useEffect(() => { send("getStreamQualityFactor", {}, resp => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 81ad5fd..08a1413 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -18,15 +18,11 @@ import useWebSocket from "react-use-websocket"; import { cx } from "@/cva.config"; import { - DeviceState, - HidState, KeyboardLedState, KeysDownState, - MountMediaState, NetworkState, - RTCState, - UIState, - UpdateState, + OtaState, + USBStates, useDeviceStore, useHidStore, useMountMediaStore, @@ -132,22 +128,22 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const sidebarView = useUiStore((state: UIState) => state.sidebarView); - const [queryParams, setQueryParams] = useSearchParams(); + const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const [ queryParams, setQueryParams ] = useSearchParams(); + + const { + peerConnection, setPeerConnection, + peerConnectionState, setPeerConnectionState, + diskChannel, setDiskChannel, + setMediaStream, + setRpcDataChannel, + isTurnServerInUse, setTurnServerInUse, + rpcDataChannel, + setTransceiver + } = useRTCStore(); - const setIsTurnServerInUse = useRTCStore((state: RTCState) => state.setTurnServerInUse); - const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); - const setPeerConnectionState = useRTCStore((state: RTCState) => state.setPeerConnectionState); - const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); - const setMediaMediaStream = useRTCStore((state: RTCState) => state.setMediaStream); - const setPeerConnection = useRTCStore((state: RTCState) => state.setPeerConnection); - const setDiskChannel = useRTCStore((state: RTCState) => state.setDiskChannel); - const setRpcDataChannel = useRTCStore((state: RTCState) => state.setRpcDataChannel); - const setTransceiver = useRTCStore((state: RTCState) => state.setTransceiver); const location = useLocation(); - const isLegacySignalingEnabled = useRef(false); - const [connectionFailed, setConnectionFailed] = useState(false); const navigate = useNavigate(); @@ -480,7 +476,7 @@ export default function KvmIdRoute() { }; pc.ontrack = function (event) { - setMediaMediaStream(event.streams[0]); + setMediaStream(event.streams[0]); }; setTransceiver(pc.addTransceiver("video", { direction: "recvonly" })); @@ -502,7 +498,7 @@ export default function KvmIdRoute() { legacyHTTPSignaling, sendWebRTCSignal, setDiskChannel, - setMediaMediaStream, + setMediaStream, setPeerConnection, setPeerConnectionState, setRpcDataChannel, @@ -517,9 +513,7 @@ export default function KvmIdRoute() { }, [peerConnectionState, cleanupAndStopReconnecting]); // Cleanup effect - const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats); - const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats); - const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); + const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore(); useEffect(() => { return () => { @@ -550,11 +544,10 @@ export default function KvmIdRoute() { if (!lastRemoteStat?.length) return; const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here - setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); - }, [peerConnectionState, setIsTurnServerInUse]); + setTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); + }, [peerConnectionState, setTurnServerInUse]); // TURN server usage reporting - const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); const lastBytesReceived = useRef(0); const lastBytesSent = useRef(0); @@ -587,17 +580,13 @@ export default function KvmIdRoute() { }); }, 10000); - const setNetworkState = useNetworkStateStore((state: NetworkState) => state.setNetworkState); - - const setUsbState = useHidStore((state: HidState) => state.setUsbState); - const setHdmiState = useVideoStore((state: VideoState) => state.setHdmiState); - - const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); - const setKeyboardLedState = useHidStore((state: HidState) => state.setKeyboardLedState); - - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); - const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); + const { setNetworkState} = useNetworkStateStore(); + const { setHdmiState } = useVideoStore(); + const { + keyboardLedState, setKeyboardLedState, + keysDownState, setKeysDownState, setUsbState, + setkeyPressReportApiAvailable + } = useHidStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -608,7 +597,7 @@ export default function KvmIdRoute() { } if (resp.method === "usbState") { - setUsbState(resp.params as unknown as HidState["usbState"]); + setUsbState(resp.params as unknown as USBStates); } if (resp.method === "videoInputState") { @@ -632,12 +621,12 @@ export default function KvmIdRoute() { if (downState) { console.debug("Setting key down state:", downState); setKeysDownState(downState); - setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } } if (resp.method === "otaState") { - const otaState = resp.params as UpdateState["otaState"]; + const otaState = resp.params as OtaState; setOtaState(otaState); if (otaState.updating === true) { @@ -661,8 +650,7 @@ export default function KvmIdRoute() { } } - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); - const [send] = useJsonRpc(onJsonRpcRequest); + const { send } = useJsonRpc(onJsonRpcRequest); useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; @@ -709,7 +697,7 @@ export default function KvmIdRoute() { if (resp.error.code === -32601) { // if we don't support key down state, we know key press is also not available console.error("Failed to get key down state, switching to old-school", resp.error); - setKeyPressAvailable(false); + setkeyPressReportApiAvailable(false); } else { console.error("Failed to get key down state", resp.error); } @@ -719,12 +707,12 @@ export default function KvmIdRoute() { if (downState) { console.debug("Keyboard key down state", downState); setKeysDownState(downState); - setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } } setNeedKeyDownState(false); }); - }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState]); + }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { @@ -733,14 +721,13 @@ export default function KvmIdRoute() { } }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); - const diskChannel = useRTCStore((state: RTCState) => state.diskChannel)!; - const file = useMountMediaStore((state: MountMediaState) => state.localFile)!; + const { localFile } = useMountMediaStore(); useEffect(() => { - if (!diskChannel || !file) return; + if (!diskChannel || !localFile) return; diskChannel.onmessage = async e => { console.debug("Received", e.data); const data = JSON.parse(e.data); - const blob = file.slice(data.start, data.end); + const blob = localFile.slice(data.start, data.end); const buf = await blob.arrayBuffer(); const header = new ArrayBuffer(16); const headerView = new DataView(header); @@ -751,11 +738,9 @@ export default function KvmIdRoute() { fullData.set(new Uint8Array(buf), header.byteLength); diskChannel.send(fullData); }; - }, [diskChannel, file]); + }, [diskChannel, localFile]); // System update - const disableVideoFocusTrap = useUiStore((state: UIState) => state.disableVideoFocusTrap); - const [kvmTerminal, setKvmTerminal] = useState(null); const [serialConsole, setSerialConsole] = useState(null); @@ -775,9 +760,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const appVersion = useDeviceStore((state: DeviceState) => state.appVersion); - const setAppVersion = useDeviceStore((state: DeviceState) => state.setAppVersion); - const setSystemVersion = useDeviceStore((state: DeviceState) => state.setSystemVersion); + const { appVersion, setAppVersion, setSystemVersion} = useDeviceStore(); useEffect(() => { if (appVersion) return;