diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25034c..58fd084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 }}) diff --git a/Cargo.toml b/Cargo.toml index 6b07caa..ece65a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] @@ -87,3 +88,7 @@ features = [ "HidReportItem", "HidUnitSystem", ] + +[target.'cfg(target_arch = "wasm32")'.dependencies.nusb] +version = "0.2.7" +optional = true diff --git a/README.md b/README.md index a73e66e..69b964f 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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 diff --git a/e2e/README.md b/e2e/README.md index 59f49e1..d9e87cc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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), @@ -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) ``` diff --git a/e2e/platform/linux/README.md b/e2e/platform/linux/README.md index f5e8439..9c845c1 100644 --- a/e2e/platform/linux/README.md +++ b/e2e/platform/linux/README.md @@ -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 @@ -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 diff --git a/e2e/platform/linux/test-vm.nix b/e2e/platform/linux/test-vm.nix index e91857f..8ed146a 100644 --- a/e2e/platform/linux/test-vm.nix +++ b/e2e/platform/linux/test-vm.nix @@ -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 ====================" ''; }; @@ -81,6 +87,7 @@ in "dummy_hcd" "libcomposite" "usb_f_hid" + "usb_f_fs" "configfs" ]; diff --git a/e2e/targets/webusb/.cargo/config.toml b/e2e/targets/webusb/.cargo/config.toml new file mode 100644 index 0000000..aa86f97 --- /dev/null +++ b/e2e/targets/webusb/.cargo/config.toml @@ -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"] diff --git a/e2e/targets/webusb/.gitignore b/e2e/targets/webusb/.gitignore new file mode 100644 index 0000000..6ef2fa0 --- /dev/null +++ b/e2e/targets/webusb/.gitignore @@ -0,0 +1,5 @@ +/pkg +/node_modules +/target +Cargo.lock +package-lock.json diff --git a/e2e/targets/webusb/Cargo.toml b/e2e/targets/webusb/Cargo.toml new file mode 100644 index 0000000..bf6dd20 --- /dev/null +++ b/e2e/targets/webusb/Cargo.toml @@ -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"] diff --git a/e2e/targets/webusb/README.md b/e2e/targets/webusb/README.md new file mode 100644 index 0000000..067d762 --- /dev/null +++ b/e2e/targets/webusb/README.md @@ -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). diff --git a/e2e/targets/webusb/fixture/Cargo.toml b/e2e/targets/webusb/fixture/Cargo.toml new file mode 100644 index 0000000..389c9de --- /dev/null +++ b/e2e/targets/webusb/fixture/Cargo.toml @@ -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] diff --git a/e2e/targets/webusb/fixture/src/main.rs b/e2e/targets/webusb/fixture/src/main.rs new file mode 100644 index 0000000..33831bc --- /dev/null +++ b/e2e/targets/webusb/fixture/src/main.rs @@ -0,0 +1,399 @@ +//! A **vendor-class** USB gadget for the WebUSB conformance test. +//! +//! WebUSB cannot claim a HID-class interface, so this cannot use `usb_f_hid` +//! the way the `linux-nusb` fixture does: the interface has to declare class +//! 0xFF. FunctionFS is what allows that — userspace supplies the descriptors +//! verbatim — at the cost of also having to service the endpoints and, with +//! `FUNCTIONFS_ALL_CTRL_RECIP`, the interface's control requests by hand. +//! +//! The device still speaks the HID protocol on the wire (GET_REPORT / +//! SET_REPORT, an interrupt IN stream), which is exactly the shape of device +//! the WebUSB backend exists for. + +use std::ffi::CString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::Duration; + +const GADGET: &str = "/sys/kernel/config/usb_gadget/hidra_webusb"; +const FFS_INSTANCE: &str = "hidra"; +const FFS_MOUNT: &str = "/dev/ffs-hidra"; + +const TEST_VID: u16 = 0x1209; +const TEST_PID: u16 = 0x000c; +const PRODUCT: &str = "hidra-conformance"; +const SERIAL: &str = "HIDRA-CONF-01"; +const MANUFACTURER: &str = "hidra"; + +const RID_INPUT: u8 = 0x11; +const RID_FEATURE: u8 = 0x33; +const IN_PAYLOAD: [u8; 8] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7]; +const FEAT_PAYLOAD: [u8; 8] = [0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7]; + +const FUNCTIONFS_DESCRIPTORS_MAGIC_V2: u32 = 3; +const FUNCTIONFS_STRINGS_MAGIC: u32 = 2; +const FUNCTIONFS_HAS_FS_DESC: u32 = 1; +const FUNCTIONFS_HAS_HS_DESC: u32 = 2; +const FUNCTIONFS_ALL_CTRL_RECIP: u32 = 1 << 6; + +const FFS_BIND: u8 = 0; +const FFS_UNBIND: u8 = 1; +const FFS_ENABLE: u8 = 2; +const FFS_DISABLE: u8 = 3; +const FFS_SETUP: u8 = 4; + +const HID_GET_REPORT: u8 = 0x01; +const HID_SET_REPORT: u8 = 0x09; +const GET_DESCRIPTOR: u8 = 0x06; +const DESCRIPTOR_TYPE_HID_REPORT: u8 = 0x22; + +/// Which shape of device to present. +#[derive(Clone, Copy, PartialEq)] +enum Variant { + /// Two interrupt endpoints and a report descriptor. + Full, + /// `bNumEndpoints` 0: every report has to go over the control pipe. + ControlOnly, + /// Endpoints, but `GET_DESCRIPTOR(Report)` stalls — the usual shape for a + /// vendor-class interface, which owes no HID class descriptor. + NoReportDescriptor, +} + +impl Variant { + fn from_arg(arg: Option<&str>) -> Self { + match arg { + None | Some("full") => Variant::Full, + Some("control-only") => Variant::ControlOnly, + Some("no-report-descriptor") => Variant::NoReportDescriptor, + Some(other) => panic!("unknown variant {other}"), + } + } + + fn has_endpoints(self) -> bool { + self != Variant::ControlOnly + } + + fn has_report_descriptor(self) -> bool { + self != Variant::NoReportDescriptor + } +} + +/// One interface descriptor, plus two interrupt endpoints unless the variant +/// is control-only, FS and HS alike. +fn function_descriptors(variant: Variant) -> Vec { + let endpoints = if variant.has_endpoints() { 2 } else { 0 }; + // interface: class 0xFF (vendor-specific) — the whole point. + let mut d = vec![9, 0x04, 0, 0, endpoints, 0xFF, 0x00, 0x00, 1]; + if variant.has_endpoints() { + // EP1 IN, interrupt, 64 bytes + d.extend_from_slice(&[7, 0x05, 0x81, 0x03, 64, 0, 4]); + // EP2 OUT, interrupt, 64 bytes + d.extend_from_slice(&[7, 0x05, 0x02, 0x03, 64, 0, 4]); + } + d +} + +fn descriptor_blob(variant: Variant) -> Vec { + let descs = function_descriptors(variant); + let flags = FUNCTIONFS_HAS_FS_DESC | FUNCTIONFS_HAS_HS_DESC | FUNCTIONFS_ALL_CTRL_RECIP; + // head (12) + fs_count (4) + hs_count (4) + both descriptor sets + let length = 12 + 4 + 4 + descs.len() * 2; + + let mut blob = Vec::with_capacity(length); + blob.extend_from_slice(&FUNCTIONFS_DESCRIPTORS_MAGIC_V2.to_le_bytes()); + blob.extend_from_slice(&(length as u32).to_le_bytes()); + blob.extend_from_slice(&flags.to_le_bytes()); + let count = if variant.has_endpoints() { 3u32 } else { 1u32 }; + blob.extend_from_slice(&count.to_le_bytes()); // fs + blob.extend_from_slice(&count.to_le_bytes()); // hs + blob.extend_from_slice(&descs); + blob.extend_from_slice(&descs); + blob +} + +fn strings_blob() -> Vec { + let text = CString::new("hidra webusb conformance").unwrap(); + // head (16) + lang code (2) + the string + let length = 16 + 2 + text.as_bytes_with_nul().len(); + + let mut blob = Vec::with_capacity(length); + blob.extend_from_slice(&FUNCTIONFS_STRINGS_MAGIC.to_le_bytes()); + blob.extend_from_slice(&(length as u32).to_le_bytes()); + blob.extend_from_slice(&1u32.to_le_bytes()); // one string + blob.extend_from_slice(&1u32.to_le_bytes()); // one language + blob.extend_from_slice(&0x0409u16.to_le_bytes()); + blob.extend_from_slice(text.as_bytes_with_nul()); + blob +} + +/// The report descriptor served over GET_DESCRIPTOR(Report). +/// +/// A vendor-class interface is not obliged to carry one, and hidra reports +/// `Unsupported` when it does not; serving it here exercises the path that +/// does exist rather than leaving it untested. +#[rustfmt::skip] +fn report_descriptor() -> Vec { + vec![ + 0x06, 0x00, 0xFF, // usage page (vendor-defined 0xFF00) + 0x09, 0x01, // usage 1 + 0xA1, 0x01, // collection (application) + 0x85, RID_INPUT, // report id + 0x09, 0x02, // usage 2 + 0x15, 0x00, // logical min 0 + 0x26, 0xFF, 0x00, // logical max 255 + 0x75, 0x08, // report size 8 + 0x95, 0x08, // report count 8 + 0x81, 0x02, // input (data, var, abs) + 0x85, RID_FEATURE, // report id + 0x09, 0x03, // usage 3 + 0x75, 0x08, 0x95, 0x08, 0xB1, 0x02, // feature + 0xC0, // end collection + ] +} + +fn w(path: &str, value: &str) -> std::io::Result<()> { + fs::write(path, value) +} + +fn teardown() { + if !Path::new(GADGET).exists() { + return; + } + let _ = w(&format!("{GADGET}/UDC"), "\n"); + let _ = fs::remove_file(format!("{GADGET}/configs/c.1/ffs.{FFS_INSTANCE}")); + let _ = fs::remove_dir(format!("{GADGET}/configs/c.1/strings/0x409")); + let _ = fs::remove_dir(format!("{GADGET}/configs/c.1")); + unmount_ffs(); + let _ = fs::remove_dir(format!("{GADGET}/functions/ffs.{FFS_INSTANCE}")); + let _ = fs::remove_dir(format!("{GADGET}/strings/0x409")); + let _ = fs::remove_dir(GADGET); +} + +fn unmount_ffs() { + if let Ok(path) = CString::new(FFS_MOUNT) { + // SAFETY: a NUL-terminated path; failure (not mounted) is fine here. + unsafe { libc::umount(path.as_ptr()) }; + } +} + +fn mount_ffs() { + fs::create_dir_all(FFS_MOUNT).expect("mkdir ffs mount point"); + let src = CString::new(FFS_INSTANCE).unwrap(); + let target = CString::new(FFS_MOUNT).unwrap(); + let fstype = CString::new("functionfs").unwrap(); + // SAFETY: all four pointers are NUL-terminated strings valid for the call. + let rc = unsafe { + libc::mount( + src.as_ptr(), + target.as_ptr(), + fstype.as_ptr(), + 0, + std::ptr::null(), + ) + }; + assert_eq!( + rc, + 0, + "mount functionfs: {}", + std::io::Error::last_os_error() + ); +} + +fn setup_gadget() { + for m in ["dummy_hcd", "libcomposite", "usb_f_fs"] { + let _ = Command::new("modprobe").arg(m).status(); + } + teardown(); + + fs::create_dir_all(format!("{GADGET}/strings/0x409")).expect("mkdir gadget"); + w(&format!("{GADGET}/idVendor"), &format!("{TEST_VID:#06x}\n")).unwrap(); + w( + &format!("{GADGET}/idProduct"), + &format!("{TEST_PID:#06x}\n"), + ) + .unwrap(); + w(&format!("{GADGET}/bcdDevice"), "0x0100\n").unwrap(); + w(&format!("{GADGET}/bcdUSB"), "0x0200\n").unwrap(); + w( + &format!("{GADGET}/strings/0x409/manufacturer"), + MANUFACTURER, + ) + .unwrap(); + w(&format!("{GADGET}/strings/0x409/product"), PRODUCT).unwrap(); + w(&format!("{GADGET}/strings/0x409/serialnumber"), SERIAL).unwrap(); + + fs::create_dir_all(format!("{GADGET}/functions/ffs.{FFS_INSTANCE}")).expect("mkdir ffs"); + fs::create_dir_all(format!("{GADGET}/configs/c.1/strings/0x409")).expect("mkdir config"); + w( + &format!("{GADGET}/configs/c.1/strings/0x409/configuration"), + "hidra\n", + ) + .unwrap(); + std::os::unix::fs::symlink( + format!("{GADGET}/functions/ffs.{FFS_INSTANCE}"), + format!("{GADGET}/configs/c.1/ffs.{FFS_INSTANCE}"), + ) + .expect("link function"); +} + +fn bind_udc() { + let udc = fs::read_dir("/sys/class/udc") + .expect("read /sys/class/udc") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().as_bytes().to_vec()) + .map(|n| String::from_utf8_lossy(&n).into_owned()) + .next() + .expect("no UDC available (dummy_hcd not loaded?)"); + w(&format!("{GADGET}/UDC"), &format!("{udc}\n")).expect("bind UDC"); +} + +/// Service ep0: the control requests the host aims at our interface. +fn ep0_loop(mut ep0: File, variant: Variant) { + let mut event = [0u8; 12]; + loop { + match ep0.read(&mut event) { + Ok(12) => {} + Ok(_) => continue, + Err(e) => { + eprintln!("ep0 read: {e}"); + return; + } + } + // struct usb_functionfs_event: an 8-byte usb_ctrlrequest, then the type. + let kind = event[8]; + match kind { + FFS_BIND => println!("BIND"), + FFS_UNBIND => println!("UNBIND"), + FFS_ENABLE => println!("ENABLE"), + FFS_DISABLE => println!("DISABLE"), + FFS_SETUP => { + let request_type = event[0]; + let request = event[1]; + let value = u16::from_le_bytes([event[2], event[3]]); + let length = u16::from_le_bytes([event[6], event[7]]) as usize; + handle_setup(&mut ep0, variant, request_type, request, value, length); + } + _ => {} + } + } +} + +fn handle_setup( + ep0: &mut File, + variant: Variant, + request_type: u8, + request: u8, + value: u16, + length: usize, +) { + let to_host = request_type & 0x80 != 0; + let report_id = (value & 0xFF) as u8; + let report_type = value >> 8; + + match (to_host, request) { + // GET_DESCRIPTOR(Report) — standard, interface recipient. + (true, GET_DESCRIPTOR) + if report_type == u16::from(DESCRIPTOR_TYPE_HID_REPORT) + && variant.has_report_descriptor() => + { + let desc = report_descriptor(); + let n = desc.len().min(length); + let _ = ep0.write(&desc[..n]); + println!("GET_DESCRIPTOR(report) {n} bytes"); + } + (true, HID_GET_REPORT) => { + let mut body = Vec::with_capacity(1 + FEAT_PAYLOAD.len()); + body.push(report_id); + body.extend_from_slice(&FEAT_PAYLOAD); + let n = body.len().min(length); + let _ = ep0.write(&body[..n]); + println!("GET_REPORT id={report_id:#04x} {n} bytes"); + } + (false, HID_SET_REPORT) => { + let mut body = vec![0u8; length]; + let n = ep0.read(&mut body).unwrap_or(0); + println!("SET_REPORT id={report_id:#04x} data={:02x?}", &body[..n]); + } + _ => { + // Everything else is stalled by transferring in the wrong direction. + println!("STALL type={request_type:#04x} req={request:#04x}"); + if to_host { + let mut sink = [0u8; 1]; + let _ = ep0.read(&mut sink); + } else { + let _ = ep0.write(&[]); + } + } + } +} + +fn main() { + let arg = std::env::args().nth(1); + let variant = Variant::from_arg(arg.as_deref()); + setup_gadget(); + mount_ffs(); + + let mut ep0 = OpenOptions::new() + .read(true) + .write(true) + .open(format!("{FFS_MOUNT}/ep0")) + .expect("open ep0"); + ep0.write_all(&descriptor_blob(variant)) + .expect("write descriptors"); + ep0.write_all(&strings_blob()).expect("write strings"); + + bind_udc(); + + // The endpoint files only appear once the descriptors are accepted, and + // only for the variants that declared any. + let endpoints = variant.has_endpoints().then(|| { + let ep1 = OpenOptions::new() + .write(true) + .open(format!("{FFS_MOUNT}/ep1")) + .expect("open ep1"); + let ep2 = OpenOptions::new() + .read(true) + .open(format!("{FFS_MOUNT}/ep2")) + .expect("open ep2"); + (ep1, ep2) + }); + + thread::spawn(move || ep0_loop(ep0, variant)); + + if let Some((ep1, ep2)) = endpoints { + // Interrupt IN: keep an input report available for the harness to read. + thread::spawn(move || { + let mut ep1 = ep1; + let mut report = Vec::with_capacity(1 + IN_PAYLOAD.len()); + report.push(RID_INPUT); + report.extend_from_slice(&IN_PAYLOAD); + loop { + if ep1.write(&report).is_err() { + thread::sleep(Duration::from_millis(100)); + } + } + }); + + // Interrupt OUT: log whatever the harness writes. + thread::spawn(move || { + let mut ep2 = ep2; + let mut buf = [0u8; 64]; + loop { + match ep2.read(&mut buf) { + Ok(n) if n > 0 => println!("OUTPUT {:02x?}", &buf[..n]), + Ok(_) => {} + Err(_) => thread::sleep(Duration::from_millis(100)), + } + } + }); + } + + println!("READY"); + loop { + thread::sleep(Duration::from_secs(3600)); + } +} diff --git a/e2e/targets/webusb/index.html b/e2e/targets/webusb/index.html new file mode 100644 index 0000000..dfb18a1 --- /dev/null +++ b/e2e/targets/webusb/index.html @@ -0,0 +1,32 @@ + + + + + hidra WebUSB test + + +

hidra WebUSB test

+ +
idle
+ + + diff --git a/e2e/targets/webusb/package.json b/e2e/targets/webusb/package.json new file mode 100644 index 0000000..8fd092a --- /dev/null +++ b/e2e/targets/webusb/package.json @@ -0,0 +1,12 @@ +{ + "name": "hidra-webusb-test", + "private": true, + "type": "module", + "description": "Playwright WebUSB conformance driver for hidra", + "scripts": { + "test": "node webusb_run.mjs" + }, + "dependencies": { + "playwright": "^1.48.0" + } +} diff --git a/e2e/targets/webusb/run.sh b/e2e/targets/webusb/run.sh new file mode 100755 index 0000000..d9de51c --- /dev/null +++ b/e2e/targets/webusb/run.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# End-to-end WebUSB conformance run: brings up the vendor-class FunctionFS +# gadget, grants it to the test origin via Chrome's WebUsbAllowDevicesForUrls +# policy, then drives hidra's WebUSB backend through Chromium/Playwright. +# +# Needs root (configfs/dummy_hcd + the system policy dir). +# Run: sudo -E ./e2e/targets/webusb/run.sh +# Expects the wasm harness (pkg/) and node_modules already built (see README). +set -uo pipefail + +WEB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PORT=8098 +POLICY_DIR=/etc/chromium/policies/managed +POLICY="$POLICY_DIR/hidra-webusb.json" + +: "${CHROMIUM:=$(command -v chromium || command -v google-chrome || true)}" +: "${NODE:=node}" +: "${XVFB_RUN:=xvfb-run}" +[ -n "$CHROMIUM" ] || { echo "set CHROMIUM to a chromium/chrome binary"; exit 2; } + +FIXTURE="$WEB/fixture/target/debug/webusb_ffs_fixture" +[ -x "$FIXTURE" ] || ( cd "$WEB/fixture" && cargo build ) + +cleanup() { + [ -n "${FPID:-}" ] && kill "$FPID" 2>/dev/null + rm -f "$POLICY" +} +trap cleanup EXIT + +# Pre-grant vid 0x1209 / pid 0x000c to the test origin so no chooser appears. +# Unlike WebHID this is what makes the run non-interactive at all: WebUSB's +# requestDevice() always opens a chooser, so the harness reads device_list() +# instead, which only sees devices already granted. +mkdir -p "$POLICY_DIR" +cat > "$POLICY" < "$FIXLOG" 2>&1 & + FPID=$! + for _ in $(seq 1 50); do grep -q READY "$FIXLOG" 2>/dev/null && break; sleep 0.2; done + if ! grep -q READY "$FIXLOG"; then + echo "fixture never became READY ($variant)"; cat "$FIXLOG"; exit 3 + fi + + ( cd "$WEB" && CHROMIUM="$CHROMIUM" HOME="${HOME:-/root}" VARIANT="$variant" \ + "$XVFB_RUN" -a "$NODE" webusb_run.mjs ) + RC=$? + [ $RC -eq 0 ] || STATUS=$RC + + echo "--- fixture (device side) saw ---" + grep -E 'OUTPUT|SET_REPORT|GET_REPORT|GET_DESCRIPTOR|STALL' "$FIXLOG" || echo '(nothing captured)' + kill "$FPID" 2>/dev/null + wait "$FPID" 2>/dev/null + FPID= + rm -f "$FIXLOG" +done + +exit $STATUS diff --git a/e2e/targets/webusb/src/lib.rs b/e2e/targets/webusb/src/lib.rs new file mode 100644 index 0000000..9616531 --- /dev/null +++ b/e2e/targets/webusb/src/lib.rs @@ -0,0 +1,141 @@ +//! WebUSB conformance harness. Drives hidra's WebUSB backend against the +//! `webusb_ffs_fixture` vendor-class gadget; returns a summary string or throws +//! on mismatch. + +use hidra::webusb::Hidra; +use wasm_bindgen::prelude::*; + +const TEST_VID: u16 = 0x1209; +const TEST_PID: u16 = 0x000c; +const RID_INPUT: u8 = 0x11; +const RID_FEATURE: u8 = 0x33; +const IN_PAYLOAD: [u8; 8] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7]; +const OUT_PAYLOAD: [u8; 8] = [0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7]; +const FEAT_PAYLOAD: [u8; 8] = [0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7]; + +fn jerr(e: E) -> JsValue { + JsValue::from_str(&format!("{e}")) +} + +/// `variant` mirrors the fixture: "full", "control-only" or +/// "no-report-descriptor". +#[wasm_bindgen] +pub async fn run_webusb_test(variant: String) -> Result { + let endpoints = variant != "control-only"; + let has_descriptor = variant != "no-report-descriptor"; + let api = Hidra::new().map_err(jerr)?; + let mut log = Vec::::new(); + + // `WebUsbAllowDevicesForUrls` pre-grants the fixture, so it is already in + // device_list() — no chooser, and so no user gesture needed. + let device = api + .device_list() + .await + .map_err(jerr)? + .into_iter() + .find(|d| (d.vendor_id(), d.product_id()) == (TEST_VID, TEST_PID)) + .ok_or_else(|| JsValue::from_str("fixture device not granted (policy applied?)"))?; + if (device.vendor_id(), device.product_id()) != (TEST_VID, TEST_PID) { + return Err(JsValue::from_str(&format!( + "vid/pid mismatch: {:#06x}:{:#06x}", + device.vendor_id(), + device.product_id() + ))); + } + log.push(format!( + "granted device product={:?}", + device.product_string() + )); + + // The gadget exposes one interface; None takes it. This is the claim + // Blink would refuse outright were the interface HID-class. + let dev = device.open(None).await.map_err(jerr)?; + log.push("claimed vendor-class interface".into()); + + let info = dev.get_device_info().map_err(jerr)?; + if (info.vendor_id(), info.product_id()) != (TEST_VID, TEST_PID) { + return Err(JsValue::from_str("device_info vid/pid mismatch")); + } + log.push(format!("interface_number={}", info.interface_number())); + + // GET_DESCRIPTOR(Report) over the vendor-class interface. A vendor-class + // interface owes no HID class descriptor, and hidra has to say so rather + // than hand back an empty buffer. + match (has_descriptor, dev.report_descriptor()) { + (true, Ok(d)) if !d.is_empty() => log.push(format!("report_descriptor={} bytes", d.len())), + (true, Ok(_)) => return Err(JsValue::from_str("empty report descriptor")), + (true, Err(e)) => return Err(jerr(e)), + (false, Err(e)) => log.push(format!("report_descriptor unsupported: {e}")), + (false, Ok(d)) => { + return Err(JsValue::from_str(&format!( + "expected no report descriptor, got {} bytes", + d.len() + ))) + } + } + + // GET_REPORT(Feature): buf[0] carries the report ID on entry. + let mut buf = vec![0u8; 1 + FEAT_PAYLOAD.len()]; + buf[0] = RID_FEATURE; + let n = dev.get_feature_report(&mut buf).await.map_err(jerr)?; + if buf[..n] != [&[RID_FEATURE][..], &FEAT_PAYLOAD[..]].concat()[..n] { + return Err(JsValue::from_str(&format!( + "feature report mismatch: {:02x?}", + &buf[..n] + ))); + } + log.push(format!("get_feature_report={n} bytes")); + + // SET_REPORT(Feature). + let mut out = vec![RID_FEATURE]; + out.extend_from_slice(&FEAT_PAYLOAD); + dev.send_feature_report(&out).await.map_err(jerr)?; + log.push("send_feature_report ok".into()); + + // An output report goes out on the interrupt OUT endpoint when there is + // one, and falls back to SET_REPORT(Output) on the control pipe when there + // is not — the shape the Sinowealth ISP bootloaders take. + let mut written = vec![RID_INPUT]; + written.extend_from_slice(&OUT_PAYLOAD); + let n = dev.write(&written).await.map_err(jerr)?; + log.push(format!("write={n} bytes")); + + if endpoints { + // Interrupt IN: the fixture streams a known report. + let mut inbuf = vec![0u8; 64]; + let n = dev.read(&mut inbuf).await.map_err(jerr)?; + if n == 0 { + return Err(JsValue::from_str("read returned 0 bytes")); + } + let expected = [&[RID_INPUT][..], &IN_PAYLOAD[..]].concat(); + if inbuf[..n] != expected[..] { + return Err(JsValue::from_str(&format!( + "input report mismatch: {:02x?}", + &inbuf[..n] + ))); + } + log.push(format!("read={n} bytes")); + } else { + // No interrupt IN endpoint: read must refuse rather than hang, and + // GET_REPORT(Input) stays available as the way to poll. + let mut inbuf = vec![0u8; 64]; + match dev.read(&mut inbuf).await { + Ok(n) => { + return Err(JsValue::from_str(&format!( + "read succeeded ({n} bytes) on an interface with no interrupt IN" + ))) + } + Err(e) => log.push(format!("read refused: {e}")), + } + + let mut poll = vec![0u8; 1 + FEAT_PAYLOAD.len()]; + poll[0] = RID_INPUT; + let n = dev.get_input_report(&mut poll).await.map_err(jerr)?; + log.push(format!("get_input_report={n} bytes")); + } + + Ok(format!( + "PASS: webusb conformance [{variant}] ({})", + log.join("; ") + )) +} diff --git a/e2e/targets/webusb/webusb_run.mjs b/e2e/targets/webusb/webusb_run.mjs new file mode 100644 index 0000000..8167b14 --- /dev/null +++ b/e2e/targets/webusb/webusb_run.mjs @@ -0,0 +1,99 @@ +// Playwright driver for the WebUSB conformance harness: serves index.html + the +// wasm pkg, launches Chromium under Xvfb, clicks Run, asserts PASS. +// +// Env: CHROMIUM = path to the chromium/chrome binary to use. + +import { chromium } from "playwright"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, normalize } from "node:path"; + +const DIR = dirname(fileURLToPath(import.meta.url)); + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".wasm": "application/wasm", + ".json": "application/json", + ".ts": "text/plain", +}; + +function startServer() { + const server = createServer(async (req, res) => { + try { + let path = normalize(decodeURIComponent(req.url.split("?")[0])); + if (path === "/favicon.ico") { + res.writeHead(204).end(); + return; + } + if (path === "/" || path === "\\") path = "/index.html"; + const file = join(DIR, path); + if (!file.startsWith(DIR)) { + res.writeHead(403).end("forbidden"); + return; + } + const body = await readFile(file); + const ext = path.slice(path.lastIndexOf(".")); + res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream" }); + res.end(body); + console.log("[srv] 200", path); + } catch { + res.writeHead(404).end("not found"); + console.log("[srv] 404", req.url); + } + }); + // Fixed port so the WebUsbAllowDevicesForUrls policy origin matches. + return new Promise((resolve) => server.listen(PORT, "127.0.0.1", () => resolve(server))); +} + +const PORT = 8098; + +async function main() { + const server = await startServer(); + const variant = process.env.VARIANT || "full"; + const url = `http://localhost:${PORT}/index.html?variant=${variant}`; + + const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM || undefined, + headless: false, // WebUSB needs full Chrome (run under Xvfb) + args: ["--no-sandbox"], + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + page.on("console", (m) => console.log("[page]", m.text())); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + page.on("requestfailed", (r) => + console.log("[reqfail]", r.url(), r.failure() && r.failure().errorText) + ); + + // The WebUsbAllowDevicesForUrls policy (see run.sh) pre-grants the fixture, so + // device_list() sees it without a chooser. + await page.goto(url); + await page.click("#run"); + try { + await page.waitForFunction(() => window.__hidraResult !== null, { timeout: 20000 }); + } catch (e) { + const text = await page.evaluate(() => document.getElementById("result").textContent); + console.error("[timeout] #result =", JSON.stringify(text)); + throw e; + } + const result = await page.evaluate(() => window.__hidraResult); + + await browser.close(); + server.close(); + + if (result && result.ok) { + console.log("WEBUSB_RESULT_OK", result.summary); + process.exit(0); + } else { + console.error("WEBUSB_RESULT_FAIL", result && result.error); + process.exit(1); + } +} + +main().catch((e) => { + console.error("driver error:", e); + process.exit(2); +}); diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 45bf740..c30952e 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -9,7 +9,9 @@ //! Backends are compiled in additively. The per-OS backend for the target is //! always available; the `nusb` feature *adds* the USB-transport backend //! beside it instead of replacing it, so enabling the feature anywhere in a -//! dependency graph cannot silently change another crate's backend. +//! dependency graph cannot silently change another crate's backend. On wasm +//! the same feature adds the `WebUSB` backend beside `WebHID`, reached through its +//! own entry point rather than this dispatch. #[cfg(not(target_arch = "wasm32"))] use core::future::Future; @@ -134,7 +136,8 @@ pub(crate) trait HidDeviceBackend: Send + Sync { /// does not, because the kernel consumes the byte itself. #[cfg(any( target_os = "macos", - all(feature = "nusb", any(target_os = "linux", target_os = "windows")) + all(feature = "nusb", any(target_os = "linux", target_os = "windows")), + all(feature = "nusb", target_arch = "wasm32") ))] pub(crate) fn payload_after_report_id(data: &[u8]) -> &[u8] { if data.first() == Some(&0) { @@ -158,6 +161,11 @@ pub(crate) mod queue; #[cfg(target_arch = "wasm32")] pub(crate) mod webhid; +// The WebUSB backend: same HID-over-USB mapping as the native nusb backend, +// for interfaces the browser will not expose over WebHID. +#[cfg(all(feature = "nusb", target_arch = "wasm32"))] +pub(crate) mod webusb; + #[cfg(all( feature = "nusb", any(target_os = "linux", target_os = "macos", target_os = "windows") diff --git a/src/backend/webusb.rs b/src/backend/webusb.rs new file mode 100644 index 0000000..7c8efb5 --- /dev/null +++ b/src/backend/webusb.rs @@ -0,0 +1,402 @@ +//! `WebUSB` backend, for devices the browser will not surface over `WebHID`. +//! +//! `WebHID` only exposes interfaces the host recognises as HID. A device whose +//! interface declares a vendor-specific class is invisible there, but remains +//! reachable over `WebUSB`, and many such devices still speak the HID protocol +//! on the wire. This backend drives them through `nusb`'s `WebUSB` platform, +//! mapping hidra's report calls onto the same control and interrupt transfers +//! the native `nusb` backend uses. +//! +//! Blink refuses `claimInterface` on the protected classes, HID (0x03) among +//! them, so this backend deliberately does not filter for HID interfaces the +//! way the native `nusb` backend does: the devices it is for are precisely the +//! ones declaring something else. + +use core::cell::RefCell; +use std::time::Duration; + +use nusb::descriptors::TransferType; +use nusb::transfer::{Buffer, ControlIn, ControlOut, ControlType, Direction, Recipient}; +use nusb::transfer::{In, Interrupt, Out}; +use nusb::{Endpoint, Interface}; + +use super::payload_after_report_id; +use crate::descriptor::{ReportDescriptor, ReportKind}; +use crate::error::{HidError, HidResult}; +use crate::{BusType, DeviceInfo, MAX_REPORT_DESCRIPTOR_SIZE}; + +const GET_DESCRIPTOR: u8 = 0x06; +const DESCRIPTOR_TYPE_HID_REPORT: u8 = 0x22; +const HID_GET_REPORT: u8 = 0x01; +const HID_SET_REPORT: u8 = 0x09; + +const REPORT_TYPE_INPUT: u16 = 1; +const REPORT_TYPE_OUTPUT: u16 = 2; +const REPORT_TYPE_FEATURE: u16 = 3; + +const TRANSFER_TIMEOUT: Duration = Duration::from_millis(1000); + +/// Cap on a single interrupt IN transfer, matching the native backend. +fn transfer_length(max_input_wire: usize, max_packet_size: usize) -> usize { + max_input_wire.max(max_packet_size).max(1) +} + +fn transfer_error(operation: &'static str, err: nusb::transfer::TransferError) -> HidError { + match err { + nusb::transfer::TransferError::Disconnected => HidError::Disconnected, + e => HidError::backend(format!("{operation}: {e}")), + } +} + +fn device_info(dev: &nusb::DeviceInfo, interface_number: u8) -> DeviceInfo { + DeviceInfo { + path: format!( + "webusb:{:04x}:{:04x}:{interface_number}", + dev.vendor_id(), + dev.product_id() + ), + vendor_id: dev.vendor_id(), + product_id: dev.product_id(), + serial_number: dev.serial_number().map(str::to_string), + release_number: dev.device_version(), + manufacturer_string: dev.manufacturer_string().map(str::to_string), + product_string: dev.product_string().map(str::to_string), + interface_number: i32::from(interface_number), + bus_type: BusType::Usb, + ..Default::default() + } +} + +/// Entry point: `navigator.usb`, reached through `nusb`. +pub(crate) struct WebUsbApi; + +impl WebUsbApi { + pub(crate) fn new() -> HidResult { + Ok(WebUsbApi) + } + + /// Devices the user has already granted access to. + pub(crate) async fn device_list(&self) -> HidResult> { + let devices = nusb::list_devices() + .await + .map_err(|e| HidError::backend(format!("navigator.usb.getDevices: {e}")))?; + Ok(devices.collect()) + } + + /// Show the browser's device chooser, resolving with the granted device. + /// + /// `selectors` narrows what the chooser offers; an empty slice offers + /// everything. Resolves with `None` when the user dismisses the chooser. + pub(crate) async fn request_device( + &self, + selectors: &[nusb::DeviceSelector], + ) -> HidResult> { + nusb::request_device(selectors) + .await + .map_err(|e| HidError::backend(format!("navigator.usb.requestDevice: {e}"))) + } +} + +/// One open device, claimed on a single interface. +pub(crate) struct WebUsbDevice { + interface: Interface, + interface_number: u8, + info: DeviceInfo, + report_descriptor: Vec, + in_endpoint: Option>>>, + out_endpoint: Option>>>, + transfer_len: usize, +} + +impl WebUsbDevice { + /// Claim `interface_number` and probe its endpoints and report descriptor. + /// + /// When `interface_number` is `None` the first interface carrying an + /// interrupt IN endpoint is used, falling back to the first interface. + pub(crate) async fn open( + dev_info: &nusb::DeviceInfo, + interface_number: Option, + ) -> HidResult { + let interface_number = match interface_number { + Some(n) => n, + None => dev_info + .interfaces() + .next() + .map(|i| i.interface_number()) + .ok_or_else(|| HidError::OpenFailed { + message: "device exposes no interfaces".into(), + })?, + }; + + let device = dev_info.open().await.map_err(|e| HidError::OpenFailed { + message: format!("opening USB device: {e}"), + })?; + let interface = device + .claim_interface(interface_number) + .await + .map_err(|e| HidError::OpenFailed { + message: format!( + "claiming interface {interface_number}: {e}; \ + the browser refuses the protected classes, HID among them" + ), + })?; + + let report_descriptor = read_report_descriptor(&interface, interface_number) + .await + .unwrap_or_default(); + let parsed = ReportDescriptor::parse(&report_descriptor).ok(); + let max_input_wire = parsed + .as_ref() + .map(|d| d.max_wire_size(ReportKind::Input)) + .unwrap_or(0); + + let mut in_address = None; + let mut out_address = None; + if let Some(desc) = interface + .descriptors() + .find(|d| d.alternate_setting() == 0) + .or_else(|| interface.descriptor()) + { + for ep in desc.endpoints() { + if ep.transfer_type() != TransferType::Interrupt { + continue; + } + match ep.direction() { + Direction::In if in_address.is_none() => in_address = Some(ep.address()), + Direction::Out if out_address.is_none() => out_address = Some(ep.address()), + _ => {} + } + } + } + + let in_endpoint = in_address + .map(|address| { + interface + .endpoint::(address) + .map_err(|e| HidError::backend(format!("opening interrupt IN endpoint: {e}"))) + }) + .transpose()?; + let out_endpoint = out_address + .map(|address| { + interface + .endpoint::(address) + .map_err(|e| HidError::backend(format!("opening interrupt OUT endpoint: {e}"))) + }) + .transpose()?; + + let transfer_len = match in_endpoint.as_ref() { + Some(endpoint) => { + let max_packet_size = endpoint.max_packet_size(); + if max_packet_size == 0 { + return Err(HidError::backend( + "interrupt IN endpoint declares a zero wMaxPacketSize", + )); + } + transfer_length(max_input_wire, max_packet_size) + } + None => 0, + }; + + let mut info = device_info(dev_info, interface_number); + if let Some((page, usage)) = parsed + .as_ref() + .and_then(|d| d.top_level_usages().first().copied()) + { + info.usage_page = page; + info.usage = usage; + } + + Ok(WebUsbDevice { + interface, + interface_number, + info, + report_descriptor, + in_endpoint: in_endpoint.map(|e| RefCell::new(Some(e))), + out_endpoint: out_endpoint.map(|e| RefCell::new(Some(e))), + transfer_len, + }) + } + + /// Send an output report: interrupt OUT when the interface has one, + /// otherwise `SET_REPORT(Output)` on the control pipe. + pub(crate) async fn write(&self, data: &[u8]) -> HidResult { + if data.is_empty() { + return Err(HidError::InvalidData { + message: "output report must contain a report ID byte".into(), + }); + } + let report_number = data[0]; + let payload = payload_after_report_id(data); + + if let Some(cell) = self.out_endpoint.as_ref() { + let mut endpoint = take_endpoint(cell)?; + let mut buf = Buffer::new(payload.len()); + buf.extend_from_slice(payload); + endpoint.submit(buf); + let completion = endpoint.next_complete().await; + *cell.borrow_mut() = Some(endpoint); + completion + .status + .map_err(|e| transfer_error("interrupt OUT", e))?; + return Ok(completion.actual_len + usize::from(report_number == 0)); + } + + self.interface + .control_out( + ControlOut { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: HID_SET_REPORT, + value: (REPORT_TYPE_OUTPUT << 8) | u16::from(report_number), + index: u16::from(self.interface_number), + data: payload, + }, + TRANSFER_TIMEOUT, + ) + .await + .map_err(|e| transfer_error("SET_REPORT (output)", e))?; + Ok(data.len()) + } + + /// Resolve with one input report from the interrupt IN endpoint. + pub(crate) async fn read(&self, buf: &mut [u8]) -> HidResult { + let cell = self + .in_endpoint + .as_ref() + .ok_or_else(|| HidError::Unsupported { + message: "interface has no interrupt IN endpoint; use get_input_report".into(), + })?; + let mut endpoint = take_endpoint(cell)?; + endpoint.submit(Buffer::new(self.transfer_len)); + let completion = endpoint.next_complete().await; + *cell.borrow_mut() = Some(endpoint); + completion + .status + .map_err(|e| transfer_error("interrupt IN", e))?; + let data = &completion.buffer[..completion.actual_len]; + let len = data.len().min(buf.len()); + buf[..len].copy_from_slice(&data[..len]); + Ok(len) + } + + /// `GET_REPORT`, shared by the feature and input paths. `buf[0]` carries + /// the report ID on entry; for ID 0 the data lands at `buf[1..]`. + async fn get_report( + &self, + report_type: u16, + buf: &mut [u8], + operation: &'static str, + ) -> HidResult { + if buf.is_empty() { + return Err(HidError::InvalidData { + message: "buffer must contain a report ID byte".into(), + }); + } + let report_number = buf[0]; + let offset = usize::from(report_number == 0); + let length = (buf.len() - offset).min(usize::from(u16::MAX)) as u16; + let data = self + .interface + .control_in( + ControlIn { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: HID_GET_REPORT, + value: (report_type << 8) | u16::from(report_number), + index: u16::from(self.interface_number), + length, + }, + TRANSFER_TIMEOUT, + ) + .await + .map_err(|e| transfer_error(operation, e))?; + let len = data.len().min(buf.len() - offset); + buf[offset..offset + len].copy_from_slice(&data[..len]); + Ok(len + offset) + } + + pub(crate) async fn send_feature_report(&self, data: &[u8]) -> HidResult<()> { + if data.is_empty() { + return Err(HidError::InvalidData { + message: "feature report must contain a report ID byte".into(), + }); + } + let report_number = data[0]; + let payload = payload_after_report_id(data); + self.interface + .control_out( + ControlOut { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: HID_SET_REPORT, + value: (REPORT_TYPE_FEATURE << 8) | u16::from(report_number), + index: u16::from(self.interface_number), + data: payload, + }, + TRANSFER_TIMEOUT, + ) + .await + .map_err(|e| transfer_error("SET_REPORT (feature)", e))?; + Ok(()) + } + + pub(crate) async fn get_feature_report(&self, buf: &mut [u8]) -> HidResult { + self.get_report(REPORT_TYPE_FEATURE, buf, "GET_REPORT (feature)") + .await + } + + pub(crate) async fn get_input_report(&self, buf: &mut [u8]) -> HidResult { + self.get_report(REPORT_TYPE_INPUT, buf, "GET_REPORT (input)") + .await + } + + pub(crate) fn get_report_descriptor(&self, buf: &mut [u8]) -> HidResult { + if self.report_descriptor.is_empty() { + return Err(HidError::Unsupported { + message: "the interface exposes no HID report descriptor".into(), + }); + } + let len = self.report_descriptor.len().min(buf.len()); + buf[..len].copy_from_slice(&self.report_descriptor[..len]); + Ok(len) + } + + pub(crate) fn device_info(&self) -> DeviceInfo { + self.info.clone() + } + + pub(crate) fn raw(&self) -> &Interface { + &self.interface + } +} + +/// `GET_DESCRIPTOR(Report)`; `None` on a vendor-class interface with no HID +/// class descriptor, which is the common case for this backend. +async fn read_report_descriptor(interface: &Interface, interface_number: u8) -> Option> { + interface + .control_in( + ControlIn { + control_type: ControlType::Standard, + recipient: Recipient::Interface, + request: GET_DESCRIPTOR, + value: u16::from(DESCRIPTOR_TYPE_HID_REPORT) << 8, + index: u16::from(interface_number), + length: MAX_REPORT_DESCRIPTOR_SIZE as u16, + }, + TRANSFER_TIMEOUT, + ) + .await + .ok() + .filter(|d| !d.is_empty()) +} + +/// Take an endpoint out of its cell for the duration of a transfer. +/// +/// wasm is single-threaded but futures still interleave, so a second transfer +/// started while one is in flight finds the cell empty and is refused rather +/// than aliasing the endpoint. +fn take_endpoint(cell: &RefCell>) -> HidResult { + cell.borrow_mut() + .take() + .ok_or_else(|| HidError::backend("a transfer is already in flight on this endpoint")) +} diff --git a/src/lib.rs b/src/lib.rs index 44f1ff8..9c04941 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,21 +5,44 @@ //! hidra talks to HID devices through native OS interfaces, no C library is //! linked: //! -//! | Target | [`Backend::Native`] | [`Backend::Nusb`] (feature `nusb`) | -//! |--------|---------------------|------------------------------------| -//! | Linux | `hidraw` device nodes + sysfs enumeration | USB interrupt/control transfers via [nusb] | -//! | Windows | `hid.dll` / `SetupAPI` via `windows-sys` declarations | as above | -//! | macOS | `IOHIDManager` via direct framework FFI | as above | +#![cfg_attr( + not(target_arch = "wasm32"), + doc = "| Target | [`Backend::Native`] | [`Backend::Nusb`] (feature `nusb`) | +|--------|---------------------|------------------------------------| +| Linux | `hidraw` device nodes + sysfs enumeration | USB interrupt/control transfers via [nusb] | +| Windows | `hid.dll` / `SetupAPI` via `windows-sys` declarations | as above | +| macOS | `IOHIDManager` via direct framework FFI | as above | +" +)] +#![cfg_attr( + target_arch = "wasm32", + doc = "| Target | Native | `nusb` (feature `nusb`) | +|--------|--------|-------------------------| +| Linux | `hidraw` device nodes + sysfs enumeration | USB interrupt/control transfers via [nusb] | +| Windows | `hid.dll` / `SetupAPI` via `windows-sys` declarations | as above | +| macOS | `IOHIDManager` via direct framework FFI | as above | +" +)] //! -//! On WebAssembly the backend is always -//! [WebHID](https://wicg.github.io/webhid/) via `web-sys`, and [`Backend`] -//! does not exist. +//! On WebAssembly the backend is [`WebHID`](https://wicg.github.io/webhid/) via +//! `web-sys`, and the `Backend` selector does not exist. //! -//! The two native backends coexist in one build: [`Backend`] selects between -//! them per [`Hidra`] instance, at run time, so a program can fall back from -//! one to the other, or drive two devices through different backends at once. -//! [`Hidra::new`] uses [`Backend::default`] (native wherever there is one); -//! [`Hidra::builder`] picks. +#![cfg_attr( + target_arch = "wasm32", + doc = "Devices the browser refuses to expose over `WebHID`, because their interface +declares a vendor-specific class rather than HID, are reachable through +[`webusb`] instead. +" +)] +#![cfg_attr( + not(target_arch = "wasm32"), + doc = "The two native backends coexist in one build: [`Backend`] selects between +them per [`Hidra`] instance, at run time, so a program can fall back from +one to the other, or drive two devices through different backends at once. +[`Hidra::new`] uses [`Backend::default`] (native wherever there is one); +[`Hidra::builder`] picks. +" +)] //! //! ```no_run //! # #[cfg(all(not(target_arch = "wasm32"), feature = "nusb"))] fn demo() -> hidra::HidResult<()> { @@ -843,3 +866,202 @@ mod web { } } } + +/// `WebUSB` entry point, for devices the browser will not expose over `WebHID`. +/// +/// `WebHID` only surfaces interfaces the host recognises as HID. A device whose +/// interface declares a vendor-specific class never appears there, but is still +/// reachable over `WebUSB`, and typically still speaks the HID protocol on the +/// wire. This module drives those devices, mapping the same report calls onto +/// the control and interrupt transfers the native `nusb` backend uses. +/// +/// Blink refuses `claimInterface` on the protected classes, HID (0x03) among +/// them, so [`open`](HidDevice::open) fails for an interface the browser +/// considers HID. That is the division of labour: `WebHID` for those, this for +/// the rest. +#[cfg(all(target_arch = "wasm32", feature = "nusb"))] +pub mod webusb { + use crate::backend::webusb::{WebUsbApi, WebUsbDevice}; + use crate::descriptor::ReportDescriptor; + use crate::{DeviceInfo, HidResult, MAX_REPORT_DESCRIPTOR_SIZE}; + + pub use nusb::DeviceSelector; + + /// Entry point to the library, backed by `WebUSB` (`navigator.usb`). + /// + /// Discovery is browser-shaped, like [`crate::Hidra`] on wasm: only devices + /// the user has granted are visible, so there is no open-by-vid-pid. Call + /// [`request_device`](Self::request_device) to show the chooser and + /// [`device_list`](Self::device_list) for devices already granted. + pub struct Hidra { + backend: WebUsbApi, + } + + impl core::fmt::Debug for Hidra { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Hidra").finish_non_exhaustive() + } + } + + impl Hidra { + /// Bind to `window.navigator.usb`. + pub fn new() -> HidResult { + Ok(Hidra { + backend: WebUsbApi::new()?, + }) + } + + /// Show the browser's device chooser, narrowed by `selectors`. + /// + /// An empty slice offers every device. Resolves with `None` when the + /// user dismisses the chooser without picking one. Must be called from + /// a user gesture, or the browser rejects it. + pub async fn request_device( + &self, + selectors: &[DeviceSelector], + ) -> HidResult> { + Ok(self + .backend + .request_device(selectors) + .await? + .map(|info| Device { info })) + } + + /// Devices the user has already granted access to. + /// + /// Unlike the chooser, this needs no user gesture, so it is the way in + /// for a device granted ahead of time (a `WebUsbAllowDevicesForUrls` + /// policy, or an earlier `request_device` the browser remembered). + pub async fn device_list(&self) -> HidResult> { + Ok(self + .backend + .device_list() + .await? + .into_iter() + .map(|info| Device { info }) + .collect()) + } + } + + /// A granted device, not yet claimed. Call [`open`](Self::open) to claim an + /// interface and get a [`HidDevice`]. + #[derive(Debug)] + pub struct Device { + info: nusb::DeviceInfo, + } + + impl Device { + /// Claim an interface and start talking to it. + /// + /// `interface_number` selects the interface; `None` takes the first the + /// device exposes. Fails when the browser refuses the claim, which it + /// does for the protected classes. + pub async fn open(&self, interface_number: Option) -> HidResult { + Ok(HidDevice { + backend: WebUsbDevice::open(&self.info, interface_number).await?, + }) + } + + /// Vendor ID. + pub fn vendor_id(&self) -> u16 { + self.info.vendor_id() + } + + /// Product ID. + pub fn product_id(&self) -> u16 { + self.info.product_id() + } + + /// Serial number string, if the device reports one. + pub fn serial_number(&self) -> Option<&str> { + self.info.serial_number() + } + + /// Product string, if the device reports one. + pub fn product_string(&self) -> Option<&str> { + self.info.product_string() + } + } + + /// One open device, claimed on a single interface. + pub struct HidDevice { + backend: WebUsbDevice, + } + + impl core::fmt::Debug for HidDevice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("HidDevice").finish_non_exhaustive() + } + } + + impl HidDevice { + /// Send an output report. + /// + /// `data[0]` must be the report ID (0 when the device has no numbered + /// reports). Goes out on the interrupt OUT endpoint when the interface + /// has one, otherwise as `SET_REPORT(Output)`. + pub async fn write(&self, data: &[u8]) -> HidResult { + self.backend.write(data).await + } + + /// Resolve with one input report from the interrupt IN endpoint. + /// + /// Fails with [`HidError::Unsupported`](crate::HidError::Unsupported) + /// on an interface without one; use + /// [`get_input_report`](Self::get_input_report) there. + pub async fn read(&self, buf: &mut [u8]) -> HidResult { + self.backend.read(buf).await + } + + /// Send a feature report via `SET_REPORT(Feature)`. `data[0]` is the + /// report ID. + pub async fn send_feature_report(&self, data: &[u8]) -> HidResult<()> { + self.backend.send_feature_report(data).await + } + + /// Read a feature report via `GET_REPORT(Feature)`. Set `buf[0]` to the + /// report ID before calling. + pub async fn get_feature_report(&self, buf: &mut [u8]) -> HidResult { + self.backend.get_feature_report(buf).await + } + + /// Read an input report via `GET_REPORT(Input)`, without waiting for the + /// device to send one. + pub async fn get_input_report(&self, buf: &mut [u8]) -> HidResult { + self.backend.get_input_report(buf).await + } + + /// Raw report descriptor. + /// + /// Fails with [`HidError::Unsupported`](crate::HidError::Unsupported) + /// on a vendor-class interface, which has no HID class descriptor to + /// read; that is the usual case here. + pub fn get_report_descriptor(&self, buf: &mut [u8]) -> HidResult { + self.backend.get_report_descriptor(buf) + } + + /// The report descriptor as an owned buffer. + pub fn report_descriptor(&self) -> HidResult> { + let mut buf = vec![0u8; MAX_REPORT_DESCRIPTOR_SIZE]; + let len = self.get_report_descriptor(&mut buf)?; + buf.truncate(len); + Ok(buf) + } + + /// The report descriptor, parsed. + pub fn parsed_report_descriptor(&self) -> HidResult { + ReportDescriptor::parse(&self.report_descriptor()?) + } + + /// Enumeration-style metadata for this interface. + pub fn get_device_info(&self) -> HidResult { + Ok(self.backend.device_info()) + } + + /// The underlying `nusb` interface, for transfers outside the HID + /// mapping. + pub fn raw(&self) -> &nusb::Interface { + self.backend.raw() + } + } +}