From 8611603a87644dc9bccbd904d4bff77dfab9f891 Mon Sep 17 00:00:00 2001 From: ydw1904 Date: Tue, 25 Aug 2026 16:32:22 +0800 Subject: [PATCH] feat(hid): serve native HID to the browser over the loopback socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firefox and Safari have no WebHID, so the OpenMouse control panel cannot reach a mouse there at all. This adds `GET /v1/hid`, a WebSocket carrying the same primitives WebHID exposes — enumerate, open, send/receive reports, stream input reports — so the web app can implement `navigator.hid` on top of Bridge and run its existing driver classes unchanged. No vendor protocol is reimplemented here. `@openmouse/protocol`'s drivers stay the single source of truth; this is transport only. The report descriptor is parsed (src/hid/descriptor.rs) rather than left empty. Every driver's `isSupported()` reads `device.collections`, including the report ids inside them, so an adapter that reports none fails the whole registry — which is why `native-hid/` and Desktop both had to bypass auto-detection and hand-maintain a brand table. Parsing here means the web app's own registry works over this socket exactly as it does over WebHID, with no second device list to keep in sync. Security, both deliberate and load-bearing: - The handshake's Origin is checked against `allowedOrigins` by hand. CORS does not apply to a WebSocket handshake, so without this any page the user visits could enumerate and write to their mouse. - Generic Desktop mouse and keyboard collections are never listed or opened, matching what Chrome withholds from WebHID. Opening one natively freezes the device's own input on macOS. - Enumeration is scoped to the vendor ids the client asks for, so a page never learns about HID devices OpenMouse has no driver for. Two hardware findings from this project's other native adapters are carried over rather than rediscovered: writes try every enumerated split of an interface and remember which one answered a given report id, and the reader thread uses try_lock plus an unconditional sleep outside the lock, without which a writer can be starved indefinitely. hidapi gains the `macos-shared-device` feature: Darwin opens exclusively by default, which freezes the mouse for as long as a handle is held. Not yet validated against real hardware — the descriptor parser and the socket protocol are unit tested, but no supported mouse was reachable from the machine this was written on. --- Cargo.lock | 159 ++++++++++++++++- Cargo.toml | 7 +- README.md | 34 +++- src/api.rs | 17 +- src/hid/descriptor.rs | 362 +++++++++++++++++++++++++++++++++++++ src/hid/mod.rs | 404 ++++++++++++++++++++++++++++++++++++++++++ src/hid/socket.rs | 344 +++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 8 files changed, 1315 insertions(+), 13 deletions(-) create mode 100644 src/hid/descriptor.rs create mode 100644 src/hid/mod.rs create mode 100644 src/hid/socket.rs diff --git a/Cargo.lock b/Cargo.lock index 5eca0fd..28fb696 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,6 +416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -434,8 +435,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -482,6 +485,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -677,8 +689,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -758,6 +770,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -797,18 +818,44 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -1277,6 +1324,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1291,6 +1344,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -1354,6 +1408,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1399,7 +1463,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -3008,6 +3072,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3126,7 +3199,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -3174,6 +3247,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -3182,7 +3265,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -3197,7 +3299,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3540,6 +3642,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3977,6 +4090,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "toml" version = "0.8.2" @@ -4209,6 +4334,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.20", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index eb2c702..33a9b6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ winresource = "0.1" [dependencies] anyhow = "1.0" -axum = "0.8" +axum = { version = "0.8", features = ["ws"] } directories = "6.0" -hidapi = "2.6.6" +# macos-shared-device turns off Darwin's exclusive-by-default open. CONFIRMED +# on real hardware (see Desktop's src-tauri/src/hid.rs): an exclusive open +# freezes the mouse's own cursor motion for as long as the handle is held. +hidapi = { version = "2.6.6", features = ["macos-shared-device"] } notify-rust = "4.11" png = "0.18" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/README.md b/README.md index 4b998e0..5c3dfdf 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,40 @@ explicit configuration file. Only configured web origins receive CORS access. The listener never binds to a LAN or public interface. +## Native HID for browsers without WebHID + +`GET /v1/hid` upgrades to a WebSocket carrying raw HID: enumerate, open, +send and receive reports, and a stream of input reports. It exists so Firefox +and other browsers with no WebHID can run the OpenMouse control panel exactly +as Chrome does — the web app wraps this socket back into a `navigator.hid` +shim, and `@openmouse/protocol`'s driver classes run unchanged on top of it. + +Unlike the rest of the API, this endpoint parses each device's HID report +descriptor (`src/hid/descriptor.rs`) and reports real `collections`. That is +what lets the web app's driver registry auto-detect a mouse here the same way +it does over WebHID, instead of falling back to a hand-maintained brand table +the way `native-hid/` has to. + +Two rules are enforced on every socket: + +- The handshake must carry an `Origin` from `allowedOrigins`. A WebSocket + handshake is not covered by CORS, so this check is made by hand — it is all + that stands between any page the user visits and their mouse. +- Generic Desktop mouse and keyboard collections are never listed or opened. + Chrome withholds the same ones from WebHID, and opening one natively freezes + the device's own input on macOS. + +Enumeration is limited to the vendor ids the client asks for, which the web app +takes from its own supported-device filters, so a page never learns about HID +devices OpenMouse has no driver for. Every device a socket opened is closed +when it disconnects. + ## Current boundary Battery readings initially come from the connected OpenMouse control panel. -True alerts while the browser is closed require native HID/protocol support in -Bridge and are a later milestone. Game detection already runs independently in -the background. +True alerts while the browser is closed require Bridge to poll a device on its +own schedule; `/v1/hid` only moves reports while a browser tab is driving it. +Game detection already runs independently in the background. ## Verify diff --git a/src/api.rs b/src/api.rs index 00acfa4..ea453e3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,10 +1,12 @@ use std::time::Duration; +use std::sync::Arc; + use axum::{ Json, Router, body::Body, extract::{Path, State}, - http::{HeaderValue, Method, Response, StatusCode, header}, + http::{HeaderMap, HeaderValue, Method, Response, StatusCode, header}, routing::{get, put}, }; use serde::{Deserialize, Serialize}; @@ -12,7 +14,7 @@ use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::Tra use crate::{ config::{ApplicationProfile, GameConfig}, - platform, + hid, platform, service::{BatteryReading, BridgeService}, }; @@ -60,6 +62,17 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/default-profile", put(set_default_profile)) .route("/v1/battery", put(record_battery)) .route("/v1/autostart", put(set_autostart)) + // Native HID for browsers without WebHID. Its own origin check runs + // inside the handler: a WebSocket handshake never passes through CORS. + .route( + "/v1/hid", + get({ + let origins = Arc::new(origins.to_vec()); + move |upgrade, headers: HeaderMap| { + hid::socket::upgrade(upgrade, headers, origins.clone()) + } + }), + ) .layer(SetResponseHeaderLayer::if_not_present( axum::http::HeaderName::from_static("access-control-allow-private-network"), HeaderValue::from_static("true"), diff --git a/src/hid/descriptor.rs b/src/hid/descriptor.rs new file mode 100644 index 0000000..0ee10fb --- /dev/null +++ b/src/hid/descriptor.rs @@ -0,0 +1,362 @@ +//! A minimal HID report-descriptor parser, just large enough to rebuild the +//! WebHID `collections` tree. +//! +//! Every `@openmouse/protocol` driver decides whether it owns a device in a +//! static `isSupported(device)` that reads `device.collections` — not only +//! the top-level usage page/usage, but the report ids declared inside it +//! (Pulsar matches "one input and one output report, both id 0x08"; WLMouse +//! walks `children` for a feature report id). A bridge that reports no +//! collections therefore fails every one of those checks, which is why both +//! earlier adapters in this project (`native-hid/src/hid-device-adapter.mjs` +//! and Desktop's `TauriHidDevice`) had to bypass the driver registry and +//! hand-maintain a brand table instead. Parsing the descriptor here lets the +//! web app's own registry auto-detect through Bridge exactly as it does over +//! WebHID, with no second list of devices to keep in sync. +//! +//! Only the items that shape `collections` are interpreted: usage page, +//! usage, report id, report size, report count, push/pop, collection, +//! end collection, and the three main data items. Logical/physical ranges, +//! units, and string/designator indices are skipped — nothing reads them. + +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportItem { + pub report_size: u32, + pub report_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportInfo { + pub report_id: u8, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionInfo { + pub usage_page: u16, + pub usage: u16, + pub input_reports: Vec, + pub output_reports: Vec, + pub feature_reports: Vec, + pub children: Vec, +} + +impl CollectionInfo { + /// Whether a page may never touch this collection. + /// + /// These are the usages Chrome withholds from WebHID: the ones the + /// operating system itself reads for cursor motion and keystrokes, and + /// authenticators. Matching that list is both a parity and a safety + /// property — opening one of these natively also freezes the device's own + /// input on macOS (confirmed on real hardware, see Desktop's + /// `src-tauri/src/hid.rs`). + /// + /// This is not a nicety for mice specifically. A Keychron M6 on Bluetooth + /// exposes nothing but these collections, so without the check it would be + /// offered to the page as a device no driver can drive. + pub fn protected(&self) -> bool { + match (self.usage_page, self.usage) { + // Generic Desktop: pointer, mouse, keyboard, keypad. + (0x01, 0x01 | 0x02 | 0x06 | 0x07) => true, + // Keyboard/Keypad and FIDO pages, whatever the usage. + (0x07 | 0xF1D0, _) => true, + // Consumer Control: media and system keys. + (0x0C, 0x01) => true, + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct Globals { + usage_page: u16, + report_id: u8, + report_size: u32, + report_count: u32, +} + +/// Parses a raw report descriptor into its top-level collections. +/// +/// Malformed input is truncated rather than rejected: a descriptor that ends +/// mid-item, or that never closes a collection, still yields everything +/// parsed up to that point. A device with a slightly wrong descriptor is +/// common enough in this hardware class that refusing to list it would be +/// worse than describing the part that made sense. +pub fn parse(bytes: &[u8]) -> Vec { + let mut top: Vec = Vec::new(); + let mut open: Vec = Vec::new(); + let mut globals = Globals::default(); + let mut saved: Vec = Vec::new(); + let mut usages: Vec = Vec::new(); + let mut index = 0usize; + + while index < bytes.len() { + let prefix = bytes[index]; + index += 1; + + // Long items carry their own size byte and are never used by the + // devices here; skip the whole item. + if prefix == 0xFE { + let size = bytes.get(index).copied().unwrap_or(0) as usize; + index = index.saturating_add(2).saturating_add(size); + continue; + } + + let size = match prefix & 0x03 { + 3 => 4, + other => other as usize, + }; + let Some(data) = bytes.get(index..index + size) else { + break; + }; + index += size; + let value = data + .iter() + .enumerate() + .fold(0u32, |accumulator, (offset, byte)| { + accumulator | (u32::from(*byte) << (8 * offset)) + }); + + match prefix & 0xFC { + // Main: Collection + 0xA0 => { + let (usage_page, usage) = + resolve_usage(globals.usage_page, usages.first().copied()); + open.push(CollectionInfo { + usage_page, + usage, + ..CollectionInfo::default() + }); + usages.clear(); + } + // Main: End Collection + 0xC0 => { + close(&mut open, &mut top); + usages.clear(); + } + // Main: Input / Output / Feature + 0x80 | 0x90 | 0xB0 => { + if let Some(current) = open.last_mut() { + let reports = match prefix & 0xFC { + 0x80 => &mut current.input_reports, + 0x90 => &mut current.output_reports, + _ => &mut current.feature_reports, + }; + let item = ReportItem { + report_size: globals.report_size, + report_count: globals.report_count, + }; + match reports + .iter_mut() + .find(|report| report.report_id == globals.report_id) + { + Some(report) => report.items.push(item), + None => reports.push(ReportInfo { + report_id: globals.report_id, + items: vec![item], + }), + } + } + usages.clear(); + } + 0x04 => globals.usage_page = value as u16, + 0x84 => globals.report_id = value as u8, + 0x74 => globals.report_size = value, + 0x94 => globals.report_count = value, + 0xA4 => saved.push(globals), + 0xB4 => { + if let Some(previous) = saved.pop() { + globals = previous; + } + } + // Local: Usage. A four-byte usage carries its own page in the + // high half and does not disturb the global page. + 0x08 => usages.push(if size == 4 { value } else { value & 0xFFFF }), + _ => {} + } + } + + // An unterminated collection still describes a real device. + while !open.is_empty() { + close(&mut open, &mut top); + } + top +} + +fn close(open: &mut Vec, top: &mut Vec) { + let Some(finished) = open.pop() else { return }; + match open.last_mut() { + Some(parent) => parent.children.push(finished), + None => top.push(finished), + } +} + +fn resolve_usage(global_page: u16, usage: Option) -> (u16, u16) { + match usage { + Some(value) if value > 0xFFFF => ((value >> 16) as u16, value as u16), + Some(value) => (global_page, value as u16), + None => (global_page, 0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shape every vendor control interface in this project has, and the + /// exact thing `PulsarHidClient.isSupported()` looks for: one input and + /// one output report sharing a report id, on a vendor usage page. + #[test] + fn parses_a_vendor_control_collection() { + let descriptor = [ + 0x06, 0x00, 0xFF, // Usage Page (0xFF00) + 0x09, 0x01, // Usage (0x01) + 0xA1, 0x01, // Collection (Application) + 0x85, 0x08, // Report ID (8) + 0x09, 0x02, // Usage (0x02) + 0x75, 0x08, // Report Size (8) + 0x95, 0x3F, // Report Count (63) + 0x81, 0x00, // Input + 0x09, 0x03, // Usage (0x03) + 0x91, 0x00, // Output + 0xC0, // End Collection + ]; + + let collections = parse(&descriptor); + + assert_eq!(collections.len(), 1); + let collection = &collections[0]; + assert_eq!(collection.usage_page, 0xFF00); + assert_eq!(collection.usage, 0x01); + assert_eq!( + collection.input_reports, + vec![ReportInfo { + report_id: 8, + items: vec![ReportItem { + report_size: 8, + report_count: 63 + }], + }] + ); + assert_eq!(collection.output_reports.len(), 1); + assert_eq!(collection.output_reports[0].report_id, 8); + assert!(collection.feature_reports.is_empty()); + assert!(!collection.protected()); + } + + #[test] + fn protects_exactly_the_usages_a_browser_withholds() { + let collection = |usage_page: u16, usage: u16| CollectionInfo { + usage_page, + usage, + ..CollectionInfo::default() + }; + + for (page, usage) in [ + (0x01, 0x01), + (0x01, 0x02), + (0x01, 0x06), + (0x01, 0x07), + (0x07, 0x00), + (0x0C, 0x01), + (0xF1D0, 0x01), + ] { + assert!( + collection(page, usage).protected(), + "0x{page:04x}:0x{usage:02x} must be protected" + ); + } + for (page, usage) in [ + (0x01, 0x04), + (0x0C, 0x02), + (0xFF00, 0x01), + (0xFFC1, 0x01), + (0xFF60, 0x61), + ] { + assert!( + !collection(page, usage).protected(), + "0x{page:04x}:0x{usage:02x} must stay reachable" + ); + } + } + + /// A boot mouse: nested physical collection, no report id, and the + /// protected usage the bridge must never open. + #[test] + fn nests_children_and_flags_the_protected_mouse_collection() { + let descriptor = [ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xA1, 0x01, // Collection (Application) + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x75, 0x08, // Report Size (8) + 0x95, 0x03, // Report Count (3) + 0x81, 0x06, // Input + 0xC0, // End Collection + 0xC0, // End Collection + ]; + + let collections = parse(&descriptor); + + assert_eq!(collections.len(), 1); + assert!(collections[0].protected()); + assert!(collections[0].input_reports.is_empty()); + let child = &collections[0].children[0]; + assert_eq!((child.usage_page, child.usage), (0x01, 0x01)); + assert_eq!(child.input_reports[0].report_id, 0); + // Pointer is protected too: a Keychron M6 on Bluetooth exposes only + // collections like these, and none of them may reach a page. + assert!(child.protected()); + } + + /// Feature reports on a child collection, which is where WLMouse keeps + /// its config channel, plus a global push/pop around them. + #[test] + fn parses_feature_reports_under_a_push_pop_pair() { + let descriptor = [ + 0x06, 0xC0, 0xFF, // Usage Page (0xFFC0) + 0x09, 0x01, // Usage (0x01) + 0xA1, 0x01, // Collection (Application) + 0x09, 0x02, // Usage (0x02) + 0xA1, 0x02, // Collection (Logical) + 0xA4, // Push + 0x85, 0x05, // Report ID (5) + 0x75, 0x08, // Report Size (8) + 0x95, 0x20, // Report Count (32) + 0xB1, 0x02, // Feature + 0xB4, // Pop + 0x85, 0x06, // Report ID (6) + 0xB1, 0x02, // Feature + 0xC0, // End Collection + 0xC0, // End Collection + ]; + + let collections = parse(&descriptor); + let child = &collections[0].children[0]; + + assert_eq!(child.feature_reports.len(), 2); + assert_eq!(child.feature_reports[0].report_id, 5); + // Pop restored the report id/size/count that were live before Push, + // so the second feature report is a fresh id with zeroed sizing. + assert_eq!(child.feature_reports[1].report_id, 6); + assert_eq!(child.feature_reports[1].items[0].report_size, 0); + } + + /// A descriptor that ends mid-item still describes what came before it. + #[test] + fn truncated_input_is_kept_not_discarded() { + let descriptor = [ + 0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x08, 0x81, 0x00, 0x06, 0x00, + ]; + + let collections = parse(&descriptor); + + assert_eq!(collections.len(), 1); + assert_eq!(collections[0].input_reports[0].report_id, 8); + } +} diff --git a/src/hid/mod.rs b/src/hid/mod.rs new file mode 100644 index 0000000..8288790 --- /dev/null +++ b/src/hid/mod.rs @@ -0,0 +1,404 @@ +//! Native HID access for the OpenMouse web app, so a browser without WebHID +//! (Firefox, Safari) can still drive a mouse through Bridge. +//! +//! The web app talks to `@openmouse/protocol`'s driver classes, and those are +//! written against WebHID's `HIDDevice`. Rather than reimplement any vendor +//! protocol here, this module exposes the same primitives WebHID does — +//! enumerate, open, send/receive reports, stream input reports — over the +//! loopback socket in `crate::api`, and the web app wraps them back into a +//! `navigator.hid` shim. The drivers never learn they are not in Chrome. +//! +//! Two behaviours here are hardware findings from this project's other native +//! HID adapters, not stylistic choices, and both are load-bearing: +//! +//! * The Generic Desktop mouse and keyboard collections are never opened. +//! Chrome refuses to expose them to WebHID for the same reason: opening one +//! freezes the device's own input on macOS. `descriptor::CollectionInfo::protected` +//! is the check; on entry it runs against the enumerated usage before any +//! handle is opened. +//! * One logical device is usually several enumerated entries ("splits"), one +//! per top-level collection, and a given report id may only be answerable on +//! one of them. Writes try every split and remember which one answered, as +//! Desktop's `src-tauri/src/hid.rs` and `native-hid/src/hid-device-adapter.mjs` +//! both had to learn. + +pub mod descriptor; +pub mod socket; + +use std::{ + collections::HashMap, + ffi::CString, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Result, anyhow, bail}; +use hidapi::{HidApi, HidDevice}; +use serde::Serialize; +use tokio::sync::mpsc::UnboundedSender; + +use descriptor::CollectionInfo; + +/// How long a blocking read waits before the reader checks its stop flag. +const READ_POLL_TIMEOUT_MS: i32 = 200; +/// WebHID sizes a feature report from the parsed descriptor; hidapi needs the +/// caller to say. 64 covers every report the OpenMouse drivers ask for, and a +/// shorter reply is simply the leading bytes of it. +const FEATURE_REPORT_LENGTH: usize = 64; +/// Largest report descriptor HID allows. +const DESCRIPTOR_LENGTH: usize = 4096; + +/// One logical device, in the shape the WebHID shim needs to build an +/// `HIDDevice` on the other side. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceSummary { + pub key: String, + pub vendor_id: u16, + pub product_id: u16, + pub product_name: String, + pub collections: Vec, +} + +/// An input report on its way to the browser. +pub struct InputReport { + pub key: String, + pub report_id: u8, + pub data: Vec, +} + +struct Split { + device: Mutex, + stop: Arc, +} + +struct OpenGroup { + splits: Vec>, + readers: Vec>, + /// Which split answered a given report id last, so the next request for + /// that id starts where it succeeded instead of re-probing from the top. + routes: Mutex>, +} + +impl Drop for OpenGroup { + fn drop(&mut self) { + for split in &self.splits { + split.stop.store(true, Ordering::Relaxed); + } + for reader in self.readers.drain(..) { + let _ = reader.join(); + } + } +} + +/// Native HID for one connected browser socket. Dropping it closes every +/// device that socket opened. +pub struct HidSession { + api: HidApi, + /// Logical device key to the enumerated paths behind it. + paths: HashMap>, + /// Parsed collections per path. A descriptor never changes for a given + /// path, and reading one costs an open, so it is read at most once. + descriptors: HashMap>, + open: HashMap, + reports: UnboundedSender, +} + +impl HidSession { + pub fn new(reports: UnboundedSender) -> Result { + Ok(Self { + api: HidApi::new()?, + paths: HashMap::new(), + descriptors: HashMap::new(), + open: HashMap::new(), + reports, + }) + } + + /// Every connectable device for the given vendor ids, with its collections. + /// + /// The vendor ids come from the web app's own `SUPPORTED_HID_FILTERS`, so + /// the browser never learns about HID devices OpenMouse has no driver for + /// — keyboards, security keys, and the rest stay invisible to the page. + pub fn list(&mut self, vendor_ids: &[u16]) -> Result> { + self.api.refresh_devices()?; + + let mut groups: Vec<(String, Vec, u16, u16, String)> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for info in self.api.device_list() { + if !vendor_ids.contains(&info.vendor_id()) { + continue; + } + // Checked before any handle is opened — see the module docs. + let probe = CollectionInfo { + usage_page: info.usage_page(), + usage: info.usage(), + ..CollectionInfo::default() + }; + if probe.protected() { + continue; + } + let key = format!( + "{:04x}:{:04x}:{}", + info.vendor_id(), + info.product_id(), + info.interface_number() + ); + let path = info.path().to_owned(); + match index.get(&key) { + Some(position) => { + let paths: &mut Vec = &mut groups[*position].1; + if !paths.contains(&path) { + paths.push(path); + } + } + None => { + index.insert(key.clone(), groups.len()); + groups.push(( + key, + vec![path], + info.vendor_id(), + info.product_id(), + info.product_string().unwrap_or_default().to_string(), + )); + } + } + } + + let mut summaries = Vec::new(); + self.paths.clear(); + for (key, paths, vendor_id, product_id, product_name) in groups { + let mut collections = Vec::new(); + for path in &paths { + collections.extend( + self.collections_for(path) + .into_iter() + .filter(|collection| !collection.protected()), + ); + } + // Nothing a page may touch: not a device as far as WebHID is + // concerned, so do not advertise it. + if collections.is_empty() { + continue; + } + self.paths.insert(key.clone(), paths); + summaries.push(DeviceSummary { + key, + vendor_id, + product_id, + product_name, + collections, + }); + } + Ok(summaries) + } + + /// Reads and parses one path's report descriptor, caching the result. + /// A path that cannot be opened or read contributes no collections rather + /// than failing the whole enumeration — one busy interface must not hide + /// every other mouse on the system. + fn collections_for(&mut self, path: &CString) -> Vec { + if let Some(cached) = self.descriptors.get(path) { + return cached.clone(); + } + let parsed = self + .api + .open_path(path) + .ok() + .and_then(|device| { + let mut buffer = vec![0u8; DESCRIPTOR_LENGTH]; + let length = device.get_report_descriptor(&mut buffer).ok()?; + buffer.truncate(length); + Some(descriptor::parse(&buffer)) + }) + .unwrap_or_default(); + self.descriptors.insert(path.clone(), parsed.clone()); + parsed + } + + pub fn open(&mut self, key: &str) -> Result<()> { + if self.open.contains_key(key) { + return Ok(()); + } + let paths = self + .paths + .get(key) + .ok_or_else(|| anyhow!("{key} is not a known device; list devices first"))? + .clone(); + + let mut splits = Vec::new(); + let mut readers = Vec::new(); + let mut last_error = None; + for path in &paths { + match self.api.open_path(path) { + Ok(device) => { + let split = Arc::new(Split { + device: Mutex::new(device), + stop: Arc::new(AtomicBool::new(false)), + }); + readers.push(spawn_reader( + key.to_string(), + split.clone(), + self.reports.clone(), + )); + splits.push(split); + } + Err(error) => last_error = Some(error), + } + } + if splits.is_empty() { + bail!( + "could not open {key}: {}", + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no interface answered".into()) + ); + } + self.open.insert( + key.to_string(), + OpenGroup { + splits, + readers, + routes: Mutex::new(HashMap::new()), + }, + ); + Ok(()) + } + + pub fn close(&mut self, key: &str) { + self.open.remove(key); + } + + pub fn is_open(&self, key: &str) -> bool { + self.open.contains_key(key) + } + + pub fn send_report(&self, key: &str, report_id: u8, data: &[u8]) -> Result<()> { + let frame = frame(report_id, data); + self.try_each(key, report_id, |device| device.write(&frame).map(|_| ())) + } + + pub fn send_feature_report(&self, key: &str, report_id: u8, data: &[u8]) -> Result<()> { + let frame = frame(report_id, data); + self.try_each(key, report_id, |device| device.send_feature_report(&frame)) + } + + pub fn receive_feature_report(&self, key: &str, report_id: u8) -> Result> { + let mut bytes = self.try_each(key, report_id, |device| { + let mut buffer = vec![0u8; FEATURE_REPORT_LENGTH]; + buffer[0] = report_id; + let length = device.get_feature_report(&mut buffer)?; + buffer.truncate(length); + Ok(buffer) + })?; + // WebHID's DataView starts after the report id; hidapi includes it. + if !bytes.is_empty() && bytes[0] == report_id { + bytes.remove(0); + } + Ok(bytes) + } + + fn group(&self, key: &str) -> Result<&OpenGroup> { + self.open + .get(key) + .ok_or_else(|| anyhow!("{key} is not open")) + } + + /// Runs an operation against whichever split accepts it, starting with the + /// one that answered this report id last time. + fn try_each( + &self, + key: &str, + report_id: u8, + mut operation: impl FnMut(&HidDevice) -> hidapi::HidResult, + ) -> Result { + let group = self.group(key)?; + let hinted = group.routes.lock().unwrap().get(&report_id).copied(); + let order: Vec = match hinted { + Some(hint) if hint < group.splits.len() => std::iter::once(hint) + .chain((0..group.splits.len()).filter(|position| *position != hint)) + .collect(), + _ => (0..group.splits.len()).collect(), + }; + + let mut last_error = None; + for position in order { + let device = group.splits[position].device.lock().unwrap(); + match operation(&device) { + Ok(value) => { + group.routes.lock().unwrap().insert(report_id, position); + return Ok(value); + } + Err(error) => last_error = Some(error), + } + } + Err(anyhow!( + "no interface of {key} accepted report 0x{report_id:02x}: {}", + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no interface is open".into()) + )) + } +} + +/// The report id leads the write buffer, as the OS HID stack expects for a +/// numbered report — and as a zero byte when the device declares none. +fn frame(report_id: u8, data: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(data.len() + 1); + frame.push(report_id); + frame.extend_from_slice(data); + frame +} + +fn spawn_reader( + key: String, + split: Arc, + reports: UnboundedSender, +) -> JoinHandle<()> { + thread::spawn(move || { + let mut buffer = [0u8; FEATURE_REPORT_LENGTH]; + while !split.stop.load(Ordering::Relaxed) { + // try_lock, plus an unconditional sleep outside the lock. A + // request/reply exchange must never queue behind this poll, and + // CONFIRMED on real hardware in Desktop's src-tauri/src/hid.rs: a + // bare try_lock is not enough on its own, because read_timeout + // holds the guard for its full duration and this loop would + // immediately re-acquire it, starving a writer parked on lock() + // indefinitely. The sleep is what leaves a gap for the writer. + let read = match split.device.try_lock() { + Ok(device) => device.read_timeout(&mut buffer, READ_POLL_TIMEOUT_MS), + Err(_) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + }; + thread::sleep(Duration::from_millis(5)); + match read { + Ok(0) => continue, + Ok(length) => { + // Some backends prefix a numbered report with its id and + // some do not; there is no reliable way to tell from the + // bytes alone. Both other adapters in this project assume + // the prefix, and the drivers match replies on payload + // content rather than strictly on report id. + let report = InputReport { + key: key.clone(), + report_id: buffer[0], + data: buffer[1..length].to_vec(), + }; + if reports.send(report).is_err() { + return; + } + } + // A disconnect surfaces as a read error. The pending request's + // own timeout is what reports it, exactly as a WebHID device + // going away does; nothing useful to do here but stop. + Err(_) => return, + } + } + }) +} diff --git a/src/hid/socket.rs b/src/hid/socket.rs new file mode 100644 index 0000000..aa479bd --- /dev/null +++ b/src/hid/socket.rs @@ -0,0 +1,344 @@ +//! The loopback WebSocket the OpenMouse web app speaks WebHID over. +//! +//! One socket is one browser tab's HID session: every device it opens is +//! closed when it disconnects. Frames are JSON, one request per frame with a +//! client-chosen `id` echoed in the reply, plus unsolicited `inputreport` +//! events. Byte payloads are plain number arrays, matching what the project's +//! other two HID adapters already exchange with these same drivers. +//! +//! Requests, all of which carry `id` and `type`: +//! +//! ```jsonc +//! { "id": 1, "type": "list", "vendorIds": [1133, 13652] } +//! { "id": 2, "type": "open", "device": "046d:c547:1" } +//! { "id": 3, "type": "close", "device": "046d:c547:1" } +//! { "id": 4, "type": "sendReport", "device": "…", "reportId": 16, "data": [255, 0] } +//! { "id": 5, "type": "sendFeatureReport", "device": "…", "reportId": 5, "data": [] } +//! { "id": 6, "type": "receiveFeatureReport", "device": "…", "reportId": 5 } +//! ``` +//! +//! Replies are `{ "id": n, "ok": true, … }` or `{ "id": n, "ok": false, "error": "…" }`. +//! Events are `{ "type": "inputreport", "device": "…", "reportId": n, "data": [] }`. +//! +//! Hot-plug is deliberately not pushed from here: the client re-runs `list` +//! and diffs, which is a few milliseconds of enumeration and keeps connect and +//! disconnect logic in one place, next to the code that turns them into WebHID +//! events. + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::unbounded_channel; + +use super::{DeviceSummary, HidSession}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Request { + id: u64, + #[serde(flatten)] + command: Command, +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum Command { + List { + vendor_ids: Vec, + }, + Open { + device: String, + }, + Close { + device: String, + }, + SendReport { + device: String, + report_id: u8, + data: Vec, + }, + SendFeatureReport { + device: String, + report_id: u8, + data: Vec, + }, + ReceiveFeatureReport { + device: String, + report_id: u8, + }, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct Reply { + id: u64, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + devices: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InputReportEvent { + #[serde(rename = "type")] + kind: &'static str, + device: String, + report_id: u8, + data: Vec, +} + +/// Upgrades a handshake from an allowed origin. +/// +/// A WebSocket handshake is not subject to CORS — the browser sends it +/// regardless of what the server's CORS layer says — so the allowlist that +/// protects the rest of the API has to be applied here by hand. Without this +/// check any page the user visits could enumerate and write to their mouse. +pub async fn upgrade( + upgrade: WebSocketUpgrade, + headers: HeaderMap, + origins: Arc>, +) -> Response { + if !origin_allowed(&headers, &origins) { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + tracing::warn!(%origin, "refused a HID socket from an origin that is not allowed"); + return ( + StatusCode::FORBIDDEN, + "This origin is not allowed to use OpenMouse Bridge.", + ) + .into_response(); + } + upgrade.on_upgrade(serve) +} + +/// Whichever of the two sides of the session produced work first. +enum Next { + Frame(Option>), + Report(Option), +} + +/// A handshake with no `Origin` header did not come from a browser, and a +/// handshake with an unlisted one came from a page that must not reach the +/// user's hardware. Both are refused: this is the only thing standing between +/// any website the user visits and their mouse. +fn origin_allowed(headers: &HeaderMap, origins: &[String]) -> bool { + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + origins.iter().any(|allowed| allowed == origin) +} + +async fn serve(mut socket: WebSocket) { + let (reports, mut incoming_reports) = unbounded_channel(); + + let session = match tokio::task::spawn_blocking(move || HidSession::new(reports)).await { + Ok(Ok(session)) => Arc::new(Mutex::new(session)), + Ok(Err(error)) => { + tracing::error!(%error, "could not start a HID session"); + return; + } + Err(error) => { + tracing::error!(%error, "the HID session task failed"); + return; + } + }; + + loop { + // The socket is only borrowed while the select is pending, so replies + // below can use it again once a branch has resolved. + let next = tokio::select! { + frame = socket.recv() => Next::Frame(frame), + report = incoming_reports.recv() => Next::Report(report), + }; + + let outgoing = match next { + Next::Frame(Some(Ok(Message::Text(text)))) => execute(&session, text.as_str()).await, + // Ping and pong are answered by axum; binary is not part of this + // protocol. + Next::Frame(Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_)))) => { + continue; + } + Next::Frame(_) => break, + Next::Report(Some(report)) => { + let event = InputReportEvent { + kind: "inputreport", + device: report.key, + report_id: report.report_id, + data: report.data, + }; + match serde_json::to_string(&event) { + Ok(encoded) => encoded, + Err(_) => continue, + } + } + Next::Report(None) => break, + }; + + if socket.send(Message::Text(outgoing.into())).await.is_err() { + break; + } + } + // Dropping the session closes every device this tab opened and stops its + // reader threads. +} + +async fn execute(session: &Arc>, text: &str) -> String { + let request: Request = match serde_json::from_str(text) { + Ok(request) => request, + Err(error) => { + return encode(Reply { + id: 0, + ok: false, + error: Some(error.to_string()), + ..Reply::default() + }); + } + }; + + let id = request.id; + let session = session.clone(); + let outcome = tokio::task::spawn_blocking(move || run(&session, request.command)).await; + + match outcome { + Ok(Ok(mut reply)) => { + reply.id = id; + reply.ok = true; + encode(reply) + } + Ok(Err(error)) => encode(Reply { + id, + ok: false, + error: Some(format!("{error:#}")), + ..Reply::default() + }), + Err(error) => encode(Reply { + id, + ok: false, + error: Some(error.to_string()), + ..Reply::default() + }), + } +} + +fn run(session: &Mutex, command: Command) -> anyhow::Result { + let mut session = session.lock().unwrap(); + match command { + Command::List { vendor_ids } => Ok(Reply { + devices: Some(session.list(&vendor_ids)?), + ..Reply::default() + }), + Command::Open { device } => { + session.open(&device)?; + Ok(Reply::default()) + } + Command::Close { device } => { + session.close(&device); + Ok(Reply::default()) + } + Command::SendReport { + device, + report_id, + data, + } => { + session.send_report(&device, report_id, &data)?; + Ok(Reply::default()) + } + Command::SendFeatureReport { + device, + report_id, + data, + } => { + session.send_feature_report(&device, report_id, &data)?; + Ok(Reply::default()) + } + Command::ReceiveFeatureReport { device, report_id } => Ok(Reply { + data: Some(session.receive_feature_report(&device, report_id)?), + ..Reply::default() + }), + } +} + +fn encode(reply: Reply) -> String { + serde_json::to_string(&reply).unwrap_or_else(|_| { + r#"{"id":0,"ok":false,"error":"Bridge could not encode its reply."}"#.to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_send_report_request() { + let request: Request = serde_json::from_str( + r#"{"id":4,"type":"sendReport","device":"046d:c547:1","reportId":16,"data":[255,0]}"#, + ) + .expect("the frame should parse"); + + assert_eq!(request.id, 4); + match request.command { + Command::SendReport { + device, + report_id, + data, + } => { + assert_eq!(device, "046d:c547:1"); + assert_eq!(report_id, 16); + assert_eq!(data, vec![255, 0]); + } + other => panic!("parsed the wrong command: {other:?}"), + } + } + + #[test] + fn only_listed_origins_may_open_a_hid_socket() { + let origins = vec!["https://openmouse.app".to_string()]; + let with = |origin: &str| { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, origin.parse().unwrap()); + headers + }; + + assert!(origin_allowed(&with("https://openmouse.app"), &origins)); + assert!(!origin_allowed( + &with("https://openmouse.app.evil.test"), + &origins + )); + assert!(!origin_allowed(&with("http://openmouse.app"), &origins)); + // No Origin at all: not a browser, and not something to serve. + assert!(!origin_allowed(&HeaderMap::new(), &origins)); + } + + #[test] + fn a_failed_reply_carries_the_reason() { + let encoded = encode(Reply { + id: 7, + ok: false, + error: Some("no interface answered".into()), + ..Reply::default() + }); + + assert_eq!( + encoded, + r#"{"id":7,"ok":false,"error":"no interface answered"}"# + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index c95f040..1d4d0d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod applications; pub mod config; pub mod drivers; pub mod games; +pub mod hid; pub mod platform; pub mod service;