diff --git a/cloud.go b/cloud.go index dbbd3bbc..479ef2b0 100644 --- a/cloud.go +++ b/cloud.go @@ -478,7 +478,7 @@ func handleSessionRequest( cloudLogger.Trace().Interface("session", session).Msg("new session accepted") // Cancel any ongoing keyboard macro when session changes - cancelKeyboardMacro() + cancelAllRunningKeyboardMacros() currentSession = session _ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) diff --git a/hidrpc.go b/hidrpc.go index ebe03daa..9e993016 100644 --- a/hidrpc.go +++ b/hidrpc.go @@ -26,20 +26,45 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { return } session.hidRPCAvailable = true + case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport: rpcErr = handleHidRPCKeyboardInput(message) + case hidrpc.TypeKeyboardMacroReport: keyboardMacroReport, err := message.KeyboardMacroReport() if err != nil { logger.Warn().Err(err).Msg("failed to get keyboard macro report") 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: rpcCancelKeyboardMacro() 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: rpcErr = handleHidRPCKeypressKeepAlive(session) + case hidrpc.TypePointerReport: pointerReport, err := message.PointerReport() if err != nil { @@ -47,6 +72,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { return } rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button) + case hidrpc.TypeMouseReport: mouseReport, err := message.MouseReport() if err != nil { @@ -54,6 +80,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { return } rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button) + default: 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) { data := msg.Data + dataLen := len(data) scopedLogger := hidRPCLogger.With(). Str("channel", msg.channel). - Bytes("data", data). + Dur("timelimit", msg.timelimit). + Int("data_len", dataLen). + Bytes("data", data[:min(dataLen, 32)]). Logger() scopedLogger.Debug().Msg("HID RPC message received") - if len(data) < 1 { - scopedLogger.Warn().Int("length", len(data)).Msg("received empty data in HID RPC message handler") + if dataLen < 1 { + scopedLogger.Warn().Msg("received empty data in HID RPC message handler") return } @@ -96,7 +126,7 @@ func onHidMessage(msg hidQueueMessage, session *Session) { r <- nil }() select { - case <-time.After(1 * time.Second): + case <-time.After(msg.timelimit * time.Second): scopedLogger.Warn().Msg("HID RPC message timed out") case <-r: 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() case hidrpc.KeyboardMacroState: message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal() + case hidrpc.KeyboardMacroTokenState: + message, err = hidrpc.NewKeyboardMacroTokenMessage(params.Token).Marshal() default: err = fmt.Errorf("unknown HID RPC message type: %T", params) } diff --git a/internal/hidrpc/hidrpc.go b/internal/hidrpc/hidrpc.go index 7313e3b5..c36f564c 100644 --- a/internal/hidrpc/hidrpc.go +++ b/internal/hidrpc/hidrpc.go @@ -2,7 +2,9 @@ package hidrpc import ( "fmt" + "time" + "github.com/google/uuid" "github.com/jetkvm/kvm/internal/usbgadget" ) @@ -22,26 +24,34 @@ const ( TypeKeyboardLedState MessageType = 0x32 TypeKeydownState MessageType = 0x33 TypeKeyboardMacroState MessageType = 0x34 + TypeKeyboardMacroTokenState MessageType = 0x35 ) +type QueueIndex int + 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. -func GetQueueIndex(messageType MessageType) int { +func GetQueueIndex(messageType MessageType) (int, time.Duration) { switch messageType { case TypeHandshake: - return 0 - case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState: - return 1 + return HandshakeQueue, 1 * time.Second + case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState: + return KeyboardQueue, 1 * time.Second case TypePointerReport, TypeMouseReport, TypeWheelReport: - return 2 - // we don't want to block the queue for this message - case TypeCancelKeyboardMacroReport: - return 3 + return MouseQueue, 1 * time.Second + // we don't want to block the queue for these messages + case TypeKeyboardMacroReport, TypeCancelKeyboardMacroReport, TypeKeyboardMacroTokenState: + return MacroQueue, 60 * time.Second default: - return 3 + return OtherQueue, 5 * time.Second } } @@ -121,3 +131,13 @@ func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message { 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, + } +} diff --git a/internal/hidrpc/message.go b/internal/hidrpc/message.go index 3f3506f7..a42ca4e3 100644 --- a/internal/hidrpc/message.go +++ b/internal/hidrpc/message.go @@ -3,6 +3,9 @@ package hidrpc import ( "encoding/binary" "fmt" + + "github.com/google/uuid" + "github.com/jetkvm/kvm/internal/usbgadget" ) // Message .. @@ -23,6 +26,9 @@ func (m *Message) Type() MessageType { func (m *Message) String() string { switch m.t { case TypeHandshake: + if len(m.d) != 0 { + return fmt.Sprintf("Handshake{Malformed: %v}", m.d) + } return "Handshake" case TypeKeypressReport: 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]) case TypeKeypressKeepAliveReport: + if len(m.d) != 0 { + return fmt.Sprintf("KeypressKeepAliveReport{Malformed: %v}", m.d) + } 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: if len(m.d) < 5 { 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])) + 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: 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 { 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{ Key: m.d[0], Press: m.d[1] == uint8(1), @@ -95,18 +136,16 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) { // Macro .. type KeyboardMacroStep struct { Modifier byte // 1 byte - Keys []byte // 6 bytes: hidKeyBufferSize + Keys []byte // 6 bytes: usbgadget.HidKeyBufferSize Delay uint16 // 2 bytes } + type KeyboardMacroReport struct { IsPaste bool StepCount uint32 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. func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) { if m.t != TypeKeyboardMacroReport { @@ -131,7 +170,7 @@ func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) { Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]), }) - offset += 1 + HidKeyBufferSize + 2 + offset += 1 + usbgadget.HidKeyBufferSize + 2 } return KeyboardMacroReport{ @@ -205,3 +244,29 @@ func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) { IsPaste: m.d[1] == uint8(1), }, 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 +} diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 74cf76f9..7a12f5ac 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -28,44 +28,55 @@ var keyboardConfig = gadgetConfigItem{ // Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt var keyboardReportDesc = []byte{ - 0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */ - 0x09, 0x06, /* USAGE (Keyboard) */ - 0xa1, 0x01, /* COLLECTION (Application) */ - 0x05, 0x07, /* USAGE_PAGE (Keyboard) */ - 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) */ + /* boot mode descriptor */ + 0x05, 0x01, /* USAGE_PAGE-global (Generic Desktop) */ + 0x09, 0x06, /* USAGE-local (Keyboard) */ + 0xA1, 0x01, /* COLLECTION-main (Application) */ - 0x05, 0x08, /* USAGE_PAGE (LEDs) */ - 0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */ - 0x29, 0x05, /* USAGE_MAXIMUM (Kana) */ - 0x91, 0x02, /* OUTPUT (Data,Var,Abs) */ - 0x95, 0x01, /* REPORT_COUNT (1) */ - 0x75, 0x03, /* REPORT_SIZE (3) */ - 0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */ - 0x95, 0x06, /* REPORT_COUNT (6) */ - 0x75, 0x08, /* REPORT_SIZE (8) */ - 0x15, 0x00, /* LOGICAL_MINIMUM (0) */ - 0x25, 0x65, /* LOGICAL_MAXIMUM (101) */ - 0x05, 0x07, /* USAGE_PAGE (Keyboard) */ - 0x19, 0x00, /* USAGE_MINIMUM (Reserved) */ - 0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */ - 0x81, 0x00, /* INPUT (Data,Ary,Abs) */ - 0xc0, /* END_COLLECTION */ + /* 8 modifier bits */ + 0x05, 0x07, /* USAGE_PAGE-global (Keyboard) */ + 0x19, 0xe0, /* USAGE_MINIMUM-local 0xE0 (Keyboard LeftControl) */ + 0x29, 0xe7, /* USAGE_MAXIMUM-local 0xE7 (Keyboard Right GUI) */ + 0x15, 0x00, /* LOGICAL_MINIMUM-global (0) Modifier bit off) */ + 0x25, 0x01, /* LOGICAL_MAXIMUM-global (1) Modifier bit on) */ + 0x75, 0x01, /* REPORT_SIZE-global (1) one bit per modifier */ + 0x95, 0x08, /* REPORT_COUNT-global (8) 8 total bits */ + 0x81, 0x02, /* INPUT-main (Data,Var,Abs) Modifier bits 0-7 */ + + /* 8 bits of padding */ + 0x95, 0x01, /* REPORT_COUNT-global (1) one field */ + 0x75, 0x08, /* REPORT_SIZE-global (8) */ + 0x81, 0x03, /* INPUT-main (Cnst,Var,Abs) reserved byte */ + + /* 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 ( hidReadBufferSize = 8 - hidKeyBufferSize = 6 + HidKeyBufferSize = 6 hidErrorRollOver = 0x01 // https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf @@ -74,9 +85,7 @@ const ( KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskKana = 1 << 4 - // power on/off LED is 5 - KeyboardLedMaskShift = 1 << 6 - ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift + ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana ) // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK, @@ -89,7 +98,6 @@ type KeyboardState struct { ScrollLock bool `json:"scroll_lock"` Compose bool `json:"compose"` Kana bool `json:"kana"` - Shift bool `json:"shift"` // This is not part of the main USB HID spec raw byte } @@ -106,7 +114,6 @@ func getKeyboardState(b byte) KeyboardState { ScrollLock: b&KeyboardLedMaskScrollLock != 0, Compose: b&KeyboardLedMaskCompose != 0, Kana: b&KeyboardLedMaskKana != 0, - Shift: b&KeyboardLedMaskShift != 0, raw: b, } } @@ -153,6 +160,21 @@ func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) { 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()) { u.onKeepAliveReset = &f } @@ -169,9 +191,9 @@ func (u *UsbGadget) scheduleAutoRelease(key byte) { } // 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. - u.kbdAutoReleaseTimers[key] = time.AfterFunc(100*time.Millisecond, func() { + u.kbdAutoReleaseTimers[key] = time.AfterFunc(DefaultAutoReleaseDuration, func() { u.performAutoRelease(key) }) } @@ -263,12 +285,13 @@ func (u *UsbGadget) listenKeyboardEvents() { time.Sleep(time.Second) continue } - // reset the counter + // reset the suppression counter u.resetLogSuppressionCounter("keyboardHidFileNil") n, err := u.keyboardHidFile.Read(buf) if err != nil { u.logWithSuppression("keyboardHidFileRead", 100, &l, err, "failed to read") + time.Sleep(100 * time.Millisecond) // Small backoff on read errors to avoid tight looping continue } u.resetLogSuppressionCounter("keyboardHidFileRead") @@ -314,11 +337,12 @@ var keyboardWriteHidFileLock sync.Mutex func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { keyboardWriteHidFileLock.Lock() defer keyboardWriteHidFileLock.Unlock() + if err := u.openKeyboardHidFile(); err != nil { 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 { u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.keyboardHidFile.Close() @@ -353,7 +377,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { u.keysDownState = state 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(...) } return state @@ -362,11 +386,11 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error { defer u.resetUserInputTime() - if len(keys) > hidKeyBufferSize { - keys = keys[:hidKeyBufferSize] + if len(keys) > HidKeyBufferSize { + keys = keys[:HidKeyBufferSize] } - if len(keys) < hidKeyBufferSize { - keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) + if len(keys) < HidKeyBufferSize { + keys = append(keys, make([]byte, HidKeyBufferSize-len(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 // from the key buffer since the buffer tracks currently pressed keys 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) // 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) @@ -460,7 +484,7 @@ func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error) // we are releasing the key, remove it from the buffer if keys[i] != 0 { copy(keys[i:], keys[i+1:]) - keys[hidKeyBufferSize-1] = 0 // Clear the last byte + keys[HidKeyBufferSize-1] = 0 // Clear the last byte } } 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) + 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 } diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index f01ae09d..7def29f1 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -135,7 +135,7 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev keyboardStateCtx: keyboardCtx, keyboardStateCancel: keyboardCancel, 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), enabledDevices: *enabledDevices, lastUserInput: time.Now(), diff --git a/jsonrpc.go b/jsonrpc.go index b401ac59..e9584684 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1,7 +1,6 @@ package kvm import ( - "bytes" "context" "encoding/json" "errors" @@ -14,6 +13,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/pion/webrtc/v4" "github.com/rs/zerolog" "go.bug.st/serial" @@ -1019,91 +1019,154 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +type RunningMacro struct { + cancel context.CancelFunc + isPaste bool +} + var ( - keyboardMacroCancel context.CancelFunc - keyboardMacroLock sync.Mutex + keyboardMacroCancelMap map[uuid.UUID]RunningMacro + keyboardMacroLock sync.Mutex + keyboardMacroOnce sync.Once ) -// cancelKeyboardMacro cancels any ongoing keyboard macro execution -func cancelKeyboardMacro() { +func getKeyboardMacroCancelMap() map[uuid.UUID]RunningMacro { + keyboardMacroOnce.Do(func() { + keyboardMacroCancelMap = make(map[uuid.UUID]RunningMacro) + }) + return keyboardMacroCancelMap +} + +func addKeyboardMacro(isPaste bool, cancel context.CancelFunc) uuid.UUID { keyboardMacroLock.Lock() defer keyboardMacroLock.Unlock() + cancelMap := getKeyboardMacroCancelMap() - if keyboardMacroCancel != nil { - keyboardMacroCancel() - logger.Info().Msg("canceled keyboard macro") - keyboardMacroCancel = nil + token := uuid.New() // Generate a unique token + cancelMap[token] = RunningMacro{ + isPaste: isPaste, + cancel: cancel, + } + return token +} + +func removeRunningKeyboardMacro(token uuid.UUID) { + keyboardMacroLock.Lock() + defer keyboardMacroLock.Unlock() + cancelMap := getKeyboardMacroCancelMap() + + delete(cancelMap, token) +} + +func cancelRunningKeyboardMacro(token uuid.UUID) { + 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 setKeyboardMacroCancel(cancel context.CancelFunc) { +func cancelAllRunningKeyboardMacros() { keyboardMacroLock.Lock() defer keyboardMacroLock.Unlock() + cancelMap := getKeyboardMacroCancelMap() - keyboardMacroCancel = cancel + for token, runningMacro := range cancelMap { + runningMacro.cancel() + delete(cancelMap, token) + logger.Info().Interface("token", token).Msg("cancelled keyboard macro") + } } -func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error { - cancelKeyboardMacro() +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()) - setKeyboardMacroCancel(cancel) + token := addKeyboardMacro(isPaste, cancel) + reportRunningMacrosState() - s := hidrpc.KeyboardMacroState{ - State: true, - IsPaste: true, - } + go func() { + defer reportRunningMacrosState() // this executes last, so the map is already updated + defer removeRunningKeyboardMacro(token) // this executes first, to update the map - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) - } + err := executeKeyboardMacro(ctx, isPaste, macro) + if err != nil { + logger.Error().Err(err).Interface("token", token).Bool("isPaste", isPaste).Msg("keyboard macro execution failed") + } + }() - err := rpcDoExecuteKeyboardMacro(ctx, macro) - - setKeyboardMacroCancel(nil) - - s.State = false - if currentSession != nil { - currentSession.reportHidRPCKeyboardMacroState(s) - } - - return err + return token } 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 { - return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys) + if token == uuid.Nil { + cancelAllRunningKeyboardMacros() + } else { + cancelRunningKeyboardMacro(token) + } } -func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error { - logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro") +func executeKeyboardMacro(ctx context.Context, isPaste bool, macro []hidrpc.KeyboardMacroStep) error { + 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 { delay := time.Duration(step.Delay) * time.Millisecond err := rpcKeyboardReport(step.Modifier, step.Keys) 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 } - // 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 select { case <-time.After(delay): // Sleep completed normally case <-ctx.Done(): - // make sure keyboard state is reset - err := rpcKeyboardReport(0, keyboardClearStateKeys) + // make sure keyboard state is reset and the client gets notified + gadget.ResumeSuspendKeyDownMessages() + err := rpcKeyboardReport(0, make([]byte, usbgadget.HidKeyBufferSize)) if err != nil { logger.Warn().Err(err).Msg("failed to reset keyboard state") } diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index dc7d5801..bdee9b64 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Videresendt af Cloudflare", "info_resolution": "Opløsning:", "info_scroll_lock": "Scroll Lock", - "info_shift": "Flytte", "info_usb_state": "USB-tilstand:", "info_video_size": "Videostørrelse:", "input_disabled": "Input deaktiveret", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index ef20ed9a..9e853e74 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Weitergeleitet von Cloudflare", "info_resolution": "Auflösung:", "info_scroll_lock": "Rollen-Taste", - "info_shift": "Schicht", "info_usb_state": "USB-Status:", "info_video_size": "Videogröße:", "input_disabled": "Eingabe deaktiviert", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 3730fe3a..a6987705 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Relayed by Cloudflare", "info_resolution": "Resolution:", "info_scroll_lock": "Scroll Lock", - "info_shift": "Shift", "info_usb_state": "USB State:", "info_video_size": "Video Size:", "input_disabled": "Input disabled", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index 32d54d9c..92b627d6 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Retransmitido por Cloudflare", "info_resolution": "Resolución:", "info_scroll_lock": "Bloq Despl", - "info_shift": "Cambio", "info_usb_state": "Estado USB:", "info_video_size": "Tamaño del vídeo:", "input_disabled": "Entrada deshabilitada", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index e13a44b9..53457c3c 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Relayé par Cloudflare", "info_resolution": "Résolution :", "info_scroll_lock": "Verrouillage du défilement", - "info_shift": "Maj", "info_usb_state": "État USB :", "info_video_size": "Taille de la vidéo :", "input_disabled": "Entrée désactivée", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 928ba99f..6acf632b 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Rilasciato da Cloudflare", "info_resolution": "Risoluzione:", "info_scroll_lock": "Blocco scorrimento", - "info_shift": "Spostare", "info_usb_state": "Stato USB:", "info_video_size": "Dimensioni video:", "input_disabled": "Input disabilitato", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 4c80dc61..9c200688 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Videresendt av Cloudflare", "info_resolution": "Oppløsning:", "info_scroll_lock": "Scroll Lock", - "info_shift": "Skifte", "info_usb_state": "USB-tilstand:", "info_video_size": "Videostørrelse:", "input_disabled": "Inndata deaktivert", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index c51e5770..37056fc9 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "Vidarebefordras av Cloudflare", "info_resolution": "Upplösning:", "info_scroll_lock": "Scroll Lock", - "info_shift": "Flytta", "info_usb_state": "USB-status:", "info_video_size": "Videostorlek:", "input_disabled": "Inmatning inaktiverad", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 7ed0d860..0e2b357f 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -355,7 +355,6 @@ "info_relayed_by_cloudflare": "由 Cloudflare 转发", "info_resolution": "分辨率:", "info_scroll_lock": "滚动锁定", - "info_shift": "Shift", "info_usb_state": "USB 状态:", "info_video_size": "视频大小:", "input_disabled": "输入禁用", diff --git a/ui/package-lock.json b/ui/package-lock.json index bd1d54ef..d32bc00b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -39,6 +39,7 @@ "tailwind-merge": "^3.3.1", "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", + "uuid": "^13.0.0", "validator": "^13.15.20", "zustand": "^4.5.2" }, @@ -76,7 +77,7 @@ "prettier-plugin-tailwindcss": "^0.7.1", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", - "vite": "^7.1.12", + "vite": "^7.2.0", "vite-tsconfig-paths": "^5.1.4" }, "engines": { @@ -127,6 +128,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1150,6 +1152,20 @@ "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": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1219,6 +1235,20 @@ "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": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", @@ -2388,6 +2418,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2397,6 +2428,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2467,6 +2499,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -2773,13 +2806,15 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3115,6 +3150,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3344,7 +3380,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cva": { "version": "1.0.0-beta.4", @@ -3940,6 +3977,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4000,6 +4038,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4073,6 +4112,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5495,6 +5535,7 @@ "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -6217,6 +6258,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6262,6 +6304,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6418,6 +6461,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6440,6 +6484,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6501,6 +6546,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6597,7 +6643,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7247,6 +7294,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7435,6 +7483,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7569,17 +7618,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validator": { @@ -7618,6 +7666,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7729,6 +7778,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7877,6 +7927,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/package.json b/ui/package.json index 70eaa662..075f6637 100644 --- a/ui/package.json +++ b/ui/package.json @@ -58,6 +58,7 @@ "tailwind-merge": "^3.3.1", "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", + "uuid": "^13.0.0", "validator": "^13.15.20", "zustand": "^4.5.2" }, @@ -95,7 +96,7 @@ "prettier-plugin-tailwindcss": "^0.7.1", "tailwindcss": "^4.1.17", "typescript": "^5.9.3", - "vite": "^7.1.12", + "vite": "^7.2.0", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 2c4eb9e0..a40afbbf 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -166,10 +166,6 @@ export default function InfoBar() { {keyboardLedState.kana ? (
{m.info_kana()}
) : null} - - {keyboardLedState.shift ? ( -
{m.info_shift()}
- ) : null} diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 9bc122d5..cf36188e 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -19,6 +19,8 @@ import { TextAreaWithLabel } from "@components/TextArea"; // uint32 max value / 4 const pasteMaxLength = 1073741824; const defaultDelay = 20; +const minimumDelay = 5; +const maximumDelay = 65534; export default function PasteModal() { const TextAreaRef = useRef(null); @@ -31,7 +33,7 @@ export default function PasteModal() { const [invalidChars, setInvalidChars] = useState([]); const [delayValue, setDelayValue] = useState(defaultDelay); const delay = useMemo(() => { - if (delayValue < 0 || delayValue > 65534) { + if (delayValue < minimumDelay || delayValue > maximumDelay) { return defaultDelay; } return delayValue; @@ -52,10 +54,12 @@ export default function PasteModal() { }, [send, setKeyboardLayout]); const onCancelPasteMode = useCallback(() => { - cancelExecuteMacro(); + if (isPasteInProgress) { + cancelExecuteMacro(); + } setDisableVideoFocusTrap(false); setInvalidChars([]); - }, [setDisableVideoFocusTrap, cancelExecuteMacro]); + }, [isPasteInProgress, setDisableVideoFocusTrap, cancelExecuteMacro]); const onConfirmPaste = useCallback(async () => { if (!TextAreaRef.current || !selectedKeyboard) return; @@ -190,18 +194,18 @@ export default function PasteModal() { type="number" label={m.paste_modal_delay_between_keys()} placeholder={m.paste_modal_delay_between_keys()} - min={50} - max={65534} + min={minimumDelay} + max={maximumDelay} value={delayValue} onChange={e => { setDelayValue(parseInt(e.target.value, 10)); }} /> - {delayValue < 50 || delayValue > 65534 && ( + {(delayValue < minimumDelay || delayValue > maximumDelay) && (
- {m.paste_modal_delay_out_of_range({ min: 50, max: 65534 })} + {m.paste_modal_delay_out_of_range({ min: minimumDelay, max: maximumDelay })}
)} @@ -225,7 +229,7 @@ export default function PasteModal() {