Skip to content

feat(hid): serve native HID to the browser over the loopback socket - #3

Open
ydw1904 wants to merge 1 commit into
OpenMouse-Project:mainfrom
ydw1904:feat/hid-socket
Open

feat(hid): serve native HID to the browser over the loopback socket#3
ydw1904 wants to merge 1 commit into
OpenMouse-Project:mainfrom
ydw1904:feat/hid-socket

Conversation

@ydw1904

@ydw1904 ydw1904 commented Aug 25, 2026

Copy link
Copy Markdown

Why

Firefox and Safari have no WebHID, and Mozilla's standards position on it is negative. In those browsers the OpenMouse control panel cannot reach a mouse at all — the app can only tell the user to switch browsers.

Bridge is already installed on the user's machine, already speaks to the hardware, and already has a vetted origin allowlist. Giving it a HID endpoint turns "unsupported browser" into "install Bridge", and the web app keeps running its existing driver classes unchanged.

What this adds

GET /v1/hid upgrades to a WebSocket carrying the same primitives WebHID exposes: enumerate, open, close, send output/feature reports, receive feature reports, and a stream of input reports. Frames are JSON with a client-chosen id echoed in each reply; byte payloads are number arrays, matching what native-hid/ already exchanges with these drivers.

No vendor protocol is implemented here. @openmouse/protocol stays the single source of truth; this is transport.

The part worth reviewing: report descriptors

src/hid/descriptor.rs parses each device's HID report descriptor into real collections.

This is not decoration. Every driver's isSupported() reads device.collections, and not just the top-level usage — Pulsar matches "one input and one output report, both id 0x08", WLMouse recurses into children looking for a feature report id. An adapter that reports no collections fails the entire registry, which is exactly why native-hid/src/hid-device-adapter.mjs and Desktop's TauriHidDevice both had to bypass auto-detection and hand-maintain a brand table. Parsing here means the web app's own registry auto-detects over this socket exactly as it does over WebHID, with no second device list drifting out of sync.

Only the items that shape collections are interpreted; logical ranges, units, and string indices are skipped. Malformed input truncates rather than failing, because a device with a slightly wrong descriptor is common in this hardware class and hiding it would be worse than describing the part that parsed.

Security

Three rules, all deliberate:

  • Origin allowlist, checked by hand. A WebSocket handshake is not covered by CORS, so the layer protecting the rest of the API does not apply. origin_allowed() rejects any handshake whose Origin is not in allowedOrigins, and one with no Origin at all. Without this, any page the user visits could enumerate and write to their mouse.
  • Protected collections are never listed or opened. Generic Desktop pointer/mouse/keyboard/keypad, the Keyboard and FIDO pages, and Consumer Control — the same set Chrome withholds from WebHID. Opening one natively also freezes the device's own input on macOS.
  • Enumeration is scoped to the vendor ids the client asks for, which the web app takes from its own supported-device filters. A page never learns about HID devices OpenMouse has no driver for.

Every device a socket opened is closed when that socket disconnects.

Carried over rather than rediscovered

Two findings from this project's other native adapters, both load-bearing:

  • Writes try every enumerated split of an interface and remember which one answered a given report id (try_each). macOS enumerates one entry per top-level collection, and a report id may only be answerable on one of them.
  • The reader thread uses try_lock plus an unconditional sleep outside the lock. Desktop's src-tauri/src/hid.rs documents why try_lock alone is not enough: read_timeout holds the guard for its full duration and the loop re-acquires immediately, starving a writer parked on lock() indefinitely.

hidapi gains the macos-shared-device feature. Darwin opens exclusively by default, which freezes the mouse for as long as a handle is held — this also affects the existing Pulsar driver.

Verification

cargo test, cargo fmt --check, and cargo clippy --all-targets -- -D warnings are clean. Seven new unit tests cover the descriptor parser (vendor control collection, nested children, push/pop, truncated input, the protected-usage set) and the socket (frame parsing, error replies, the origin gate).

Validated end to end against a Keychron M6 on macOS:

descriptor   ffc1:1  in [0xb4, 0xb6]  out [0xb3, 0xb5]
native       sent 0xb3 cmd 6 -> 0xb4 reply, DPI stages + battery byte decoded
browser      list over the socket from a real page returned all three interfaces
origin gate  https://evil.test -> 403,  http://localhost:5173 -> 101
driver       KeychronM6HidClient auto-detected from collections alone;
             readStatus() 800 DPI / 500 Hz / 100% wired; DPI write 800 -> 1600 -> 800

Out of scope

  • Safari. It does not treat loopback as a potentially trustworthy origin and blocks the socket as mixed content. Reaching Safari means Bridge serving the app itself over loopback — a separate change that would also cover offline use.
  • Hot-plug push. The client re-enumerates and diffs instead, which keeps connect/disconnect handling in one place next to the code that turns them into WebHID events.
  • Battery alerts with no browser open. Still needs Bridge polling on its own schedule; this socket only moves reports while a tab is driving it.

Companion change

The web-side navigator.hid shim that consumes this: OpenMouse-Project/openmouse#127

🤖 Generated with Claude Code

Firefox and Safari have no WebHID, so the OpenMouse control panel cannot
reach a mouse there at all. This adds `GET /v1/hid`, a WebSocket carrying
the same primitives WebHID exposes — enumerate, open, send/receive reports,
stream input reports — so the web app can implement `navigator.hid` on top
of Bridge and run its existing driver classes unchanged.

No vendor protocol is reimplemented here. `@openmouse/protocol`'s drivers
stay the single source of truth; this is transport only.

The report descriptor is parsed (src/hid/descriptor.rs) rather than left
empty. Every driver's `isSupported()` reads `device.collections`, including
the report ids inside them, so an adapter that reports none fails the whole
registry — which is why `native-hid/` and Desktop both had to bypass
auto-detection and hand-maintain a brand table. Parsing here means the web
app's own registry works over this socket exactly as it does over WebHID,
with no second device list to keep in sync.

Security, both deliberate and load-bearing:

- The handshake's Origin is checked against `allowedOrigins` by hand. CORS
  does not apply to a WebSocket handshake, so without this any page the
  user visits could enumerate and write to their mouse.
- Generic Desktop mouse and keyboard collections are never listed or
  opened, matching what Chrome withholds from WebHID. Opening one natively
  freezes the device's own input on macOS.
- Enumeration is scoped to the vendor ids the client asks for, so a page
  never learns about HID devices OpenMouse has no driver for.

Two hardware findings from this project's other native adapters are carried
over rather than rediscovered: writes try every enumerated split of an
interface and remember which one answered a given report id, and the reader
thread uses try_lock plus an unconditional sleep outside the lock, without
which a writer can be starved indefinitely.

hidapi gains the `macos-shared-device` feature: Darwin opens exclusively by
default, which freezes the mouse for as long as a handle is held.

Not yet validated against real hardware — the descriptor parser and the
socket protocol are unit tested, but no supported mouse was reachable from
the machine this was written on.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant