From 59ff82a507f289c786dc394b9c91bc9b059521a7 Mon Sep 17 00:00:00 2001 From: Maurus Cuelenaere Date: Thu, 14 May 2026 12:28:31 +0200 Subject: [PATCH 1/3] feat(hidrpc): dispatch binary TypeWheelReport (0x04) frames The wheel opcode and its queue routing were already in place, but handleHidRPCMessage had no case for it, so binary wheel frames hit the default warn-log arm and were silently dropped. Wheel input had to take the JSON-RPC slow path, paying a per-event encode/parse on the ARM and foregoing the unreliable-ordered transport mouse motion already uses. Add a length-strict WheelReport decoder (Y-only, 1-byte payload) and route 0x04 through the same rpcWheelReport backend the JSON-RPC handler uses. The JSON-RPC path is unchanged so old clients keep working. Co-Authored-By: Claude Opus 4.7 --- hidrpc.go | 7 +++ internal/hidrpc/message.go | 27 ++++++++++ internal/hidrpc/message_test.go | 91 +++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 internal/hidrpc/message_test.go 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) + } + }) + } +} From 06b5eabb5a077cc4e24c6f3b54ffe7e7167e23bd Mon Sep 17 00:00:00 2001 From: Maurus Cuelenaere Date: Thu, 14 May 2026 13:21:38 +0200 Subject: [PATCH 2/3] feat(ui): send mouse wheel via binary HID-RPC when available Mirror the existing rel/abs mouse pattern: when the HID-RPC binary channel is ready, send a WheelReportMessage (0x04) over the unreliable-ordered channel instead of the wheelReport JSON-RPC call. Falls back to JSON-RPC during the WebRTC/handshake window or against older firmware that does not dispatch the binary opcode. Also fix two pre-existing oxlint warnings in the same files (restrict-template-expressions) forced by lint-staged. Co-Authored-By: Claude Opus 4.7 --- ui/src/hooks/hidRpc.ts | 21 ++++++++++++++++++++- ui/src/hooks/useHidRpc.ts | 14 +++++++++++++- ui/src/hooks/useMouse.ts | 11 ++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) 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..f3d45352c 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,12 @@ export default function useMouse() { if (wheelY === 0 && wheelX === 0) return; - send("wheelReport", { wheelY, wheelX }); + if (rpcHidReady) { + reportWheelEvent(wheelY, wheelX); + } else { + // kept for backward compatibility + send("wheelReport", { wheelY, wheelX }); + } // Apply blocking delay based of throttling settings if (scrollThrottling && !blockWheelEvent) { @@ -150,7 +155,7 @@ export default function useMouse() { setTimeout(() => setBlockWheelEvent(false), scrollThrottling); } }, - [send, blockWheelEvent, scrollThrottling, invertScroll], + [send, reportWheelEvent, rpcHidReady, blockWheelEvent, scrollThrottling, invertScroll], ); const resetMousePosition = useCallback(() => { From a395b6656fa3c4e57946c95b51112c6ec08708ca Mon Sep 17 00:00:00 2001 From: Maurus Cuelenaere Date: Mon, 18 May 2026 10:39:28 +0200 Subject: [PATCH 3/3] refactor: retire wheelReport JSON-RPC fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review (#1462): the Cloud SPA is versioned and LAN access always serves a matching Go API and JS SPA, so the backwards-compat fallback is unnecessary. Drop the `rpcHidReady ? binary : send(...)` branch from getMouseWheelHandler — always use the binary path — and remove the `wheelReport` entry from the JSON-RPC handler map. The binary 0x04 dispatch and rpcWheelReport itself stay; the function is now reached only via handleHidRPCMessage. Co-Authored-By: Claude Opus 4.7 --- jsonrpc.go | 1 - ui/src/hooks/useMouse.ts | 9 ++------- 2 files changed, 2 insertions(+), 8 deletions(-) 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/useMouse.ts b/ui/src/hooks/useMouse.ts index f3d45352c..f176c6dd3 100644 --- a/ui/src/hooks/useMouse.ts +++ b/ui/src/hooks/useMouse.ts @@ -142,12 +142,7 @@ export default function useMouse() { if (wheelY === 0 && wheelX === 0) return; - if (rpcHidReady) { - reportWheelEvent(wheelY, wheelX); - } else { - // kept for backward compatibility - send("wheelReport", { wheelY, wheelX }); - } + reportWheelEvent(wheelY, wheelX); // Apply blocking delay based of throttling settings if (scrollThrottling && !blockWheelEvent) { @@ -155,7 +150,7 @@ export default function useMouse() { setTimeout(() => setBlockWheelEvent(false), scrollThrottling); } }, - [send, reportWheelEvent, rpcHidReady, blockWheelEvent, scrollThrottling, invertScroll], + [reportWheelEvent, blockWheelEvent, scrollThrottling, invertScroll], ); const resetMousePosition = useCallback(() => {