diff --git a/hidrpc.go b/hidrpc.go index 8c107626a..e4d61ab9e 100644 --- a/hidrpc.go +++ b/hidrpc.go @@ -54,6 +54,13 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) { return } rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button) + case hidrpc.TypeWheelReport: + wheelReport, err := message.WheelReport() + if err != nil { + logger.Warn().Err(err).Msg("failed to get wheel report") + return + } + rpcErr = rpcWheelReport(wheelReport.DeltaY, wheelReport.DeltaX) default: logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type") } diff --git a/internal/hidrpc/message.go b/internal/hidrpc/message.go index 3f3506f7f..9badab329 100644 --- a/internal/hidrpc/message.go +++ b/internal/hidrpc/message.go @@ -44,6 +44,11 @@ func (m *Message) String() string { return fmt.Sprintf("MouseReport{Malformed: %v}", m.d) } return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2]) + case TypeWheelReport: + if len(m.d) < 2 { + return fmt.Sprintf("WheelReport{Malformed: %v}", m.d) + } + return fmt.Sprintf("WheelReport{DeltaY: %d, DeltaX: %d}", int8(m.d[0]), int8(m.d[1])) case TypeKeypressKeepAliveReport: return "KeypressKeepAliveReport" case TypeKeyboardMacroReport: @@ -189,6 +194,28 @@ func (m *Message) MouseReport() (MouseReport, error) { }, nil } +// WheelReport .. +type WheelReport struct { + DeltaY int8 + DeltaX int8 +} + +// WheelReport returns the wheel report from the message. +func (m *Message) WheelReport() (WheelReport, error) { + if m.t != TypeWheelReport { + return WheelReport{}, fmt.Errorf("invalid message type: %d", m.t) + } + + if len(m.d) != 2 { + return WheelReport{}, fmt.Errorf("invalid message length: %d", len(m.d)) + } + + return WheelReport{ + DeltaY: int8(m.d[0]), + DeltaX: int8(m.d[1]), + }, nil +} + type KeyboardMacroState struct { State bool IsPaste bool diff --git a/internal/hidrpc/message_test.go b/internal/hidrpc/message_test.go new file mode 100644 index 000000000..51d7909f0 --- /dev/null +++ b/internal/hidrpc/message_test.go @@ -0,0 +1,91 @@ +package hidrpc + +import "testing" + +func TestWheelReport(t *testing.T) { + tests := []struct { + name string + message Message + wantDeltaY int8 + wantDeltaX int8 + wantErr bool + }{ + { + name: "decode positive Y, zero X", + message: Message{t: TypeWheelReport, d: []byte{0x01, 0x00}}, + wantDeltaY: 1, + wantDeltaX: 0, + }, + { + name: "decode negative Y as two's complement", + message: Message{t: TypeWheelReport, d: []byte{0xFF, 0x00}}, + wantDeltaY: -1, + wantDeltaX: 0, + }, + { + name: "decode zero", + message: Message{t: TypeWheelReport, d: []byte{0x00, 0x00}}, + wantDeltaY: 0, + wantDeltaX: 0, + }, + { + name: "decode positive X, zero Y", + message: Message{t: TypeWheelReport, d: []byte{0x00, 0x01}}, + wantDeltaY: 0, + wantDeltaX: 1, + }, + { + name: "decode negative X as two's complement", + message: Message{t: TypeWheelReport, d: []byte{0x00, 0xFF}}, + wantDeltaY: 0, + wantDeltaX: -1, + }, + { + name: "decode both axes", + message: Message{t: TypeWheelReport, d: []byte{0x02, 0xFE}}, + wantDeltaY: 2, + wantDeltaX: -2, + }, + { + name: "wrong message type", + message: Message{t: TypeMouseReport, d: []byte{0x01, 0x02}}, + wantErr: true, + }, + { + name: "empty payload", + message: Message{t: TypeWheelReport, d: []byte{}}, + wantErr: true, + }, + { + name: "payload too short", + message: Message{t: TypeWheelReport, d: []byte{0x01}}, + wantErr: true, + }, + { + name: "payload too long", + message: Message{t: TypeWheelReport, d: []byte{0x01, 0x02, 0x03}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.message.WheelReport() + if tt.wantErr { + if err == nil { + t.Fatalf("WheelReport() expected error, got nil (deltaY=%d, deltaX=%d)", got.DeltaY, got.DeltaX) + } + return + } + if err != nil { + t.Fatalf("WheelReport() unexpected error: %v", err) + } + if got.DeltaY != tt.wantDeltaY { + t.Fatalf("WheelReport() DeltaY = %d, want %d", got.DeltaY, tt.wantDeltaY) + } + if got.DeltaX != tt.wantDeltaX { + t.Fatalf("WheelReport() DeltaX = %d, want %d", got.DeltaX, tt.wantDeltaX) + } + }) + } +} diff --git a/jsonrpc.go b/jsonrpc.go index 7a60656e1..90e06d723 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1344,7 +1344,6 @@ var rpcHandlers = map[string]RPCHandler{ "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, - "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY", "wheelX"}}, "wakeHost": {Func: rpcWakeHost}, "getVideoState": {Func: rpcGetVideoState}, "getUSBState": {Func: rpcGetUSBState}, diff --git a/ui/src/hooks/hidRpc.ts b/ui/src/hooks/hidRpc.ts index a6b08b90f..c335caac9 100644 --- a/ui/src/hooks/hidRpc.ts +++ b/ui/src/hooks/hidRpc.ts @@ -240,7 +240,7 @@ export class KeyboardMacroReportMessage extends RpcMessage { // Ensure the keys are within the KEYS_LENGTH range const keys = step.keys; if (keys.length > this.KEYS_LENGTH) { - throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`); + throw new Error(`Keys ${keys.join(",")} is not within the hidKeyBufferSize range`); } else if (keys.length < this.KEYS_LENGTH) { keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0)); } @@ -400,6 +400,25 @@ export class KeypressKeepAliveMessage extends RpcMessage { } } +export class WheelReportMessage extends RpcMessage { + deltaY: number; + deltaX: number; + + constructor(deltaY: number, deltaX: number) { + super(HID_RPC_MESSAGE_TYPES.WheelReport); + this.deltaY = deltaY; + this.deltaX = deltaX; + } + + marshal(): Uint8Array { + return new Uint8Array([ + this.messageType, + fromInt8ToUint8(this.deltaY), + fromInt8ToUint8(this.deltaX), + ]); + } +} + export const messageRegistry = { [HID_RPC_MESSAGE_TYPES.Handshake]: HandshakeMessage, [HID_RPC_MESSAGE_TYPES.KeysDownState]: KeysDownStateMessage, diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts index ae3055c5e..2b9d6f36f 100644 --- a/ui/src/hooks/useHidRpc.ts +++ b/ui/src/hooks/useHidRpc.ts @@ -15,6 +15,7 @@ import { MouseReportMessage, PointerReportMessage, RpcMessage, + WheelReportMessage, unmarshalHidRpcMessage, } from "./hidRpc"; @@ -273,6 +274,16 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { [sendMessage], ); + const reportWheelEvent = useCallback( + (deltaY: number, deltaX: number) => { + // Wheel events are motion-like — a single dropped detent self-corrects + // via the next event, so we ride the unreliable-ordered channel for + // lower latency, matching how mouse motion is sent. + sendMessage(new WheelReportMessage(deltaY, deltaX), { useUnreliableChannel: true }); + }, + [sendMessage], + ); + const reportKeyboardMacroEvent = useCallback( (steps: KeyboardMacroStep[]) => { sendMessage(new KeyboardMacroReportMessage(false, steps.length, steps)); @@ -314,7 +325,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { }; const errorHandler = (e: Event) => { - console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e}`); + console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e.type}`); }; rpcHidChannel.addEventListener("message", messageHandler); @@ -331,6 +342,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { reportKeypressEvent, reportAbsMouseEvent, reportRelMouseEvent, + reportWheelEvent, reportKeyboardMacroEvent, cancelOngoingKeyboardMacro, reportKeypressKeepAlive, diff --git a/ui/src/hooks/useMouse.ts b/ui/src/hooks/useMouse.ts index e69d2a3d8..f176c6dd3 100644 --- a/ui/src/hooks/useMouse.ts +++ b/ui/src/hooks/useMouse.ts @@ -25,7 +25,7 @@ export default function useMouse() { // RPC hooks const { send } = useJsonRpc(); - const { reportAbsMouseEvent, reportRelMouseEvent, rpcHidReady } = useHidRpc(); + const { reportAbsMouseEvent, reportRelMouseEvent, reportWheelEvent, rpcHidReady } = useHidRpc(); // Mouse-related const sendRelMouseMovement = useCallback( @@ -142,7 +142,7 @@ export default function useMouse() { if (wheelY === 0 && wheelX === 0) return; - send("wheelReport", { wheelY, wheelX }); + reportWheelEvent(wheelY, wheelX); // Apply blocking delay based of throttling settings if (scrollThrottling && !blockWheelEvent) { @@ -150,7 +150,7 @@ export default function useMouse() { setTimeout(() => setBlockWheelEvent(false), scrollThrottling); } }, - [send, blockWheelEvent, scrollThrottling, invertScroll], + [reportWheelEvent, blockWheelEvent, scrollThrottling, invertScroll], ); const resetMousePosition = useCallback(() => {