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)
Fix handling of meta keys in client
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.
Use the KeysDownState for the infobar
Strong typed in the typescript realm.
Enable still working with devices that haven't been upgraded
Return the KeysDownState from keyboardReport
Clear out the hidErrorRollOver once sent to reset the keyboard to nothing down.
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.
Add documentation on the legacy support.
Cleanup react state management to enable upgrading Zustand
This commit is contained in:
Marc Brooks 2025-08-07 06:23:35 +00:00
parent 8527b1eff1
commit 23e74bfff8
No known key found for this signature in database
GPG Key ID: 583A6AF2D6AE1DC6
61 changed files with 1552 additions and 1327 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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...)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 != "" {

View File

@ -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.Warn().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().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
u.keyboardState = state
if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState)
(*u.onKeyboardStateChange)(getKeyboardState(state))
}
}
@ -123,7 +128,35 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keyboardState
return getKeyboardState(u.keyboardState)
}
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 +175,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
@ -159,7 +192,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
@ -195,12 +228,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[:hidKeyBufferSize]...))
if err != nil {
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close()
@ -211,22 +244,145 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
return nil
}
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) 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()
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]})
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
return err
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
}
u.resetUserInputTime()
return nil
return u.UpdateKeysDown(modifier, keys), err
}
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)
)
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,
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()
// 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...)
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 dynamic 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")
}
}
}
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")
}
return u.UpdateKeysDown(modifier, keys), err
}

View File

@ -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
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

View File

@ -75,14 +75,14 @@ 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
byte(mx), // X
byte(my), // Y
0, // Wheel
})
if err != nil {

View File

@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{
MassStorage: true,
}
type KeysDownState struct {
Modifier byte `json:"modifier"`
Keys ByteSlice `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: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,

View File

@ -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...)
@ -81,7 +107,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()

View File

@ -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"
@ -21,21 +22,21 @@ import (
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
ID interface{} `json:"id,omitempty"`
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"`
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"`
Params any `json:"params,omitempty"`
}
type DisplayRotationSettings struct {
@ -61,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,
@ -102,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",
},
@ -123,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",
},
@ -133,13 +134,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
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{
JSONRPC: "2.0",
Error: map[string]interface{}{
Error: map[string]any{
"code": -32603,
"message": "Internal error",
"data": err.Error(),
@ -200,7 +200,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
}
@ -240,7 +240,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
}
@ -467,12 +467,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(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 {
@ -486,11 +486,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]any) (any, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
@ -499,20 +499,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
}
for i := 0; i < numParams; i++ {
args := make([]reflect.Value, numParams)
for i := range numParams {
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 +533,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 +549,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 +565,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
}
}
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args)
if len(results) == 0 {
@ -568,23 +573,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)
}
return nil, nil
if ok, err := asError(results[0]); ok {
return nil, err
}
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)
if len(results) == 2 {
if ok, err := asError(results[1]); ok {
if err != nil {
return nil, err
}
}
return results[0].Interface(), nil
}
return nil, errors.New("unexpected return values from handler")
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
}
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) {
@ -923,7 +937,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)
@ -931,10 +945,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")
}
@ -942,7 +956,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)
}
@ -960,16 +974,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)
@ -977,7 +991,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)
@ -1047,6 +1061,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"}},

2
log.go
View File

@ -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...)
}

View File

@ -23,14 +23,14 @@ var ctrlSocketConn net.Conn
type CtrlAction struct {
Action string `json:"action"`
Seq int32 `json:"seq,omitempty"`
Params map[string]interface{} `json:"params,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"`
Result map[string]any `json:"result,omitempty"`
Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
@ -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")
}

View File

@ -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),

View File

@ -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: {

663
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "2025.08.07.001",
"version": "2025.08.15.2119",
"type": "module",
"engines": {
"node": "22.15.0"
@ -39,10 +39,10 @@
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.5.2",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.106",
"react-simple-keyboard": "^3.8.111",
"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/postcss": "^4.1.12",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9",
"@tailwindcss/vite": "^4.1.12",
"@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",
@ -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"

View File

@ -26,17 +26,13 @@ export default function Actionbar({
requestFullscreen: () => Promise<void>;
}) {
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);
console.log("Popover is closing. Returning focus trap to video");
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);
}}
/>
</PopoverButton>
@ -123,7 +119,7 @@ export default function Actionbar({
);
}}
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
@ -154,7 +150,7 @@ export default function Actionbar({
theme="light"
text="Wake on LAN"
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
@ -204,7 +200,7 @@ export default function Actionbar({
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
</div>
@ -218,7 +214,7 @@ export default function Actionbar({
text="Extension"
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(true);
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
@ -243,7 +239,7 @@ export default function Actionbar({
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
<div className="hidden md:block">

View File

@ -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";

View File

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { cx } from "@/cva.config";
import {
@ -7,65 +7,68 @@ import {
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();
const { mouseX, mouseY, mouseMove } = useMouseStore();
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 settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
const { rpcDataChannel } = useRTCStore();
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
rpcDataChannel.onerror = (e: Event) =>
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const { keyboardLedState, usbState } = useHidStore();
const { isTurnServerInUse } = useRTCStore();
const { hdmiState } = useVideoStore();
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const displayKeys = useMemo(() => {
if (!showPressedKeys)
return "";
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
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);
return [...modifierNames,...keyNames].join(", ");
}, [keysDownState, showPressedKeys]);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
{debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? (
{(debugMode && mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
@ -74,7 +77,7 @@ export default function InfoBar() {
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? (
{(debugMode && mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs">
@ -85,13 +88,13 @@ export default function InfoBar() {
</div>
) : null}
{settings.debugMode && (
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
{debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
@ -102,14 +105,7 @@ export default function InfoBar() {
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...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}
</h2>
</div>
)}
@ -122,23 +118,10 @@ export default function InfoBar() {
</div>
)}
{keyboardLedStateSyncAvailable ? (
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedSync !== "browser"
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
>
{keyboardLedSync === "browser" ? "Browser" : "Host"}
</div>
) : null}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.caps_lock
keyboardLedState.caps_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
@ -148,7 +131,7 @@ export default function InfoBar() {
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.num_lock
keyboardLedState.num_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
@ -158,23 +141,28 @@ export default function InfoBar() {
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.scroll_lock
keyboardLedState.scroll_lock
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
{keyboardLedState?.compose ? (
{keyboardLedState.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Compose
</div>
) : null}
{keyboardLedState?.kana ? (
{keyboardLedState.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Kana
</div>
) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
) : null}
</div>
</div>
</div>

View File

@ -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);

View File

@ -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,
},
)}
>

View File

@ -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,
@ -67,7 +65,7 @@ export default function USBStateStatus({
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupported USB state: ", state);
console.warn("Unsupported USB state: ", state);
return;
}

View File

@ -59,7 +59,7 @@ const usbPresets = [
];
export function UsbDeviceSetting() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =

View File

@ -54,7 +54,7 @@ const usbConfigs = [
type UsbConfigMap = Record<string, USBConfig>;
export function UsbInfoSetting() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState("");
@ -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";
@ -205,7 +205,7 @@ function USBConfigDialog({
product: "",
});
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => {

View File

@ -1,7 +1,6 @@
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,9 +12,9 @@ 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";
import { keyDisplayMap, keys } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@ -26,33 +25,30 @@ const AttachIcon = ({ className }: { className?: string }) => {
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const [layoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore();
const { keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const { handleKeyPress, executeMacro } = 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
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
// 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]);
}
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
// 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;
@ -124,93 +120,67 @@ function KeyboardWrapper() {
}, [endDrag, onDrag, startDrag]);
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"));
};
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") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]);
return;
}
if (key === "CtrlAltBackspace") {
sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []);
// 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;
}
// 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;
}
// Handle caps lock state change
if (isKeyCaps && !isKeyboardLedManagedByHost) {
setIsCapsLockActive(!isCapsLockActive);
}
// 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");
}
setTimeout(resetKeyboardState, 100);
// 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);
},
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
[executeMacro, handleKeyPress, 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 } = useShallow(useHidStore());
// // Handle toggle of layout for shift or caps lock
// const toggleLayout = () => {
// setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
// };
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
}}
>
<AnimatePresence>
{virtualKeyboard && (
{isVirtualKeyboardEnabled && (
<motion.div
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
@ -222,30 +192,30 @@ function KeyboardWrapper() {
>
<div
className={cx(
!showAttachedVirtualKeyboard
!isAttachedVirtualKeyboardVisible
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
...(!isAttachedVirtualKeyboardVisible
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
"rounded-none": isAttachedVirtualKeyboardVisible,
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? (
{isAttachedVirtualKeyboardVisible ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/>
) : (
<Button
@ -253,7 +223,7 @@ function KeyboardWrapper() {
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/>
)}
</div>
@ -266,7 +236,7 @@ function KeyboardWrapper() {
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
onClick={() => setVirtualKeyboardEnabled(false)}
/>
</div>
</div>

View File

@ -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,
@ -28,15 +27,14 @@ import {
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { handleKeyPress, resetKeyboardState } = useKeyboard();
const { setMousePosition, setMouseMove } = useMouseStore();
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
@ -44,49 +42,39 @@ export default function WebRTCVideo() {
height: videoHeight,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
hdmiState,
} = useVideoStore();
// Video enhancement settings
const videoSaturation = useSettingsStore(state => state.videoSaturation);
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);
const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const { peerConnection } = useRTCStore();
// HDMI and UI states
const hdmiState = useVideoStore(state => 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
const [send] = useJsonRpc();
const { send } = useJsonRpc();
// Video-related
const handleResize = useCallback(
( { width, height }: { width: number | undefined; height: number | undefined }) => {
if (!videoElm.current) return;
// Do something with width and height, e.g.:
setVideoClientSize(width || 0, height || 0);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
},
[setVideoClientSize, setVideoSize]
);
useResizeObserver({
ref: videoElm as React.RefObject<HTMLElement>,
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 +95,7 @@ export default function WebRTCVideo() {
function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
[updateVideoSizeStore],
);
// Pointer lock and keyboard lock related
@ -115,7 +103,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
}
@ -150,28 +138,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;
@ -344,153 +334,58 @@ 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();
let code = e.code;
const key = e.key;
const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (hidKey === undefined) {
console.warn(`Key down not mapped: ${code}`);
return;
}
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],
]);
// 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(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
console.debug(`Forcing the meta key release of associated key: ${hidKey}`);
handleKeyPress(hidKey, false);
}, 10);
}
console.debug(`Key down: ${hidKey}`);
handleKeyPress(hidKey, true);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
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);
}
},
[
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
[handleKeyPress, isKeyboardLockActive],
);
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 (hidKey === undefined) {
console.warn(`Key up not mapped: ${code}`);
return;
}
// 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)]);
console.debug(`Key up: ${hidKey}`);
handleKeyPress(hidKey, false);
},
[
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
[handleKeyPress],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -501,7 +396,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();
}
}
@ -544,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
@ -606,7 +495,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,
@ -667,6 +556,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 (
<div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col">

View File

@ -23,7 +23,7 @@ export function ATXPowerControl() {
> | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) {
const { send } = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") {
setAtxState(resp.params as ATXState);
}

View File

@ -19,7 +19,7 @@ interface DCPowerState {
}
export function DCPowerControl() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => {

View File

@ -17,7 +17,7 @@ interface SerialSettings {
}
export function SerialConsole() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [settings, setSettings] = useState<SerialSettings>({
baudRate: "9600",
dataBits: "8",
@ -49,7 +49,7 @@ export function SerialConsole() {
setSettings(newSettings);
});
};
const setTerminalType = useUiStore(state => state.setTerminalType);
const { setTerminalType } = useUiStore();
return (
<div className="space-y-4">

View File

@ -39,7 +39,7 @@ const AVAILABLE_EXTENSIONS: Extension[] = [
];
export default function ExtensionPopover() {
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const [activeExtension, setActiveExtension] = useState<Extension | null>(null);
// Load active extension on component mount

View File

@ -21,8 +21,8 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const { diskDataChannelStats } = useRTCStore();
const { send } = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();

View File

@ -25,25 +25,23 @@ const noModifier = 0
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(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<string[]>([]);
const close = useClose();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
const { keyboardLayout, setKeyboardLayout } = 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]);
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) {

View File

@ -14,11 +14,9 @@ import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
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<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);

View File

@ -37,10 +37,18 @@ function createChartArray<T, K extends keyof T>(
}
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<T, K extends keyof T>(
stream: Map<number, T>,
@ -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) {

View File

@ -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<UIState>(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<UIState>(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<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void;
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void;
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>;
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<number, RTCDataChannelStats>;
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<RTCState>(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,17 @@ export const useMouseStore = create<MouseState>(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 type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting";
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
export interface HdmiState {
ready: boolean;
error?: HdmiErrorStates;
}
export interface VideoState {
width: number;
height: number;
@ -242,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<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
error?: HdmiErrorStates;
}) => void;
}
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
export const useVideoStore = create<VideoState>(set => ({
width: 0,
height: 0,
@ -263,13 +265,13 @@ export const useVideoStore = create<VideoState>(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,9 +285,13 @@ export const useVideoStore = create<VideoState>(set => ({
},
}));
export type KeyboardLedSync = "auto" | "browser" | "host";
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
interface SettingsState {
export interface SettingsState {
isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void;
@ -308,9 +314,6 @@ interface SettingsState {
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
scrollThrottling: number;
setScrollThrottling: (value: number) => void;
@ -330,17 +333,17 @@ export const useSettingsStore = create(
persist<SettingsState>(
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 }),
@ -354,24 +357,21 @@ export const useSettingsStore = create(
set({ backlightSettings: settings }),
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
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",
@ -411,23 +411,23 @@ export interface MountMediaState {
export const useMountMediaStore = create<MountMediaState>(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 {
@ -436,41 +436,33 @@ 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 const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01;
export interface KeysDownState {
modifier: number;
keys: number[];
}
export type USBStates =
| "configured"
| "attached"
| "not attached"
| "suspended"
| "addressed";
export interface HidState {
activeKeys: number[];
activeModifiers: number[];
updateActiveKeysAndModifiers: (keysAndModifiers: {
keys: number[];
modifiers: number[];
}) => void;
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;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
keyboardLedStateSyncAvailable: boolean;
setKeyboardLedStateSyncAvailable: (available: boolean) => void;
keysDownState: KeysDownState;
setKeysDownState: (state: KeysDownState) => void;
keyPressReportApiAvailable: boolean;
setkeyPressReportApiAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -478,55 +470,29 @@ 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<HidState>((set, get) => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
export const useHidStore = create<HidState>(set => ({
keyboardLedState: {} as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
altGrArmed: false,
setAltGrArmed: armed => set({ altGrArmed: armed }),
keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
altGrTimer: 0,
setAltGrTimer: timeout => set({ altGrTimer: timeout }),
altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
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 }),
keyPressReportApiAvailable: true,
setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }),
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: USBStates) => set({ usbState: state }),
}));
export const useUserStore = create<UserState>(set => ({
@ -534,11 +500,15 @@ export const useUserStore = create<UserState>(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;
@ -567,24 +537,24 @@ 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;
}
export const useUpdateStore = create<UpdateState>(set => ({
isUpdatePending: false,
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }),
setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }),
otaState: {
@ -608,18 +578,22 @@ export const useUpdateStore = create<UpdateState>(set => ({
},
updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: hasBeenMinimized =>
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: view => set({ modalView: view }),
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
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;
}
@ -634,24 +608,26 @@ export interface UsbConfigState {
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
setModalView: (view: UsbConfigModalViews) => set({ modalView: view }),
setErrorMessage: (message: string | null) => set({ errorMessage: message }),
}));
interface LocalAuthModalState {
modalView:
export type LocalAuthModalViews =
| "createPassword"
| "deletePassword"
| "updatePassword"
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
setModalView: (view: LocalAuthModalState["modalView"]) => void;
export interface LocalAuthModalState {
modalView:LocalAuthModalViews;
setModalView: (view:LocalAuthModalViews) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
setModalView: view => set({ modalView: view }),
setModalView: (view: LocalAuthModalViews) => set({ modalView: view }),
}));
export interface DeviceState {
@ -666,8 +642,8 @@ export const useDeviceStore = create<DeviceState>(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 {
@ -833,7 +809,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try {
await new Promise<void>((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));
@ -913,7 +889,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
sendFn(
"setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } },
response => {
(response: JsonRpcResponse) => {
resolve(response);
},
);

View File

@ -33,10 +33,10 @@ const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(
let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const { rpcDataChannel } = useRTCStore();
const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
if (rpcDataChannel?.readyState !== "open") return;
requestCounter++;
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
@ -45,7 +45,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
rpcDataChannel.send(JSON.stringify(payload));
},
[rpcDataChannel],
[rpcDataChannel]
);
useEffect(() => {
@ -61,7 +61,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return;
}
if ("error" in payload) console.error(payload.error);
if ("error" in payload) console.error("RPC error", payload);
if (!payload.id) return;
const callback = callbackStore.get(payload.id);
@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
return () => {
rpcDataChannel.removeEventListener("message", messageHandler);
};
}, [rpcDataChannel, onRequest]);
},
[rpcDataChannel, onRequest]);
return [send];
return { send };
}

View File

@ -1,42 +1,120 @@
import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { keys, modifiers } from "@/keyboardMappings";
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 => state.rpcDataChannel);
const updateActiveKeysAndModifiers = useHidStore(
state => state.updateActiveKeysAndModifiers,
);
const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState } = useHidStore();
// 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(
(keys: number[], modifiers: number[]) => {
async (state: KeysDownState) => {
if (rpcDataChannel?.readyState !== "open") return;
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
send("keyboardReport", { keys, modifier: accModifier });
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 {
// If the device supports keyPressReport API, it will (also) return the keysDownState when we send
// the keyboardReport
const keysDownState = resp.result as KeysDownState;
// We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
if (keysDownState) {
setKeysDownState(keysDownState); // treat the response as the canonical state
setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport
} else {
// older devices versions do not return the keyDownState
// so we just pretend they accepted what we sent
setKeysDownState(state);
setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport
}
}
});
},
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
[rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable],
);
const resetKeyboardState = useCallback(() => {
sendKeyboardEvent([], []);
}, [sendKeyboardEvent]);
// 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(
async (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 because the device is running an older version
if (resp.error.code === -32601) {
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);
}
} else {
const keysDownState = resp.result as KeysDownState;
if (keysDownState) {
setKeysDownState(keysDownState);
// we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here
}
}
});
},
[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(
async () => {
console.debug("Resetting keyboard state");
// Reset the keys buffer to zeros and the modifier state to zero
keysDownState.keys.length = hidKeyBufferSize;
keysDownState.keys.fill(0);
keysDownState.modifier = 0;
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) || [];
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();
@ -52,5 +130,91 @@ export default function useKeyboard() {
}
};
return { sendKeyboardEvent, resetKeyboardState, executeMacro };
// 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(
async (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
// if we just sent ErrorRollOver, reset to empty state
if (downState.keys[0] === hidErrorRollOver) {
resetKeyboardState();
}
}
},
[keyPressReportApiAvailable, keysDownState, resetKeyboardState, 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.
let modifiers = state.modifier;
const keys = state.keys;
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.
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; 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
break;
}
}
// 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 current keys ${keys}, key: ${key} not added`);
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize;
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 };
}
return { handleKeyPress, resetKeyboardState, executeMacro };
}

View File

@ -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,
@ -42,6 +42,7 @@ export const keys = {
F10: 0x43,
F11: 0x44,
F12: 0x45,
F13: 0x68,
F14: 0x69,
F15: 0x6a,
F16: 0x6b,
@ -85,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
@ -120,6 +120,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<string, number>;
export const modifiers = {
@ -133,6 +141,17 @@ export const modifiers = {
MetaRight: 0x80,
} as Record<string, number>;
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<number, number>;
export const modifierDisplayMap: Record<string, string> = {
ControlLeft: "Left Ctrl",
ControlRight: "Right Ctrl",

View File

@ -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 => {
@ -689,7 +689,7 @@ function DeviceFileView({
const [currentPage, setCurrentPage] = useState(1);
const filesPerPage = 5;
const [send] = useJsonRpc();
const { send } = useJsonRpc();
interface StorageSpace {
bytesUsed: number;
@ -1001,7 +1001,7 @@ function UploadFileView({
const [fileError, setFileError] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const rtcDataChannelRef = useRef<RTCDataChannel | null>(null);
useEffect(() => {

View File

@ -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<string | null>(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) => {

View File

@ -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<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const { setDeveloperMode } = useSettingsStore();
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);

View File

@ -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);

View File

@ -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

View File

@ -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<AbortController | null>(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<SystemVersionInfo>((resolve, reject) => {

View File

@ -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

View File

@ -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";
@ -12,34 +12,20 @@ import { SelectMenuBasic } from "../components/SelectMenuBasic";
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,
);
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 ledSyncOptions = [
{ value: "auto", label: "Automatic" },
{ value: "browser", label: "Browser Only" },
{ value: "host", label: "Host Only" },
];
const [send] = useJsonRpc();
const { send } = useJsonRpc();
useEffect(() => {
send("getKeyboardLayout", {}, resp => {
@ -91,23 +77,6 @@ export default function SettingsKeyboardRoute() {
</p>
</div>
<div className="space-y-4">
{ /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }
<SettingsItem
title="LED state synchronization"
description="Synchronize the LED state of the keyboard with the target device"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={keyboardLedSync}
onChange={e => setKeyboardLedSync(e.target.value as KeyboardLedSync)}
options={ledSyncOptions}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Show Pressed Keys"

View File

@ -64,14 +64,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<JigglerValues | null>(null);
@ -87,7 +84,7 @@ export default function SettingsMouseRoute() {
{ value: "100", label: "Very High" },
];
const [send] = useJsonRpc();
const { send } = useJsonRpc();
const syncJigglerSettings = useCallback(() => {
send("getJigglerState", {}, resp => {
@ -196,8 +193,8 @@ export default function SettingsMouseRoute() {
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => setHideCursor(e.target.checked)}
checked={isCursorHidden}
onChange={e => setCursorVisibility(e.target.checked)}
/>
</SettingsItem>

View File

@ -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,
@ -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");

View File

@ -28,8 +28,8 @@ 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 { sendKeyboardEvent } = useKeyboard();
const { setDisableVideoFocusTrap } = useUiStore();
const { resetKeyboardState } = useKeyboard();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
@ -67,19 +67,19 @@ 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
(document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
console.debug("Just disabled focus trap");
}, 300);
return () => {
setDisableVideoFocusTrap(false);
};
}, [sendKeyboardEvent, setDisableVideoFocusTrap]);
}, [resetKeyboardState, setDisableVideoFocusTrap]);
return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">

View File

@ -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<string | null>(null);
const [edid, setEdid] = useState<string | null>(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 => {

View File

@ -18,10 +18,11 @@ import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import {
HidState,
KeyboardLedState,
KeysDownState,
NetworkState,
UpdateState,
OtaState,
USBStates,
useDeviceStore,
useHidStore,
useMountMediaStore,
@ -37,7 +38,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";
@ -127,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 => 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 => 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 location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate();
@ -211,7 +212,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,
@ -247,27 +248,27 @@ export default function KvmIdRoute() {
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
console.debug("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.log("[Websocket] shouldReconnect", event);
console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
console.log("[Websocket] onClose", event);
console.debug("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
console.log("[Websocket] onError", event);
console.error("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.log("[Websocket] onOpen");
console.debug("[Websocket] onOpen");
},
onMessage: message => {
@ -289,8 +290,8 @@ export default function KvmIdRoute() {
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.log("[Websocket] Received device-metadata message");
console.log("[Websocket] Device version", deviceVersion);
console.debug("[Websocket] Received device-metadata message");
console.debug("[Websocket] Device version", deviceVersion);
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
@ -308,7 +309,7 @@ export default function KvmIdRoute() {
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.log("[Websocket] Received answer");
console.debug("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
@ -322,7 +323,7 @@ export default function KvmIdRoute() {
// Set so we don't accept an answer while we're setting the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.log(
console.debug(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
@ -338,7 +339,7 @@ export default function KvmIdRoute() {
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.log("[Websocket] Received new-ice-candidate");
console.debug("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
}
@ -384,7 +385,7 @@ export default function KvmIdRoute() {
return;
}
console.log("Successfully got Remote Session Description. Setting.");
console.debug("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
@ -395,13 +396,13 @@ export default function KvmIdRoute() {
);
const setupPeerConnection = useCallback(async () => {
console.log("[setupPeerConnection] Setting up peer connection");
console.debug("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
console.debug("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
@ -411,7 +412,7 @@ export default function KvmIdRoute() {
});
setPeerConnectionState(pc.connectionState);
console.log("[setupPeerConnection] Peer connection created", pc);
console.debug("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
@ -423,13 +424,13 @@ export default function KvmIdRoute() {
// Set up event listeners and data channels
pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
console.debug("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState);
};
pc.onnegotiationneeded = async () => {
try {
console.log("[setupPeerConnection] Creating offer");
console.debug("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
@ -439,7 +440,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(
@ -461,7 +462,7 @@ export default function KvmIdRoute() {
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
console.debug("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
@ -469,13 +470,13 @@ export default function KvmIdRoute() {
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
console.debug("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]);
setMediaStream(event.streams[0]);
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
@ -497,7 +498,7 @@ export default function KvmIdRoute() {
legacyHTTPSignaling,
sendWebRTCSignal,
setDiskChannel,
setMediaMediaStream,
setMediaStream,
setPeerConnection,
setPeerConnectionState,
setRpcDataChannel,
@ -506,15 +507,13 @@ 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]);
// Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore();
useEffect(() => {
return () => {
@ -545,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 => state.isTurnServerInUse);
const lastBytesReceived = useRef<number>(0);
const lastBytesSent = useRef<number>(0);
@ -582,15 +580,13 @@ export default function KvmIdRoute() {
});
}, 10000);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
const { setNetworkState} = useNetworkStateStore();
const { setHdmiState } = useVideoStore();
const {
keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState,
setkeyPressReportApiAvailable
} = useHidStore();
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@ -601,27 +597,38 @@ export default function KvmIdRoute() {
}
if (resp.method === "usbState") {
setUsbState(resp.params as unknown as HidState["usbState"]);
const usbState = resp.params as unknown as USBStates;
console.debug("Setting USB state", usbState);
setUsbState(usbState);
}
if (resp.method === "videoInputState") {
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
const hdmiState = resp.params as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
}
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);
setKeyboardLedStateSyncAvailable(true);
}
if (resp.method === "keysDownState") {
const downState = resp.params as KeysDownState;
console.debug("Setting key down state:", downState);
setKeysDownState(downState);
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;
console.debug("Setting OTA state", otaState);
setOtaState(otaState);
if (otaState.updating === true) {
@ -645,39 +652,67 @@ export default function KvmIdRoute() {
}
}
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc(onJsonRpcRequest);
const { send } = useJsonRpc(onJsonRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
send("getVideoState", {}, resp => {
console.log("Requesting video state");
send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
console.debug("Setting HDMI state", hdmiState);
setHdmiState(hdmiState);
});
}, [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 => {
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to get keyboard led state", resp.error);
return;
} else {
const ledState = resp.result as KeyboardLedState;
console.debug("Keyboard led state: ", ledState);
setKeyboardLedState(ledState);
}
setNeedLedState(false);
});
}, [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 (!needKeyDownState) return;
console.log("Requesting keys down state");
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
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);
// if we don't support key down state, we know key press is also not available
console.warn("Failed to get key down state, switching to old-school", resp.error);
setkeyPressReportApiAvailable(false);
} else {
console.error("Failed to get keyboard led state", resp.error);
console.error("Failed to get key down state", resp.error);
}
return;
} else {
const downState = resp.result as KeysDownState;
console.debug("Keyboard key down state", downState);
setKeysDownState(downState);
setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport
}
console.log("Keyboard led state", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState);
setKeyboardLedStateSyncAvailable(true);
setNeedKeyDownState(false);
});
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]);
}, [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(() => {
@ -686,14 +721,13 @@ export default function KvmIdRoute() {
}
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!;
const { localFile } = useMountMediaStore();
useEffect(() => {
if (!diskChannel || !file) return;
if (!diskChannel || !localFile) 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 blob = localFile.slice(data.start, data.end);
const buf = await blob.arrayBuffer();
const header = new ArrayBuffer(16);
const headerView = new DataView(header);
@ -704,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 => state.disableVideoFocusTrap);
const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);
@ -728,14 +760,12 @@ 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, setAppVersion, setSystemVersion} = useDeviceStore();
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
@ -875,7 +905,7 @@ interface SidebarContainerProps {
}
function SidebarContainer(props: SidebarContainerProps) {
const { sidebarView }= props;
const { sidebarView } = props;
return (
<div
className={cx(

24
usb.go
View File

@ -31,21 +31,31 @@ 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) (usbgadget.KeysDownState, error) {
return gadget.KeyboardReport(modifier, keys)
}
func rpcAbsMouseReport(x, y int, buttons uint8) error {
func rpcKeypressReport(key byte, press bool) (usbgadget.KeysDownState, error) {
return gadget.KeypressReport(key, press)
}
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)
}
@ -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()
}

View File

@ -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,9 +190,10 @@ func newSession(config SessionConfig) (*Session, error) {
currentSession = nil
}
if session.shouldUmountVirtualMedia {
err := rpcUnmountImage()
if err := rpcUnmountImage(); err != nil {
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
}
}
if isConnected {
isConnected = false
actionSessions--

2
wol.go
View File

@ -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)
}