Skip to content

Add a WebUSB backend for devices WebHID will not expose - #9

Closed
karstx wants to merge 4 commits into
feat/runtime-backend-selectionfrom
feat/webusb-backend
Closed

karstx wants to merge 4 commits into
feat/runtime-backend-selectionfrom
feat/webusb-backend

Conversation

@karstx

@karstx karstx commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Adds hidra::webusb, a wasm backend built on nusb's WebUSB platform, for devices the browser will not expose over WebHID.

Stacked on #8.

Why

WebHID only surfaces interfaces the host recognises as HID. A device whose interface declares a vendor-specific class never appears there, even though it typically still speaks the HID protocol on the wire.

The split is forced by the platform: Blink refuses claimInterface on the protected classes — HID (0x03), Audio, Video, Mass Storage, Smart Card, Wireless Controller. So the two backends are complementary rather than interchangeable:

interface class WebHID WebUSB
HID (0x03) yes no — SecurityError
vendor-specific (0xFF) no — invisible yes

This is why the backend deliberately does not filter for HID-class interfaces the way the native nusb backend does: those are precisely the ones it cannot have, and WebHID already covers them.

What

  • src/backend/webusb.rs — the HID-over-USB mapping, mirroring the native nusb backend: SET_REPORT/GET_REPORT class requests for feature and input reports, interrupt OUT for writes (falling back to SET_REPORT(Output)), interrupt IN for reads, GET_DESCRIPTOR(Report) where the interface has one.
  • hidra::webusb::{Hidra, Device, HidDevice, DeviceSelector} — async throughout, since the browser event loop cannot block. request_device drives the chooser; open claims an interface (None takes the first).
  • Endpoints sit behind a take/put RefCell: wasm is single-threaded but futures still interleave, so a second in-flight transfer is refused rather than aliasing the endpoint.
  • Gated on feature = "nusb" + wasm32, so the WebHID-only build is unchanged.

Incidental

The crate-level docs linked native-only items (Backend, Hidra::builder) unconditionally, so every wasm doc build failed. Pre-existing, and invisible because the docs job only documents the native target. The platform paragraphs are now cfg_attr-gated, and the wasm CI job additionally checks the --no-default-features configuration.

Testing

fmt, clippy (native, wasm default, wasm no-default), test, MSRV 1.85 and both doc targets pass locally.

No e2e leg yet: exercising WebUSB needs either a real vendor-class device or browser-side emulation, so it is not covered by the virtual-device suites. Worth adding once there is a device to point it at.

WebHID only surfaces interfaces the host recognises as HID, so a device
declaring a vendor-specific class is invisible there even though it still
speaks the HID protocol on the wire. This reaches those through nusb's
WebUSB platform, reusing the native nusb backend's mapping of reports
onto control and interrupt transfers.

It deliberately does not filter for HID-class interfaces the way the
native backend does: Blink refuses claimInterface on the protected
classes, HID among them, so those are exactly the ones this cannot have
and WebHID already covers.

The crate-level docs linked native-only items unconditionally, which
broke every wasm doc build; the platform paragraphs are now gated, and
the wasm CI job also checks the WebHID-only configuration.
The README, the nusb feature comment and the backend module docs all
still described wasm as WebHID-only. Records why the two browser backends
are complements rather than alternatives, and notes in the e2e README
that WebUSB has no target there and why.
Tests the WebUSB backend the same way linux-nusb tests the native one:
dummy_hcd plus a configfs gadget, driven through Chromium/Playwright.

The gadget has to be FunctionFS rather than usb_f_hid. Blink refuses
claimInterface on the protected classes, so a HID-class interface cannot
be claimed at all, and the device this backend exists for is one that
declares class 0xFF. FunctionFS is what lets userspace say so, at the
price of servicing the interface's control requests by hand.

requestDevice() always opens a picker, so the harness reads device_list()
instead, which WebUsbAllowDevicesForUrls pre-populates. That needed
device_list to hand back openable devices rather than bare DeviceInfo.
The degenerate shapes are the ones with their own code paths, and both
were going untested.

control-only declares bNumEndpoints 0, which is what the Sinowealth ISP
bootloaders look like: write has to fall back to SET_REPORT(Output),
read has to refuse rather than hang, and get_input_report stays the way
to poll. no-report-descriptor stalls GET_DESCRIPTOR(Report), the usual
shape for a vendor-class interface, and report_descriptor has to report
Unsupported rather than hand back an empty buffer.

run.sh rebuilds the gadget between the three.
@karstx

karstx commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Closing: the devices this was built for cannot be reached over WebUSB.

Tested against a real plugged-in Sinowealth mouse (258a:0036) in Chrome with WebUsbAllowDevicesForUrls granting it. The backend itself behaves correctly — it enumerated the device by name through device_list and reported a clear error — but the claim is refused by the browser:

An attempt to claim a USB device interface has been blocked
because it implements a protected interface class.

Every archived Sinowealth ISP bootloader declares bInterfaceClass 0x03 (0904 0000 0003 0000 00, bNumEndpoints 0), which is precisely the class Blink blocks. WebUSB is only a way in for devices declaring a non-HID interface class; it cannot substitute for WebHID when a HID-class device is unreachable, which was the premise here.

The branch stays if a genuinely vendor-class device ever turns up. What it contained:

  • hidra::webusb, mapping reports onto control and interrupt transfers via nusb's WebUSB platform
  • a webusb e2e target: a dummy_hcd + configfs FunctionFS gadget declaring class 0xFF, driven through Chromium/Playwright, covering three device shapes (full, control-only, no-report-descriptor) — all passing in the Linux test VM

One fix here is worth salvaging and is unrelated to WebUSB: the crate-level docs link native-only items (Backend, Hidra::builder) unconditionally, so every cargo doc --target wasm32-unknown-unknown build fails. That is pre-existing on master and invisible in CI, because the docs job only documents the native target. Happy to land the cfg_attr gating as its own PR.

@karstx karstx closed this Sep 3, 2026
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