Compare commits

...

11 Commits

Author SHA1 Message Date
Marc Brooks f7918a0f6c
Merge 8e00b3d581 into 28919bf37c 2025-11-05 20:56:40 +00:00
Marc Brooks 8e00b3d581
Fix CoPilot complaints 2025-11-05 14:56:27 -06:00
Marc Brooks e12ddaff79
Use a single exported HidKeyBufferSize from hid_keyboard 2025-11-05 14:56:04 -06:00
Marc Brooks 8de61db3d8
Return a duration with the queue (not a bare int) 2025-11-05 14:54:40 -06:00
Marc Brooks da8c82da34
Remove unused translation for Shift. 2025-11-05 14:54:06 -06:00
Marc Brooks 49b9a35951
Be explicit about minimum and maximum delay
Use range correctly in the UI element and error messaging.
2025-11-05 14:09:10 -06:00
Marc Brooks 05057cb6fa
Better loop name
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 13:53:55 -06:00
Marc Brooks 57a7aa6a8b
Protect suspension mutex
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 13:53:23 -06:00
Aveline 28919bf37c
fix: await sleep needs to be called inside async function (#946) 2025-11-05 13:25:13 -06:00
Marc Brooks f9dcee1377
Clean up the keyboard HID report
Was declaring too much output breaking boot keyboard on some devices.
Got rid of the lockec-Shift LED.
Added a short snooze when we get errors reading the LED state.
2025-11-03 13:28:16 -06:00
Marc Brooks deb258b717
Reduce traffic during pastes
Suspend KeyDownMessages while processing a macro.
Make sure we don't emit huge debugging traces.
Allow 30 seconds for RPC to finish (not ideal)
Reduced default delay between keys (and allow as low as 0)
Move the HID keyboard descriptor LED state
as it seems to interfere with boot mode
Run paste/macros in background on their own queue and return a token for cancellation.
Fixed error in length check for macro key state.
Removed redundant clear operation.
Use Once instead of init()
Add a time limit for each message type/queue.
Update ui/src/components/popovers/PasteModal.tsx
2025-11-03 13:27:39 -06:00
32 changed files with 585 additions and 314 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

@ -12,6 +12,7 @@ import { TextAreaWithLabel } from "@components/TextArea";
import { isOnDevice } from "@/main"; import { isOnDevice } from "@/main";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils";
export default function SettingsAdvancedRoute() { export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
@ -311,7 +312,7 @@ export default function SettingsAdvancedRoute() {
size="SM" size="SM"
theme="light" theme="light"
text={m.advanced_reset_config_button()} text={m.advanced_reset_config_button()}
onClick={() => { onClick={async () => {
handleResetConfig(); handleResetConfig();
// Add 2s delay between resetting the configuration and calling reload() to prevent reload from interrupting the RPC call to reset things. // Add 2s delay between resetting the configuration and calling reload() to prevent reload from interrupting the RPC call to reset things.
await sleep(2000); await sleep(2000);

View File

@ -4,12 +4,13 @@ import { useNavigate } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils";
export default function SettingsGeneralRebootRoute() { export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const onClose = useCallback(() => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
await sleep(1000); await sleep(1000);

View File

@ -21,7 +21,7 @@ export default function SettingsGeneralUpdateRoute() {
const { setModalView, otaState } = useUpdateStore(); const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const onClose = useCallback(() => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
await sleep(1000); await sleep(1000);

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)