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;