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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 153 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 15 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
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};
use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer};

use crate::{
config::{ApplicationProfile, GameConfig},
platform,
hid, platform,
service::{BatteryReading, BridgeService},
};

Expand Down Expand Up @@ -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"),
Expand Down
Loading