Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
sudo HIDRA_NUSB_REQUIRED=1 "$bin" --nocapture --test-threads=1

wasm:
name: wasm32 / WebHID
name: wasm32 / WebHID + WebUSB
runs-on: ubuntu-latest
# Repeated from .cargo/config.toml because setting RUSTFLAGS here would otherwise override it.
env:
Expand All @@ -89,6 +89,11 @@ jobs:
run: cargo check --target wasm32-unknown-unknown
- name: Clippy
run: cargo clippy --target wasm32-unknown-unknown -- -D warnings
# WebHID alone, without the WebUSB backend the `nusb` feature adds.
- name: Check (no default features)
run: cargo check --no-default-features --target wasm32-unknown-unknown
- name: Clippy (no default features)
run: cargo clippy --no-default-features --target wasm32-unknown-unknown -- -D warnings

msrv:
name: msrv (${{ matrix.os }})
Expand Down
9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ doc_markdown = "warn"
[features]
default = ["nusb"]
# Additive: compiles the nusb USB-transport backend in *beside* the per-OS one,
# for `Backend::Nusb` to select at run time. Enabling it anywhere in a
# dependency graph cannot change which backend another crate ends up using.
# for `Backend::Nusb` to select at run time, and on wasm32 adds the WebUSB
# backend beside WebHID. Enabling it anywhere in a dependency graph cannot
# change which backend another crate ends up using.
nusb = ["dep:nusb"]

[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dependencies]
Expand Down Expand Up @@ -87,3 +88,7 @@ features = [
"HidReportItem",
"HidUnitSystem",
]

[target.'cfg(target_arch = "wasm32")'.dependencies.nusb]
version = "0.2.7"
optional = true
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
[![docs.rs](https://docs.rs/hidra/badge.svg)](https://docs.rs/hidra)

A pure-Rust HID library with a unified async API (with blocking `.wait()` on
native targets, like nusb), a WebHID backend for WebAssembly, and standalone
HID report-descriptor primitives.
native targets, like nusb), WebHID and WebUSB backends for WebAssembly, and
standalone HID report-descriptor primitives.

No C library is linked. One `Hidra` / `HidDevice` regardless of backend:

Expand All @@ -16,9 +16,11 @@ No C library is linked. One `Hidra` / `HidDevice` regardless of backend:
| Windows | `hid.dll` + SetupAPI (via `windows-sys` declarations) | |
| macOS | IOHIDManager (direct framework FFI) | |
| Browsers | [WebHID](https://wicg.github.io/webhid/) via `web-sys` | same `Hidra`/`HidDevice`, await-only; no `Backend` |
| Browsers | [WebUSB](https://wicg.github.io/webusb/) via nusb | `hidra::webusb`, for devices WebHID will not expose |

`Backend::Nusb` adds raw USB transfers via [nusb](https://docs.rs/nusb) on
Linux, macOS and Windows — see [Backends](#backends).
Linux, macOS and Windows; on `wasm32` the same feature adds the WebUSB backend
— see [Backends](#backends).

## Quick start

Expand Down Expand Up @@ -82,6 +84,39 @@ The `nusb` feature is additive: it compiles the second backend in beside the
first rather than replacing it, so enabling it anywhere in a dependency graph
cannot change which backend another crate ends up using.

### WebUSB

On `wasm32` the `nusb` feature adds [`hidra::webusb`], a second browser backend.
It is not an alternative to WebHID but a complement: WebHID only surfaces
interfaces the host recognises as HID, while Blink refuses `claimInterface` on
the [protected classes](https://groups.google.com/a/chromium.org/g/blink-dev/c/LZXocaeCwDw),
HID among them. The two reach disjoint sets of devices:

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

So a device that declares a vendor-specific class, yet still speaks the HID
protocol on the wire, is reachable only through `hidra::webusb`. It has its own
`Hidra` / `HidDevice` (browser APIs are permission-gated and chooser-driven, so
there is no enumerate or open-by-vid-pid), and unlike the native `nusb` backend
it does not filter for HID-class interfaces.

```rust,ignore
use hidra::webusb::{DeviceSelector, Hidra};

let api = Hidra::new()?;
let device = api
.request_device(&[DeviceSelector::all().with_vid_pid(0x0603, 0x1020)])
.await?
.ok_or("no device selected")?;
let handle = device.open(None).await?; // None: first interface
handle.send_feature_report(&report).await?;
```

[`hidra::webusb`]: https://docs.rs/hidra/latest/wasm32-unknown-unknown/hidra/webusb/index.html

## Async and blocking

Following nusb's design, every `Hidra` / `HidDevice` method returns an
Expand Down
2 changes: 2 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ e2e/
| `macos` | macOS | `IOHIDUserDevice` | root + SIP/AMFI off + signed entitlement — see [`platform/macos`](platform/macos/README.md) |
| `windows` | Windows | WinUHid driver | test-signed driver — see [`platform/windows`](platform/windows/README.md) |
| `webhid` | Linux + Chromium | `uhid` via Playwright | root; own sub-tree (wasm harness + native fixture) — see [`targets/webhid`](targets/webhid/README.md) |
| `webusb` | Linux + Chromium | vendor-class FunctionFS gadget via Playwright | root; the interface must not be HID-class, so `usb_f_hid` will not do — see [`targets/webusb`](targets/webusb/README.md) |
| `cynthion` | all | real USB via Cynthion + Facedancer | emulated *real* hardware, not virtual, on both backends — see [`targets/cynthion`](targets/cynthion/README.md) |

Which hidra backend a suite drives is [`Caps::backend`](shared/conformance/src/lib.rs),
Expand All @@ -45,6 +46,7 @@ sudo -E cargo test -p linux-nusb # Linux, needs dummy_hcd
cargo test -p macos # macOS: sign + run as root (platform/macos)
cargo test -p windows # Windows: needs the WinUHid driver (platform/windows)
cd targets/webhid && ./run.sh # WebHID via Playwright/Xvfb/Chromium
cd targets/webusb && ./run.sh # WebUSB via Playwright/Xvfb/Chromium
./targets/cynthion/run.sh # real USB via Cynthion (targets/cynthion)
```

Expand Down
6 changes: 4 additions & 2 deletions e2e/platform/linux/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Linux testing VM

Covers all three Linux paths against software virtual devices: **hidraw** (kernel
Covers all four Linux paths against software virtual devices: **hidraw** (kernel
`uhid`), **nusb** (`dummy_hcd` + configfs `g_hid` gadget), and **WebHID** (wasm32,
a `uhid` device read through headless Chromium).
a `uhid` device read through headless Chromium), plus **WebUSB** (wasm32, a
vendor-class FunctionFS gadget claimed through Chromium).

Codified in [`test-vm.nix`](test-vm.nix): `nix run .#test-vm` (interactive, log in
as root, `run-hidra-tests`) or headless CI (auto-run + poweroff, results on the
Expand All @@ -17,6 +18,7 @@ kernels often omit the USB-gadget ones (`dummy_hcd` is the usual missing one):
```
uhid # hidraw path: /dev/uhid
dummy_hcd libcomposite usb_f_hid configfs # nusb path: virtual USB gadget
usb_f_fs # WebUSB path: vendor-class FunctionFS gadget
```

## Privileges
Expand Down
7 changes: 7 additions & 0 deletions e2e/platform/linux/test-vm.nix
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ let
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 ${pkgs.nodejs_22}/bin/npm install
./run.sh # builds the fixture crate itself

echo "==================== WebUSB (Chromium + Playwright) ===================="
cd /root/hidra/e2e/targets/webusb
wasm-pack build --target web --dev
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 ${pkgs.nodejs_22}/bin/npm install
./run.sh # builds the fixture crate itself

echo "==================== ALL HIDRA LINUX TESTS PASSED ===================="
'';
};
Expand All @@ -81,6 +87,7 @@ in
"dummy_hcd"
"libcomposite"
"usb_f_hid"
"usb_f_fs"
"configfs"
];

Expand Down
6 changes: 6 additions & 0 deletions e2e/targets/webusb/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# hidra's webhid backend needs this unstable web-sys cfg.
# The wasm32 target is deliberately NOT pinned here: wasm-pack sets it, and pinning
# would cascade into the sibling native fixture/ crate (cargo config applies to
# subdirectories) and force it to build for wasm too.
[build]
rustflags = ["--cfg=web_sys_unstable_apis"]
5 changes: 5 additions & 0 deletions e2e/targets/webusb/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/pkg
/node_modules
/target
Cargo.lock
package-lock.json
20 changes: 20 additions & 0 deletions e2e/targets/webusb/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# wasm harness driving hidra's WebUSB backend, exercised by the Playwright test here.
[package]
name = "hidra-webusb-test"
version = "0.0.0"
edition = "2021"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
hidra = { path = "../../..", features = ["nusb"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }

# Own workspace so the native fixture/ crate is not folded into this wasm one.
[workspace]
exclude = ["fixture"]
70 changes: 70 additions & 0 deletions e2e/targets/webusb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# WebUSB backend conformance test

End-to-end test of hidra's **WebUSB** backend, running in **Chromium on Linux**
driven by **Playwright** — the WebUSB counterpart to [`../webhid`](../webhid).

The device under test has to declare a **vendor-specific interface class**:
Blink refuses `claimInterface` on the protected classes, HID among them, so a
`usb_f_hid` gadget like the one `linux-nusb` uses would be rejected outright.
That rules out the stock HID function and calls for FunctionFS, where userspace
supplies the descriptors verbatim.

Two crates, as separate cargo workspaces so the wasm build's `.cargo/config`
does not cascade into the native fixture:

1. **`fixture/`** — a native crate that stands up a `dummy_hcd` + configfs
**FunctionFS** gadget with the shared conformance identity (`1209:000c`),
one vendor-class (0xFF) interface and two interrupt endpoints. It services
the interface's control requests by hand (`FUNCTIONFS_ALL_CTRL_RECIP`):
`GET_DESCRIPTOR(Report)`, `GET_REPORT`, `SET_REPORT`, stalling the rest. It
streams a known input report on EP1 IN and logs whatever arrives on EP2 OUT.
2. **`src/lib.rs`** — a `wasm-bindgen` harness (`run_webusb_test`) driving
hidra's WebUSB backend against it: `device_list` → `open` → device info →
report descriptor → `get_feature_report` → `send_feature_report` → `write` →
`read`, asserting payloads.
3. **`webusb_run.mjs`** — serves `index.html` + the wasm on a fixed port,
launches Chromium (headed, under Xvfb), reads the harness result.

## Device shapes

[`run.sh`](run.sh) runs the harness three times, rebuilding the gadget between
each, because the degenerate shapes are the ones with their own code paths:

| variant | gadget | what it pins down |
|---------|--------|-------------------|
| `full` | 2 interrupt endpoints, report descriptor | the ordinary path |
| `control-only` | `bNumEndpoints` 0 | every report goes over the control pipe: `write` falls back to `SET_REPORT(Output)`, `read` must refuse instead of hanging, and `get_input_report` remains the way to poll |
| `no-report-descriptor` | endpoints, `GET_DESCRIPTOR(Report)` stalls | `report_descriptor` reports `Unsupported` rather than handing back an empty buffer |

`control-only` is not hypothetical: it is the shape of the Sinowealth ISP
bootloaders, which sinowisp drives entirely through feature reports.

## Why `device_list` and not the chooser

`navigator.usb.requestDevice()` always opens a picker and needs a user gesture,
so it cannot be driven unattended. The `WebUsbAllowDevicesForUrls` enterprise
policy ([`run.sh`](run.sh) writes it to `/etc/chromium/policies/managed/` and
removes it afterwards) pre-grants the fixture to the test origin, after which it
appears in `navigator.usb.getDevices()` — hidra's `webusb::Hidra::device_list`
— with no interaction at all.

## Running

Needs root (configfs, `dummy_hcd`, the system policy dir) and: `wasm-pack`,
`wasm-bindgen-cli`, a Chromium/Chrome, `nodejs`, `xvfb-run`. Kernel modules
`dummy_hcd`, `libcomposite` and `usb_f_fs` must be available. From this
directory:

```sh
# 1. build the wasm harness
nix shell nixpkgs#wasm-pack nixpkgs#wasm-bindgen-cli -c wasm-pack build --target web --dev

# 2. install the Playwright npm package (browser download skipped)
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 nix shell nixpkgs#nodejs -c npm install

# 3. run everything (run.sh builds the fixture crate itself)
sudo -E ./run.sh
```

The whole thing runs unattended in the Linux test VM — see
[`../../platform/linux`](../../platform/linux/README.md).
16 changes: 16 additions & 0 deletions e2e/targets/webusb/fixture/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Native fixture: a vendor-class USB gadget (dummy_hcd + configfs FunctionFS)
# for the WebUSB conformance harness to talk to.
[package]
name = "webusb-ffs-fixture"
version = "0.0.0"
edition = "2021"
publish = false

[[bin]]
name = "webusb_ffs_fixture"
path = "src/main.rs"

[dependencies]
libc = "0.2"

[workspace]
Loading
Loading