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
17 changes: 13 additions & 4 deletions native-hid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ for that brand's vendor id(s) and tries each candidate class directly:
throw is treated as correct, and `setDpi()`/`setPollingRate()` are called on
it.

The Attack Shark X11 family is the exception. Its settings channel is not
visible through WebHID, so `apply.mjs` first checks for VID `0x1d57`, PIDs
`0xfa55`/`0xfa60`/`0xfa61`, interface 2 and sends its verified polling feature
report directly through `node-hid`. This keeps the normal operating-system
HID driver in place; WinUSB and Zadig are not required. Native X11 DPI support
is deliberately rejected until its six stages can be read and preserved.

## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`)

- `0` — a device was found and the requested settings were applied.
Expand Down Expand Up @@ -81,10 +88,12 @@ runtime first and falls back to `node` on `PATH`, so local development

## Status

- The adapter and dispatch logic are structurally tested (module resolution,
exit codes, control flow) but have not been run against real hardware from
this environment — no gaming mouse was reachable here. Validate against an
actual device before relying on this for a non-Pulsar mouse.
- Attack Shark X11 polling was hardware-verified on Windows with the wireless
PID `0xfa60`, interface 2, using the standard Microsoft HID driver. Switching
between 500 Hz and 1000 Hz succeeded without WinUSB or Zadig.
- Other adapter and dispatch paths are structurally tested (module resolution,
exit codes and control flow) and still require validation on their respective
hardware.
- G-Wolves is deliberately left out of `brands.mjs`: it isn't a finished,
exported driver in `mouse-protocol` yet (untracked source, no
`package.json` export, no `registry.ts` entry as of this writing).
3 changes: 3 additions & 0 deletions native-hid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"description": "Node.js helper Bridge spawns to push saved profile DPI/polling-rate settings to a mouse over native HID, reusing OpenMouse's own hardware-verified WebHID driver classes from @openmouse/protocol instead of reimplementing per-vendor protocols.",
"type": "module",
"scripts": {
"test": "node --test \"src/**/*.test.mjs\""
},
"engines": {
"node": ">=20"
},
Expand Down
14 changes: 13 additions & 1 deletion native-hid/src/apply.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
globalThis.window ??= globalThis;

import { BRAND_DRIVERS, brandKey } from "./brands.mjs";
import { applyX11PollingRate, x11DeviceInfos } from "./attack-shark-x11.mjs";
import { candidateDevices } from "./hid-device-adapter.mjs";

const EXIT_APPLIED = 0;
Expand Down Expand Up @@ -74,12 +75,23 @@ async function probe(device, candidate) {
async function main() {
const input = JSON.parse(await readStdin());
const brand = typeof input.brand === "string" ? input.brand : "";
const entry = BRAND_DRIVERS[brandKey(brand)];
const normalizedBrand = brandKey(brand);
const entry = BRAND_DRIVERS[normalizedBrand];
if (!entry) {
console.error(`[native-hid] no driver registered for brand "${brand}"`);
process.exit(EXIT_NO_DRIVER);
}

if (normalizedBrand === "attack shark" && x11DeviceInfos().length > 0) {
if (Number.isFinite(input.dpi)) {
throw new Error("Attack Shark X11 native DPI control is not implemented yet; polling-only requests are supported.");
}
if (Number.isFinite(input.pollingRateHz)) {
applyX11PollingRate(input.pollingRateHz);
process.exit(EXIT_APPLIED);
}
}

const attempts = [];
let client = null;
outer: for (const vendorId of entry.vendorIds) {
Expand Down
55 changes: 55 additions & 0 deletions native-hid/src/attack-shark-x11.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { HID, devices } from "node-hid";

export const X11_VENDOR_ID = 0x1d57;
export const X11_PRODUCT_IDS = new Set([0xfa55, 0xfa60, 0xfa61]);
export const X11_SETTINGS_INTERFACE = 2;

const POLLING_CODES = new Map([
[125, 0x08],
[250, 0x04],
[500, 0x02],
[1000, 0x01],
]);

export function x11DeviceInfos(infos = devices()) {
return infos.filter((info) =>
info.vendorId === X11_VENDOR_ID
&& X11_PRODUCT_IDS.has(info.productId)
&& info.interface === X11_SETTINGS_INTERFACE
&& typeof info.path === "string"
&& info.path.length > 0,
);
}

export function buildX11PollingReport(pollingRateHz) {
const code = POLLING_CODES.get(pollingRateHz);
if (code === undefined) {
throw new Error(`Attack Shark X11 does not support ${pollingRateHz} Hz; expected 125, 250, 500, or 1000 Hz.`);
}
return [0x06, 0x09, 0x01, code, (0xff - code) & 0xff, 0x00, 0x00, 0x00, 0x00];
}

export function applyX11PollingRate(
pollingRateHz,
{ infos = devices(), open = (path) => new HID(path) } = {},
) {
const candidates = x11DeviceInfos(infos);
if (candidates.length === 0) return false;

const report = buildX11PollingReport(pollingRateHz);
const attempts = [];
for (const candidate of candidates) {
let device;
try {
device = open(candidate.path);
device.sendFeatureReport(report);
return true;
} catch (error) {
attempts.push(`PID 0x${candidate.productId.toString(16)} interface 2: ${error.message}`);
} finally {
device?.close();
}
}

throw new Error(`No Attack Shark X11 settings interface accepted the polling report. Tried:\n ${attempts.join("\n ")}`);
}
47 changes: 47 additions & 0 deletions native-hid/src/attack-shark-x11.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
applyX11PollingRate,
buildX11PollingReport,
x11DeviceInfos,
} from "./attack-shark-x11.mjs";

test("buildX11PollingReport matches the verified feature reports", () => {
assert.deepEqual(buildX11PollingReport(125), [0x06, 0x09, 0x01, 0x08, 0xf7, 0, 0, 0, 0]);
assert.deepEqual(buildX11PollingReport(250), [0x06, 0x09, 0x01, 0x04, 0xfb, 0, 0, 0, 0]);
assert.deepEqual(buildX11PollingReport(500), [0x06, 0x09, 0x01, 0x02, 0xfd, 0, 0, 0, 0]);
assert.deepEqual(buildX11PollingReport(1000), [0x06, 0x09, 0x01, 0x01, 0xfe, 0, 0, 0, 0]);
assert.throws(() => buildX11PollingReport(2000), /does not support 2000 Hz/);
});

test("x11DeviceInfos selects only X11-family interface 2 paths", () => {
const match = { vendorId: 0x1d57, productId: 0xfa60, interface: 2, path: "x11" };
assert.deepEqual(x11DeviceInfos([
match,
{ ...match, interface: 1, path: "wrong-interface" },
{ ...match, productId: 0x1234, path: "wrong-product" },
{ ...match, vendorId: 0x25a7, path: "wrong-vendor" },
{ ...match, path: undefined },
]), [match]);
});

test("applyX11PollingRate sends the feature report and always closes the handle", () => {
const reports = [];
let closed = false;
const applied = applyX11PollingRate(500, {
infos: [{ vendorId: 0x1d57, productId: 0xfa55, interface: 2, path: "wired" }],
open: () => ({
sendFeatureReport: (report) => reports.push(report),
close: () => { closed = true; },
}),
});

assert.equal(applied, true);
assert.deepEqual(reports, [[0x06, 0x09, 0x01, 0x02, 0xfd, 0, 0, 0, 0]]);
assert.equal(closed, true);
});

test("applyX11PollingRate falls through when no X11 settings interface exists", () => {
assert.equal(applyX11PollingRate(1000, { infos: [], open: () => assert.fail("must not open") }), false);
});
42 changes: 41 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::Tra

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

Expand Down Expand Up @@ -40,6 +40,14 @@ struct ProfilesPayload {
profiles: Vec<ApplicationProfile>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NativeSettingsPayload {
brand: String,
dpi: Option<u32>,
polling_rate_hz: Option<u32>,
}

pub fn router(service: BridgeService, origins: &[String]) -> Router {
let allowed: Vec<HeaderValue> = origins
.iter()
Expand All @@ -58,6 +66,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router {
.route("/v1/applications/{icon_id}/icon", get(application_icon))
.route("/v1/profiles", get(profiles).put(replace_profiles))
.route("/v1/default-profile", put(set_default_profile))
.route("/v1/native/settings", put(apply_native_settings))
.route("/v1/battery", put(record_battery))
.route("/v1/autostart", put(set_autostart))
.layer(SetResponseHeaderLayer::if_not_present(
Expand Down Expand Up @@ -158,6 +167,37 @@ async fn set_autostart(
Ok(Json(ApiResult { ok: true }))
}

async fn apply_native_settings(
Json(payload): Json<NativeSettingsPayload>,
) -> Result<Json<ApiResult>, (StatusCode, String)> {
if payload.dpi.is_none() && payload.polling_rate_hz.is_none() {
return Err((
StatusCode::BAD_REQUEST,
"at least one of dpi or pollingRateHz is required".into(),
));
}

let NativeSettingsPayload {
brand,
dpi,
polling_rate_hz,
} = payload;
let error_brand = brand.clone();
let applied =
tokio::task::spawn_blocking(move || drivers::apply_settings(&brand, dpi, polling_rate_hz))
.await
.map_err(|error| internal_error(anyhow::anyhow!(error)))?
.map_err(internal_error)?;

if !applied {
return Err((
StatusCode::NOT_FOUND,
format!("no native driver is registered for {error_brand}"),
));
}
Ok(Json(ApiResult { ok: true }))
}

fn internal_error(error: anyhow::Error) -> (StatusCode, String) {
tracing::error!(%error, "Bridge request failed");
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
const DEFAULT_BATTERY_THRESHOLD: u8 = 20;
const DEFAULT_ALERT_COOLDOWN_MINUTES: u64 = 360;
const OFFICIAL_ORIGINS: &[&str] = &[
"https://control.openmouse.app",
"https://dev.openmouse.app",
"https://openmouse.app",
"https://www.openmouse.app",
Expand Down Expand Up @@ -187,6 +188,7 @@ const fn default_alert_cooldown() -> u64 {

fn default_origins() -> Vec<String> {
vec![
"https://control.openmouse.app".to_owned(),
"https://dev.openmouse.app".to_owned(),
"https://openmouse.app".to_owned(),
"https://www.openmouse.app".to_owned(),
Expand Down
7 changes: 7 additions & 0 deletions src/drivers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result<bool> {
}
}

/// Applies settings requested directly by the OpenMouse web UI through the
/// local Bridge. Native-only devices use this path when WebHID cannot access
/// their protected configuration interface.
pub fn apply_settings(brand: &str, dpi: Option<u32>, polling_rate_hz: Option<u32>) -> Result<bool> {
native_hid::apply_settings(brand, dpi, polling_rate_hz)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
17 changes: 14 additions & 3 deletions src/drivers/native_hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,23 @@ struct ApplyRequest<'a> {
/// web app over WebHID instead), and `Err` when a driver exists but the
/// apply itself failed (including the helper or Node.js being unavailable).
pub fn apply(profile: &ApplicationProfile) -> Result<bool> {
let script = locate_apply_script()?;
let brand = profile.device.id.split(':').next().unwrap_or_default();
apply_settings(
brand,
profile.settings.dpi,
profile.settings.polling_rate_hz,
)
}

/// Pushes an explicit settings request from the local HTTP API. This shares
/// the exact helper path used by automatic profiles, so native device control
/// and profile switching cannot drift into separate protocol implementations.
pub fn apply_settings(brand: &str, dpi: Option<u32>, polling_rate_hz: Option<u32>) -> Result<bool> {
let script = locate_apply_script()?;
let request = ApplyRequest {
brand,
dpi: profile.settings.dpi,
polling_rate_hz: profile.settings.polling_rate_hz,
dpi,
polling_rate_hz,
};
let payload =
serde_json::to_vec(&request).context("could not encode the native-hid request")?;
Expand Down