This commit is contained in:
Marc Brooks 2025-08-14 04:17:07 +00:00 committed by GitHub
commit e5f58332b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 877 additions and 792 deletions

View File

@ -30,7 +30,7 @@ const (
// do not call this function directly, use switchToScreenIfDifferent instead // do not call this function directly, use switchToScreenIfDifferent instead
// this function is not thread safe // this function is not thread safe
func switchToScreen(screen string) { 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 { if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return return
@ -39,15 +39,15 @@ func switchToScreen(screen string) {
} }
func lvObjSetState(objName string, state string) (*CtrlResponse, error) { 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) { 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) { 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) { 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 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) { 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) { 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) { 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) { 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) { 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) { func updateLabelIfChanged(objName string, newText string) {

View File

@ -16,22 +16,22 @@ import (
type FieldConfig struct { type FieldConfig struct {
Name string Name string
Required bool Required bool
RequiredIf map[string]interface{} RequiredIf map[string]any
OneOf []string OneOf []string
ValidateTypes []string ValidateTypes []string
Defaults interface{} Defaults any
IsEmpty bool IsEmpty bool
CurrentValue interface{} CurrentValue any
TypeString string TypeString string
Delegated bool Delegated bool
shouldUpdateValue bool shouldUpdateValue bool
} }
func SetDefaultsAndValidate(config interface{}) error { func SetDefaultsAndValidate(config any) error {
return setDefaultsAndValidate(config, true) 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 // first we need to check if the config is a pointer
if reflect.TypeOf(config).Kind() != reflect.Ptr { if reflect.TypeOf(config).Kind() != reflect.Ptr {
return fmt.Errorf("config is not a pointer") return fmt.Errorf("config is not a pointer")
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
Name: field.Name, Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")), OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")), ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]interface{}), RequiredIf: make(map[string]any),
CurrentValue: fieldValue.Interface(), CurrentValue: fieldValue.Interface(),
IsEmpty: false, IsEmpty: false,
TypeString: fieldType, TypeString: fieldType,
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// now check if the field has required_if // now check if the field has required_if
requiredIf := field.Tag.Get("required_if") requiredIf := field.Tag.Get("required_if")
if requiredIf != "" { if requiredIf != "" {
requiredIfParts := strings.Split(requiredIf, ",") requiredIfParts := strings.SplitSeq(requiredIf, ",")
for _, part := range requiredIfParts { for part := range requiredIfParts {
partVal := strings.SplitN(part, "=", 2) partVal := strings.SplitN(part, "=", 2)
if len(partVal) != 2 { if len(partVal) != 2 {
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) 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 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 // now we can start to validate the fields
for _, fieldConfig := range fields { for _, fieldConfig := range fields {
if err := fieldConfig.validate(fields); err != nil { if err := fieldConfig.validate(fields); err != nil {
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil return nil
} }
func (f *FieldConfig) populate(config interface{}) { func (f *FieldConfig) populate(config any) {
// update the field if it's not empty // update the field if it's not empty
if !f.shouldUpdateValue { if !f.shouldUpdateValue {
return return

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",") return strings.Split(s, ",")
} }
func toString(v interface{}) (string, error) { func toString(v any) (string, error) {
switch v := v.(type) { switch v := v.(type) {
case string: case string:
return v, nil return v, nil

View File

@ -50,7 +50,7 @@ var (
TimeFormat: time.RFC3339, TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"}, PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"}, FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string { FormatPartValueByName: func(value any, name string) string {
val := fmt.Sprintf("%s", value) val := fmt.Sprintf("%s", value)
if name == "component" { if name == "component" {
if value == nil { if value == nil {
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
continue continue
} }
scopes := strings.Split(strings.ToLower(env), ",") scopes := strings.SplitSeq(strings.ToLower(env), ",")
for _, scope := range scopes { for scope := range scopes {
l.scopeLevels[scope] = level l.scopeLevels[scope] = level
} }
} }

View File

@ -13,32 +13,32 @@ type pionLogger struct {
func (c pionLogger) Trace(msg string) { func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg) 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...) c.logger.Trace().Msgf(format, args...)
} }
func (c pionLogger) Debug(msg string) { func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg) 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...) c.logger.Debug().Msgf(format, args...)
} }
func (c pionLogger) Info(msg string) { func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg) 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...) c.logger.Info().Msgf(format, args...)
} }
func (c pionLogger) Warn(msg string) { func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg) 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...) c.logger.Warn().Msgf(format, args...)
} }
func (c pionLogger) Error(msg string) { func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg) 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...) c.logger.Error().Msgf(format, args...)
} }

View File

@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
return &defaultLogger 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 // TODO: move rootLogger to logging package
if l == nil { if l == nil {
l = &defaultLogger 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) hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false 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") { if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true hostLineExists = true
line = hostLine line = hostLine

View File

@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
return &t return &t
} }
func IsSame(a, b interface{}) bool { func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a) aJSON, err := json.Marshal(a)
if err != nil { if err != nil {
return false return false

View File

@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
func UnmarshalDHCPCLease(lease *Lease, str string) error { func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map // parse the lease file as a map
data := make(map[string]string) data := make(map[string]string)
for _, line := range strings.Split(str, "\n") { for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// skip empty lines and comments // skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
field.Set(reflect.ValueOf(ip)) field.Set(reflect.ValueOf(ip))
case []net.IP: case []net.IP:
val := make([]net.IP, 0) val := make([]net.IP, 0)
for _, ipStr := range strings.Fields(value) { for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr) ip := net.ParseIP(ipStr)
if ip == nil { if ip == nil {
continue continue

View File

@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
} }
func (c *DHCPClient) getWatchPaths() []string { func (c *DHCPClient) getWatchPaths() []string {
watchPaths := make(map[string]interface{}) watchPaths := make(map[string]any)
watchPaths[filepath.Dir(c.leaseFile)] = nil watchPaths[filepath.Dir(c.leaseFile)] = nil
if c.pidFile != "" { if c.pidFile != "" {

View File

@ -1,10 +1,10 @@
package usbgadget package usbgadget
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
"reflect"
"time" "time"
) )
@ -61,6 +61,8 @@ var keyboardReportDesc = []byte{
const ( const (
hidReadBufferSize = 8 hidReadBufferSize = 8
hidKeyBufferSize = 6
hidErrorRollOver = 0x01
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0 KeyboardLedMaskNumLock = 1 << 0
@ -68,7 +70,9 @@ const (
KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4 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, // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
@ -81,6 +85,7 @@ type KeyboardState struct {
ScrollLock bool `json:"scroll_lock"` ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"` Compose bool `json:"compose"`
Kana bool `json:"kana"` Kana bool `json:"kana"`
Shift bool `json:"shift"` // This is not part of the main USB HID spec
} }
func getKeyboardState(b byte) KeyboardState { func getKeyboardState(b byte) KeyboardState {
@ -91,27 +96,27 @@ func getKeyboardState(b byte) KeyboardState {
ScrollLock: b&KeyboardLedMaskScrollLock != 0, ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0, Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 0, Kana: b&KeyboardLedMaskKana != 0,
Shift: b&KeyboardLedMaskShift != 0,
} }
} }
func (u *UsbGadget) updateKeyboardState(b byte) { func (u *UsbGadget) updateKeyboardState(state byte) {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
if b&^ValidKeyboardLedMasks != 0 { if state&^ValidKeyboardLedMasks != 0 {
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring") u.log.Error().Uint8("state", state).Msg("ignoring invalid bits")
return return
} }
newState := getKeyboardState(b) if u.keyboardState == state {
if reflect.DeepEqual(u.keyboardState, newState) {
return return
} }
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated") u.log.Trace().Interface("old", u.keyboardState).Interface("new", state).Msg("keyboardState updated")
u.keyboardState = newState u.keyboardState = state
if u.onKeyboardStateChange != nil { if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState) (*u.onKeyboardStateChange)(getKeyboardState(state))
} }
} }
@ -123,7 +128,52 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock() u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock() defer u.keyboardStateLock.Unlock()
return u.keyboardState return getKeyboardState(u.keyboardState)
}
const (
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C
ModifierMaskLeftControl = 0x01
ModifierMaskRightControl = 0x10
ModifierMaskLeftShift = 0x02
ModifierMaskRightShift = 0x20
ModifierMaskLeftAlt = 0x04
ModifierMaskRightAlt = 0x40
ModifierMaskLeftSuper = 0x08
ModifierMaskRightSuper = 0x80
EitherShiftMask = ModifierMaskLeftShift | ModifierMaskRightShift
EitherControlMask = ModifierMaskLeftControl | ModifierMaskRightControl
EitherAltMask = ModifierMaskLeftAlt | ModifierMaskRightAlt
EitherSuperMask = ModifierMaskLeftSuper | ModifierMaskRightSuper
)
func (u *UsbGadget) GetKeysDownState() KeysDownState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keysDownState
}
func (u *UsbGadget) updateKeyDownState(state KeysDownState) {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if u.keysDownState.Modifier == state.Modifier &&
bytes.Equal(u.keysDownState.Keys, state.Keys) {
return // No change in key down state
}
u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated")
u.keysDownState = state
if u.onKeysDownChange != nil {
(*u.onKeysDownChange)(state)
}
}
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
} }
func (u *UsbGadget) listenKeyboardEvents() { func (u *UsbGadget) listenKeyboardEvents() {
@ -142,7 +192,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
l.Info().Msg("context done") l.Info().Msg("context done")
return return
default: default:
l.Trace().Msg("reading from keyboard") l.Trace().Msg("reading from keyboard for LED state changes")
if u.keyboardHidFile == nil { if u.keyboardHidFile == nil {
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs // show the error every 100 times to avoid spamming the logs
@ -159,7 +209,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") 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 { if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got") l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue continue
@ -195,12 +245,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile() 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 { if err := u.openKeyboardHidFile(); err != nil {
return err return err
} }
_, err := u.keyboardHidFile.Write(data) _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
if err != nil { if err != nil {
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close() u.keyboardHidFile.Close()
@ -211,22 +261,112 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
return nil return nil
} }
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error { func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error {
u.keyboardLock.Lock() u.keyboardLock.Lock()
defer u.keyboardLock.Unlock() defer u.keyboardLock.Unlock()
defer u.resetUserInputTime()
if len(keys) > 6 { if len(keys) > hidKeyBufferSize {
keys = keys[:6] keys = keys[:hidKeyBufferSize]
} }
if len(keys) < 6 { if len(keys) < hidKeyBufferSize {
keys = append(keys, make([]uint8, 6-len(keys))...) 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]}) return u.keyboardWriteHidFile(modifier, keys)
if err != nil { }
return err
} const (
// https://www.usb.org/sites/default/files/documents/hut1_2.pdf
u.resetUserInputTime() // Dynamic Flags (DV)
return nil 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)
)
// 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()
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 modifier keys like Shift, Control, Alt, and Super.
if press {
modifier |= mask
} else {
modifier &^= mask
}
} else {
// handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys
overrun := true
for i := range hidKeyBufferSize {
// If we find the key in the buffer the buffer, we either remove it (if press is false)
// or do nothing (if down is true) because the buffer tracks currently pressed keys
// and if we find a zero byte, we can place the key there (if press is true)
if keys[i] == key || keys[i] == 0 {
if press {
keys[i] = key // overwrites the zero byte or the same key if already pressed
} else {
// we are releasing the key, remove it from the buffer
if keys[i] != 0 {
copy(keys[i:], keys[i+1:])
keys[hidKeyBufferSize-1] = 0 // Clear the last byte
}
}
overrun = false // We found a slot for the key
break
}
}
// If we reach here it means we didn't find an empty slot or the key in the buffer
if overrun {
if press {
u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added")
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
for i := range keys {
keys[i] = hidErrorRollOver
}
} else {
// If we are releasing a key, and we didn't find it in a slot, who cares?
u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release")
}
}
}
if err := u.keyboardWriteHidFile(modifier, keys); err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0")
}
var result = KeysDownState{
Modifier: modifier,
Keys: []byte(keys[:]),
}
u.updateKeyDownState(result)
return result, nil
} }

View File

@ -85,17 +85,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
return nil 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() u.absMouseLock.Lock()
defer u.absMouseLock.Unlock() defer u.absMouseLock.Unlock()
err := u.absMouseWriteHidFile([]byte{ err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1 1, // Report ID 1
buttons, // Buttons buttons, // Buttons
uint8(x), // X Low Byte byte(x), // X Low Byte
uint8(x >> 8), // X High Byte byte(x >> 8), // X High Byte
uint8(y), // Y Low Byte byte(y), // Y Low Byte
uint8(y >> 8), // Y High Byte byte(y >> 8), // Y High Byte
}) })
if err != nil { if err != nil {
return err return err

View File

@ -75,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
return nil 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() u.relMouseLock.Lock()
defer u.relMouseLock.Unlock() defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{ err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons buttons, // Buttons
uint8(mx), // X byte(mx), // X
uint8(my), // Y byte(my), // Y
0, // Wheel 0, // Wheel
}) })
if err != nil { if err != nil {
return err return err

View File

@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{
MassStorage: true, MassStorage: true,
} }
type KeysDownState struct {
Modifier byte `json:"modifier"`
Keys ByteSlice `json:"keys"`
}
// UsbGadget is a struct that represents a USB gadget. // UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct { type UsbGadget struct {
name string name string
@ -60,7 +65,9 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex 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 keyboardStateLock sync.Mutex
keyboardStateCtx context.Context keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc keyboardStateCancel context.CancelFunc
@ -77,6 +84,7 @@ type UsbGadget struct {
txLock sync.Mutex txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState) onKeyboardStateChange *func(state KeyboardState)
onKeysDownChange *func(state KeysDownState)
log *zerolog.Logger log *zerolog.Logger
@ -122,7 +130,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
txLock: sync.Mutex{}, txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx, keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel, 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, enabledDevices: *enabledDevices,
lastUserInput: time.Now(), lastUserInput: time.Now(),
log: logger, log: logger,

View File

@ -2,6 +2,7 @@ package usbgadget
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -10,6 +11,31 @@ import (
"github.com/rs/zerolog" "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 { func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...) pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...) return filepath.Join(pathArr...)
@ -81,7 +107,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
return false 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() u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock() defer u.logSuppressionLock.Unlock()

View File

@ -13,29 +13,30 @@ import (
"time" "time"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
type JSONRPCRequest struct { type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Method string `json:"method"` Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
ID interface{} `json:"id,omitempty"` ID any `json:"id,omitempty"`
} }
type JSONRPCResponse struct { type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"` Result any `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"` Error any `json:"error,omitempty"`
ID interface{} `json:"id"` ID any `json:"id"`
} }
type JSONRPCEvent struct { type JSONRPCEvent struct {
JSONRPC string `json:"jsonrpc"` JSONRPC string `json:"jsonrpc"`
Method string `json:"method"` Method string `json:"method"`
Params interface{} `json:"params,omitempty"` Params any `json:"params,omitempty"`
} }
type DisplayRotationSettings struct { 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{ request := JSONRPCEvent{
JSONRPC: "2.0", JSONRPC: "2.0",
Method: event, Method: event,
@ -102,7 +103,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32700, "code": -32700,
"message": "Parse error", "message": "Parse error",
}, },
@ -123,7 +124,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
if !ok { if !ok {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32601, "code": -32601,
"message": "Method not found", "message": "Method not found",
}, },
@ -134,12 +135,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
} }
scopedLogger.Trace().Msg("Calling RPC handler") scopedLogger.Trace().Msg("Calling RPC handler")
result, err := callRPCHandler(handler, request.Params) result, err := callRPCHandler(scopedLogger, handler, request.Params)
if err != nil { if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler") scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
Error: map[string]interface{}{ Error: map[string]any{
"code": -32603, "code": -32603,
"message": "Internal error", "message": "Internal error",
"data": err.Error(), "data": err.Error(),
@ -200,7 +201,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
func rpcSetStreamQualityFactor(factor float64) error { func rpcSetStreamQualityFactor(factor float64) error {
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor") 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 { if err != nil {
return err return err
} }
@ -240,7 +241,7 @@ func rpcSetEDID(edid string) error {
} else { } else {
logger.Info().Str("edid", edid).Msg("Setting EDID") 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 { if err != nil {
return err return err
} }
@ -467,12 +468,12 @@ func rpcSetTLSState(state TLSState) error {
} }
type RPCHandler struct { type RPCHandler struct {
Func interface{} Func any
Params []string Params []string
} }
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls // 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 // Use defer to recover from a panic
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -486,11 +487,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i
}() }()
// Call the handler // Call the handler
result, err = riskyCallRPCHandler(handler, params) result, err = riskyCallRPCHandler(logger, handler, params)
return result, err 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) handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type() handlerType := handlerValue.Type()
@ -499,20 +500,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
numParams := handlerType.NumIn() numParams := handlerType.NumIn()
args := make([]reflect.Value, numParams) paramNames := handler.Params // Get the parameter names from the RPCHandler
// Get the parameter names from the RPCHandler
paramNames := handler.Params
if len(paramNames) != numParams { 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) paramType := handlerType.In(i)
paramName := paramNames[i] paramName := paramNames[i]
paramValue, ok := params[paramName] paramValue, ok := params[paramName]
if !ok { 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) convertedValue := reflect.ValueOf(paramValue)
@ -529,7 +534,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
intValue := int(elemValue.Float()) intValue := int(elemValue.Float())
if intValue < 0 || intValue > 255 { 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)) newSlice.Index(j).SetUint(uint64(intValue))
} else { } else {
@ -545,12 +550,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
jsonData, err := json.Marshal(convertedValue.Interface()) jsonData, err := json.Marshal(convertedValue.Interface())
if err != nil { 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() newStruct := reflect.New(paramType).Interface()
if err := json.Unmarshal(jsonData, newStruct); err != nil { 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() args[i] = reflect.ValueOf(newStruct).Elem()
} else { } else {
@ -561,6 +566,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
} }
logger.Trace().Msg("Calling RPC handler")
results := handlerValue.Call(args) results := handlerValue.Call(args)
if len(results) == 0 { if len(results) == 0 {
@ -568,23 +574,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
} }
if len(results) == 1 { if len(results) == 1 {
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { if ok, err := asError(results[0]); ok {
if !results[0].IsNil() { return nil, err
return nil, results[0].Interface().(error) }
return results[0].Interface(), nil
}
if len(results) == 2 {
if ok, err := asError(results[1]); ok {
if err != nil {
return nil, err
} }
return nil, nil
} }
return results[0].Interface(), nil return results[0].Interface(), nil
} }
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { return nil, fmt.Errorf("too many return values from handler: %d", len(results))
if !results[1].IsNil() { }
return nil, results[1].Interface().(error)
}
return results[0].Interface(), nil
}
return nil, errors.New("unexpected return values from handler") func asError(value reflect.Value) (bool, error) {
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if value.IsNil() {
return true, nil
}
return true, value.Interface().(error)
}
return false, nil
} }
func rpcSetMassStorageMode(mode string) (string, error) { func rpcSetMassStorageMode(mode string) (string, error) {
@ -923,7 +938,7 @@ func rpcSetKeyboardLayout(layout string) error {
return nil return nil
} }
func getKeyboardMacros() (interface{}, error) { func getKeyboardMacros() (any, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros)) macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros) copy(macros, config.KeyboardMacros)
@ -931,10 +946,10 @@ func getKeyboardMacros() (interface{}, error) {
} }
type KeyboardMacrosParams struct { 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 { if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter") return nil, fmt.Errorf("missing or invalid macros parameter")
} }
@ -942,7 +957,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
newMacros := make([]KeyboardMacro, 0, len(params.Macros)) newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros { for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{}) macroMap, ok := item.(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i) return nil, fmt.Errorf("invalid macro at index %d", i)
} }
@ -960,16 +975,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
} }
steps := []KeyboardMacroStep{} steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok { if stepsArray, ok := macroMap["steps"].([]any); ok {
for _, stepItem := range stepsArray { for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{}) stepMap, ok := stepItem.(map[string]any)
if !ok { if !ok {
continue continue
} }
step := KeyboardMacroStep{} step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok { if keysArray, ok := stepMap["keys"].([]any); ok {
for _, k := range keysArray { for _, k := range keysArray {
if keyStr, ok := k.(string); ok { if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr) step.Keys = append(step.Keys, keyStr)
@ -977,7 +992,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
} }
} }
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok { if modsArray, ok := stepMap["modifiers"].([]any); ok {
for _, m := range modsArray { for _, m := range modsArray {
if modStr, ok := m.(string); ok { if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr) step.Modifiers = append(step.Modifiers, modStr)
@ -1047,6 +1062,8 @@ var rpcHandlers = map[string]RPCHandler{
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
"getKeyDownState": {Func: rpcGetKeysDownState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},

2
log.go
View File

@ -5,7 +5,7 @@ import (
"github.com/rs/zerolog" "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...) return logging.ErrorfL(l, format, err, args...)
} }

View File

@ -21,18 +21,18 @@ import (
var ctrlSocketConn net.Conn var ctrlSocketConn net.Conn
type CtrlAction struct { type CtrlAction struct {
Action string `json:"action"` Action string `json:"action"`
Seq int32 `json:"seq,omitempty"` Seq int32 `json:"seq,omitempty"`
Params map[string]interface{} `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
} }
type CtrlResponse struct { type CtrlResponse struct {
Seq int32 `json:"seq,omitempty"` Seq int32 `json:"seq,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Errno int32 `json:"errno,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"` Event string `json:"event,omitempty"`
Data json.RawMessage `json:"data,omitempty"` Data json.RawMessage `json:"data,omitempty"`
} }
type EventHandler func(event CtrlResponse) type EventHandler func(event CtrlResponse)
@ -48,7 +48,7 @@ var (
nativeCmdLock = &sync.Mutex{} 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() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
ctrlAction := CtrlAction{ ctrlAction := CtrlAction{
@ -429,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error {
func restoreHdmiEdid() { func restoreHdmiEdid() {
if config.EdidString != "" { if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID") 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 { if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID") 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 mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock() virtualMediaStateMutex.RUnlock()
end := offset + size end := min(offset+size, mountedImageSize)
if end > mountedImageSize {
end = mountedImageSize
}
req := DiskReadRequest{ req := DiskReadRequest{
Start: uint64(offset), Start: uint64(offset),
End: uint64(end), End: uint64(end),

View File

@ -66,6 +66,10 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"], groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always", "newlines-between": "always",
}], }],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
}, },
settings: { settings: {

440
ui/package-lock.json generated
View File

@ -31,7 +31,7 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.106", "react-simple-keyboard": "^3.8.109",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
@ -41,22 +41,22 @@
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.1", "@eslint/compat": "^1.3.2",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0", "@eslint/js": "^9.33.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.2", "@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react-swc": "^3.10.2", "@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.32.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
@ -112,9 +112,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -128,9 +128,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -144,9 +144,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -160,9 +160,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -176,9 +176,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -192,9 +192,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -208,9 +208,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -224,9 +224,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -240,9 +240,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -256,9 +256,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -272,9 +272,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -288,9 +288,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -304,9 +304,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -320,9 +320,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -336,9 +336,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -352,9 +352,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -368,9 +368,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -384,9 +384,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -400,9 +400,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -416,9 +416,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -432,9 +432,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -448,9 +448,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -464,9 +464,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -480,9 +480,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -496,9 +496,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -512,9 +512,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -555,9 +555,9 @@
} }
}, },
"node_modules/@eslint/compat": { "node_modules/@eslint/compat": {
"version": "1.3.1", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.2.tgz",
"integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", "integrity": "sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -587,18 +587,18 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.3.0", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.15.1", "version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
@ -643,9 +643,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.32.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz",
"integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -664,12 +664,12 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.3.4", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.15.1", "@eslint/core": "^0.15.2",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@ -845,9 +845,9 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.12", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -866,16 +866,16 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29", "version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1964,9 +1964,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.9", "version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -1996,17 +1996,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz",
"integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/type-utils": "8.39.1",
"@typescript-eslint/utils": "8.39.0", "@typescript-eslint/utils": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.1",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -2020,7 +2020,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/parser": "^8.39.1",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@ -2036,16 +2036,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz",
"integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -2061,14 +2061,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
"integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/tsconfig-utils": "^8.39.1",
"@typescript-eslint/types": "^8.39.0", "@typescript-eslint/types": "^8.39.1",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -2083,14 +2083,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz",
"integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.0" "@typescript-eslint/visitor-keys": "8.39.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2101,9 +2101,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
"integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2118,15 +2118,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz",
"integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.1",
"@typescript-eslint/utils": "8.39.0", "@typescript-eslint/utils": "8.39.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@ -2143,9 +2143,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2157,16 +2157,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
"integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/project-service": "8.39.1",
"@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.1",
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/visitor-keys": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.1",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -2212,16 +2212,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz",
"integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"@typescript-eslint/typescript-estree": "8.39.0" "@typescript-eslint/typescript-estree": "8.39.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2236,13 +2236,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.39.0", "version": "8.39.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
"integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.39.0", "@typescript-eslint/types": "8.39.1",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -2650,9 +2650,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.25.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
"integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2670,8 +2670,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.199",
"node-releases": "^2.0.19", "node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.3"
}, },
@ -2739,9 +2739,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001731", "version": "1.0.30001734",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz",
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3159,9 +3159,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.198", "version": "1.5.200",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
"integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -3350,9 +3350,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.8", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -3362,32 +3362,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.8", "@esbuild/aix-ppc64": "0.25.9",
"@esbuild/android-arm": "0.25.8", "@esbuild/android-arm": "0.25.9",
"@esbuild/android-arm64": "0.25.8", "@esbuild/android-arm64": "0.25.9",
"@esbuild/android-x64": "0.25.8", "@esbuild/android-x64": "0.25.9",
"@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-arm64": "0.25.9",
"@esbuild/darwin-x64": "0.25.8", "@esbuild/darwin-x64": "0.25.9",
"@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.9",
"@esbuild/freebsd-x64": "0.25.8", "@esbuild/freebsd-x64": "0.25.9",
"@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm": "0.25.9",
"@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-arm64": "0.25.9",
"@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-ia32": "0.25.9",
"@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-loong64": "0.25.9",
"@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-mips64el": "0.25.9",
"@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-ppc64": "0.25.9",
"@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-riscv64": "0.25.9",
"@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-s390x": "0.25.9",
"@esbuild/linux-x64": "0.25.8", "@esbuild/linux-x64": "0.25.9",
"@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.9",
"@esbuild/netbsd-x64": "0.25.8", "@esbuild/netbsd-x64": "0.25.9",
"@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.9",
"@esbuild/openbsd-x64": "0.25.8", "@esbuild/openbsd-x64": "0.25.9",
"@esbuild/openharmony-arm64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.9",
"@esbuild/sunos-x64": "0.25.8", "@esbuild/sunos-x64": "0.25.9",
"@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-arm64": "0.25.9",
"@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-ia32": "0.25.9",
"@esbuild/win32-x64": "0.25.8" "@esbuild/win32-x64": "0.25.9"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@ -3413,19 +3413,19 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.32.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz",
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0", "@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.0", "@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.32.0", "@eslint/js": "9.33.0",
"@eslint/plugin-kit": "^0.3.4", "@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@ -4747,9 +4747,9 @@
} }
}, },
"node_modules/js-base64": { "node_modules/js-base64": {
"version": "3.7.7", "version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
@ -5851,9 +5851,9 @@
} }
}, },
"node_modules/react-simple-keyboard": { "node_modules/react-simple-keyboard": {
"version": "3.8.106", "version": "3.8.109",
"resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.106.tgz", "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.109.tgz",
"integrity": "sha512-ItCHCdhVCzn9huhenuyuHQMOGsl3UMLu5xAO1bkjj4AAgVoktFC1DQ4HWkOS6BGPvUJejFM3Q5hVM8Bl2oX9pA==", "integrity": "sha512-FLlivKL4tb5G2cWOo2slOrMEkzzFX0Yg8P7k5qzisN8+TnqUPq+8G7N8D2+0oVkSmfeqZn6PyLCurGSitK4QIQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",

View File

@ -42,7 +42,7 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.106", "react-simple-keyboard": "^3.8.109",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
@ -52,22 +52,22 @@
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.1", "@eslint/compat": "^1.3.2",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0", "@eslint/js": "^9.33.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.2", "@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/parser": "^8.39.1",
"@vitejs/plugin-react-swc": "^3.10.2", "@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.32.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",

View File

@ -1,50 +1,65 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
HidState,
KeysDownState,
MouseState,
RTCState,
SettingsState,
useHidStore, useHidStore,
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
VideoState,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() { export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys); const keysDownState = useHidStore((state: HidState) => state.keysDownState);
const activeModifiers = useHidStore(state => state.activeModifiers); const mouseX = useMouseStore((state: MouseState) => state.mouseX);
const mouseX = useMouseStore(state => state.mouseX); const mouseY = useMouseStore((state: MouseState) => state.mouseY);
const mouseY = useMouseStore(state => state.mouseY); const mouseMove = useMouseStore((state: MouseState) => state.mouseMove);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( 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( const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`, (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
); );
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel);
const settings = useSettingsStore(); const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys); const showPressedKeys = useSettingsStore((state: SettingsState) => state.showPressedKeys);
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e => rpcDataChannel.onerror = (e: Event) =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const keyboardLedState = useHidStore(state => state.keyboardLedState); const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState);
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const usbState = useHidStore((state: HidState) => state.usbState);
const hdmiState = useVideoStore((state: VideoState) => state.hdmiState);
const usbState = useHidStore(state => state.usbState); const displayKeys = useMemo(() => {
const hdmiState = useVideoStore(state => state.hdmiState); if (!showPressedKeys || !keysDownState)
return "";
const state = keysDownState as KeysDownState;
const activeModifierMask = state.modifier || 0;
const keysDown = state.keys || [];
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
return [...modifierNames,...keyNames].join(", ");
}, [keysDownState, showPressedKeys]);
return ( 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="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">
@ -102,14 +117,7 @@ export default function InfoBar() {
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs"> <h2 className="text-xs">
{[ {displayKeys}
...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(", ")}
</h2> </h2>
</div> </div>
)} )}
@ -122,19 +130,6 @@ export default function InfoBar() {
</div> </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 <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
@ -175,6 +170,11 @@ export default function InfoBar() {
Kana Kana
</div> </div>
) : null} ) : null}
{keyboardLedState?.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Shift
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion"; 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 Keyboard from "react-simple-keyboard";
import Card from "@components/Card"; import Card from "@components/Card";
@ -13,7 +13,7 @@ import "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
@ -44,15 +44,19 @@ function KeyboardWrapper() {
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock)); const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
// HID related states /*
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); // These will be used to display the currently pressed keys and modifiers on the virtual keyboard
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); // used to show the modifier keys that are in the "down state" on the virtual keyboard
const keyNamesFromModifierMask = (activeModifiers: number): string[] => {
return Object.entries(modifiers).filter(m => (activeModifiers & m[1]) !== 0).map(m => m[0]);
}
// used to show the regular keys that are in the "down state" on the virtual keyboard
const keyNamesFromDownKeys = (downKeys: number[]) => {
return Object.entries(keys).filter(([_, code]) => downKeys.includes(code)).map(([name, _]) => name);
}
*/
const startDrag = useCallback((e: MouseEvent | TouchEvent) => { const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return; if (!keyboardRef.current) return;
@ -168,19 +172,11 @@ function KeyboardWrapper() {
toggleLayout(); toggleLayout();
if (isCapsLockActive) { if (isCapsLockActive) {
if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []); sendKeyboardEvent([keys["CapsLock"]], []);
return; return;
} }
} }
// Handle caps lock state change
if (isKeyCaps && !isKeyboardLedManagedByHost) {
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers // Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : []; const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers = const newModifiers =
@ -196,7 +192,7 @@ function KeyboardWrapper() {
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
}, },
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], [isCapsLockActive, sendKeyboardEvent, resetKeyboardState],
); );
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);

View File

@ -9,13 +9,16 @@ import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys } from "@/keyboardMappings";
import { import {
useHidStore, MouseState,
RTCState,
SettingsState,
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
VideoState,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { import {
@ -28,15 +31,15 @@ import {
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream); const mediaStream = useRTCStore((state: RTCState) => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState); const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState);
const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isPointerLockActive, setIsPointerLockActive] = useState(false);
// Store hooks // Store hooks
const settings = useSettingsStore(); const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const { sendKeypressEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition); const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove); const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove);
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -47,27 +50,15 @@ export default function WebRTCVideo() {
} = useVideoStore(); } = useVideoStore();
// Video enhancement settings // Video enhancement settings
const videoSaturation = useSettingsStore(state => state.videoSaturation); const videoSaturation = useSettingsStore((state: SettingsState) => state.videoSaturation);
const videoBrightness = useSettingsStore(state => state.videoBrightness); const videoBrightness = useSettingsStore((state: SettingsState) => state.videoBrightness);
const videoContrast = useSettingsStore(state => state.videoContrast); const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast);
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore((state: RTCState ) => state.peerConnection);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore((state: VideoState) => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
@ -344,153 +335,45 @@ export default function WebRTCVideo() {
sendAbsMouseMovement(0, 0, 0); sendAbsMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]); }, [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( const keyDownHandler = useCallback(
async (e: KeyboardEvent) => { async (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e);
let code = e.code; const hidKey = keys[code];
const key = e.key;
if (!isKeyboardLedManagedByHost) { if (hidKey === undefined) {
setIsNumLockActive(e.getModifierState("NumLock")); console.warn(`Key down not mapped: ${code}`);
setIsCapsLockActive(e.getModifierState("CapsLock")); return;
setIsScrollLockActive(e.getModifierState("ScrollLock"));
} }
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup // 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 // event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) { if (e.metaKey && hidKey < 0xE0) {
setTimeout(() => { setTimeout(() => {
const prev = useHidStore.getState(); sendKeypressEvent(hidKey, false);
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10); }, 10);
} }
sendKeypressEvent(hidKey, true);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [sendKeypressEvent],
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
); );
const keyUpHandler = useCallback( const keyUpHandler = useCallback(
(e: KeyboardEvent) => { async (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const code = getAdjustedKeyCode(e);
const hidKey = keys[code];
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock")); if (hidKey === undefined) {
setIsCapsLockActive(e.getModifierState("CapsLock")); console.warn(`Key up not mapped: ${code}`);
setIsScrollLockActive(e.getModifierState("ScrollLock")); return;
} }
// Filtering out the key that was just released (keys[e.code]) sendKeypressEvent(hidKey, false);
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)]);
}, },
[ [sendKeypressEvent],
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
@ -667,6 +550,18 @@ export default function WebRTCVideo() {
}; };
}, [videoSaturation, videoBrightness, videoContrast]); }, [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 ( return (
<div className="grid h-full w-full grid-rows-(--grid-layout)"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">

View File

@ -47,12 +47,12 @@ export interface User {
picture?: string; picture?: string;
} }
interface UserState { export interface UserState {
user: User | null; user: User | null;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
} }
interface UIState { export interface UIState {
sidebarView: AvailableSidebarViews | null; sidebarView: AvailableSidebarViews | null;
setSidebarView: (view: AvailableSidebarViews | null) => void; setSidebarView: (view: AvailableSidebarViews | null) => void;
@ -68,21 +68,21 @@ interface UIState {
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
terminalType: AvailableTerminalTypes; terminalType: AvailableTerminalTypes;
setTerminalType: (enabled: UIState["terminalType"]) => void; setTerminalType: (type: UIState["terminalType"]) => void;
} }
export const useUiStore = create<UIState>(set => ({ export const useUiStore = create<UIState>(set => ({
terminalType: "none", terminalType: "none",
setTerminalType: type => set({ terminalType: type }), setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }),
sidebarView: null, sidebarView: null,
setSidebarView: view => set({ sidebarView: view }), setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }),
disableVideoFocusTrap: false, disableVideoFocusTrap: false,
setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }), setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
isWakeOnLanModalVisible: false, isWakeOnLanModalVisible: false,
setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }), setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
toggleSidebarView: view => toggleSidebarView: view =>
set(state => { set(state => {
@ -94,11 +94,11 @@ export const useUiStore = create<UIState>(set => ({
}), }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled => setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
})); }));
interface RTCState { export interface RTCState {
peerConnection: RTCPeerConnection | null; peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void; setPeerConnection: (pc: RTCState["peerConnection"]) => void;
@ -118,18 +118,18 @@ interface RTCState {
setMediaStream: (stream: MediaStream) => void; setMediaStream: (stream: MediaStream) => void;
videoStreamStats: RTCInboundRtpStreamStats | null; videoStreamStats: RTCInboundRtpStreamStats | null;
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void; appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void;
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>; videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean; isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void; setTurnServerInUse: (inUse: boolean) => void;
inboundRtpStats: Map<number, RTCInboundRtpStreamStats>; inboundRtpStats: Map<number, RTCInboundRtpStreamStats>;
appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void; appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void;
clearInboundRtpStats: () => void; clearInboundRtpStats: () => void;
candidatePairStats: Map<number, RTCIceCandidatePairStats>; candidatePairStats: Map<number, RTCIceCandidatePairStats>;
appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void; appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void;
clearCandidatePairStats: () => void; clearCandidatePairStats: () => void;
// Remote ICE candidates stat type doesn't exist as of today // 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 // Disk data channel stats type doesn't exist as of today
diskDataChannelStats: Map<number, RTCDataChannelStats>; diskDataChannelStats: Map<number, RTCDataChannelStats>;
appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void; appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void;
terminalChannel: RTCDataChannel | null; terminalChannel: RTCDataChannel | null;
setTerminalChannel: (channel: RTCDataChannel) => void; setTerminalChannel: (channel: RTCDataChannel) => void;
@ -149,78 +149,78 @@ interface RTCState {
export const useRTCStore = create<RTCState>(set => ({ export const useRTCStore = create<RTCState>(set => ({
peerConnection: null, peerConnection: null,
setPeerConnection: pc => set({ peerConnection: pc }), setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: channel => set({ rpcDataChannel: channel }), setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: transceiver => set({ transceiver }), setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }), setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
diskChannel: null, diskChannel: null,
setDiskChannel: channel => set({ diskChannel: channel }), setDiskChannel: (channel: RTCDataChannel) => set({ diskChannel: channel }),
mediaStream: null, mediaStream: null,
setMediaStream: stream => set({ mediaStream: stream }), setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: stats => set({ videoStreamStats: stats }), appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: newStat => { appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
})); }));
}, },
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: newStat => { appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats), candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
})); }));
}, },
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: newStat => { appendLocalCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats), localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: newStat => { appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: newStat => { appendDiskDataChannelStats: (stats: RTCDataChannelStats) => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
})); }));
}, },
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: channel => set({ terminalChannel: channel }), setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }),
})); }));
interface MouseMove { export interface MouseMove {
x: number; x: number;
y: number; y: number;
buttons: number; buttons: number;
} }
interface MouseState { export interface MouseState {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
mouseMove?: MouseMove; mouseMove?: MouseMove;
@ -232,9 +232,14 @@ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }),
})); }));
export interface HdmiState {
ready: boolean;
error?: Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">;
}
export interface VideoState { export interface VideoState {
width: number; width: number;
height: number; height: number;
@ -263,13 +268,13 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0, clientHeight: 0,
// The video element's client size // The video element's client size
setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width, height) => set({ width, height }), setSize: (width: number, height: number) => set({ width, height }),
hdmiState: "connecting", hdmiState: "connecting",
setHdmiState: state => { setHdmiState: (state: HdmiState) => {
if (!state) return; if (!state) return;
const { ready, error } = state; const { ready, error } = state;
@ -283,9 +288,7 @@ export const useVideoStore = create<VideoState>(set => ({
}, },
})); }));
export type KeyboardLedSync = "auto" | "browser" | "host"; export interface SettingsState {
interface SettingsState {
isCursorHidden: boolean; isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void; setCursorVisibility: (enabled: boolean) => void;
@ -308,9 +311,6 @@ interface SettingsState {
keyboardLayout: string; keyboardLayout: string;
setKeyboardLayout: (layout: string) => void; setKeyboardLayout: (layout: string) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
scrollThrottling: number; scrollThrottling: number;
setScrollThrottling: (value: number) => void; setScrollThrottling: (value: number) => void;
@ -330,17 +330,17 @@ export const useSettingsStore = create(
persist<SettingsState>( persist<SettingsState>(
set => ({ set => ({
isCursorHidden: false, isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }), setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }),
mouseMode: "absolute", mouseMode: "absolute",
setMouseMode: mode => set({ mouseMode: mode }), setMouseMode: (mode: string) => set({ mouseMode: mode }),
debugMode: import.meta.env.DEV, debugMode: import.meta.env.DEV,
setDebugMode: enabled => set({ debugMode: enabled }), setDebugMode: (enabled: boolean) => set({ debugMode: enabled }),
// Add developer mode with default value // Add developer mode with default value
developerMode: false, developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }), setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }),
displayRotation: "270", displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
@ -354,24 +354,21 @@ export const useSettingsStore = create(
set({ backlightSettings: settings }), set({ backlightSettings: settings }),
keyboardLayout: "en-US", keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }), setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
scrollThrottling: 0, scrollThrottling: 0,
setScrollThrottling: value => set({ scrollThrottling: value }), setScrollThrottling: (value: number) => set({ scrollThrottling: value }),
showPressedKeys: true, showPressedKeys: true,
setShowPressedKeys: show => set({ showPressedKeys: show }), setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }),
// Video enhancement settings with default values (1.0 = normal) // Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0, videoSaturation: 1.0,
setVideoSaturation: value => set({ videoSaturation: value }), setVideoSaturation: (value: number) => set({ videoSaturation: value }),
videoBrightness: 1.0, videoBrightness: 1.0,
setVideoBrightness: value => set({ videoBrightness: value }), setVideoBrightness: (value: number) => set({ videoBrightness: value }),
videoContrast: 1.0, videoContrast: 1.0,
setVideoContrast: value => set({ videoContrast: value }), setVideoContrast: (value: number) => set({ videoContrast: value }),
}), }),
{ {
name: "settings", name: "settings",
@ -411,23 +408,23 @@ export interface MountMediaState {
export const useMountMediaStore = create<MountMediaState>(set => ({ export const useMountMediaStore = create<MountMediaState>(set => ({
localFile: null, localFile: null,
setLocalFile: file => set({ localFile: file }), setLocalFile: (file: MountMediaState["localFile"]) => set({ localFile: file }),
remoteVirtualMediaState: null, remoteVirtualMediaState: null,
setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: view => set({ modalView: view }), setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: file => addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })), set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })),
errorMessage: null, errorMessage: null,
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: (message: string | null) => set({ errorMessage: message }),
})); }));
export interface KeyboardLedState { export interface KeyboardLedState {
@ -436,24 +433,15 @@ export interface KeyboardLedState {
scroll_lock: boolean; scroll_lock: boolean;
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED
}; };
const defaultKeyboardLedState: KeyboardLedState = {
num_lock: false, export interface KeysDownState {
caps_lock: false, modifier: number;
scroll_lock: false, keys: number[];
compose: false, }
kana: false,
};
export interface HidState { export interface HidState {
activeKeys: number[];
activeModifiers: number[];
updateActiveKeysAndModifiers: (keysAndModifiers: {
keys: number[];
modifiers: number[];
}) => void;
altGrArmed: boolean; altGrArmed: boolean;
setAltGrArmed: (armed: boolean) => void; setAltGrArmed: (armed: boolean) => void;
@ -465,12 +453,9 @@ export interface HidState {
keyboardLedState?: KeyboardLedState; keyboardLedState?: KeyboardLedState;
setKeyboardLedState: (state: KeyboardLedState) => void; setKeyboardLedState: (state: KeyboardLedState) => void;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
keyboardLedStateSyncAvailable: boolean; keysDownState?: KeysDownState;
setKeyboardLedStateSyncAvailable: (available: boolean) => void; setKeysDownState: (state: KeysDownState) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
@ -482,51 +467,31 @@ export interface HidState {
setUsbState: (state: HidState["usbState"]) => void; setUsbState: (state: HidState["usbState"]) => void;
} }
export const useHidStore = create<HidState>((set, get) => ({ export const useHidStore = create<HidState>(set => ({
activeKeys: [],
activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
return set({ activeKeys: keys, activeModifiers: modifiers });
},
altGrArmed: false, altGrArmed: false,
setAltGrArmed: armed => set({ altGrArmed: armed }), setAltGrArmed: (armed: boolean): void => set({ altGrArmed: armed }),
altGrTimer: 0, altGrTimer: 0,
setAltGrTimer: timeout => set({ altGrTimer: timeout }), setAltGrTimer: (timeout: number | null): void => set({ altGrTimer: timeout }),
altGrCtrlTime: 0, altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), setAltGrCtrlTime: (time: number): void => set({ altGrCtrlTime: time }),
setKeyboardLedState: ledState => set({ keyboardLedState: ledState }), keyboardLedState: undefined,
setIsNumLockActive: active => { setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
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, keysDownState: undefined,
setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }),
// Add these new properties for USB state // Add these new properties for USB state
usbState: "not attached", usbState: "not attached",
setUsbState: state => set({ usbState: state }), setUsbState: (state: HidState["usbState"]) => set({ usbState: state }),
})); }));
export const useUserStore = create<UserState>(set => ({ export const useUserStore = create<UserState>(set => ({
@ -584,7 +549,7 @@ export interface UpdateState {
export const useUpdateStore = create<UpdateState>(set => ({ export const useUpdateStore = create<UpdateState>(set => ({
isUpdatePending: false, isUpdatePending: false,
setIsUpdatePending: isPending => set({ isUpdatePending: isPending }), setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }),
setOtaState: state => set({ otaState: state }), setOtaState: state => set({ otaState: state }),
otaState: { otaState: {
@ -608,12 +573,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
}, },
updateDialogHasBeenMinimized: false, updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: hasBeenMinimized => setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }), set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading", modalView: "loading",
setModalView: view => set({ modalView: view }), setModalView: (view: UpdateState["modalView"]) => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
})); }));
interface UsbConfigModalState { interface UsbConfigModalState {
@ -634,8 +599,8 @@ export interface UsbConfigState {
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig", modalView: "updateUsbConfig",
errorMessage: null, errorMessage: null,
setModalView: view => set({ modalView: view }), setModalView: (view: UsbConfigModalState["modalView"]) => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: (message: string | null) => set({ errorMessage: message }),
})); }));
interface LocalAuthModalState { interface LocalAuthModalState {
@ -651,7 +616,7 @@ interface LocalAuthModalState {
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword", modalView: "createPassword",
setModalView: view => set({ modalView: view }), setModalView: (view: LocalAuthModalState["modalView"]) => set({ modalView: view }),
})); }));
export interface DeviceState { export interface DeviceState {
@ -666,8 +631,8 @@ export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null, appVersion: null,
systemVersion: null, systemVersion: null,
setAppVersion: version => set({ appVersion: version }), setAppVersion: (version: string) => set({ appVersion: version }),
setSystemVersion: version => set({ systemVersion: version }), setSystemVersion: (version: string) => set({ systemVersion: version }),
})); }));
export interface DhcpLease { export interface DhcpLease {
@ -833,7 +798,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, response => { sendFn("getKeyboardMacros", {}, (response: JsonRpcResponse) => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
@ -913,7 +878,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
sendFn( sendFn(
"setKeyboardMacros", "setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } }, { params: { macros: macrosWithSortOrder } },
response => { (response: JsonRpcResponse) => {
resolve(response); resolve(response);
}, },
); );

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRTCStore } from "@/hooks/stores"; import { RTCState, useRTCStore } from "@/hooks/stores";
export interface JsonRpcRequest { export interface JsonRpcRequest {
jsonrpc: string; jsonrpc: string;
@ -33,7 +33,7 @@ const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(
let requestCounter = 0; let requestCounter = 0;
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel);
const send = useCallback( const send = useCallback(
(method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {

View File

@ -1,16 +1,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useHidStore, useRTCStore } from "@/hooks/stores"; import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function useKeyboard() { export default function useKeyboard() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel);
const updateActiveKeysAndModifiers = useHidStore( const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState);
state => state.updateActiveKeysAndModifiers,
);
const sendKeyboardEvent = useCallback( const sendKeyboardEvent = useCallback(
(keys: number[], modifiers: number[]) => { (keys: number[], modifiers: number[]) => {
@ -18,11 +16,28 @@ export default function useKeyboard() {
const accModifier = modifiers.reduce((acc, val) => acc + val, 0); const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
send("keyboardReport", { keys, modifier: accModifier }); send("keyboardReport", { keys, modifier: accModifier });
//TODO would be nice if the keyboardReport rpc call returned the current state like keypressReport does
// We do this for the info bar to display the currently pressed keys for the user // We do this for the info bar to display the currently pressed keys for the user
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers }); setKeysDownState({ keys: keys, modifier: accModifier });
}, },
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], [rpcDataChannel?.readyState, send, setKeysDownState],
);
const sendKeypressEvent = useCallback(
(key: number, press: boolean) => {
if (rpcDataChannel?.readyState !== "open") return;
send("keypressReport", { key, press }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to send keypress:", resp.error);
} else {
const keyDownState = resp.result as KeysDownState;
// We do this for the info bar to display the currently pressed keys for the user
setKeysDownState(keyDownState);
}
});
},
[rpcDataChannel?.readyState, send, setKeysDownState],
); );
const resetKeyboardState = useCallback(() => { const resetKeyboardState = useCallback(() => {
@ -52,5 +67,5 @@ export default function useKeyboard() {
} }
}; };
return { sendKeyboardEvent, resetKeyboardState, executeMacro }; return { sendKeyboardEvent, sendKeypressEvent, resetKeyboardState, executeMacro };
} }

View File

@ -14,7 +14,7 @@ export const keys = {
CapsLock: 0x39, CapsLock: 0x39,
Comma: 0x36, Comma: 0x36,
Compose: 0x65, Compose: 0x65,
ContextMenu: 0, ContextMenu: 0x65, // same as Compose
Delete: 0x4c, Delete: 0x4c,
Digit0: 0x27, Digit0: 0x27,
Digit1: 0x1e, Digit1: 0x1e,
@ -42,6 +42,7 @@ export const keys = {
F10: 0x43, F10: 0x43,
F11: 0x44, F11: 0x44,
F12: 0x45, F12: 0x45,
F13: 0x68,
F14: 0x69, F14: 0x69,
F15: 0x6a, F15: 0x6a,
F16: 0x6b, F16: 0x6b,
@ -120,6 +121,14 @@ export const keys = {
Space: 0x2c, Space: 0x2c,
SystemRequest: 0x9a, SystemRequest: 0x9a,
Tab: 0x2b, Tab: 0x2b,
ControlLeft: 0xe0,
ControlRight: 0xe4,
ShiftLeft: 0xe1,
ShiftRight: 0xe5,
AltLeft: 0xe2,
AltRight: 0xe6,
MetaLeft: 0xe3,
MetaRight: 0xe7,
} as Record<string, number>; } as Record<string, number>;
export const modifiers = { export const modifiers = {

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
@ -13,14 +13,10 @@ import { SettingsItem } from "./devices.$id.settings";
export default function SettingsKeyboardRoute() { export default function SettingsKeyboardRoute() {
const keyboardLayout = useSettingsStore(state => state.keyboardLayout); const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const showPressedKeys = useSettingsStore(state => state.showPressedKeys); const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
const setKeyboardLayout = useSettingsStore( const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout, state => state.setKeyboardLayout,
); );
const setKeyboardLedSync = useSettingsStore(
state => state.setKeyboardLedSync,
);
const setShowPressedKeys = useSettingsStore( const setShowPressedKeys = useSettingsStore(
state => state.setShowPressedKeys, state => state.setShowPressedKeys,
); );
@ -33,11 +29,6 @@ export default function SettingsKeyboardRoute() {
}, [keyboardLayout]); }, [keyboardLayout]);
const layoutOptions = keyboardOptions(); 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();
@ -91,23 +82,6 @@ export default function SettingsKeyboardRoute() {
</p> </p>
</div> </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"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Show Pressed Keys" title="Show Pressed Keys"

View File

@ -18,9 +18,14 @@ import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
DeviceState,
HidState, HidState,
KeyboardLedState, KeyboardLedState,
KeysDownState,
MountMediaState,
NetworkState, NetworkState,
RTCState,
UIState,
UpdateState, UpdateState,
useDeviceStore, useDeviceStore,
useHidStore, useHidStore,
@ -37,7 +42,7 @@ import WebRTCVideo from "@components/WebRTCVideo";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; 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 Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
@ -127,18 +132,18 @@ export default function KvmIdRoute() {
const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const authMode = "authMode" in loaderResp ? loaderResp.authMode : null;
const params = useParams() as { id: string }; const params = useParams() as { id: string };
const sidebarView = useUiStore(state => state.sidebarView); const sidebarView = useUiStore((state: UIState) => state.sidebarView);
const [queryParams, setQueryParams] = useSearchParams(); const [queryParams, setQueryParams] = useSearchParams();
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const setIsTurnServerInUse = useRTCStore((state: RTCState) => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore((state: RTCState) => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); const setPeerConnectionState = useRTCStore((state: RTCState) => state.setPeerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState); const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setMediaMediaStream = useRTCStore((state: RTCState) => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setPeerConnection = useRTCStore((state: RTCState) => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setDiskChannel = useRTCStore((state: RTCState) => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); const setRpcDataChannel = useRTCStore((state: RTCState) => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore((state: RTCState) => state.setTransceiver);
const location = useLocation(); const location = useLocation();
const isLegacySignalingEnabled = useRef(false); const isLegacySignalingEnabled = useRef(false);
@ -512,9 +517,9 @@ export default function KvmIdRoute() {
}, [peerConnectionState, cleanupAndStopReconnecting]); }, [peerConnectionState, cleanupAndStopReconnecting]);
// Cleanup effect // Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats);
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats); const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView); const setSidebarView = useUiStore((state: UIState) => state.setSidebarView);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -549,7 +554,7 @@ export default function KvmIdRoute() {
}, [peerConnectionState, setIsTurnServerInUse]); }, [peerConnectionState, setIsTurnServerInUse]);
// TURN server usage reporting // TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse);
const lastBytesReceived = useRef<number>(0); const lastBytesReceived = useRef<number>(0);
const lastBytesSent = useRef<number>(0); const lastBytesSent = useRef<number>(0);
@ -582,15 +587,16 @@ export default function KvmIdRoute() {
}); });
}, 10000); }, 10000);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState); const setNetworkState = useNetworkStateStore((state: NetworkState) => state.setNetworkState);
const setUsbState = useHidStore(state => state.setUsbState); const setUsbState = useHidStore((state: HidState) => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState); const setHdmiState = useVideoStore((state: VideoState) => state.setHdmiState);
const keyboardLedState = useHidStore(state => state.keyboardLedState); const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState); const setKeyboardLedState = useHidStore((state: HidState) => state.setKeyboardLedState);
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable); const keysDownState = useHidStore((state: HidState) => state.keysDownState);
const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
@ -617,7 +623,12 @@ export default function KvmIdRoute() {
const ledState = resp.params as KeyboardLedState; const ledState = resp.params as KeyboardLedState;
console.log("Setting keyboard led state", ledState); console.log("Setting keyboard led state", ledState);
setKeyboardLedState(ledState); setKeyboardLedState(ledState);
setKeyboardLedStateSyncAvailable(true); }
if (resp.method === "keysDownState") {
const downState = resp.params as KeysDownState;
console.log("Setting key down state", downState);
setKeysDownState(downState);
} }
if (resp.method === "otaState") { if (resp.method === "otaState") {
@ -645,12 +656,12 @@ export default function KvmIdRoute() {
} }
} }
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel);
const [send] = useJsonRpc(onJsonRpcRequest); const [send] = useJsonRpc(onJsonRpcRequest);
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
send("getVideoState", {}, resp => { send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]); setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
}); });
@ -662,22 +673,31 @@ export default function KvmIdRoute() {
if (keyboardLedState !== undefined) return; if (keyboardLedState !== undefined) return;
console.log("Requesting keyboard led state"); console.log("Requesting keyboard led state");
send("getKeyboardLedState", {}, resp => { send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
// -32601 means the method is not supported console.error("Failed to get keyboard led state", resp.error);
if (resp.error.code === -32601) {
setKeyboardLedStateSyncAvailable(false);
console.error("Failed to get keyboard led state, disabling sync", resp.error);
} else {
console.error("Failed to get keyboard led state", resp.error);
}
return; return;
} }
console.log("Keyboard led state", resp.result); console.log("Keyboard led state", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState); setKeyboardLedState(resp.result as KeyboardLedState);
setKeyboardLedStateSyncAvailable(true);
}); });
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]); }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState]);
// request keyboard key down state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (keysDownState !== undefined) return;
console.log("Requesting keys down state");
send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to get key down state", resp.error);
return;
}
console.log("Keyboard key down state", resp.result);
setKeysDownState(resp.result as KeysDownState);
});
}, [keysDownState, rpcDataChannel?.readyState, send, setKeysDownState]);
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
@ -686,8 +706,8 @@ export default function KvmIdRoute() {
} }
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!; const diskChannel = useRTCStore((state: RTCState) => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!; const file = useMountMediaStore((state: MountMediaState) => state.localFile)!;
useEffect(() => { useEffect(() => {
if (!diskChannel || !file) return; if (!diskChannel || !file) return;
diskChannel.onmessage = async e => { diskChannel.onmessage = async e => {
@ -707,7 +727,7 @@ export default function KvmIdRoute() {
}, [diskChannel, file]); }, [diskChannel, file]);
// System update // System update
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap); const disableVideoFocusTrap = useUiStore((state: UIState) => state.disableVideoFocusTrap);
const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null); const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null); const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);
@ -728,14 +748,14 @@ export default function KvmIdRoute() {
if (location.pathname !== "/other-session") navigateTo("/"); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]); }, [navigateTo, location.pathname]);
const appVersion = useDeviceStore(state => state.appVersion); const appVersion = useDeviceStore((state: DeviceState) => state.appVersion);
const setAppVersion = useDeviceStore(state => state.setAppVersion); const setAppVersion = useDeviceStore((state: DeviceState) => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion); const setSystemVersion = useDeviceStore((state: DeviceState) => state.setSystemVersion);
useEffect(() => { useEffect(() => {
if (appVersion) return; if (appVersion) return;
send("getUpdateStatus", {}, async resp => { send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
notifications.error(`Failed to get device version: ${resp.error}`); notifications.error(`Failed to get device version: ${resp.error}`);
return return

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 // open the keyboard hid file to listen for keyboard events
if err := gadget.OpenKeyboardHidFile(); err != nil { if err := gadget.OpenKeyboardHidFile(); err != nil {
usbLogger.Error().Err(err).Msg("failed to open keyboard hid file") usbLogger.Error().Err(err).Msg("failed to open keyboard hid file")
} }
} }
func rpcKeyboardReport(modifier uint8, keys []uint8) error { func rpcKeyboardReport(modifier byte, keys []byte) error {
return gadget.KeyboardReport(modifier, keys) 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) 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) return gadget.RelMouseReport(dx, dy, buttons)
} }
@ -57,6 +67,10 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
return gadget.GetKeyboardState() return gadget.GetKeyboardState()
} }
func rpcGetKeysDownState() (state usbgadget.KeysDownState) {
return gadget.GetKeysDownState()
}
var usbState = "unknown" var usbState = "unknown"
func rpcGetUSBState() (state string) { func rpcGetUSBState() (state string) {
@ -66,7 +80,7 @@ func rpcGetUSBState() (state string) {
func triggerUSBStateUpdate() { func triggerUSBStateUpdate() {
go func() { go func() {
if currentSession == nil { 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 return
} }
writeJSONRPCEvent("usbState", usbState, currentSession) writeJSONRPCEvent("usbState", usbState, currentSession)
@ -78,9 +92,9 @@ func checkUSBState() {
if newState == usbState { if newState == usbState {
return return
} }
usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed")
usbState = newState usbState = newState
usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed")
requestDisplayUpdate(true) requestDisplayUpdate(true)
triggerUSBStateUpdate() triggerUSBStateUpdate()
} }

View File

@ -102,6 +102,7 @@ func newSession(config SessionConfig) (*Session, error) {
ICEServers: []webrtc.ICEServer{iceServer}, ICEServers: []webrtc.ICEServer{iceServer},
}) })
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to create PeerConnection")
return nil, err return nil, err
} }
session := &Session{peerConnection: peerConnection} 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") session.VideoTrack, err = webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, "video", "kvm")
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to create VideoTrack")
return nil, err return nil, err
} }
rtpSender, err := peerConnection.AddTrack(session.VideoTrack) rtpSender, err := peerConnection.AddTrack(session.VideoTrack)
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to add VideoTrack to PeerConnection")
return nil, err return nil, err
} }
@ -187,8 +190,9 @@ func newSession(config SessionConfig) (*Session, error) {
currentSession = nil currentSession = nil
} }
if session.shouldUmountVirtualMedia { if session.shouldUmountVirtualMedia {
err := rpcUnmountImage() if err := rpcUnmountImage(); err != nil {
scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close") scopedLogger.Warn().Err(err).Msg("unmount image failed on connection close")
}
} }
if isConnected { if isConnected {
isConnected = false isConnected = false

2
wol.go
View File

@ -65,7 +65,7 @@ func createMagicPacket(mac net.HardwareAddr) []byte {
buf.Write(bytes.Repeat([]byte{0xFF}, 6)) buf.Write(bytes.Repeat([]byte{0xFF}, 6))
// Write the target MAC address 16 times // Write the target MAC address 16 times
for i := 0; i < 16; i++ { for range 16 {
_ = binary.Write(&buf, binary.BigEndian, mac) _ = binary.Write(&buf, binary.BigEndian, mac)
} }