This commit is contained in:
Marc Brooks 2025-11-05 20:56:40 +00:00 committed by GitHub
commit f7918a0f6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 580 additions and 311 deletions

View File

@ -478,7 +478,7 @@ func handleSessionRequest(
cloudLogger.Trace().Interface("session", session).Msg("new session accepted") cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
// Cancel any ongoing keyboard macro when session changes // Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro() cancelAllRunningKeyboardMacros()
currentSession = session currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})

View File

@ -26,20 +26,45 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return return
} }
session.hidRPCAvailable = true session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport: case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
rpcErr = handleHidRPCKeyboardInput(message) rpcErr = handleHidRPCKeyboardInput(message)
case hidrpc.TypeKeyboardMacroReport: case hidrpc.TypeKeyboardMacroReport:
keyboardMacroReport, err := message.KeyboardMacroReport() keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report") logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return return
} }
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps) token := rpcExecuteKeyboardMacro(keyboardMacroReport.IsPaste, keyboardMacroReport.Steps)
logger.Debug().Str("token", token.String()).Msg("started keyboard macro")
message, err := hidrpc.NewKeyboardMacroTokenMessage(token).Marshal()
if err != nil {
logger.Warn().Err(err).Msg("failed to marshal running macro token message")
return
}
if err := session.HidChannel.Send(message); err != nil {
logger.Warn().Err(err).Msg("failed to send running macro token message")
return
}
case hidrpc.TypeCancelKeyboardMacroReport: case hidrpc.TypeCancelKeyboardMacroReport:
rpcCancelKeyboardMacro() rpcCancelKeyboardMacro()
return return
case hidrpc.TypeKeyboardMacroTokenState:
tokenState, err := message.KeyboardMacroTokenState()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro token")
return
}
rpcCancelKeyboardMacroByToken(tokenState.Token)
return
case hidrpc.TypeKeypressKeepAliveReport: case hidrpc.TypeKeypressKeepAliveReport:
rpcErr = handleHidRPCKeypressKeepAlive(session) rpcErr = handleHidRPCKeypressKeepAlive(session)
case hidrpc.TypePointerReport: case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport() pointerReport, err := message.PointerReport()
if err != nil { if err != nil {
@ -47,6 +72,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return return
} }
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button) rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
case hidrpc.TypeMouseReport: case hidrpc.TypeMouseReport:
mouseReport, err := message.MouseReport() mouseReport, err := message.MouseReport()
if err != nil { if err != nil {
@ -54,6 +80,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return return
} }
rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button) rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button)
default: default:
logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type") logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type")
} }
@ -65,15 +92,18 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
func onHidMessage(msg hidQueueMessage, session *Session) { func onHidMessage(msg hidQueueMessage, session *Session) {
data := msg.Data data := msg.Data
dataLen := len(data)
scopedLogger := hidRPCLogger.With(). scopedLogger := hidRPCLogger.With().
Str("channel", msg.channel). Str("channel", msg.channel).
Bytes("data", data). Dur("timelimit", msg.timelimit).
Int("data_len", dataLen).
Bytes("data", data[:min(dataLen, 32)]).
Logger() Logger()
scopedLogger.Debug().Msg("HID RPC message received") scopedLogger.Debug().Msg("HID RPC message received")
if len(data) < 1 { if dataLen < 1 {
scopedLogger.Warn().Int("length", len(data)).Msg("received empty data in HID RPC message handler") scopedLogger.Warn().Msg("received empty data in HID RPC message handler")
return return
} }
@ -96,7 +126,7 @@ func onHidMessage(msg hidQueueMessage, session *Session) {
r <- nil r <- nil
}() }()
select { select {
case <-time.After(1 * time.Second): case <-time.After(msg.timelimit * time.Second):
scopedLogger.Warn().Msg("HID RPC message timed out") scopedLogger.Warn().Msg("HID RPC message timed out")
case <-r: case <-r:
scopedLogger.Debug().Dur("duration", time.Since(t)).Msg("HID RPC message handled") scopedLogger.Debug().Dur("duration", time.Since(t)).Msg("HID RPC message handled")
@ -212,6 +242,8 @@ func reportHidRPC(params any, session *Session) {
message, err = hidrpc.NewKeydownStateMessage(params).Marshal() message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
case hidrpc.KeyboardMacroState: case hidrpc.KeyboardMacroState:
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal() message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
case hidrpc.KeyboardMacroTokenState:
message, err = hidrpc.NewKeyboardMacroTokenMessage(params.Token).Marshal()
default: default:
err = fmt.Errorf("unknown HID RPC message type: %T", params) err = fmt.Errorf("unknown HID RPC message type: %T", params)
} }

View File

@ -2,7 +2,9 @@ package hidrpc
import ( import (
"fmt" "fmt"
"time"
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
) )
@ -22,26 +24,35 @@ const (
TypeKeyboardLedState MessageType = 0x32 TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33 TypeKeydownState MessageType = 0x33
TypeKeyboardMacroState MessageType = 0x34 TypeKeyboardMacroState MessageType = 0x34
TypeKeyboardMacroTokenState MessageType = 0x35
) )
type QueueIndex int
const ( const (
Version byte = 0x01 // Version of the HID RPC protocol Version byte = 0x01 // Version of the HID RPC protocol
HandshakeQueue int = 0 // Queue index for handshake messages
KeyboardQueue int = 1 // Queue index for keyboard messages
MouseQueue int = 2 // Queue index for mouse messages
MacroQueue int = 3 // Queue index for macro messages
OtherQueue int = 4 // Queue index for other messages
) )
// GetQueueIndex returns the index of the queue to which the message should be enqueued. // GetQueueIndex returns the index of the queue to which the message should be enqueued.
func GetQueueIndex(messageType MessageType) int { func GetQueueIndex(messageType MessageType) (int, time.Duration) {
switch messageType { switch messageType {
case TypeHandshake: case TypeHandshake:
return 0 return HandshakeQueue, 1 * time.Second
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState: case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState:
return 1 return KeyboardQueue, 1 * time.Second
case TypePointerReport, TypeMouseReport, TypeWheelReport: case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2 return MouseQueue, 1 * time.Second
// we don't want to block the queue for this message // we don't want to block the queue for these messages
case TypeCancelKeyboardMacroReport: case TypeKeyboardMacroReport, TypeCancelKeyboardMacroReport, TypeKeyboardMacroTokenState:
return 3 return MacroQueue, 60 * time.Second
default: default:
return 3 return OtherQueue, 5 * time.Second
} }
} }
@ -121,3 +132,13 @@ func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
d: data, d: data,
} }
} }
// NewKeyboardMacroTokenMessage creates a new keyboard macro token message.
func NewKeyboardMacroTokenMessage(token uuid.UUID) *Message {
data, _ := token.MarshalBinary()
return &Message{
t: TypeKeyboardMacroTokenState,
d: data,
}
}

View File

@ -3,6 +3,9 @@ package hidrpc
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/usbgadget"
) )
// Message .. // Message ..
@ -23,6 +26,9 @@ func (m *Message) Type() MessageType {
func (m *Message) String() string { func (m *Message) String() string {
switch m.t { switch m.t {
case TypeHandshake: case TypeHandshake:
if len(m.d) != 0 {
return fmt.Sprintf("Handshake{Malformed: %v}", m.d)
}
return "Handshake" return "Handshake"
case TypeKeypressReport: case TypeKeypressReport:
if len(m.d) < 2 { if len(m.d) < 2 {
@ -45,12 +51,45 @@ func (m *Message) String() string {
} }
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2]) return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeypressKeepAliveReport: case TypeKeypressKeepAliveReport:
if len(m.d) != 0 {
return fmt.Sprintf("KeypressKeepAliveReport{Malformed: %v}", m.d)
}
return "KeypressKeepAliveReport" return "KeypressKeepAliveReport"
case TypeWheelReport:
if len(m.d) < 3 {
return fmt.Sprintf("WheelReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("WheelReport{Vertical: %d, Horizontal: %d}", int8(m.d[0]), int8(m.d[1]))
case TypeKeyboardMacroReport: case TypeKeyboardMacroReport:
if len(m.d) < 5 { if len(m.d) < 5 {
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d) return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
} }
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5])) return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
case TypeCancelKeyboardMacroReport:
if len(m.d) != 0 {
return fmt.Sprintf("CancelKeyboardMacroReport{Malformed: %v}", m.d)
}
return "CancelKeyboardMacroReport"
case TypeKeyboardMacroTokenState:
if len(m.d) != 16 {
return fmt.Sprintf("KeyboardMacroTokenState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroTokenState{Token: %s}", uuid.Must(uuid.FromBytes(m.d)).String())
case TypeKeyboardLedState:
if len(m.d) < 1 {
return fmt.Sprintf("KeyboardLedState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardLedState{State: %d}", m.d[0])
case TypeKeydownState:
if len(m.d) < 1 {
return fmt.Sprintf("KeydownState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeydownState{State: %d}", m.d[0])
case TypeKeyboardMacroState:
if len(m.d) < 2 {
return fmt.Sprintf("KeyboardMacroState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroState{State: %v, IsPaste: %v}", m.d[0] == uint8(1), m.d[1] == uint8(1))
default: default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d) return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
} }
@ -67,7 +106,9 @@ func (m *Message) KeypressReport() (KeypressReport, error) {
if m.t != TypeKeypressReport { if m.t != TypeKeypressReport {
return KeypressReport{}, fmt.Errorf("invalid message type: %d", m.t) return KeypressReport{}, fmt.Errorf("invalid message type: %d", m.t)
} }
if len(m.d) < 2 {
return KeypressReport{}, fmt.Errorf("invalid message data length: %d", len(m.d))
}
return KeypressReport{ return KeypressReport{
Key: m.d[0], Key: m.d[0],
Press: m.d[1] == uint8(1), Press: m.d[1] == uint8(1),
@ -95,18 +136,16 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
// Macro .. // Macro ..
type KeyboardMacroStep struct { type KeyboardMacroStep struct {
Modifier byte // 1 byte Modifier byte // 1 byte
Keys []byte // 6 bytes: hidKeyBufferSize Keys []byte // 6 bytes: usbgadget.HidKeyBufferSize
Delay uint16 // 2 bytes Delay uint16 // 2 bytes
} }
type KeyboardMacroReport struct { type KeyboardMacroReport struct {
IsPaste bool IsPaste bool
StepCount uint32 StepCount uint32
Steps []KeyboardMacroStep Steps []KeyboardMacroStep
} }
// HidKeyBufferSize is the size of the keys buffer in the keyboard report.
const HidKeyBufferSize = 6
// KeyboardMacroReport returns the keyboard macro report from the message. // KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) { func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
if m.t != TypeKeyboardMacroReport { if m.t != TypeKeyboardMacroReport {
@ -131,7 +170,7 @@ func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]), Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
}) })
offset += 1 + HidKeyBufferSize + 2 offset += 1 + usbgadget.HidKeyBufferSize + 2
} }
return KeyboardMacroReport{ return KeyboardMacroReport{
@ -205,3 +244,29 @@ func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) {
IsPaste: m.d[1] == uint8(1), IsPaste: m.d[1] == uint8(1),
}, nil }, nil
} }
type KeyboardMacroTokenState struct {
Token uuid.UUID
}
// KeyboardMacroTokenState returns the keyboard macro token UUID from the message.
func (m *Message) KeyboardMacroTokenState() (KeyboardMacroTokenState, error) {
if m.t != TypeKeyboardMacroTokenState {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid message type: %d", m.t)
}
if len(m.d) == 0 {
return KeyboardMacroTokenState{Token: uuid.Nil}, nil
}
if len(m.d) != 16 {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid UUID length: %d", len(m.d))
}
token, err := uuid.FromBytes(m.d)
if err != nil {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid UUID: %v", err)
}
return KeyboardMacroTokenState{Token: token}, nil
}

View File

@ -28,44 +28,55 @@ var keyboardConfig = gadgetConfigItem{
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt // Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
var keyboardReportDesc = []byte{ var keyboardReportDesc = []byte{
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */ /* boot mode descriptor */
0x09, 0x06, /* USAGE (Keyboard) */ 0x05, 0x01, /* USAGE_PAGE-global (Generic Desktop) */
0xa1, 0x01, /* COLLECTION (Application) */ 0x09, 0x06, /* USAGE-local (Keyboard) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */ 0xA1, 0x01, /* COLLECTION-main (Application) */
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x95, 0x08, /* REPORT_COUNT (8) */
0x81, 0x02, /* INPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */ /* 8 modifier bits */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */ 0x05, 0x07, /* USAGE_PAGE-global (Keyboard) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */ 0x19, 0xe0, /* USAGE_MINIMUM-local 0xE0 (Keyboard LeftControl) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */ 0x29, 0xe7, /* USAGE_MAXIMUM-local 0xE7 (Keyboard Right GUI) */
0x95, 0x01, /* REPORT_COUNT (1) */ 0x15, 0x00, /* LOGICAL_MINIMUM-global (0) Modifier bit off) */
0x75, 0x03, /* REPORT_SIZE (3) */ 0x25, 0x01, /* LOGICAL_MAXIMUM-global (1) Modifier bit on) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */ 0x75, 0x01, /* REPORT_SIZE-global (1) one bit per modifier */
0x95, 0x06, /* REPORT_COUNT (6) */ 0x95, 0x08, /* REPORT_COUNT-global (8) 8 total bits */
0x75, 0x08, /* REPORT_SIZE (8) */ 0x81, 0x02, /* INPUT-main (Data,Var,Abs) Modifier bits 0-7 */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */ /* 8 bits of padding */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */ 0x95, 0x01, /* REPORT_COUNT-global (1) one field */
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */ 0x75, 0x08, /* REPORT_SIZE-global (8) */
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */ 0x81, 0x03, /* INPUT-main (Cnst,Var,Abs) reserved byte */
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
0xc0, /* END_COLLECTION */ /* 6 key codes for the 104 key keyboard */
0x95, 0x06, /* REPORT_COUNT-global (6) keycodes */
0x75, 0x08, /* REPORT_SIZE-global (8) bits each (a byte) */
0x15, 0x00, /* LOGICAL_MINIMUM-global (0) */
0x25, 0xDF, /* LOGICAL_MAXIMUM-global 0xDF (104-key HID codes) */
0x05, 0x07, /* USAGE_PAGE-global (Keyboard) */
0x19, 0x00, /* USAGE_MINIMUM-local (Reserved/0) no key */
0x29, 0xE7, /* USAGE_MAXIMUM-local (Keyboard Right GUI) */
0x81, 0x00, /* INPUT-main (Data,Ary,Abs) array of keycodes */
/* LED report 5 bits for Num Lock through Kana */
0x95, 0x05, /* REPORT_COUNT-global (5) 5 LED bits */
0x75, 0x01, /* REPORT_SIZE-global (1) each 1 bit */
0x05, 0x08, /* USAGE_PAGE-global (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM-local (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM-local (Kana) */
0x91, 0x02, /* OUTPUT-main (Data,Var,Abs) bits 0-4 */
/* 3 bits of padding for the rest of the byte */
0x95, 0x01, /* REPORT_COUNT-global (1) one field */
0x75, 0x03, /* REPORT_SIZE-global (3) of three bits */
0x91, 0x03, /* OUTPUT-main (Cnst,Var,Abs) bit 7 pad */
0xC0, /* END_COLLECTION */
} }
const ( const (
hidReadBufferSize = 8 hidReadBufferSize = 8
hidKeyBufferSize = 6 HidKeyBufferSize = 6
hidErrorRollOver = 0x01 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
@ -74,9 +85,7 @@ const (
KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4 KeyboardLedMaskKana = 1 << 4
// power on/off LED is 5 ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
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,
@ -89,7 +98,6 @@ 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
raw byte raw byte
} }
@ -106,7 +114,6 @@ 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,
raw: b, raw: b,
} }
} }
@ -153,6 +160,21 @@ func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f u.onKeysDownChange = &f
} }
var suspendedKeyDownMessages bool = false
var suspendedKeyDownMessagesLock sync.Mutex
func (u *UsbGadget) SuspendKeyDownMessages() {
suspendedKeyDownMessagesLock.Lock()
suspendedKeyDownMessages = true
suspendedKeyDownMessagesLock.Unlock()
}
func (u *UsbGadget) ResumeSuspendKeyDownMessages() {
suspendedKeyDownMessagesLock.Lock()
suspendedKeyDownMessages = false
suspendedKeyDownMessagesLock.Unlock()
}
func (u *UsbGadget) SetOnKeepAliveReset(f func()) { func (u *UsbGadget) SetOnKeepAliveReset(f func()) {
u.onKeepAliveReset = &f u.onKeepAliveReset = &f
} }
@ -169,9 +191,9 @@ func (u *UsbGadget) scheduleAutoRelease(key byte) {
} }
// TODO: make this configurable // TODO: make this configurable
// We currently hardcode the duration to 100ms // We currently hardcode the duration to the default of 100ms
// However, it should be the same as the duration of the keep-alive reset called baseExtension. // However, it should be the same as the duration of the keep-alive reset called baseExtension.
u.kbdAutoReleaseTimers[key] = time.AfterFunc(100*time.Millisecond, func() { u.kbdAutoReleaseTimers[key] = time.AfterFunc(DefaultAutoReleaseDuration, func() {
u.performAutoRelease(key) u.performAutoRelease(key)
}) })
} }
@ -263,12 +285,13 @@ func (u *UsbGadget) listenKeyboardEvents() {
time.Sleep(time.Second) time.Sleep(time.Second)
continue continue
} }
// reset the counter // reset the suppression counter
u.resetLogSuppressionCounter("keyboardHidFileNil") u.resetLogSuppressionCounter("keyboardHidFileNil")
n, err := u.keyboardHidFile.Read(buf) n, err := u.keyboardHidFile.Read(buf)
if err != nil { if err != nil {
u.logWithSuppression("keyboardHidFileRead", 100, &l, err, "failed to read") u.logWithSuppression("keyboardHidFileRead", 100, &l, err, "failed to read")
time.Sleep(100 * time.Millisecond) // Small backoff on read errors to avoid tight looping
continue continue
} }
u.resetLogSuppressionCounter("keyboardHidFileRead") u.resetLogSuppressionCounter("keyboardHidFileRead")
@ -314,11 +337,12 @@ var keyboardWriteHidFileLock sync.Mutex
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
keyboardWriteHidFileLock.Lock() keyboardWriteHidFileLock.Lock()
defer keyboardWriteHidFileLock.Unlock() defer keyboardWriteHidFileLock.Unlock()
if err := u.openKeyboardHidFile(); err != nil { if err := u.openKeyboardHidFile(); err != nil {
return err return err
} }
_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) _, err := u.writeWithTimeout(u.keyboardHidFile, 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()
@ -353,7 +377,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
u.keysDownState = state u.keysDownState = state
u.keyboardStateLock.Unlock() u.keyboardStateLock.Unlock()
if u.onKeysDownChange != nil { if u.onKeysDownChange != nil && !suspendedKeyDownMessages {
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...) (*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
} }
return state return state
@ -362,11 +386,11 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error {
defer u.resetUserInputTime() defer u.resetUserInputTime()
if len(keys) > hidKeyBufferSize { if len(keys) > HidKeyBufferSize {
keys = keys[:hidKeyBufferSize] keys = keys[:HidKeyBufferSize]
} }
if len(keys) < hidKeyBufferSize { if len(keys) < HidKeyBufferSize {
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) keys = append(keys, make([]byte, HidKeyBufferSize-len(keys))...)
} }
err := u.keyboardWriteHidFile(modifier, keys) err := u.keyboardWriteHidFile(modifier, keys)
@ -449,7 +473,7 @@ func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error)
// handle other keys that are not modifier keys by placing or removing them // handle other keys that are not modifier keys by placing or removing them
// from the key buffer since the buffer tracks currently pressed keys // from the key buffer since the buffer tracks currently pressed keys
overrun := true overrun := true
for i := range hidKeyBufferSize { for i := range HidKeyBufferSize {
// If we find the key in the buffer the buffer, we either remove it (if press is false) // 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 // 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) // and if we find a zero byte, we can place the key there (if press is true)
@ -460,7 +484,7 @@ func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error)
// we are releasing the key, remove it from the buffer // we are releasing the key, remove it from the buffer
if keys[i] != 0 { if keys[i] != 0 {
copy(keys[i:], keys[i+1:]) copy(keys[i:], keys[i+1:])
keys[hidKeyBufferSize-1] = 0 // Clear the last byte keys[HidKeyBufferSize-1] = 0 // Clear the last byte
} }
} }
overrun = false // We found a slot for the key overrun = false // We found a slot for the key
@ -484,6 +508,10 @@ func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error)
} }
err := u.keyboardWriteHidFile(modifier, keys) err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err return u.UpdateKeysDown(modifier, keys), err
} }

View File

@ -135,7 +135,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
keyboardStateCtx: keyboardCtx, keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel, keyboardStateCancel: keyboardCancel,
keyboardState: 0, keyboardState: 0,
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to usbgadget.HidKeyBufferSize (6) zero bytes
kbdAutoReleaseTimers: make(map[byte]*time.Timer), kbdAutoReleaseTimers: make(map[byte]*time.Timer),
enabledDevices: *enabledDevices, enabledDevices: *enabledDevices,
lastUserInput: time.Now(), lastUserInput: time.Now(),

View File

@ -1,7 +1,6 @@
package kvm package kvm
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -14,6 +13,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/google/uuid"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.bug.st/serial" "go.bug.st/serial"
@ -1063,91 +1063,154 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil return nil
} }
type RunningMacro struct {
cancel context.CancelFunc
isPaste bool
}
var ( var (
keyboardMacroCancel context.CancelFunc keyboardMacroCancelMap map[uuid.UUID]RunningMacro
keyboardMacroLock sync.Mutex keyboardMacroLock sync.Mutex
keyboardMacroOnce sync.Once
) )
// cancelKeyboardMacro cancels any ongoing keyboard macro execution func getKeyboardMacroCancelMap() map[uuid.UUID]RunningMacro {
func cancelKeyboardMacro() { keyboardMacroOnce.Do(func() {
keyboardMacroCancelMap = make(map[uuid.UUID]RunningMacro)
})
return keyboardMacroCancelMap
}
func addKeyboardMacro(isPaste bool, cancel context.CancelFunc) uuid.UUID {
keyboardMacroLock.Lock() keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock() defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
if keyboardMacroCancel != nil { token := uuid.New() // Generate a unique token
keyboardMacroCancel() cancelMap[token] = RunningMacro{
logger.Info().Msg("canceled keyboard macro") isPaste: isPaste,
keyboardMacroCancel = nil cancel: cancel,
} }
return token
} }
func setKeyboardMacroCancel(cancel context.CancelFunc) { func removeRunningKeyboardMacro(token uuid.UUID) {
keyboardMacroLock.Lock() keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock() defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
keyboardMacroCancel = cancel delete(cancelMap, token)
} }
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { func cancelRunningKeyboardMacro(token uuid.UUID) {
cancelKeyboardMacro() keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
if runningMacro, exists := cancelMap[token]; exists {
runningMacro.cancel()
delete(cancelMap, token)
logger.Info().Interface("token", token).Msg("canceled keyboard macro by token")
} else {
logger.Debug().Interface("token", token).Msg("no running keyboard macro found for token")
}
}
func cancelAllRunningKeyboardMacros() {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
for token, runningMacro := range cancelMap {
runningMacro.cancel()
delete(cancelMap, token)
logger.Info().Interface("token", token).Msg("cancelled keyboard macro")
}
}
func reportRunningMacrosState() {
if currentSession != nil {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
isPaste := false
for _, runningMacro := range cancelMap {
if runningMacro.isPaste {
isPaste = true
break
}
}
state := hidrpc.KeyboardMacroState{
State: len(cancelMap) > 0,
IsPaste: isPaste,
}
currentSession.reportHidRPCKeyboardMacroState(state)
}
}
func rpcExecuteKeyboardMacro(isPaste bool, macro []hidrpc.KeyboardMacroStep) uuid.UUID {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
setKeyboardMacroCancel(cancel) token := addKeyboardMacro(isPaste, cancel)
reportRunningMacrosState()
s := hidrpc.KeyboardMacroState{ go func() {
State: true, defer reportRunningMacrosState() // this executes last, so the map is already updated
IsPaste: true, defer removeRunningKeyboardMacro(token) // this executes first, to update the map
err := executeKeyboardMacro(ctx, isPaste, macro)
if err != nil {
logger.Error().Err(err).Interface("token", token).Bool("isPaste", isPaste).Msg("keyboard macro execution failed")
} }
}()
if currentSession != nil { return token
currentSession.reportHidRPCKeyboardMacroState(s)
}
err := rpcDoExecuteKeyboardMacro(ctx, macro)
setKeyboardMacroCancel(nil)
s.State = false
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
return err
} }
func rpcCancelKeyboardMacro() { func rpcCancelKeyboardMacro() {
cancelKeyboardMacro() defer reportRunningMacrosState()
cancelAllRunningKeyboardMacros()
} }
var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize) func rpcCancelKeyboardMacroByToken(token uuid.UUID) {
defer reportRunningMacrosState()
func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool { if token == uuid.Nil {
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys) cancelAllRunningKeyboardMacros()
} else {
cancelRunningKeyboardMacro(token)
}
} }
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error { func executeKeyboardMacro(ctx context.Context, isPaste bool, macro []hidrpc.KeyboardMacroStep) error {
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro") logger.Debug().
Int("macro_steps", len(macro)).
Bool("isPaste", isPaste).
Msg("Executing keyboard macro")
// don't report keyboard state changes while executing the macro
gadget.SuspendKeyDownMessages()
defer gadget.ResumeSuspendKeyDownMessages()
for i, step := range macro { for i, step := range macro {
delay := time.Duration(step.Delay) * time.Millisecond delay := time.Duration(step.Delay) * time.Millisecond
err := rpcKeyboardReport(step.Modifier, step.Keys) err := rpcKeyboardReport(step.Modifier, step.Keys)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard macro") logger.Warn().Err(err).Int("step", i).Msg("failed to execute keyboard macro")
return err return err
} }
// notify the device that the keyboard state is being cleared
if isClearKeyStep(step) {
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
}
// Use context-aware sleep that can be cancelled // Use context-aware sleep that can be cancelled
select { select {
case <-time.After(delay): case <-time.After(delay):
// Sleep completed normally // Sleep completed normally
case <-ctx.Done(): case <-ctx.Done():
// make sure keyboard state is reset // make sure keyboard state is reset and the client gets notified
err := rpcKeyboardReport(0, keyboardClearStateKeys) gadget.ResumeSuspendKeyDownMessages()
err := rpcKeyboardReport(0, make([]byte, usbgadget.HidKeyBufferSize))
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("failed to reset keyboard state") logger.Warn().Err(err).Msg("failed to reset keyboard state")
} }

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Videresendt af Cloudflare", "info_relayed_by_cloudflare": "Videresendt af Cloudflare",
"info_resolution": "Opløsning:", "info_resolution": "Opløsning:",
"info_scroll_lock": "Scroll Lock", "info_scroll_lock": "Scroll Lock",
"info_shift": "Flytte",
"info_usb_state": "USB-tilstand:", "info_usb_state": "USB-tilstand:",
"info_video_size": "Videostørrelse:", "info_video_size": "Videostørrelse:",
"input_disabled": "Input deaktiveret", "input_disabled": "Input deaktiveret",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Weitergeleitet von Cloudflare", "info_relayed_by_cloudflare": "Weitergeleitet von Cloudflare",
"info_resolution": "Auflösung:", "info_resolution": "Auflösung:",
"info_scroll_lock": "Rollen-Taste", "info_scroll_lock": "Rollen-Taste",
"info_shift": "Schicht",
"info_usb_state": "USB-Status:", "info_usb_state": "USB-Status:",
"info_video_size": "Videogröße:", "info_video_size": "Videogröße:",
"input_disabled": "Eingabe deaktiviert", "input_disabled": "Eingabe deaktiviert",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Relayed by Cloudflare", "info_relayed_by_cloudflare": "Relayed by Cloudflare",
"info_resolution": "Resolution:", "info_resolution": "Resolution:",
"info_scroll_lock": "Scroll Lock", "info_scroll_lock": "Scroll Lock",
"info_shift": "Shift",
"info_usb_state": "USB State:", "info_usb_state": "USB State:",
"info_video_size": "Video Size:", "info_video_size": "Video Size:",
"input_disabled": "Input disabled", "input_disabled": "Input disabled",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Retransmitido por Cloudflare", "info_relayed_by_cloudflare": "Retransmitido por Cloudflare",
"info_resolution": "Resolución:", "info_resolution": "Resolución:",
"info_scroll_lock": "Bloq Despl", "info_scroll_lock": "Bloq Despl",
"info_shift": "Cambio",
"info_usb_state": "Estado USB:", "info_usb_state": "Estado USB:",
"info_video_size": "Tamaño del vídeo:", "info_video_size": "Tamaño del vídeo:",
"input_disabled": "Entrada deshabilitada", "input_disabled": "Entrada deshabilitada",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Relayé par Cloudflare", "info_relayed_by_cloudflare": "Relayé par Cloudflare",
"info_resolution": "Résolution :", "info_resolution": "Résolution :",
"info_scroll_lock": "Verrouillage du défilement", "info_scroll_lock": "Verrouillage du défilement",
"info_shift": "Maj",
"info_usb_state": "État USB :", "info_usb_state": "État USB :",
"info_video_size": "Taille de la vidéo :", "info_video_size": "Taille de la vidéo :",
"input_disabled": "Entrée désactivée", "input_disabled": "Entrée désactivée",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Rilasciato da Cloudflare", "info_relayed_by_cloudflare": "Rilasciato da Cloudflare",
"info_resolution": "Risoluzione:", "info_resolution": "Risoluzione:",
"info_scroll_lock": "Blocco scorrimento", "info_scroll_lock": "Blocco scorrimento",
"info_shift": "Spostare",
"info_usb_state": "Stato USB:", "info_usb_state": "Stato USB:",
"info_video_size": "Dimensioni video:", "info_video_size": "Dimensioni video:",
"input_disabled": "Input disabilitato", "input_disabled": "Input disabilitato",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Videresendt av Cloudflare", "info_relayed_by_cloudflare": "Videresendt av Cloudflare",
"info_resolution": "Oppløsning:", "info_resolution": "Oppløsning:",
"info_scroll_lock": "Scroll Lock", "info_scroll_lock": "Scroll Lock",
"info_shift": "Skifte",
"info_usb_state": "USB-tilstand:", "info_usb_state": "USB-tilstand:",
"info_video_size": "Videostørrelse:", "info_video_size": "Videostørrelse:",
"input_disabled": "Inndata deaktivert", "input_disabled": "Inndata deaktivert",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "Vidarebefordras av Cloudflare", "info_relayed_by_cloudflare": "Vidarebefordras av Cloudflare",
"info_resolution": "Upplösning:", "info_resolution": "Upplösning:",
"info_scroll_lock": "Scroll Lock", "info_scroll_lock": "Scroll Lock",
"info_shift": "Flytta",
"info_usb_state": "USB-status:", "info_usb_state": "USB-status:",
"info_video_size": "Videostorlek:", "info_video_size": "Videostorlek:",
"input_disabled": "Inmatning inaktiverad", "input_disabled": "Inmatning inaktiverad",

View File

@ -331,7 +331,6 @@
"info_relayed_by_cloudflare": "由 Cloudflare 转发", "info_relayed_by_cloudflare": "由 Cloudflare 转发",
"info_resolution": "分辨率:", "info_resolution": "分辨率:",
"info_scroll_lock": "滚动锁定", "info_scroll_lock": "滚动锁定",
"info_shift": "Shift",
"info_usb_state": "USB 状态:", "info_usb_state": "USB 状态:",
"info_video_size": "视频大小:", "info_video_size": "视频大小:",
"input_disabled": "输入禁用", "input_disabled": "输入禁用",

235
ui/package-lock.json generated
View File

@ -38,6 +38,7 @@
"recharts": "^3.3.0", "recharts": "^3.3.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"validator": "^13.15.15", "validator": "^13.15.15",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
@ -847,21 +848,21 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.4.1", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.16.0" "@eslint/core": "^0.17.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.16.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
@ -909,7 +910,6 @@
"version": "9.39.0", "version": "9.39.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz",
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==",
"dev": true,
"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"
@ -928,12 +928,12 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.4.0", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.16.0", "@eslint/core": "^0.17.0",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@ -1163,6 +1163,20 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@inlang/sdk/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -1232,6 +1246,20 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@lix-js/sdk/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@lix-js/server-protocol-schema": { "node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
@ -1742,9 +1770,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.14.0.tgz",
"integrity": "sha512-umBaSb65O1v6Lt8RV3o5srw0nKr25amf/yRIGFPug63sAerL9n2UkmfGywA1l1aN81W7faXIynF0JmlQ2wPSdw==", "integrity": "sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
@ -1760,16 +1788,16 @@
"url": "https://opencollective.com/swc" "url": "https://opencollective.com/swc"
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-darwin-arm64": "1.13.21", "@swc/core-darwin-arm64": "1.14.0",
"@swc/core-darwin-x64": "1.13.21", "@swc/core-darwin-x64": "1.14.0",
"@swc/core-linux-arm-gnueabihf": "1.13.21", "@swc/core-linux-arm-gnueabihf": "1.14.0",
"@swc/core-linux-arm64-gnu": "1.13.21", "@swc/core-linux-arm64-gnu": "1.14.0",
"@swc/core-linux-arm64-musl": "1.13.21", "@swc/core-linux-arm64-musl": "1.14.0",
"@swc/core-linux-x64-gnu": "1.13.21", "@swc/core-linux-x64-gnu": "1.14.0",
"@swc/core-linux-x64-musl": "1.13.21", "@swc/core-linux-x64-musl": "1.14.0",
"@swc/core-win32-arm64-msvc": "1.13.21", "@swc/core-win32-arm64-msvc": "1.14.0",
"@swc/core-win32-ia32-msvc": "1.13.21", "@swc/core-win32-ia32-msvc": "1.14.0",
"@swc/core-win32-x64-msvc": "1.13.21" "@swc/core-win32-x64-msvc": "1.14.0"
}, },
"peerDependencies": { "peerDependencies": {
"@swc/helpers": ">=0.5.17" "@swc/helpers": ">=0.5.17"
@ -1781,9 +1809,9 @@
} }
}, },
"node_modules/@swc/core-darwin-arm64": { "node_modules/@swc/core-darwin-arm64": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.14.0.tgz",
"integrity": "sha512-0jaz9r7f0PDK8OyyVooadv8dkFlQmVmBK6DtAnWSRjkCbNt4sdqsc9ZkyEDJXaxOVcMQ3pJx/Igniyw5xqACLw==", "integrity": "sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1798,9 +1826,9 @@
} }
}, },
"node_modules/@swc/core-darwin-x64": { "node_modules/@swc/core-darwin-x64": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.14.0.tgz",
"integrity": "sha512-pLeZn+NTGa7oW/ysD6oM82BjKZl71WNJR9BKXRsOhrNQeUWv55DCoZT2P4DzeU5Xgjmos+iMoDLg/9R6Ngc0PA==", "integrity": "sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1815,9 +1843,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm-gnueabihf": { "node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.14.0.tgz",
"integrity": "sha512-p9aYzTmP7qVDPkXxnbekOfbT11kxnPiuLrUbgpN/vn6sxXDCObMAiY63WlDR0IauBK571WUdmgb04goe/xTQWw==", "integrity": "sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1832,9 +1860,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm64-gnu": { "node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.14.0.tgz",
"integrity": "sha512-yRqFoGlCwEX1nS7OajBE23d0LPeONmFAgoe4rgRYvaUb60qGxIJoMMdvF2g3dum9ZyVDYAb3kP09hbXFbMGr4A==", "integrity": "sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1849,9 +1877,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm64-musl": { "node_modules/@swc/core-linux-arm64-musl": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.14.0.tgz",
"integrity": "sha512-wu5EGA86gtdYMW69eU80jROzArzD3/6G6zzK0VVR+OFt/0zqbajiiszIpaniOVACObLfJEcShQ05B3q0+CpUEg==", "integrity": "sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1866,9 +1894,9 @@
} }
}, },
"node_modules/@swc/core-linux-x64-gnu": { "node_modules/@swc/core-linux-x64-gnu": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.14.0.tgz",
"integrity": "sha512-AoGGVPNXH3C4S7WlJOxN1nGW5nj//J9uKysS7CIBotRmHXfHO4wPK3TVFRTA4cuouAWBBn7O8m3A99p/GR+iaw==", "integrity": "sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1883,9 +1911,9 @@
} }
}, },
"node_modules/@swc/core-linux-x64-musl": { "node_modules/@swc/core-linux-x64-musl": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.14.0.tgz",
"integrity": "sha512-cBy2amuDuxMZnEq16MqGu+DUlEFqI+7F/OACNlk7zEJKq48jJKGEMqJz3X2ucJE5jqUIg6Pos6Uo/y+vuWQymQ==", "integrity": "sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1900,9 +1928,9 @@
} }
}, },
"node_modules/@swc/core-win32-arm64-msvc": { "node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.14.0.tgz",
"integrity": "sha512-2xfR5gnqBGOMOlY3s1QiFTXZaivTILMwX67FD2uzT6OCbT/3lyAM/4+3BptBXD8pUkkOGMFLsdeHw4fbO1GrpQ==", "integrity": "sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1917,9 +1945,9 @@
} }
}, },
"node_modules/@swc/core-win32-ia32-msvc": { "node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.14.0.tgz",
"integrity": "sha512-0pkpgKlBDwUImWTQxLakKbzZI6TIGVVAxk658oxrY8VK+hxRy2iezFY6m5Urmeds47M/cnW3dO+OY4C2caOF8A==", "integrity": "sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1934,9 +1962,9 @@
} }
}, },
"node_modules/@swc/core-win32-x64-msvc": { "node_modules/@swc/core-win32-x64-msvc": {
"version": "1.13.21", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.21.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.14.0.tgz",
"integrity": "sha512-DAnIw2J95TOW4Kr7NBx12vlZPW3QndbpFMmuC7x+fPoozoLpEscaDkiYhk7/sTtY9pubPMfHFPBORlbqyQCfOQ==", "integrity": "sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2488,9 +2516,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/validator": { "node_modules/@types/validator": {
"version": "13.15.3", "version": "13.15.4",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.4.tgz",
"integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", "integrity": "sha512-LSFfpSnJJY9wbC0LQxgvfb+ynbHftFo0tMsFOl/J4wexLnYMmDSPaj2ZyDv3TkfL1UePxPrxOWJfbiRS8mQv7A==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -3136,9 +3164,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.8.22",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.22.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "integrity": "sha512-/tk9kky/d8T8CTXIQYASLyhAxR5VwL3zct1oAoVTaOUHwrmsGnfbRwNdEq+vOl2BN8i3PcDdP0o4Q+jjKQoFbQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -3259,9 +3287,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001751", "version": "1.0.30001752",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001752.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "integrity": "sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3612,9 +3640,9 @@
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.18", "version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -3732,9 +3760,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.240", "version": "1.5.244",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.240.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz",
"integrity": "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==", "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -4009,19 +4037,19 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.38.0", "version": "9.39.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1", "@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1", "@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.16.0", "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.38.0", "@eslint/js": "9.39.0",
"@eslint/plugin-kit": "^0.4.0", "@eslint/plugin-kit": "^0.4.1",
"@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",
@ -4596,12 +4624,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/focus-trap": { "node_modules/focus-trap": {
"version": "7.6.5", "version": "7.6.6",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz",
"integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tabbable": "^6.2.0" "tabbable": "^6.3.0"
} }
}, },
"node_modules/focus-trap-react": { "node_modules/focus-trap-react": {
@ -5008,9 +5036,9 @@
} }
}, },
"node_modules/immer": { "node_modules/immer": {
"version": "10.1.3", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -6022,9 +6050,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.26", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -6533,9 +6561,9 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.65.0", "version": "7.66.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@ -6627,9 +6655,9 @@
} }
}, },
"node_modules/react-simple-keyboard": { "node_modules/react-simple-keyboard": {
"version": "3.8.131", "version": "3.8.132",
"resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.131.tgz", "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.132.tgz",
"integrity": "sha512-gICYtaV38AU/E1PTTwzJOF6s5fu6Nu3GZQwnaSNB4VGOO3UwOn8rioDEFBLvjMWpP8kwfWp2of8xywY647rTxA==", "integrity": "sha512-GoXK+6SRu72Jn8qT8fy+PxstIdZEACyIi/7zy0qXcrB6EJaN6zZk0/w3Sv3ALLwXqQd/3t3yUL4DQOwoNO1cbw==",
"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",
@ -6918,9 +6946,9 @@
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.1", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-function-length": { "node_modules/set-function-length": {
@ -7642,23 +7670,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "10.0.0", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"dev": true,
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/validator": { "node_modules/validator": {
"version": "13.15.15", "version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"

View File

@ -57,6 +57,7 @@
"recharts": "^3.3.0", "recharts": "^3.3.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"validator": "^13.15.15", "validator": "^13.15.15",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },

View File

@ -166,10 +166,6 @@ export default function InfoBar() {
{keyboardLedState.kana ? ( {keyboardLedState.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">{m.info_kana()}</div> <div className="shrink-0 p-1 px-1.5 text-xs">{m.info_kana()}</div>
) : null} ) : null}
{keyboardLedState.shift ? (
<div className="shrink-0 p-1 px-1.5 text-xs">{m.info_shift()}</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,6 +19,8 @@ import { TextAreaWithLabel } from "@components/TextArea";
// uint32 max value / 4 // uint32 max value / 4
const pasteMaxLength = 1073741824; const pasteMaxLength = 1073741824;
const defaultDelay = 20; const defaultDelay = 20;
const minimumDelay = 10;
const maximumDelay = 65534;
export default function PasteModal() { export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null); const TextAreaRef = useRef<HTMLTextAreaElement>(null);
@ -31,7 +33,7 @@ export default function PasteModal() {
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const [delayValue, setDelayValue] = useState(defaultDelay); const [delayValue, setDelayValue] = useState(defaultDelay);
const delay = useMemo(() => { const delay = useMemo(() => {
if (delayValue < 0 || delayValue > 65534) { if (delayValue < minimumDelay || delayValue > maximumDelay) {
return defaultDelay; return defaultDelay;
} }
return delayValue; return delayValue;
@ -189,18 +191,18 @@ export default function PasteModal() {
type="number" type="number"
label={m.paste_modal_delay_between_keys()} label={m.paste_modal_delay_between_keys()}
placeholder={m.paste_modal_delay_between_keys()} placeholder={m.paste_modal_delay_between_keys()}
min={50} min={minimumDelay}
max={65534} max={maximumDelay}
value={delayValue} value={delayValue}
onChange={e => { onChange={e => {
setDelayValue(parseInt(e.target.value, 10)); setDelayValue(parseInt(e.target.value, 10));
}} }}
/> />
{delayValue < 50 || delayValue > 65534 && ( {(delayValue < minimumDelay || delayValue > maximumDelay) && (
<div className="mt-2 flex items-center gap-x-2"> <div className="mt-2 flex items-center gap-x-2">
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" /> <ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400"> <span className="text-xs text-red-500 dark:text-red-400">
{m.paste_modal_delay_out_of_range({ min: 50, max: 65534 })} {m.paste_modal_delay_out_of_range({ min: minimumDelay, max: maximumDelay })}
</span> </span>
</div> </div>
)} )}

View File

@ -1,3 +1,5 @@
import { parse as uuidParse , stringify as uuidStringify } from "uuid";
import { hidKeyBufferSize, KeyboardLedState, KeysDownState } from "./stores"; import { hidKeyBufferSize, KeyboardLedState, KeysDownState } from "./stores";
export const HID_RPC_MESSAGE_TYPES = { export const HID_RPC_MESSAGE_TYPES = {
@ -13,6 +15,7 @@ export const HID_RPC_MESSAGE_TYPES = {
KeyboardLedState: 0x32, KeyboardLedState: 0x32,
KeysDownState: 0x33, KeysDownState: 0x33,
KeyboardMacroState: 0x34, KeyboardMacroState: 0x34,
CancelKeyboardMacroByTokenReport: 0x35,
} }
export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES]; export type HidRpcMessageType = typeof HID_RPC_MESSAGE_TYPES[keyof typeof HID_RPC_MESSAGE_TYPES];
@ -300,7 +303,7 @@ export class KeyboardMacroStateMessage extends RpcMessage {
} }
public static unmarshal(data: Uint8Array): KeyboardMacroStateMessage | undefined { public static unmarshal(data: Uint8Array): KeyboardMacroStateMessage | undefined {
if (data.length < 1) { if (data.length < 2) {
throw new Error(`Invalid keyboard macro state report message length: ${data.length}`); throw new Error(`Invalid keyboard macro state report message length: ${data.length}`);
} }
@ -379,13 +382,30 @@ export class PointerReportMessage extends RpcMessage {
} }
export class CancelKeyboardMacroReportMessage extends RpcMessage { export class CancelKeyboardMacroReportMessage extends RpcMessage {
token: string;
constructor() { constructor(token: string) {
super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport); super(HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport);
this.token = (token == null || token === "")
? "00000000-0000-0000-0000-000000000000"
: token;
} }
marshal(): Uint8Array { marshal(): Uint8Array {
return new Uint8Array([this.messageType]); const tokenBytes = uuidParse(this.token);
return new Uint8Array([this.messageType, ...tokenBytes]);
}
public static unmarshal(data: Uint8Array): CancelKeyboardMacroReportMessage | undefined {
if (data.length === 0) {
return new CancelKeyboardMacroReportMessage("00000000-0000-0000-0000-000000000000");
}
if (data.length !== 16) {
throw new Error(`Invalid cancel message length: ${data.length}`);
}
return new CancelKeyboardMacroReportMessage(uuidStringify(data.slice(0, 16)));
} }
} }
@ -431,6 +451,7 @@ export const messageRegistry = {
[HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage, [HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroReport]: CancelKeyboardMacroReportMessage,
[HID_RPC_MESSAGE_TYPES.KeyboardMacroState]: KeyboardMacroStateMessage, [HID_RPC_MESSAGE_TYPES.KeyboardMacroState]: KeyboardMacroStateMessage,
[HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport]: KeypressKeepAliveMessage, [HID_RPC_MESSAGE_TYPES.KeypressKeepAliveReport]: KeypressKeepAliveMessage,
[HID_RPC_MESSAGE_TYPES.CancelKeyboardMacroByTokenReport]: CancelKeyboardMacroReportMessage,
} }
export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => { export const unmarshalHidRpcMessage = (data: Uint8Array): RpcMessage | undefined => {

View File

@ -473,7 +473,6 @@ 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
}; };
export const hidKeyBufferSize = 6; export const hidKeyBufferSize = 6;
@ -509,7 +508,7 @@ export interface HidState {
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false } as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState, keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState,

View File

@ -142,7 +142,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
const cancelOngoingKeyboardMacro = useCallback( const cancelOngoingKeyboardMacro = useCallback(
() => { () => {
sendMessage(new CancelKeyboardMacroReportMessage()); sendMessage(new CancelKeyboardMacroReportMessage(""));
}, },
[sendMessage], [sendMessage],
); );

View File

@ -290,9 +290,7 @@ export default function useKeyboard() {
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
const modifierMask: number = (step.modifiers || []) const modifierMask: number = (step.modifiers || [])
.map(mod => modifiers[mod]) .map(mod => modifiers[mod])
.reduce((acc, val) => acc + val, 0); .reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay // If the step has keys and/or modifiers, press them and hold for the delay
@ -303,9 +301,8 @@ export default function useKeyboard() {
} }
sendKeyboardMacroEventHidRpc(macro); sendKeyboardMacroEventHidRpc(macro);
}, }, [sendKeyboardMacroEventHidRpc]);
[sendKeyboardMacroEventHidRpc],
);
const executeMacroClientSide = useCallback( const executeMacroClientSide = useCallback(
async (steps: MacroSteps) => { async (steps: MacroSteps) => {

View File

@ -3,12 +3,12 @@
// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf) // [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf)
// These are all the key codes (not scan codes) that an 85/101/102 keyboard might have on it // These are all the key codes (not scan codes) that an 85/101/102 keyboard might have on it
export const keys = { export const keys = {
Again: 0x79, Again: 0x79, // aka Clear
AlternateErase: 0x9d, AlternateErase: 0x9d,
AltGr: 0xe6, // aka AltRight AltGr: 0xe6, // aka AltRight
AltLeft: 0xe2, AltLeft: 0xe2,
AltRight: 0xe6, AltRight: 0xe6,
Application: 0x65, Application: 0x65, // aka ContextMenu
ArrowDown: 0x51, ArrowDown: 0x51,
ArrowLeft: 0x50, ArrowLeft: 0x50,
ArrowRight: 0x4f, ArrowRight: 0x4f,
@ -25,11 +25,10 @@ export const keys = {
ClearAgain: 0xa2, ClearAgain: 0xa2,
Comma: 0x36, Comma: 0x36,
Compose: 0xe3, Compose: 0xe3,
ContextMenu: 0x65,
ControlLeft: 0xe0, ControlLeft: 0xe0,
ControlRight: 0xe4, ControlRight: 0xe4,
Copy: 0x7c, Copy: 0x7c,
CrSel: 0xa3, CrSel: 0xa3, // aka Props
CurrencySubunit: 0xb5, CurrencySubunit: 0xb5,
CurrencyUnit: 0xb4, CurrencyUnit: 0xb4,
Cut: 0x7b, Cut: 0x7b,
@ -49,7 +48,7 @@ export const keys = {
Enter: 0x28, Enter: 0x28,
Equal: 0x2e, Equal: 0x2e,
Escape: 0x29, Escape: 0x29,
Execute: 0x74, Execute: 0x74, // aka Open
ExSel: 0xa4, ExSel: 0xa4,
F1: 0x3a, F1: 0x3a,
F2: 0x3b, F2: 0x3b,
@ -77,14 +76,14 @@ export const keys = {
F24: 0x73, F24: 0x73,
Find: 0x7e, Find: 0x7e,
Grave: 0x35, Grave: 0x35,
HashTilde: 0x32, // non-US # and ~ HashTilde: 0x32, // non-US # and ~ (typically near Enter key)
Help: 0x75, Help: 0x75,
Home: 0x4a, Home: 0x4a,
Insert: 0x49, Insert: 0x49,
International7: 0x8d, International7: 0x8d,
International8: 0x8e, International8: 0x8e,
International9: 0x8f, International9: 0x8f,
IntlBackslash: 0x64, // non-US \ and | IntlBackslash: 0x64, // non-US \ and | (typically near Left Shift key)
KeyA: 0x04, KeyA: 0x04,
KeyB: 0x05, KeyB: 0x05,
KeyC: 0x06, KeyC: 0x06,
@ -111,17 +110,17 @@ export const keys = {
KeyX: 0x1b, KeyX: 0x1b,
KeyY: 0x1c, KeyY: 0x1c,
KeyZ: 0x1d, KeyZ: 0x1d,
KeyRO: 0x87, RO: 0x87, // aka International1
KatakanaHiragana: 0x88, KatakanaHiragana: 0x88, // aka International2
Yen: 0x89, Yen: 0x89, // aka International3
Henkan: 0x8a, Henkan: 0x8a, // aka International4
Muhenkan: 0x8b, Muhenkan: 0x8b, // aka International5
KPJPComma: 0x8c, KPJPComma: 0x8c, // aka International6
Hangeul: 0x90, Hangeul: 0x90, // aka Lang1
Hanja: 0x91, Hanja: 0x91, // aka Lang2
Katakana: 0x92, Katakana: 0x92, // aka Lang3
Hiragana: 0x93, Hiragana: 0x93, // aka Lang4
ZenkakuHankaku: 0x94, ZenkakuHankaku: 0x94, // aka Lang5
LockingCapsLock: 0x82, LockingCapsLock: 0x82,
LockingNumLock: 0x83, LockingNumLock: 0x83,
LockingScrollLock: 0x84, LockingScrollLock: 0x84,
@ -129,9 +128,29 @@ export const keys = {
Lang7: 0x96, Lang7: 0x96,
Lang8: 0x97, Lang8: 0x97,
Lang9: 0x98, Lang9: 0x98,
MediaBack: 0xF1,
MediaCalc: 0xFB,
MediaCoffee: 0xF9,
MediaEdit: 0xF7,
MediaEjectCD: 0xEC,
MediaFind: 0xF4,
MediaForward: 0xF2,
MediaMute: 0xEF,
MediaNextSong: 0xEB,
MediaPlayPause: 0xE8,
MediaPreviousSong: 0xEA,
MediaRefresh: 0xFA,
MediaScrollDown: 0xF6,
MediaScrollUp: 0xF5,
MediaSleep: 0xF8,
MediaStop: 0xF3,
MediaStopCD: 0xE9,
MediaVolumeDown: 0xEE,
MediaVolumeUp: 0xED,
MediaWWW: 0xF0,
Menu: 0x76, Menu: 0x76,
MetaLeft: 0xe3, MetaLeft: 0xe3, // aka LeftGUI
MetaRight: 0xe7, MetaRight: 0xe7, // aka RightGUI
Minus: 0x2d, Minus: 0x2d,
Mute: 0x7f, Mute: 0x7f,
NumLock: 0x53, // and Clear NumLock: 0x53, // and Clear
@ -157,9 +176,8 @@ export const keys = {
NumpadClearEntry: 0xd9, NumpadClearEntry: 0xd9,
NumpadColon: 0xcb, NumpadColon: 0xcb,
NumpadComma: 0x85, NumpadComma: 0x85,
NumpadDecimal: 0x63, // and Delete NumpadDecimal: 0x63, // and NumpadDelete
NumpadDecimalBase: 0xdc, NumpadDecimalBase: 0xdc,
NumpadDelete: 0x63,
NumpadDivide: 0x54, NumpadDivide: 0x54,
NumpadDownArrow: 0x5a, NumpadDownArrow: 0x5a,
NumpadEnd: 0x59, NumpadEnd: 0x59,
@ -213,12 +231,12 @@ export const keys = {
Pause: 0x48, Pause: 0x48,
Period: 0x37, // aka Dot Period: 0x37, // aka Dot
Power: 0x66, Power: 0x66,
PrintScreen: 0x46, PrintScreen: 0x46, // aka SysRq
Prior: 0x9d, Prior: 0x9d,
Quote: 0x34, // aka Single Quote or Apostrophe Quote: 0x34, // aka Single Quote or Apostrophe
Return: 0x9e, Return: 0x9e,
ScrollLock: 0x47, ScrollLock: 0x47, // aka ScrLk
Select: 0x77, Select: 0x77, // aka Front
Semicolon: 0x33, Semicolon: 0x33,
Separator: 0x9f, Separator: 0x9f,
ShiftLeft: 0xe1, ShiftLeft: 0xe1,
@ -240,7 +258,7 @@ export const deadKeys = {
Breve: 0x02d8, Breve: 0x02d8,
Caron: 0x02c7, Caron: 0x02c7,
Cedilla: 0x00b8, Cedilla: 0x00b8,
Circumflex: 0x005e, // or 0x02c6? Circumflex: 0x02c6,
Comma: 0x002c, Comma: 0x002c,
Dot: 0x00b7, Dot: 0x00b7,
DoubleAcute: 0x02dd, DoubleAcute: 0x02dd,

View File

@ -89,7 +89,7 @@ def main(argv):
) )
report = { report = {
"generated_at": datetime.utcnow().isoformat() + "Z", "generated_at": datetime.now().isoformat(),
"en_json": str(en_path), "en_json": str(en_path),
"total_string_keys": total_keys, "total_string_keys": total_keys,
"duplicate_groups": sorted( "duplicate_groups": sorted(

View File

@ -82,7 +82,7 @@ def main():
print(f"Generating report for {len(usages)} usages ...") print(f"Generating report for {len(usages)} usages ...")
report = { report = {
"generated_at": datetime.utcnow().isoformat() + "Z", "generated_at": datetime.now().isoformat(),
"en_json": str(en_path), "en_json": str(en_path),
"src_root": args.src, "src_root": args.src,
"total_keys": len(keys), "total_keys": len(keys),

2
web.go
View File

@ -230,7 +230,7 @@ func handleWebRTCSession(c *gin.Context) {
} }
// Cancel any ongoing keyboard macro when session changes // Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro() cancelAllRunningKeyboardMacros()
currentSession = session currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd}) c.JSON(http.StatusOK, gin.H{"sd": sd})

View File

@ -34,7 +34,7 @@ type Session struct {
lastTimerResetTime time.Time // Track when auto-release timer was last reset lastTimerResetTime time.Time // Track when auto-release timer was last reset
keepAliveJitterLock sync.Mutex // Protect jitter compensation timing state keepAliveJitterLock sync.Mutex // Protect jitter compensation timing state
hidQueueLock sync.Mutex hidQueueLock sync.Mutex
hidQueue []chan hidQueueMessage hidQueues []chan hidQueueMessage
keysDownStateQueue chan usbgadget.KeysDownState keysDownStateQueue chan usbgadget.KeysDownState
} }
@ -77,6 +77,7 @@ func (s *Session) resetKeepAliveTime() {
type hidQueueMessage struct { type hidQueueMessage struct {
webrtc.DataChannelMessage webrtc.DataChannelMessage
channel string channel string
timelimit time.Duration
} }
type SessionConfig struct { type SessionConfig struct {
@ -121,19 +122,20 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) {
return base64.StdEncoding.EncodeToString(localDescription), nil return base64.StdEncoding.EncodeToString(localDescription), nil
} }
func (s *Session) initQueues() { func (s *Session) initHidQueues() {
s.hidQueueLock.Lock() s.hidQueueLock.Lock()
defer s.hidQueueLock.Unlock() defer s.hidQueueLock.Unlock()
s.hidQueue = make([]chan hidQueueMessage, 0) s.hidQueues = make([]chan hidQueueMessage, hidrpc.OtherQueue+1)
for i := 0; i < 4; i++ { s.hidQueues[hidrpc.HandshakeQueue] = make(chan hidQueueMessage, 2) // we don't really want to queue many handshake messages
q := make(chan hidQueueMessage, 256) s.hidQueues[hidrpc.KeyboardQueue] = make(chan hidQueueMessage, 256)
s.hidQueue = append(s.hidQueue, q) s.hidQueues[hidrpc.MouseQueue] = make(chan hidQueueMessage, 256)
} s.hidQueues[hidrpc.MacroQueue] = make(chan hidQueueMessage, 10) // macros can be long, but we don't want to queue too many
s.hidQueues[hidrpc.OtherQueue] = make(chan hidQueueMessage, 256)
} }
func (s *Session) handleQueues(index int) { func (s *Session) handleQueue(queue chan hidQueueMessage) {
for msg := range s.hidQueue[index] { for msg := range queue {
onHidMessage(msg, s) onHidMessage(msg, s)
} }
} }
@ -188,17 +190,18 @@ func getOnHidMessageHandler(session *Session, scopedLogger *zerolog.Logger, chan
l.Trace().Msg("received data in HID RPC message handler") l.Trace().Msg("received data in HID RPC message handler")
// Enqueue to ensure ordered processing // Enqueue to ensure ordered processing
queueIndex := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0])) queueIndex, timelimit := hidrpc.GetQueueIndex(hidrpc.MessageType(msg.Data[0]))
if queueIndex >= len(session.hidQueue) || queueIndex < 0 { if queueIndex >= len(session.hidQueues) || queueIndex < 0 {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found") l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue index not found")
queueIndex = 3 queueIndex = hidrpc.OtherQueue
} }
queue := session.hidQueue[queueIndex] queue := session.hidQueues[queueIndex]
if queue != nil { if queue != nil {
queue <- hidQueueMessage{ queue <- hidQueueMessage{
DataChannelMessage: msg, DataChannelMessage: msg,
channel: channel, channel: channel,
timelimit: timelimit,
} }
} else { } else {
l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil") l.Warn().Int("queueIndex", queueIndex).Msg("received data in HID RPC message handler, but queue is nil")
@ -248,7 +251,7 @@ func newSession(config SessionConfig) (*Session, error) {
session := &Session{peerConnection: peerConnection} session := &Session{peerConnection: peerConnection}
session.rpcQueue = make(chan webrtc.DataChannelMessage, 256) session.rpcQueue = make(chan webrtc.DataChannelMessage, 256)
session.initQueues() session.initHidQueues()
session.initKeysDownStateQueue() session.initKeysDownStateQueue()
go func() { go func() {
@ -258,8 +261,8 @@ func newSession(config SessionConfig) (*Session, error) {
} }
}() }()
for i := 0; i < len(session.hidQueue); i++ { for queueIndex := range session.hidQueues {
go session.handleQueues(i) go session.handleQueue(session.hidQueues[queueIndex])
} }
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) { peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
@ -284,7 +287,11 @@ func newSession(config SessionConfig) (*Session, error) {
session.RPCChannel = d session.RPCChannel = d
d.OnMessage(func(msg webrtc.DataChannelMessage) { d.OnMessage(func(msg webrtc.DataChannelMessage) {
// Enqueue to ensure ordered processing // Enqueue to ensure ordered processing
if session.rpcQueue != nil {
session.rpcQueue <- msg session.rpcQueue <- msg
} else {
scopedLogger.Warn().Msg("RPC message received but rpcQueue is nil")
}
}) })
triggerOTAStateUpdate() triggerOTAStateUpdate()
triggerVideoStateUpdate() triggerVideoStateUpdate()
@ -352,22 +359,23 @@ func newSession(config SessionConfig) (*Session, error) {
_ = peerConnection.Close() _ = peerConnection.Close()
} }
if connectionState == webrtc.ICEConnectionStateClosed { if connectionState == webrtc.ICEConnectionStateClosed {
scopedLogger.Debug().Msg("ICE Connection State is closed, unmounting virtual media") scopedLogger.Debug().Msg("ICE Connection State is closed, tearing down session")
if session == currentSession { if session == currentSession {
// Cancel any ongoing keyboard report multi when session closes // Cancel any ongoing keyboard report multi when session closes
cancelKeyboardMacro() cancelAllRunningKeyboardMacros()
currentSession = nil currentSession = nil
} }
// Stop RPC processor // Stop RPC processor
if session.rpcQueue != nil { if session.rpcQueue != nil {
close(session.rpcQueue) close(session.rpcQueue)
session.rpcQueue = nil session.rpcQueue = nil
} }
// Stop HID RPC processor // Stop HID RPC processors
for i := 0; i < len(session.hidQueue); i++ { for i := 0; i < len(session.hidQueues); i++ {
close(session.hidQueue[i]) close(session.hidQueues[i])
session.hidQueue[i] = nil session.hidQueues[i] = nil
} }
close(session.keysDownStateQueue) close(session.keysDownStateQueue)