From b1d19bc86809932f6ab9192b24ae11fb649646c4 Mon Sep 17 00:00:00 2001 From: Karolis Stasaitis Date: Thu, 3 Sep 2026 07:58:41 +0200 Subject: [PATCH 1/4] Add a WebUSB backend for devices WebHID will not expose 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. --- .github/workflows/ci.yml | 7 +- Cargo.toml | 4 + src/backend/mod.rs | 8 +- src/backend/webusb.rs | 408 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 229 ++++++++++++++++++++-- 5 files changed, 641 insertions(+), 15 deletions(-) create mode 100644 src/backend/webusb.rs 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..bc2ad60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,3 +87,7 @@ features = [ "HidReportItem", "HidUnitSystem", ] + +[target.'cfg(target_arch = "wasm32")'.dependencies.nusb] +version = "0.2.7" +optional = true diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 45bf740..bd70d58 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -134,7 +134,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 +159,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..97c8742 --- /dev/null +++ b/src/backend/webusb.rs @@ -0,0 +1,408 @@ +//! `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 + .flat_map(|dev| { + dev.interfaces() + .map(|i| device_info(&dev, i.interface_number())) + .collect::>() + }) + .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..29c9c19 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,183 @@ 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, one entry per + /// interface. + pub async fn device_list(&self) -> HidResult> { + self.backend.device_list().await + } + } + + /// 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() + } + } + + /// 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() + } + } +} From 512e1ee9c84882e6725e6a334f1e88d2aa938d6b Mon Sep 17 00:00:00 2001 From: Karolis Stasaitis Date: Thu, 3 Sep 2026 08:21:06 +0200 Subject: [PATCH 2/4] Document the WebUSB backend 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. --- Cargo.toml | 5 +++-- README.md | 41 ++++++++++++++++++++++++++++++++++++++--- e2e/README.md | 6 ++++++ src/backend/mod.rs | 4 +++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bc2ad60..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] 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..0c1d1cf 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -31,6 +31,12 @@ e2e/ | `webhid` | Linux + Chromium | `uhid` via Playwright | root; own sub-tree (wasm harness + native fixture) — see [`targets/webhid`](targets/webhid/README.md) | | `cynthion` | all | real USB via Cynthion + Facedancer | emulated *real* hardware, not virtual, on both backends — see [`targets/cynthion`](targets/cynthion/README.md) | +The WebUSB backend (`hidra::webusb`) has no target here: it exists for devices +that declare a vendor-specific interface class, which neither `uhid` nor the +`g_hid` gadget can present, and Chromium has no WebUSB equivalent of the +`WebHidAllowDevicesForUrls` policy to skip the chooser. Covering it needs real +hardware — most plausibly the `cynthion` route. + Which hidra backend a suite drives is [`Caps::backend`](shared/conformance/src/lib.rs), picked at run time, so the whole workspace builds at once and one binary can cover both backends (`cynthion` does, via `HIDRA_BACKEND`). diff --git a/src/backend/mod.rs b/src/backend/mod.rs index bd70d58..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; From 90de76ff9f99253f8e21afa08078c11ef7f09e9a Mon Sep 17 00:00:00 2001 From: Karolis Stasaitis Date: Thu, 3 Sep 2026 10:04:34 +0200 Subject: [PATCH 3/4] Add a WebUSB e2e target against a vendor-class gadget 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. --- e2e/README.md | 8 +- e2e/platform/linux/README.md | 6 +- e2e/platform/linux/test-vm.nix | 7 + e2e/targets/webusb/.cargo/config.toml | 6 + e2e/targets/webusb/.gitignore | 5 + e2e/targets/webusb/Cargo.toml | 20 ++ e2e/targets/webusb/README.md | 56 ++++ e2e/targets/webusb/fixture/Cargo.toml | 16 ++ e2e/targets/webusb/fixture/src/main.rs | 346 +++++++++++++++++++++++++ e2e/targets/webusb/index.html | 31 +++ e2e/targets/webusb/package.json | 12 + e2e/targets/webusb/run.sh | 56 ++++ e2e/targets/webusb/src/lib.rs | 104 ++++++++ e2e/targets/webusb/webusb_run.mjs | 98 +++++++ src/backend/webusb.rs | 10 +- src/lib.rs | 27 +- 16 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 e2e/targets/webusb/.cargo/config.toml create mode 100644 e2e/targets/webusb/.gitignore create mode 100644 e2e/targets/webusb/Cargo.toml create mode 100644 e2e/targets/webusb/README.md create mode 100644 e2e/targets/webusb/fixture/Cargo.toml create mode 100644 e2e/targets/webusb/fixture/src/main.rs create mode 100644 e2e/targets/webusb/index.html create mode 100644 e2e/targets/webusb/package.json create mode 100755 e2e/targets/webusb/run.sh create mode 100644 e2e/targets/webusb/src/lib.rs create mode 100644 e2e/targets/webusb/webusb_run.mjs diff --git a/e2e/README.md b/e2e/README.md index 0c1d1cf..d9e87cc 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -29,14 +29,9 @@ 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) | -The WebUSB backend (`hidra::webusb`) has no target here: it exists for devices -that declare a vendor-specific interface class, which neither `uhid` nor the -`g_hid` gadget can present, and Chromium has no WebUSB equivalent of the -`WebHidAllowDevicesForUrls` policy to skip the chooser. Covering it needs real -hardware — most plausibly the `cynthion` route. - Which hidra backend a suite drives is [`Caps::backend`](shared/conformance/src/lib.rs), picked at run time, so the whole workspace builds at once and one binary can cover both backends (`cynthion` does, via `HIDRA_BACKEND`). @@ -51,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..ad60b50 --- /dev/null +++ b/e2e/targets/webusb/README.md @@ -0,0 +1,56 @@ +# 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. + +## 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..d02418a --- /dev/null +++ b/e2e/targets/webusb/fixture/src/main.rs @@ -0,0 +1,346 @@ +//! 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; + +/// One interface descriptor plus two interrupt endpoints, FS and HS alike. +fn function_descriptors() -> Vec { + let mut d = Vec::new(); + // interface: 2 endpoints, class 0xFF (vendor-specific) — the whole point. + d.extend_from_slice(&[9, 0x04, 0, 0, 2, 0xFF, 0x00, 0x00, 1]); + // 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() -> Vec { + let descs = function_descriptors(); + 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()); + blob.extend_from_slice(&3u32.to_le_bytes()); // fs: interface + 2 endpoints + blob.extend_from_slice(&3u32.to_le_bytes()); // hs: same + 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) { + 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, request_type, request, value, length); + } + _ => {} + } + } +} + +fn handle_setup(ep0: &mut File, 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) => { + 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() { + 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()) + .expect("write descriptors"); + ep0.write_all(&strings_blob()).expect("write strings"); + + bind_udc(); + + // The endpoint files only appear once the descriptors are accepted. + 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"); + + thread::spawn(move || ep0_loop(ep0)); + + // 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..8f46b16 --- /dev/null +++ b/e2e/targets/webusb/index.html @@ -0,0 +1,31 @@ + + + + + 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..b6968be --- /dev/null +++ b/e2e/targets/webusb/run.sh @@ -0,0 +1,56 @@ +#!/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 +grep -q READY "$FIXLOG" || { echo "fixture never became READY"; cat "$FIXLOG"; exit 3; } + +cd "$WEB" +CHROMIUM="$CHROMIUM" HOME="${HOME:-/root}" "$XVFB_RUN" -a "$NODE" webusb_run.mjs +RC=$? + +echo "--- fixture (device side) saw ---" +grep -E 'OUTPUT|SET_REPORT|GET_REPORT|GET_DESCRIPTOR|STALL' "$FIXLOG" || echo "(nothing captured)" +rm -f "$FIXLOG" +exit $RC diff --git a/e2e/targets/webusb/src/lib.rs b/e2e/targets/webusb/src/lib.rs new file mode 100644 index 0000000..50d1d7c --- /dev/null +++ b/e2e/targets/webusb/src/lib.rs @@ -0,0 +1,104 @@ +//! 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}")) +} + +#[wasm_bindgen] +pub async fn run_webusb_test() -> Result { + 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. + let descriptor = dev.report_descriptor().map_err(jerr)?; + if descriptor.is_empty() { + return Err(JsValue::from_str("empty report descriptor")); + } + log.push(format!("report_descriptor={} bytes", descriptor.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()); + + // Interrupt OUT. + 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")); + + // 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")); + + Ok(format!("PASS: webusb conformance ({})", log.join("; "))) +} diff --git a/e2e/targets/webusb/webusb_run.mjs b/e2e/targets/webusb/webusb_run.mjs new file mode 100644 index 0000000..fcf0a99 --- /dev/null +++ b/e2e/targets/webusb/webusb_run.mjs @@ -0,0 +1,98 @@ +// 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 url = `http://localhost:${PORT}/index.html`; + + 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/webusb.rs b/src/backend/webusb.rs index 97c8742..7c8efb5 100644 --- a/src/backend/webusb.rs +++ b/src/backend/webusb.rs @@ -76,17 +76,11 @@ impl WebUsbApi { } /// Devices the user has already granted access to. - pub(crate) async fn device_list(&self) -> HidResult> { + 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 - .flat_map(|dev| { - dev.interfaces() - .map(|i| device_info(&dev, i.interface_number())) - .collect::>() - }) - .collect()) + Ok(devices.collect()) } /// Show the browser's device chooser, resolving with the granted device. diff --git a/src/lib.rs b/src/lib.rs index 29c9c19..9c04941 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -927,10 +927,19 @@ pub mod webusb { .map(|info| Device { info })) } - /// Devices the user has already granted access to, one entry per - /// interface. - pub async fn device_list(&self) -> HidResult> { - self.backend.device_list().await + /// 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()) } } @@ -962,6 +971,16 @@ pub mod webusb { 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. From 982b5f07971b697fb9936908365a736b8dac4bb7 Mon Sep 17 00:00:00 2001 From: Karolis Stasaitis Date: Thu, 3 Sep 2026 11:00:36 +0200 Subject: [PATCH 4/4] Cover the endpoint-less and descriptor-less device shapes 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. --- e2e/targets/webusb/README.md | 14 +++ e2e/targets/webusb/fixture/src/main.rs | 157 +++++++++++++++++-------- e2e/targets/webusb/index.html | 3 +- e2e/targets/webusb/run.sh | 49 +++++--- e2e/targets/webusb/src/lib.rs | 79 +++++++++---- e2e/targets/webusb/webusb_run.mjs | 3 +- 6 files changed, 216 insertions(+), 89 deletions(-) diff --git a/e2e/targets/webusb/README.md b/e2e/targets/webusb/README.md index ad60b50..067d762 100644 --- a/e2e/targets/webusb/README.md +++ b/e2e/targets/webusb/README.md @@ -25,6 +25,20 @@ does not cascade into the native fixture: 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, diff --git a/e2e/targets/webusb/fixture/src/main.rs b/e2e/targets/webusb/fixture/src/main.rs index d02418a..33831bc 100644 --- a/e2e/targets/webusb/fixture/src/main.rs +++ b/e2e/targets/webusb/fixture/src/main.rs @@ -51,20 +51,54 @@ const HID_SET_REPORT: u8 = 0x09; const GET_DESCRIPTOR: u8 = 0x06; const DESCRIPTOR_TYPE_HID_REPORT: u8 = 0x22; -/// One interface descriptor plus two interrupt endpoints, FS and HS alike. -fn function_descriptors() -> Vec { - let mut d = Vec::new(); - // interface: 2 endpoints, class 0xFF (vendor-specific) — the whole point. - d.extend_from_slice(&[9, 0x04, 0, 0, 2, 0xFF, 0x00, 0x00, 1]); - // 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]); +/// 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() -> Vec { - let descs = function_descriptors(); +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; @@ -73,8 +107,9 @@ fn descriptor_blob() -> Vec { 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()); - blob.extend_from_slice(&3u32.to_le_bytes()); // fs: interface + 2 endpoints - blob.extend_from_slice(&3u32.to_le_bytes()); // hs: same + 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 @@ -217,7 +252,7 @@ fn bind_udc() { } /// Service ep0: the control requests the host aims at our interface. -fn ep0_loop(mut ep0: File) { +fn ep0_loop(mut ep0: File, variant: Variant) { let mut event = [0u8; 12]; loop { match ep0.read(&mut event) { @@ -240,21 +275,31 @@ fn ep0_loop(mut ep0: File) { 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, request_type, request, value, length); + handle_setup(&mut ep0, variant, request_type, request, value, length); } _ => {} } } } -fn handle_setup(ep0: &mut File, request_type: u8, request: u8, value: u16, length: usize) { +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) => { + (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]); @@ -287,6 +332,8 @@ fn handle_setup(ep0: &mut File, request_type: u8, request: u8, value: u16, lengt } fn main() { + let arg = std::env::args().nth(1); + let variant = Variant::from_arg(arg.as_deref()); setup_gadget(); mount_ffs(); @@ -295,49 +342,55 @@ fn main() { .write(true) .open(format!("{FFS_MOUNT}/ep0")) .expect("open ep0"); - ep0.write_all(&descriptor_blob()) + 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. - 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"); - - thread::spawn(move || ep0_loop(ep0)); - - // 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)); - } - } + // 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) }); - // 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)), + 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 { diff --git a/e2e/targets/webusb/index.html b/e2e/targets/webusb/index.html index 8f46b16..dfb18a1 100644 --- a/e2e/targets/webusb/index.html +++ b/e2e/targets/webusb/index.html @@ -17,7 +17,8 @@

hidra WebUSB test

document.getElementById("run").addEventListener("click", async () => { result.textContent = "running"; try { - const summary = await run_webusb_test(); + const variant = new URLSearchParams(location.search).get("variant") || "full"; + const summary = await run_webusb_test(variant); result.textContent = summary; window.__hidraResult = { ok: true, summary }; } catch (e) { diff --git a/e2e/targets/webusb/run.sh b/e2e/targets/webusb/run.sh index b6968be..d9de51c 100755 --- a/e2e/targets/webusb/run.sh +++ b/e2e/targets/webusb/run.sh @@ -40,17 +40,38 @@ cat > "$POLICY" < "$FIXLOG" 2>&1 & -FPID=$! -for _ in $(seq 1 50); do grep -q READY "$FIXLOG" 2>/dev/null && break; sleep 0.2; done -grep -q READY "$FIXLOG" || { echo "fixture never became READY"; cat "$FIXLOG"; exit 3; } - -cd "$WEB" -CHROMIUM="$CHROMIUM" HOME="${HOME:-/root}" "$XVFB_RUN" -a "$NODE" webusb_run.mjs -RC=$? - -echo "--- fixture (device side) saw ---" -grep -E 'OUTPUT|SET_REPORT|GET_REPORT|GET_DESCRIPTOR|STALL' "$FIXLOG" || echo "(nothing captured)" -rm -f "$FIXLOG" -exit $RC +# Each variant is a different device shape, so the gadget is torn down and +# rebuilt between them. +# +# full two interrupt endpoints and a report descriptor +# control-only bNumEndpoints 0 — every report over the control pipe, +# which is what the Sinowealth ISP bootloaders look like +# no-report-descriptor endpoints, but GET_DESCRIPTOR(Report) stalls, the +# usual shape for a vendor-class interface +VARIANTS="${VARIANTS:-full control-only no-report-descriptor}" +STATUS=0 + +for variant in $VARIANTS; do + echo "########## webusb variant: $variant ##########" + FIXLOG="$(mktemp)" + "$FIXTURE" "$variant" > "$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 index 50d1d7c..9616531 100644 --- a/e2e/targets/webusb/src/lib.rs +++ b/e2e/targets/webusb/src/lib.rs @@ -17,8 +17,12 @@ 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() -> Result { +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(); @@ -54,12 +58,21 @@ pub async fn run_webusb_test() -> Result { } log.push(format!("interface_number={}", info.interface_number())); - // GET_DESCRIPTOR(Report) over the vendor-class interface. - let descriptor = dev.report_descriptor().map_err(jerr)?; - if descriptor.is_empty() { - return Err(JsValue::from_str("empty report descriptor")); + // 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() + ))) + } } - log.push(format!("report_descriptor={} bytes", descriptor.len())); // GET_REPORT(Feature): buf[0] carries the report ID on entry. let mut buf = vec![0u8; 1 + FEAT_PAYLOAD.len()]; @@ -79,26 +92,50 @@ pub async fn run_webusb_test() -> Result { dev.send_feature_report(&out).await.map_err(jerr)?; log.push("send_feature_report ok".into()); - // Interrupt OUT. + // 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")); - // 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] - ))); + 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")); } - log.push(format!("read={n} bytes")); - Ok(format!("PASS: webusb conformance ({})", log.join("; "))) + Ok(format!( + "PASS: webusb conformance [{variant}] ({})", + log.join("; ") + )) } diff --git a/e2e/targets/webusb/webusb_run.mjs b/e2e/targets/webusb/webusb_run.mjs index fcf0a99..8167b14 100644 --- a/e2e/targets/webusb/webusb_run.mjs +++ b/e2e/targets/webusb/webusb_run.mjs @@ -51,7 +51,8 @@ const PORT = 8098; async function main() { const server = await startServer(); - const url = `http://localhost:${PORT}/index.html`; + 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,