From 82cddd83d8a68a5d2eaa7bdde802d8d8dc2255a0 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:13:32 +0300 Subject: [PATCH 01/15] feat(AttackShark): control bridge --- Cargo.lock | 14 ++ Cargo.toml | 1 + src/api.rs | 66 ++++++++- src/config.rs | 1 + src/desktop.rs | 5 +- src/devices/attackshark.rs | 177 +++++++++++++++++++++++ src/devices/mod.rs | 278 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 5 +- 9 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 src/devices/attackshark.rs create mode 100644 src/devices/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 23baba1..d5c7d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1669,6 +1669,19 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hidapi" +version = "2.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -2547,6 +2560,7 @@ dependencies = [ "axum", "directories", "eframe", + "hidapi", "notify-rust", "objc2-app-kit 0.3.2", "png", diff --git a/Cargo.toml b/Cargo.toml index 1963dd8..ef40ec6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ winresource = "0.1" anyhow = "1.0" axum = "0.8" directories = "6.0" +hidapi = "2.6" notify-rust = "4.11" png = "0.18" serde = { version = "1.0", features = ["derive"] } diff --git a/src/api.rs b/src/api.rs index 00acfa4..11af43e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,7 +3,7 @@ use std::time::Duration; use axum::{ Json, Router, body::Body, - extract::{Path, State}, + extract::{FromRef, Path, State}, http::{HeaderValue, Method, Response, StatusCode, header}, routing::{get, put}, }; @@ -12,10 +12,26 @@ use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::Tra use crate::{ config::{ApplicationProfile, GameConfig}, + devices::{DeviceInfo, DeviceManager}, platform, service::{BatteryReading, BridgeService}, }; +/// Everything the HTTP handlers share. `FromRef` lets existing handlers keep +/// extracting `State` unchanged while new ones reach the +/// device manager. +#[derive(Clone)] +pub struct AppState { + service: BridgeService, + devices: Option, +} + +impl FromRef for BridgeService { + fn from_ref(state: &AppState) -> Self { + state.service.clone() + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ApiResult { @@ -40,7 +56,24 @@ struct ProfilesPayload { profiles: Vec, } -pub fn router(service: BridgeService, origins: &[String]) -> Router { +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PollingPayload { + hz: u16, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PollingResult { + ok: bool, + polling_rate_hz: u16, +} + +pub fn router( + service: BridgeService, + devices: Option, + origins: &[String], +) -> Router { let allowed: Vec = origins .iter() .filter_map(|origin| origin.parse().ok()) @@ -60,13 +93,40 @@ 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)) + .route("/v1/devices", get(list_devices)) + .route("/v1/devices/{id}/polling", put(set_device_polling)) .layer(SetResponseHeaderLayer::if_not_present( axum::http::HeaderName::from_static("access-control-allow-private-network"), HeaderValue::from_static("true"), )) .layer(cors) .layer(TraceLayer::new_for_http()) - .with_state(service) + .with_state(AppState { service, devices }) +} + +async fn list_devices(State(state): State) -> Json> { + match state.devices { + Some(manager) => Json(manager.list().await), + None => Json(Vec::new()), + } +} + +async fn set_device_polling( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let manager = state + .devices + .ok_or((StatusCode::SERVICE_UNAVAILABLE, "native device support is unavailable".to_owned()))?; + let confirmed = manager + .set_polling(id, payload.hz) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(PollingResult { + ok: true, + polling_rate_hz: confirmed, + })) } async fn status(State(service): State) -> Json { diff --git a/src/config.rs b/src/config.rs index 5023d68..8cdf785 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ const DEFAULT_ALERT_COOLDOWN_MINUTES: u64 = 360; const OFFICIAL_ORIGINS: &[&str] = &[ "https://dev.openmouse.app", "https://openmouse.app", + "https://openmouse-sable.vercel.app", // Debuging by viix0dev "https://www.openmouse.app", ]; diff --git a/src/desktop.rs b/src/desktop.rs index a2cd52b..b412ac3 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,7 +15,7 @@ use eframe::egui::{ self, Align, Color32, CornerRadius, Layout, RichText, Sense, Stroke, Vec2, ViewportCommand, }; use openmouse_bridge::{ - BRIDGE_PORT, BRIDGE_VERSION, api, config, platform, + BRIDGE_PORT, BRIDGE_VERSION, api, config, devices::DeviceManager, platform, service::{BridgeService, BridgeSnapshot}, }; #[cfg(target_os = "windows")] @@ -160,6 +160,7 @@ fn run_server(events: Sender, shutdown: oneshot::Receiver<()>) -> let origins = bridge_config.allowed_origins.clone(); let service = BridgeService::new(bridge_config, path.clone()); service.start_game_monitor(); + let devices = DeviceManager::start(service.clone()); let snapshot_service = service.clone(); let snapshot_events = events.clone(); @@ -189,7 +190,7 @@ fn run_server(events: Sender, shutdown: oneshot::Receiver<()>) -> }; tracing::info!(%address, config = %path.display(), "OpenMouse Bridge is ready"); let _ = events.send(DesktopEvent::Ready); - axum::serve(listener, api::router(service, &origins)) + axum::serve(listener, api::router(service, devices, &origins)) .with_graceful_shutdown(async { let _ = shutdown.await; }) diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs new file mode 100644 index 0000000..ab6e8cc --- /dev/null +++ b/src/devices/attackshark.rs @@ -0,0 +1,177 @@ +//! Attack Shark X11 / R1 wire protocol (VID 0x1d57). +//! +//! These are pure encoders/decoders with no I/O so they can be unit-tested +//! against the reference driver's own vectors +//! (dressedinblack5/attack-shark-x11-electron). The transport that carries +//! them lives in the parent module. +//! +//! Config travels as HID feature reports on USB interface 2 — the exact +//! channel a browser cannot reach, because Chrome hides those collections and +//! never lets a page write to them. A native HID handle has no such block, so +//! the Bridge can send these where the web app cannot. +//! +//! Safety: only the fully specified, low-risk operations are encoded here +//! (polling rate, and read-only battery/polling readback). DPI, RGB, macros +//! and reset involve large packets with many fixed bytes and a reverse- +//! engineered checksum; sending those from scratch risks corrupting unknown +//! state, so they are deliberately left to the native reference driver until a +//! read-modify-write path is verified on hardware. + +/// Attack Shark OEM vendor id shared by the X11 and R1 families. +pub const X11_VID: u16 = 0x1d57; + +/// Documented product ids: wired X11, wireless X11 receiver, R1. +pub const X11_WIRED_PID: u16 = 0xfa55; +pub const X11_WIRELESS_PID: u16 = 0xfa60; +pub const R1_PID: u16 = 0xfa61; + +/// USB interface that carries the configuration feature reports. +pub const CONTROL_INTERFACE: i32 = 2; + +/// Feature report id the polling-rate command is written on. +pub const POLLING_REPORT_ID: u8 = 0x06; +/// Feature report id used to request a state read-back. +pub const READ_REQUEST_REPORT_ID: u8 = 0xa0; +/// Input report id the autonomous battery packet arrives on. +pub const BATTERY_REPORT_ID: u8 = 0x03; + +/// Battery input report signature; byte 4 is the percentage. +const BATTERY_SIGNATURE: [u8; 4] = [0x03, 0x55, 0x40, 0x01]; + +/// Supported polling rates as (hz, wire code). The code is the value written +/// at byte 3; the checksum byte is `0xff - code`. +pub const POLLING_RATES: [(u16, u8); 4] = [ + (125, 0x08), + (250, 0x04), + (500, 0x02), + (1000, 0x01), +]; + +/// True when a VID/PID pair is an X11-family unit this module understands. +pub fn is_x11(vendor_id: u16, product_id: u16) -> bool { + vendor_id == X11_VID + && matches!(product_id, X11_WIRED_PID | X11_WIRELESS_PID | R1_PID) +} + +/// Human model name for a product id. +pub fn model_name(product_id: u16) -> &'static str { + match product_id { + X11_WIRED_PID | X11_WIRELESS_PID => "Attack Shark X11", + R1_PID => "Attack Shark R1", + _ => "Attack Shark", + } +} + +/// Only the wireless receiver reports battery and connects wirelessly. +pub fn is_wireless(product_id: u16) -> bool { + product_id == X11_WIRELESS_PID +} + +/// Polling rates this family accepts, in ascending order. +pub fn supported_polling_rates() -> Vec { + POLLING_RATES.iter().map(|&(hz, _)| hz).collect() +} + +/// Encode the X11 polling-rate feature report, or `None` for an unsupported +/// rate. Byte 0 is the report id (hidapi consumes it as the leading byte). +/// +/// Layout: `06 09 01 <0xff-code> 00 00 00 00`. +pub fn polling_packet(hz: u16) -> Option<[u8; 9]> { + let code = POLLING_RATES + .iter() + .find_map(|&(rate, code)| (rate == hz).then_some(code))?; + Some([ + POLLING_REPORT_ID, + 0x09, + 0x01, + code, + 0xff - code, + 0x00, + 0x00, + 0x00, + 0x00, + ]) +} + +/// Encode the read-request that asks the mouse to publish its current polling +/// rate on `POLLING_REPORT_ID`. Byte 0 is the `READ_REQUEST_REPORT_ID`. +pub fn polling_read_request() -> [u8; 8] { + [READ_REQUEST_REPORT_ID, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] +} + +/// Decode a polling-rate feature report read back from the mouse. `report` +/// includes the leading report id, so the rate code sits at index 2. Returns +/// the rate in Hz, or `None` if the code is unknown or the buffer is short. +pub fn parse_polling_reply(report: &[u8]) -> Option { + let code = *report.get(2)?; + POLLING_RATES + .iter() + .find_map(|&(hz, wire)| (wire == code).then_some(hz)) +} + +/// Decode a battery input report. `report` includes the leading report id, so +/// the signature occupies bytes 0..4 and the percentage byte 4. Returns the +/// percentage (0..=100) or `None` for a non-battery or out-of-range report. +pub fn parse_battery(report: &[u8]) -> Option { + if report.len() < 5 || report[..4] != BATTERY_SIGNATURE { + return None; + } + let percent = report[4]; + (percent <= 100).then_some(percent) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } + + #[test] + fn polling_packets_match_reference_vectors() { + // From attack-shark-x11-electron __tests__/PollingRateBuilder.test.ts. + assert_eq!(hex(&polling_packet(125).unwrap()), "06090108f700000000"); + assert_eq!(hex(&polling_packet(250).unwrap()), "06090104fb00000000"); + assert_eq!(hex(&polling_packet(500).unwrap()), "06090102fd00000000"); + assert_eq!(hex(&polling_packet(1000).unwrap()), "06090101fe00000000"); + } + + #[test] + fn polling_packet_rejects_unsupported_rates() { + assert!(polling_packet(2000).is_none()); + assert!(polling_packet(0).is_none()); + } + + #[test] + fn polling_reply_round_trips_every_rate() { + for &(hz, code) in &POLLING_RATES { + let reply = [POLLING_REPORT_ID, 0x00, code, 0x00]; + assert_eq!(parse_polling_reply(&reply), Some(hz)); + } + assert_eq!(parse_polling_reply(&[POLLING_REPORT_ID, 0x00, 0x99]), None); + assert_eq!(parse_polling_reply(&[POLLING_REPORT_ID]), None); + } + + #[test] + fn battery_parse_validates_signature_and_range() { + assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x01, 80]), Some(80)); + assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x01, 100]), Some(100)); + // Wrong signature, out of range, and short buffers are rejected. + assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x00, 80]), None); + assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x01, 101]), None); + assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x01]), None); + } + + #[test] + fn family_recognition_covers_documented_pids() { + assert!(is_x11(0x1d57, 0xfa55)); + assert!(is_x11(0x1d57, 0xfa60)); + assert!(is_x11(0x1d57, 0xfa61)); + assert!(!is_x11(0x1d57, 0x1234)); + assert!(!is_x11(0x25a7, 0xfa60)); + assert_eq!(model_name(0xfa61), "Attack Shark R1"); + assert!(is_wireless(0xfa60)); + assert!(!is_wireless(0xfa55)); + } +} diff --git a/src/devices/mod.rs b/src/devices/mod.rs new file mode 100644 index 0000000..10902d7 --- /dev/null +++ b/src/devices/mod.rs @@ -0,0 +1,278 @@ +//! Native mouse access for the Bridge. +//! +//! The web app drives mice over WebHID, but some devices keep their +//! configuration channel on HID collections the browser refuses to touch +//! (protected keyboard/system-control usages). The Attack Shark X11 is one of +//! them: everything a page needs is hidden or blocked. A native HID handle is +//! not subject to that block, so the Bridge can talk to interface 2 directly. +//! +//! hidapi is blocking, and its handles are not `Sync`, so all device I/O runs +//! on one dedicated OS thread. The async world talks to it through a command +//! channel and never touches a raw handle. + +pub mod attackshark; + +use std::{ + sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Result, anyhow}; +use hidapi::{HidApi, HidDevice}; +use serde::Serialize; +use tokio::{runtime::Handle, sync::oneshot}; + +use crate::service::{BatteryReading, BridgeService}; + +/// How often the worker polls for plug/unplug and drains battery reports. +const POLL_INTERVAL: Duration = Duration::from_secs(2); +/// Feature-report buffers are 65 bytes (id + 64) — the largest report here. +const REPORT_BUFFER: usize = 65; + +/// A mouse the Bridge can see natively, as reported to the web app. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + /// Stable id, e.g. `"1d57:fa60"`. + pub id: String, + pub name: String, + pub vendor_id: u16, + pub product_id: u16, + /// `"wired"` or `"wireless"`. + pub connection: &'static str, + pub battery_percent: Option, + pub polling_rate_hz: Option, + pub supported_polling_rates: Vec, + /// Why settings may be limited, when they are. + pub note: &'static str, +} + +/// Commands the async side sends to the blocking worker. +enum Command { + List(oneshot::Sender>), + SetPolling { + id: String, + hz: u16, + reply: oneshot::Sender>, + }, +} + +/// Handle to the device worker thread. Cloneable and cheap. +#[derive(Clone)] +pub struct DeviceManager { + commands: Sender, +} + +impl DeviceManager { + /// Start the worker. Battery readings are forwarded into `service` so they + /// reuse the existing low-battery notification pipeline. Returns `None` + /// when hidapi is unavailable, so the Bridge still runs without it. + pub fn start(service: BridgeService) -> Option { + // Captured here (inside the async runtime) so the blocking worker can + // hand battery readings back to the runtime without owning one. + let runtime = match Handle::try_current() { + Ok(handle) => handle, + Err(_) => { + tracing::error!("device worker needs a tokio runtime; native support is off"); + return None; + } + }; + let (tx, rx) = mpsc::channel(); + let spawned = thread::Builder::new() + .name("openmouse-devices".into()) + .spawn(move || worker(rx, service, runtime)); + match spawned { + Ok(_) => Some(Self { commands: tx }), + Err(error) => { + tracing::error!(%error, "could not start the device worker"); + None + } + } + } + + /// List every X11-family mouse currently attached. + pub async fn list(&self) -> Vec { + let (tx, rx) = oneshot::channel(); + if self.commands.send(Command::List(tx)).is_err() { + return Vec::new(); + } + rx.await.unwrap_or_default() + } + + /// Set the polling rate on one device and return the value it confirmed. + pub async fn set_polling(&self, id: String, hz: u16) -> Result { + let (tx, rx) = oneshot::channel(); + self.commands + .send(Command::SetPolling { id, hz, reply: tx }) + .map_err(|_| anyhow!("the device worker is not running"))?; + rx.await.map_err(|_| anyhow!("the device worker dropped the request"))? + } +} + +/// One attached, opened control interface. +struct OpenDevice { + info: DeviceInfo, + handle: HidDevice, +} + +fn worker(commands: Receiver, service: BridgeService, runtime: Handle) { + let mut api = match HidApi::new() { + Ok(api) => api, + Err(error) => { + tracing::error!(%error, "hidapi is unavailable; native device support is off"); + return; + } + }; + let mut devices: Vec = Vec::new(); + + // Discover once up front so an already-attached mouse is ready immediately. + refresh(&mut api, &mut devices); + let mut last_refresh = Instant::now(); + + loop { + match commands.recv_timeout(POLL_INTERVAL) { + Ok(Command::List(reply)) => { + let _ = reply.send(devices.iter().map(|device| device.info.clone()).collect()); + } + Ok(Command::SetPolling { id, hz, reply }) => { + let _ = reply.send(set_polling(&mut devices, &id, hz)); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + + // Battery is drained on every wake so a fresh command still refreshes + // it, but enumeration is throttled so a busy command stream cannot + // spin hidapi. + drain_battery(&mut devices, &service, &runtime); + if last_refresh.elapsed() >= POLL_INTERVAL { + refresh(&mut api, &mut devices); + last_refresh = Instant::now(); + } + } +} + +/// Re-enumerate and reconcile the open-device list with what is attached now. +fn refresh(api: &mut HidApi, devices: &mut Vec) { + if let Err(error) = api.refresh_devices() { + tracing::debug!(%error, "hidapi refresh failed"); + return; + } + + // Drop devices that are no longer present. + let present: Vec<(u16, u16)> = api + .device_list() + .filter(|entry| attackshark::is_x11(entry.vendor_id(), entry.product_id())) + .map(|entry| (entry.vendor_id(), entry.product_id())) + .collect(); + devices.retain(|device| { + present.contains(&(device.info.vendor_id, device.info.product_id)) + }); + + for entry in api.device_list() { + let (vid, pid) = (entry.vendor_id(), entry.product_id()); + if !attackshark::is_x11(vid, pid) { + continue; + } + // Only the control interface answers; the boot mouse/keyboard entries + // are the wrong endpoint. hidapi reports -1 when it cannot tell, in + // which case we fall back to the system-control usage page (0x0c). + let is_control = entry.interface_number() == attackshark::CONTROL_INTERFACE + || (entry.interface_number() < 0 && entry.usage_page() == 0x000c); + if !is_control { + continue; + } + if devices.iter().any(|device| device.info.vendor_id == vid && device.info.product_id == pid) { + continue; + } + match entry.open_device(api) { + Ok(handle) => { + // Battery arrives unprompted; never block waiting for it. + let _ = handle.set_blocking_mode(false); + let info = DeviceInfo { + id: format!("{vid:04x}:{pid:04x}"), + name: attackshark::model_name(pid).to_owned(), + vendor_id: vid, + product_id: pid, + connection: if attackshark::is_wireless(pid) { "wireless" } else { "wired" }, + battery_percent: None, + polling_rate_hz: read_polling(&handle), + supported_polling_rates: attackshark::supported_polling_rates(), + note: "Battery and polling rate are read natively; other settings still require the desktop driver.", + }; + tracing::info!(device = %info.id, "opened Attack Shark control interface"); + devices.push(OpenDevice { info, handle }); + } + Err(error) => { + tracing::debug!(%error, vid, pid, "could not open Attack Shark control interface"); + } + } + } +} + +/// Ask the mouse for its polling rate and decode the reply. Best-effort. +fn read_polling(handle: &HidDevice) -> Option { + handle.send_feature_report(&attackshark::polling_read_request()).ok()?; + let mut buffer = [0u8; REPORT_BUFFER]; + buffer[0] = attackshark::POLLING_REPORT_ID; + let read = handle.get_feature_report(&mut buffer).ok()?; + attackshark::parse_polling_reply(&buffer[..read]) +} + +fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { + let device = devices + .iter_mut() + .find(|device| device.info.id == id) + .ok_or_else(|| anyhow!("no attached device with id {id}"))?; + let packet = attackshark::polling_packet(hz) + .ok_or_else(|| anyhow!("{hz} Hz is not a supported polling rate"))?; + device + .handle + .send_feature_report(&packet) + .map_err(|error| anyhow!("the mouse refused the polling command: {error}"))?; + + // Confirm the mouse actually kept the new rate before reporting success. + let confirmed = read_polling(&device.handle).unwrap_or(hz); + if confirmed != hz { + return Err(anyhow!("the mouse kept {confirmed} Hz instead of {hz} Hz")); + } + device.info.polling_rate_hz = Some(confirmed); + Ok(confirmed) +} + +/// Drain any pending battery input reports and push them into the service so +/// the existing low-battery notifier fires. Non-blocking; skips quiet devices. +fn drain_battery(devices: &mut [OpenDevice], service: &BridgeService, runtime: &Handle) { + for device in devices.iter_mut() { + let mut buffer = [0u8; REPORT_BUFFER]; + // Read until the queue is empty so we always keep the freshest value. + let mut latest: Option = None; + while let Ok(read) = device.handle.read_timeout(&mut buffer, 0) { + if read == 0 { + break; + } + if let Some(percent) = attackshark::parse_battery(&buffer[..read]) { + latest = Some(percent); + } + } + let Some(percent) = latest else { continue }; + if device.info.battery_percent == Some(percent) { + continue; + } + device.info.battery_percent = Some(percent); + let reading = BatteryReading { + device_id: device.info.id.clone(), + device_name: device.info.name.clone(), + percent, + charging: false, + }; + let service = service.clone(); + // record_battery is async; hop onto the runtime without blocking here. + runtime.spawn(async move { + if let Err(error) = service.record_battery(reading).await { + tracing::debug!(%error, "could not record native battery reading"); + } + }); + } +} diff --git a/src/lib.rs b/src/lib.rs index a39f76b..5ae5647 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod api; pub mod applications; pub mod config; +pub mod devices; pub mod games; pub mod platform; pub mod service; diff --git a/src/main.rs b/src/main.rs index 5828137..125b7de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[cfg(not(any(target_os = "windows", target_os = "macos")))] use anyhow::{Context, Result}; #[cfg(not(any(target_os = "windows", target_os = "macos")))] -use openmouse_bridge::{BRIDGE_PORT, api, config, service::BridgeService}; +use openmouse_bridge::{BRIDGE_PORT, api, config, devices::DeviceManager, service::BridgeService}; #[cfg(not(any(target_os = "windows", target_os = "macos")))] use tokio::net::TcpListener; use tracing_subscriber::EnvFilter; @@ -42,12 +42,13 @@ async fn run() -> Result<()> { let origins = config.allowed_origins.clone(); let service = BridgeService::new(config, path.clone()); service.start_game_monitor(); + let devices = DeviceManager::start(service.clone()); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), BRIDGE_PORT); let listener = TcpListener::bind(address) .await .with_context(|| format!("could not bind http://{address}; is Bridge already running?"))?; tracing::info!(%address, config = %path.display(), "OpenMouse Bridge is ready"); - axum::serve(listener, api::router(service, &origins)) + axum::serve(listener, api::router(service, devices, &origins)) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) From 3166b02658416c2bf1effd33fb7462ca5a41f051 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:16:59 +0300 Subject: [PATCH 02/15] Adding docs for added api --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4b998e0..18960e1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ explicit configuration file. Only configured web origins receive CORS access. The listener never binds to a LAN or public interface. +## Attack Shark - Beta Bridge (Under Testing) +- `GET /v1/devices` returns the devices +- `PUT /v1/devices/{id}/polling` changes polling rate + ## Current boundary Battery readings initially come from the connected OpenMouse control panel. From c8076635dd0a7cff1d334fa4237972c3eec164fd Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:28:44 +0300 Subject: [PATCH 03/15] fix: CORS policy block fixed --- Cargo.toml | 2 +- src/api.rs | 13 ++++++++----- src/config.rs | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ef40ec6..d1f6769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sysinfo = "0.37" tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } -tower-http = { version = "0.6", features = ["cors", "set-header", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/api.rs b/src/api.rs index 11af43e..7295fa7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -8,7 +8,7 @@ use axum::{ routing::{get, put}, }; use serde::{Deserialize, Serialize}; -use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer}; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; use crate::{ config::{ApplicationProfile, GameConfig}, @@ -78,10 +78,17 @@ pub fn router( .iter() .filter_map(|origin| origin.parse().ok()) .collect(); + // allow_private_network puts `Access-Control-Allow-Private-Network: true` + // on the CORS preflight itself. A public HTTPS origin (the deployed site) + // reaching this loopback server is a private-network request, and the + // browser only accepts it when that header is on the *preflight* response. + // A separate response-header layer cannot do this: CorsLayer answers the + // preflight OPTIONS directly and never calls inner layers. let cors = CorsLayer::new() .allow_origin(allowed) .allow_methods([Method::GET, Method::PUT]) .allow_headers([axum::http::header::CONTENT_TYPE]) + .allow_private_network(true) .max_age(Duration::from_secs(3600)); Router::new() .route("/v1/status", get(status)) @@ -95,10 +102,6 @@ pub fn router( .route("/v1/autostart", put(set_autostart)) .route("/v1/devices", get(list_devices)) .route("/v1/devices/{id}/polling", put(set_device_polling)) - .layer(SetResponseHeaderLayer::if_not_present( - axum::http::HeaderName::from_static("access-control-allow-private-network"), - HeaderValue::from_static("true"), - )) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(AppState { service, devices }) diff --git a/src/config.rs b/src/config.rs index 8cdf785..057dce4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -191,6 +191,7 @@ fn default_origins() -> Vec { "https://dev.openmouse.app".to_owned(), "https://openmouse.app".to_owned(), "https://www.openmouse.app".to_owned(), + "https://openmouse-sable.vercel.app".to_owned(), // Debuging by viix0dev "http://localhost:5173".to_owned(), "http://127.0.0.1:5173".to_owned(), ] From 3b0d3e3f18af2eb0613ffbe12e1f082f9f334762 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:41:00 +0300 Subject: [PATCH 04/15] fixing the connection error --- src/api.rs | 7 ++++--- src/desktop.rs | 4 +++- src/devices/attackshark.rs | 21 ++++++++++++--------- src/devices/mod.rs | 22 +++++++++++++++------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/api.rs b/src/api.rs index 7295fa7..70779c8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -119,9 +119,10 @@ async fn set_device_polling( Path(id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { - let manager = state - .devices - .ok_or((StatusCode::SERVICE_UNAVAILABLE, "native device support is unavailable".to_owned()))?; + let manager = state.devices.ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "native device support is unavailable".to_owned(), + ))?; let confirmed = manager .set_polling(id, payload.hz) .await diff --git a/src/desktop.rs b/src/desktop.rs index b412ac3..0ab2bca 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,7 +15,9 @@ use eframe::egui::{ self, Align, Color32, CornerRadius, Layout, RichText, Sense, Stroke, Vec2, ViewportCommand, }; use openmouse_bridge::{ - BRIDGE_PORT, BRIDGE_VERSION, api, config, devices::DeviceManager, platform, + BRIDGE_PORT, BRIDGE_VERSION, api, config, + devices::DeviceManager, + platform, service::{BridgeService, BridgeSnapshot}, }; #[cfg(target_os = "windows")] diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs index ab6e8cc..9b7de60 100644 --- a/src/devices/attackshark.rs +++ b/src/devices/attackshark.rs @@ -40,17 +40,11 @@ const BATTERY_SIGNATURE: [u8; 4] = [0x03, 0x55, 0x40, 0x01]; /// Supported polling rates as (hz, wire code). The code is the value written /// at byte 3; the checksum byte is `0xff - code`. -pub const POLLING_RATES: [(u16, u8); 4] = [ - (125, 0x08), - (250, 0x04), - (500, 0x02), - (1000, 0x01), -]; +pub const POLLING_RATES: [(u16, u8); 4] = [(125, 0x08), (250, 0x04), (500, 0x02), (1000, 0x01)]; /// True when a VID/PID pair is an X11-family unit this module understands. pub fn is_x11(vendor_id: u16, product_id: u16) -> bool { - vendor_id == X11_VID - && matches!(product_id, X11_WIRED_PID | X11_WIRELESS_PID | R1_PID) + vendor_id == X11_VID && matches!(product_id, X11_WIRED_PID | X11_WIRELESS_PID | R1_PID) } /// Human model name for a product id. @@ -96,7 +90,16 @@ pub fn polling_packet(hz: u16) -> Option<[u8; 9]> { /// Encode the read-request that asks the mouse to publish its current polling /// rate on `POLLING_REPORT_ID`. Byte 0 is the `READ_REQUEST_REPORT_ID`. pub fn polling_read_request() -> [u8; 8] { - [READ_REQUEST_REPORT_ID, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00] + [ + READ_REQUEST_REPORT_ID, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] } /// Decode a polling-rate feature report read back from the mouse. `report` diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 10902d7..dfb58ea 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -106,7 +106,8 @@ impl DeviceManager { self.commands .send(Command::SetPolling { id, hz, reply: tx }) .map_err(|_| anyhow!("the device worker is not running"))?; - rx.await.map_err(|_| anyhow!("the device worker dropped the request"))? + rx.await + .map_err(|_| anyhow!("the device worker dropped the request"))? } } @@ -166,9 +167,7 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { .filter(|entry| attackshark::is_x11(entry.vendor_id(), entry.product_id())) .map(|entry| (entry.vendor_id(), entry.product_id())) .collect(); - devices.retain(|device| { - present.contains(&(device.info.vendor_id, device.info.product_id)) - }); + devices.retain(|device| present.contains(&(device.info.vendor_id, device.info.product_id))); for entry in api.device_list() { let (vid, pid) = (entry.vendor_id(), entry.product_id()); @@ -183,7 +182,10 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { if !is_control { continue; } - if devices.iter().any(|device| device.info.vendor_id == vid && device.info.product_id == pid) { + if devices + .iter() + .any(|device| device.info.vendor_id == vid && device.info.product_id == pid) + { continue; } match entry.open_device(api) { @@ -195,7 +197,11 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { name: attackshark::model_name(pid).to_owned(), vendor_id: vid, product_id: pid, - connection: if attackshark::is_wireless(pid) { "wireless" } else { "wired" }, + connection: if attackshark::is_wireless(pid) { + "wireless" + } else { + "wired" + }, battery_percent: None, polling_rate_hz: read_polling(&handle), supported_polling_rates: attackshark::supported_polling_rates(), @@ -213,7 +219,9 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { /// Ask the mouse for its polling rate and decode the reply. Best-effort. fn read_polling(handle: &HidDevice) -> Option { - handle.send_feature_report(&attackshark::polling_read_request()).ok()?; + handle + .send_feature_report(&attackshark::polling_read_request()) + .ok()?; let mut buffer = [0u8; REPORT_BUFFER]; buffer[0] = attackshark::POLLING_REPORT_ID; let read = handle.get_feature_report(&mut buffer).ok()?; From 353b1b0195aa2bd05ceb41e9d47541ce03f1a9f0 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:11:41 +0300 Subject: [PATCH 05/15] adding logging system to debug --- Cargo.lock | 31 +++++++++++++++ Cargo.toml | 1 + src/desktop.rs | 67 +++++++++++++++++++++++++++++++- src/devices/mod.rs | 68 +++++++++++++++++++++++++------- src/lib.rs | 1 + src/logging.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 17 +++----- 7 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index d5c7d42..853445a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2570,6 +2570,7 @@ dependencies = [ "tokio", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "tray-icon", "windows-sys 0.61.2", @@ -3338,6 +3339,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -3507,6 +3514,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3515,6 +3523,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.53.1" @@ -3693,6 +3711,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.20", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index d1f6769..1e4dfe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ png = "0.18" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sysinfo = "0.37" +tracing-appender = "0.2" tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" diff --git a/src/desktop.rs b/src/desktop.rs index 0ab2bca..cf868c9 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -17,9 +17,10 @@ use eframe::egui::{ use openmouse_bridge::{ BRIDGE_PORT, BRIDGE_VERSION, api, config, devices::DeviceManager, - platform, + logging, platform, service::{BridgeService, BridgeSnapshot}, }; +use std::path::Path; #[cfg(target_os = "windows")] use std::ptr::null_mut; use tokio::{net::TcpListener, sync::oneshot}; @@ -438,6 +439,32 @@ impl BridgeDesktop { }); ui.add_space(4.0); ui.separator(); + ui.horizontal(|ui| { + ui.set_min_height(26.0); + ui.vertical(|ui| { + ui.label(RichText::new("Diagnostic log").color(TEXT).size(11.0)); + ui.label( + RichText::new("Share this file when reporting a device issue") + .color(MUTED) + .size(9.0), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let open = egui::Button::new( + RichText::new("Open logs folder").color(TEXT).size(10.0), + ) + .fill(SURFACE) + .stroke(Stroke::new(1.0, BORDER)) + .corner_radius(CornerRadius::same(6)); + if ui.add(open).clicked() + && let Err(error) = open_path(&logging::log_dir()) + { + self.error = Some(error.to_string()); + } + }); + }); + ui.add_space(4.0); + ui.separator(); setting_row(ui, "Version", BRIDGE_VERSION); }); } @@ -845,6 +872,44 @@ fn open_openmouse() -> Result<()> { Ok(()) } +#[cfg(target_os = "windows")] +fn open_path(path: &Path) -> Result<()> { + // Ensure the folder exists so Explorer has somewhere to open. + let _ = std::fs::create_dir_all(path); + let operation = wide("open"); + let target = wide(&path.to_string_lossy()); + let result = unsafe { + ShellExecuteW( + null_mut(), + operation.as_ptr(), + target.as_ptr(), + null_mut(), + null_mut(), + SW_SHOWNORMAL, + ) + } as isize; + if result <= 32 { + return Err(anyhow!( + "Windows could not open {} (code {result})", + path.display() + )); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn open_path(path: &Path) -> Result<()> { + let _ = std::fs::create_dir_all(path); + let status = std::process::Command::new("open") + .arg(path) + .status() + .context("macOS could not open the logs folder")?; + if !status.success() { + return Err(anyhow!("macOS could not open {}", path.display())); + } + Ok(()) +} + #[cfg(target_os = "windows")] fn wide(value: &str) -> Vec { value.encode_utf16().chain(Some(0)).collect() diff --git a/src/devices/mod.rs b/src/devices/mod.rs index dfb58ea..39f73b0 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -207,7 +207,13 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { supported_polling_rates: attackshark::supported_polling_rates(), note: "Battery and polling rate are read natively; other settings still require the desktop driver.", }; - tracing::info!(device = %info.id, "opened Attack Shark control interface"); + tracing::info!( + device = %info.id, + interface = entry.interface_number(), + usage_page = format_args!("{:#06x}", entry.usage_page()), + usage = format_args!("{:#06x}", entry.usage()), + "opened Attack Shark control interface", + ); devices.push(OpenDevice { info, handle }); } Err(error) => { @@ -218,14 +224,31 @@ fn refresh(api: &mut HidApi, devices: &mut Vec) { } /// Ask the mouse for its polling rate and decode the reply. Best-effort. +/// +/// The X11's HID descriptor declares no feature reports, so on Windows the HID +/// API can reject these calls outright (HidD_Set/GetFeature validate the buffer +/// length against the descriptor). We log the exact error so a failing unit +/// tells us whether the HID path is viable or whether raw USB (WinUSB/Zadig) is +/// required, rather than silently reporting "unknown". fn read_polling(handle: &HidDevice) -> Option { - handle - .send_feature_report(&attackshark::polling_read_request()) - .ok()?; + if let Err(error) = handle.send_feature_report(&attackshark::polling_read_request()) { + tracing::warn!(%error, "polling read-request (feature report 0xa0) was refused"); + return None; + } let mut buffer = [0u8; REPORT_BUFFER]; buffer[0] = attackshark::POLLING_REPORT_ID; - let read = handle.get_feature_report(&mut buffer).ok()?; - attackshark::parse_polling_reply(&buffer[..read]) + let read = match handle.get_feature_report(&mut buffer) { + Ok(read) => read, + Err(error) => { + tracing::warn!(%error, "polling read-back (feature report 0x06) was refused"); + return None; + } + }; + let parsed = attackshark::parse_polling_reply(&buffer[..read]); + if parsed.is_none() { + tracing::warn!(bytes = ?&buffer[..read], "polling read-back did not decode"); + } + parsed } fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { @@ -238,15 +261,34 @@ fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { device .handle .send_feature_report(&packet) - .map_err(|error| anyhow!("the mouse refused the polling command: {error}"))?; + .map_err(|error| { + anyhow!( + "the mouse refused the polling command (HID feature report 0x06): {error}. \ + This mouse declares no HID feature reports, so Windows likely needs the \ + interface bound to WinUSB (Zadig) and a raw-USB transport." + ) + })?; - // Confirm the mouse actually kept the new rate before reporting success. - let confirmed = read_polling(&device.handle).unwrap_or(hz); - if confirmed != hz { - return Err(anyhow!("the mouse kept {confirmed} Hz instead of {hz} Hz")); + // Try to confirm the mouse kept the new rate. The read-back can fail on + // this device even when the write lands, so distinguish the two: a hard + // mismatch is an error, but an unreadable rate is reported as "sent, + // unverified" rather than a false success. + match read_polling(&device.handle) { + Some(confirmed) if confirmed == hz => { + device.info.polling_rate_hz = Some(confirmed); + Ok(confirmed) + } + Some(other) => Err(anyhow!("the mouse kept {other} Hz instead of {hz} Hz")), + None => { + // Write was accepted but the rate is not read-backable here. + device.info.polling_rate_hz = Some(hz); + tracing::info!( + hz, + "polling command sent; rate is not read-backable on this unit" + ); + Ok(hz) + } } - device.info.polling_rate_hz = Some(confirmed); - Ok(confirmed) } /// Drain any pending battery input reports and push them into the service so diff --git a/src/lib.rs b/src/lib.rs index 5ae5647..65cceaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod applications; pub mod config; pub mod devices; pub mod games; +pub mod logging; pub mod platform; pub mod service; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..89092da --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,97 @@ +//! File + console logging for the Bridge. +//! +//! The release build on Windows has no console (`windows_subsystem = +//! "windows"`), so testers never see stdout. This writes the same logs to a +//! fixed file — `bridge.log` in the data directory — that anyone can find and +//! send back. The file is truncated on each start so it holds exactly one +//! session: reproduce the issue, then share the file. + +use std::{env, fs, io, path::PathBuf}; + +use directories::ProjectDirs; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +/// Default verbosity when `RUST_LOG` is unset. Debug on our own crate so device +/// enumeration and HID errors are captured; quieter for dependencies. +const DEFAULT_FILTER: &str = "openmouse_bridge=debug,tower_http=info"; + +/// Directory the log file lives in. Overridable with `OPENMOUSE_BRIDGE_LOG_DIR` +/// so a tester can redirect it somewhere obvious if needed. +pub fn log_dir() -> PathBuf { + if let Some(dir) = env::var_os("OPENMOUSE_BRIDGE_LOG_DIR") { + return PathBuf::from(dir); + } + if let Some(dirs) = ProjectDirs::from("io", "OpenMouse", "OpenMouse Bridge") { + return dirs.data_dir().join("logs"); + } + env::temp_dir().join("openmouse-bridge-logs") +} + +/// Full path to the log file testers should send. +pub fn log_file_path() -> PathBuf { + log_dir().join("bridge.log") +} + +fn make_filter() -> EnvFilter { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER)) +} + +/// Initialize logging to both stdout and the log file. The returned guard +/// flushes the non-blocking file writer on drop, so the caller must hold it for +/// the lifetime of the program. +pub fn init() -> WorkerGuard { + let dir = log_dir(); + let _ = fs::create_dir_all(&dir); + // Start each run with a fresh file so a shared log is only this session. + let _ = fs::remove_file(dir.join("bridge.log")); + + let (file_writer, guard) = + tracing_appender::non_blocking(tracing_appender::rolling::never(&dir, "bridge.log")); + + tracing_subscriber::registry() + .with( + fmt::layer() + .with_writer(io::stdout) + .with_filter(make_filter()), + ) + .with( + fmt::layer() + .with_ansi(false) + .with_writer(file_writer) + .with_filter(make_filter()), + ) + .init(); + + tracing::info!(path = %log_file_path().display(), "OpenMouse Bridge logging started"); + guard +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_dir_honors_the_override() { + // SAFETY: single-threaded test, restores the variable before returning. + let previous = env::var_os("OPENMOUSE_BRIDGE_LOG_DIR"); + unsafe { env::set_var("OPENMOUSE_BRIDGE_LOG_DIR", "/tmp/openmouse-test-logs") }; + assert_eq!(log_dir(), PathBuf::from("/tmp/openmouse-test-logs")); + assert_eq!( + log_file_path(), + PathBuf::from("/tmp/openmouse-test-logs").join("bridge.log"), + ); + match previous { + Some(value) => unsafe { env::set_var("OPENMOUSE_BRIDGE_LOG_DIR", value) }, + None => unsafe { env::remove_var("OPENMOUSE_BRIDGE_LOG_DIR") }, + } + } + + #[test] + fn log_file_is_named_bridge_log() { + assert_eq!( + log_file_path().file_name().and_then(|name| name.to_str()), + Some("bridge.log"), + ); + } +} diff --git a/src/main.rs b/src/main.rs index 125b7de..766d6c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,11 +15,13 @@ use anyhow::{Context, Result}; use openmouse_bridge::{BRIDGE_PORT, api, config, devices::DeviceManager, service::BridgeService}; #[cfg(not(any(target_os = "windows", target_os = "macos")))] use tokio::net::TcpListener; -use tracing_subscriber::EnvFilter; + +use openmouse_bridge::logging; #[cfg(any(target_os = "windows", target_os = "macos"))] fn main() { - init_tracing(); + // Held for the whole program so the file logger keeps flushing. + let _log = logging::init(); if let Err(error) = desktop::run() { tracing::error!(%error, "OpenMouse Bridge failed"); std::process::exit(1); @@ -29,7 +31,7 @@ fn main() { #[cfg(not(any(target_os = "windows", target_os = "macos")))] #[tokio::main] async fn main() { - init_tracing(); + let _log = logging::init(); if let Err(error) = run().await { eprintln!("OpenMouse Bridge failed: {error:#}"); std::process::exit(1); @@ -54,15 +56,6 @@ async fn run() -> Result<()> { Ok(()) } -fn init_tracing() { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "openmouse_bridge=info,tower_http=info".into()), - ) - .init(); -} - #[cfg(not(any(target_os = "windows", target_os = "macos")))] async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; From 19a59810846500e01660ddfd61c0f983fc5b5c3a Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:52:06 +0300 Subject: [PATCH 06/15] feat(AttackShark): native X11 control via nusb with one-click WinUSB install --- Cargo.lock | 119 ++++++++- Cargo.toml | 2 +- driver/OpenMouse-AttackShark-X11.inf | 70 +++++ driver/README.md | 87 ++++++ driver/sign-and-install.ps1 | 94 +++++++ driver/uninstall.ps1 | 43 +++ src/api.rs | 25 ++ src/desktop.rs | 60 +++-- src/devices/attackshark.rs | 59 ++--- src/devices/mod.rs | 379 +++++++++++++-------------- src/driver.rs | 166 ++++++++++++ src/lib.rs | 1 + 12 files changed, 822 insertions(+), 283 deletions(-) create mode 100644 driver/OpenMouse-AttackShark-X11.inf create mode 100644 driver/README.md create mode 100644 driver/sign-and-install.ps1 create mode 100644 driver/uninstall.ps1 create mode 100644 src/driver.rs diff --git a/Cargo.lock b/Cargo.lock index 853445a..a192272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1669,19 +1669,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hidapi" -version = "2.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" -dependencies = [ - "cc", - "cfg-if", - "libc", - "pkg-config", - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.5.0" @@ -1786,6 +1773,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2049,6 +2046,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2251,6 +2257,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nusb" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e" +dependencies = [ + "atomic-waker", + "core-foundation", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "libc", + "log", + "once_cell", + "rustix 0.38.44", + "slab", + "windows-sys 0.48.0", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -2560,8 +2585,8 @@ dependencies = [ "axum", "directories", "eframe", - "hidapi", "notify-rust", + "nusb", "objc2-app-kit 0.3.2", "png", "serde", @@ -4334,6 +4359,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4370,6 +4404,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4430,6 +4479,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4442,6 +4497,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4454,6 +4515,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4478,6 +4545,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4490,6 +4563,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4502,6 +4581,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4514,6 +4599,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 1e4dfe4..fd7043d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ winresource = "0.1" anyhow = "1.0" axum = "0.8" directories = "6.0" -hidapi = "2.6" +nusb = "0.1" notify-rust = "4.11" png = "0.18" serde = { version = "1.0", features = ["derive"] } diff --git a/driver/OpenMouse-AttackShark-X11.inf b/driver/OpenMouse-AttackShark-X11.inf new file mode 100644 index 0000000..00513cb --- /dev/null +++ b/driver/OpenMouse-AttackShark-X11.inf @@ -0,0 +1,70 @@ +; OpenMouse-AttackShark-X11.inf +; +; Binds Microsoft's in-box WinUSB.sys to the CONFIGURATION interface +; (interface 2) of the Attack Shark X11 / R1 family so the OpenMouse Bridge can +; send settings over raw USB. This is the same driver assignment Zadig makes, +; but scoped and installed by us. +; +; SAFETY +; * No firmware is written. This only changes which Windows driver serves one +; USB interface. The mouse firmware is never modified, so it cannot be +; bricked by installing or removing this package. +; * It matches ONLY interface 2 (MI_02) of three specific product IDs. Windows +; binds drivers by exact hardware ID, so this package physically cannot +; attach to the pointing interface, the keyboard interface, or any other +; device. The sensor and buttons live on interface 1 and are never touched. +; * Fully reversible: remove the package (pnputil /delete-driver ... /uninstall +; or Device Manager) and Windows restores the default HID driver on the next +; reconnect. +; +; Interface 2 is the composite system-control/consumer interface. While it is +; bound to WinUSB, that interface no longer acts as HID (its media keys, if any, +; pause); pointing and clicking are unaffected. + +[Version] +Signature = "$WINDOWS NT$" +Class = USBDevice +ClassGuid = {88BAE032-5A81-49F0-BC3D-A4FF138216D6} +Provider = %ProviderName% +CatalogFile = OpenMouse-AttackShark-X11.cat +DriverVer = 08/16/2026,1.0.0.0 +PnpLockdown = 1 + +; --------------------------------------------------------------------------- +; Only interface 2 of the known X11-family product IDs is listed here, so the +; driver store can never match this package against another interface, another +; product, or another vendor. +; --------------------------------------------------------------------------- +[Manufacturer] +%ProviderName% = OpenMouse, NTamd64, NTarm64 + +[OpenMouse.NTamd64] +%DeviceName.FA60% = USB_Install, USB\VID_1D57&PID_FA60&MI_02 +%DeviceName.FA55% = USB_Install, USB\VID_1D57&PID_FA55&MI_02 +%DeviceName.FA61% = USB_Install, USB\VID_1D57&PID_FA61&MI_02 + +[OpenMouse.NTarm64] +%DeviceName.FA60% = USB_Install, USB\VID_1D57&PID_FA60&MI_02 +%DeviceName.FA55% = USB_Install, USB\VID_1D57&PID_FA55&MI_02 +%DeviceName.FA61% = USB_Install, USB\VID_1D57&PID_FA61&MI_02 + +[USB_Install] +Include = winusb.inf +Needs = WINUSB.NT + +[USB_Install.Services] +Include = winusb.inf +Needs = WINUSB.NT.Services + +[USB_Install.HW] +AddReg = Dev_AddReg + +; Device interface GUID the OpenMouse Bridge uses to find this WinUSB device. +[Dev_AddReg] +HKR,,DeviceInterfaceGUIDs,0x10000,"{E001BC92-43DE-4903-A5D5-1FFE3E82900D}" + +[Strings] +ProviderName = "OpenMouse" +DeviceName.FA60 = "Attack Shark X11 config interface (OpenMouse WinUSB)" +DeviceName.FA55 = "Attack Shark X11 wired config interface (OpenMouse WinUSB)" +DeviceName.FA61 = "Attack Shark R1 config interface (OpenMouse WinUSB)" diff --git a/driver/README.md b/driver/README.md new file mode 100644 index 0000000..1b4a2f4 --- /dev/null +++ b/driver/README.md @@ -0,0 +1,87 @@ +# OpenMouse Attack Shark X11 WinUSB driver package + +`OpenMouse-AttackShark-X11.inf` binds Microsoft's in-box **WinUSB** driver to +**interface 2** (the configuration interface) of the Attack Shark X11 / R1 +family, so the OpenMouse Bridge can send settings over raw USB. It replaces the +need for Zadig — it makes the same WinUSB assignment, but scoped and packaged by +us. + +## Why this is safe + +- **No firmware is written.** This only changes which Windows driver serves one + USB interface. The mouse's firmware is never modified, so installing or + removing this package **cannot brick the mouse**. +- **It matches only interface 2 of three product IDs** + (`VID_1D57&PID_FA60&MI_02`, `…FA55&MI_02`, `…FA61&MI_02`). Windows binds + drivers by exact hardware ID, so this package **cannot** attach to the + pointing interface (interface 1), the keyboard interface, or any other device. + Pointing and clicking are never affected. +- **Fully reversible.** Remove the package and Windows restores the default HID + driver on the next reconnect. While interface 2 is on WinUSB, that interface + stops acting as HID (its media keys, if any, pause); everything else works. + +## Requirements + +Windows will only install a driver package whose catalog (`.cat`) is signed with +a trusted certificate. There are two routes. + +### A. Production (recommended for release) + +1. Generate the catalog with the WDK's `inf2cat`: + ``` + inf2cat /driver:. /os:10_X64,10_ARM64 + ``` +2. Sign `OpenMouse-AttackShark-X11.cat` with your code-signing certificate + (attestation-signed through the Microsoft Partner Center for a fully silent, + universally trusted install), e.g.: + ``` + signtool sign /fd sha256 /a /tr http://timestamp.digicert.com /td sha256 OpenMouse-AttackShark-X11.cat + ``` +3. Ship the `.inf` + signed `.cat` with the Bridge. + +### B. Local testing (developers, one machine) — the easy way + +Run the bundled script from an **administrator** PowerShell. It creates a free +self-signed certificate, trusts it, builds and signs the catalog, and installs +the package — no paid certificate and no Windows Driver Kit required: + +```powershell +powershell -ExecutionPolicy Bypass -File .\sign-and-install.ps1 +``` + +To revert: + +```powershell +powershell -ExecutionPolicy Bypass -File .\uninstall.ps1 +``` + +The script uses only built-in Windows/PowerShell tooling (`New-SelfSignedCertificate`, +`New-FileCatalog`, `Set-AuthenticodeSignature`, `pnputil`), and automatically +uses the WDK's `inf2cat` instead if you happen to have it installed. + +## Install + +From an **administrator** prompt, with the mouse plugged in: + +``` +pnputil /add-driver OpenMouse-AttackShark-X11.inf /install +``` + +`pnputil` adds the package to the driver store and applies it to any matching +present device. Because the `[Models]` section lists only interface 2 of the +three known PIDs, it can only bind there. Reconnect the mouse if prompted; the +OpenMouse Bridge should then show it as controllable. + +## Uninstall (revert to the normal HID driver) + +Find the published name of the package, then delete it: + +``` +pnputil /enum-drivers &:: look for Provider "OpenMouse" +pnputil /delete-driver oemNN.inf /uninstall +``` + +(Replace `oemNN.inf` with the published name shown by `/enum-drivers`.) Or, in +Device Manager, right-click the "Attack Shark … config interface (OpenMouse +WinUSB)" device → Uninstall device → tick "Attempt to remove the driver". Unplug +and replug the mouse afterwards. diff --git a/driver/sign-and-install.ps1 b/driver/sign-and-install.ps1 new file mode 100644 index 0000000..8c91537 --- /dev/null +++ b/driver/sign-and-install.ps1 @@ -0,0 +1,94 @@ +# OpenMouse Attack Shark X11 — self-sign and install the WinUSB driver package. +# +# For a machine you control (testing). It creates a free self-signed +# certificate, tells Windows to trust it, builds and signs the driver catalog, +# and installs the package. No paid certificate and no Windows Driver Kit are +# required — everything here is built into Windows/PowerShell, with the WDK's +# inf2cat used automatically if it happens to be installed. +# +# Run from an ADMIN PowerShell: powershell -ExecutionPolicy Bypass -File .\sign-and-install.ps1 +# +# It is safe and reversible: no firmware is written, only interface 2 of the +# known Attack Shark PIDs is affected, and .\uninstall.ps1 (or Device Manager) +# reverts it. See README.md. + +#Requires -RunAsAdministrator +$ErrorActionPreference = 'Stop' + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$inf = Join-Path $here 'OpenMouse-AttackShark-X11.inf' +$cat = Join-Path $here 'OpenMouse-AttackShark-X11.cat' +$subject = 'CN=OpenMouse Test Signing' + +if (-not (Test-Path $inf)) { throw "Cannot find $inf" } + +# Record a transcript the Bridge can read back if the install fails. +$transcript = Join-Path $env:TEMP 'openmouse-driver.log' +Start-Transcript -Path $transcript -Force -ErrorAction SilentlyContinue | Out-Null + +Write-Host '[1/6] Creating or reusing the self-signed certificate...' +$cert = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $subject } | Select-Object -First 1 +if (-not $cert) { + $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject ` + -CertStoreLocation Cert:\CurrentUser\My -KeyUsage DigitalSignature ` + -NotAfter (Get-Date).AddYears(5) +} +Write-Host " thumbprint $($cert.Thumbprint)" + +Write-Host '[2/6] Trusting the certificate (Trusted Root + Trusted Publishers)...' +$cer = Join-Path $env:TEMP 'openmouse-test.cer' +Export-Certificate -Cert $cert -FilePath $cer | Out-Null +Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\Root | Out-Null +Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null +Remove-Item $cer -Force + +Write-Host '[3/6] Building the driver catalog...' +if (Test-Path $cat) { Remove-Item $cat -Force } +$inf2cat = Get-Command inf2cat.exe -ErrorAction SilentlyContinue +if ($inf2cat) { + Write-Host ' using WDK inf2cat' + & inf2cat.exe /driver:"$here" /os:10_X64,10_ARM64 +} else { + Write-Host ' using built-in New-FileCatalog' + New-FileCatalog -Path $inf -CatalogFilePath $cat -CatalogVersion 2 | Out-Null +} + +Write-Host '[4/6] Signing the catalog...' +$result = Set-AuthenticodeSignature -FilePath $cat -Certificate $cert +if ($result.Status -ne 'Valid') { throw "Signing failed: $($result.StatusMessage)" } + +Write-Host '[5/6] Adding the driver to the store (interface 2 of the Attack Shark X11 only)...' +& pnputil /add-driver "$inf" /install +$code = $LASTEXITCODE +if ($code -ne 0 -and $code -ne 3010 -and $code -ne 259) { + throw "pnputil failed with exit code $code. See README.md." +} + +Write-Host '[6/6] Re-binding interface 2 so Windows applies the new driver...' +# Adding a package stages it, but the interface keeps its old (HID) driver until +# it re-enumerates. Restart just the Attack Shark config interface (VID_1D57, +# MI_02) so Windows re-evaluates and binds WinUSB. This only touches interface 2 +# (the config interface); the pointer/keyboard interfaces are untouched. +$dev = Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | + Where-Object { $_.InstanceId -match 'VID_1D57' -and $_.InstanceId -match 'MI_0?2($|\\)' } +if ($dev) { + foreach ($d in $dev) { + Write-Host " restarting $($d.InstanceId)" + Disable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 1 + foreach ($d in $dev) { + Enable-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction SilentlyContinue + } + Write-Host ' done' +} else { + Write-Host ' interface 2 not found as a present device; unplug and replug the mouse instead.' +} + +Stop-Transcript -ErrorAction SilentlyContinue | Out-Null + +Write-Host '' +Write-Host 'Done. Check OpenMouse (Interface settings -> Bridge -> Native devices);' +Write-Host 'the X11 should now show as controllable. If not, unplug and replug it once.' +Write-Host 'To revert: run .\uninstall.ps1 from an admin PowerShell.' diff --git a/driver/uninstall.ps1 b/driver/uninstall.ps1 new file mode 100644 index 0000000..8387086 --- /dev/null +++ b/driver/uninstall.ps1 @@ -0,0 +1,43 @@ +# OpenMouse Attack Shark X11 — remove the WinUSB driver package. +# +# Reverts sign-and-install.ps1: finds our driver package by provider name +# (locale-independent) and removes it, restoring the default HID driver on the +# next reconnect. No firmware is touched. Run from an ADMIN PowerShell: +# powershell -ExecutionPolicy Bypass -File .\uninstall.ps1 + +#Requires -RunAsAdministrator +$ErrorActionPreference = 'Stop' + +Write-Host 'Looking for the OpenMouse driver package...' + +# Packages bound to a live device (provider name is locale-independent). +$infs = @(Get-CimInstance Win32_PnPSignedDriver -ErrorAction SilentlyContinue | + Where-Object { $_.DriverProviderName -eq 'OpenMouse' } | + ForEach-Object { $_.InfName }) + +# Also catch a package that was staged in the driver store but never bound: scan +# pnputil's output for our original INF name (a literal value, so this works in +# any Windows display language) and pull the oemNN.inf published name near it. +$enum = (& pnputil /enum-drivers) -join "`n" +foreach ($block in ($enum -split "`r?`n`r?`n")) { + if ($block -match 'OpenMouse-AttackShark-X11\.inf') { + $m = [regex]::Match($block, 'oem\d+\.inf') + if ($m.Success) { $infs += $m.Value } + } +} +$infs = @($infs | Sort-Object -Unique) + +if (-not $infs) { + Write-Host 'No OpenMouse driver package is installed. Nothing to do.' + return +} + +foreach ($inf in $infs) { + Write-Host "Removing $inf ..." + & pnputil /delete-driver $inf /uninstall + if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { + Write-Warning "pnputil returned $LASTEXITCODE for $inf. You can also remove it from Device Manager." + } +} + +Write-Host 'Done. Unplug and replug the mouse to restore its normal driver.' diff --git a/src/api.rs b/src/api.rs index 70779c8..9d084dc 100644 --- a/src/api.rs +++ b/src/api.rs @@ -69,6 +69,13 @@ struct PollingResult { polling_rate_hz: u16, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DriverPayload { + /// "install" or "uninstall". + action: String, +} + pub fn router( service: BridgeService, devices: Option, @@ -102,6 +109,7 @@ pub fn router( .route("/v1/autostart", put(set_autostart)) .route("/v1/devices", get(list_devices)) .route("/v1/devices/{id}/polling", put(set_device_polling)) + .route("/v1/driver", put(driver_action)) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(AppState { service, devices }) @@ -133,6 +141,23 @@ async fn set_device_polling( })) } +/// Install or remove the WinUSB driver package so the config interface becomes +/// reachable (Windows only). Runs behind a UAC prompt and may block, so it hops +/// to a blocking thread. +async fn driver_action( + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let outcome = tokio::task::spawn_blocking(move || match payload.action.as_str() { + "install" => crate::driver::install(), + "uninstall" => crate::driver::uninstall(), + other => Err(anyhow::anyhow!("unknown driver action: {other}")), + }) + .await + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + outcome.map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(ApiResult { ok: true })) +} + async fn status(State(service): State) -> Json { Json(service.snapshot().await) } diff --git a/src/desktop.rs b/src/desktop.rs index cf868c9..3741693 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -132,10 +132,10 @@ pub fn run() -> Result<()> { let app_icon = Arc::new(openmouse_app_icon()?); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([420.0, 300.0]) + .with_inner_size([420.0, 380.0]) .with_min_inner_size([420.0, 300.0]) - .with_max_inner_size([420.0, 300.0]) - .with_resizable(false) + .with_max_inner_size([420.0, 720.0]) + .with_resizable(true) .with_decorations(false) .with_icon(app_icon) .with_transparent(true), @@ -549,30 +549,36 @@ impl eframe::App for BridgeDesktop { .show(ui, |ui| { ui.set_min_size(ui.available_size()); self.title_bar(ui); - egui::Frame::new() - .inner_margin(20.0) - .show(ui, |ui| match self.page { - DesktopPage::Home => { - self.status_card(ui); - ui.add_space(8.0); - self.activity(ui); - ui.add_space(12.0); - let button = egui::Button::new( - RichText::new("Open OpenMouse") - .color(BACKGROUND) - .strong() - .size(12.0), - ) - .fill(ACCENT) - .stroke(Stroke::NONE) - .corner_radius(CornerRadius::same(6)); - if ui.add_sized([ui.available_width(), 34.0], button).clicked() - && let Err(error) = open_openmouse() - { - self.error = Some(error.to_string()); - } - } - DesktopPage::Settings => self.settings(ui), + // The Settings page can grow taller than the fixed window, so + // let the body scroll instead of clipping. + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + egui::Frame::new() + .inner_margin(20.0) + .show(ui, |ui| match self.page { + DesktopPage::Home => { + self.status_card(ui); + ui.add_space(8.0); + self.activity(ui); + ui.add_space(12.0); + let button = egui::Button::new( + RichText::new("Open OpenMouse") + .color(BACKGROUND) + .strong() + .size(12.0), + ) + .fill(ACCENT) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(6)); + if ui.add_sized([ui.available_width(), 34.0], button).clicked() + && let Err(error) = open_openmouse() + { + self.error = Some(error.to_string()); + } + } + DesktopPage::Settings => self.settings(ui), + }); }); }); } diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs index 9b7de60..aab17a3 100644 --- a/src/devices/attackshark.rs +++ b/src/devices/attackshark.rs @@ -25,13 +25,25 @@ pub const X11_WIRED_PID: u16 = 0xfa55; pub const X11_WIRELESS_PID: u16 = 0xfa60; pub const R1_PID: u16 = 0xfa61; -/// USB interface that carries the configuration feature reports. -pub const CONTROL_INTERFACE: i32 = 2; - +/// USB interface that carries the configuration channel. +pub const CONTROL_INTERFACE: u8 = 2; + +/// The config channel is reached with raw USB control transfers, exactly like +/// the reference driver (dressedinblack5/attack-shark-x11-electron): a HID +/// class SET_REPORT to interface 2. Windows' HID stack refuses these because +/// the descriptor declares no feature reports, so this must go through a raw +/// USB handle (WinUSB / Zadig on Windows, hidraw/libusb elsewhere). +/// +/// bmRequestType 0x21 = Host->Device | Class | Interface. +pub const SET_REPORT_REQUEST: u8 = 0x09; +/// wValue high byte: HID report type 0x03 = Feature. +pub const FEATURE_REPORT_TYPE: u16 = 0x03; /// Feature report id the polling-rate command is written on. pub const POLLING_REPORT_ID: u8 = 0x06; -/// Feature report id used to request a state read-back. -pub const READ_REQUEST_REPORT_ID: u8 = 0xa0; +/// wValue for the polling SET_REPORT: (Feature << 8) | report id = 0x0306. +pub const POLLING_WVALUE: u16 = (FEATURE_REPORT_TYPE << 8) | POLLING_REPORT_ID as u16; +/// Interrupt IN endpoint that streams battery packets on the wireless receiver. +pub const BATTERY_ENDPOINT: u8 = 0x83; /// Input report id the autonomous battery packet arrives on. pub const BATTERY_REPORT_ID: u8 = 0x03; @@ -87,31 +99,6 @@ pub fn polling_packet(hz: u16) -> Option<[u8; 9]> { ]) } -/// Encode the read-request that asks the mouse to publish its current polling -/// rate on `POLLING_REPORT_ID`. Byte 0 is the `READ_REQUEST_REPORT_ID`. -pub fn polling_read_request() -> [u8; 8] { - [ - READ_REQUEST_REPORT_ID, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - ] -} - -/// Decode a polling-rate feature report read back from the mouse. `report` -/// includes the leading report id, so the rate code sits at index 2. Returns -/// the rate in Hz, or `None` if the code is unknown or the buffer is short. -pub fn parse_polling_reply(report: &[u8]) -> Option { - let code = *report.get(2)?; - POLLING_RATES - .iter() - .find_map(|&(hz, wire)| (wire == code).then_some(hz)) -} - /// Decode a battery input report. `report` includes the leading report id, so /// the signature occupies bytes 0..4 and the percentage byte 4. Returns the /// percentage (0..=100) or `None` for a non-battery or out-of-range report. @@ -147,13 +134,11 @@ mod tests { } #[test] - fn polling_reply_round_trips_every_rate() { - for &(hz, code) in &POLLING_RATES { - let reply = [POLLING_REPORT_ID, 0x00, code, 0x00]; - assert_eq!(parse_polling_reply(&reply), Some(hz)); - } - assert_eq!(parse_polling_reply(&[POLLING_REPORT_ID, 0x00, 0x99]), None); - assert_eq!(parse_polling_reply(&[POLLING_REPORT_ID]), None); + fn polling_control_transfer_parameters_match_reference() { + // Reference PollingRateBuilder: bRequest 0x09, wValue 0x0306, wIndex 2. + assert_eq!(SET_REPORT_REQUEST, 0x09); + assert_eq!(POLLING_WVALUE, 0x0306); + assert_eq!(CONTROL_INTERFACE, 2); } #[test] diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 39f73b0..793a9d0 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -1,34 +1,41 @@ //! Native mouse access for the Bridge. //! -//! The web app drives mice over WebHID, but some devices keep their -//! configuration channel on HID collections the browser refuses to touch -//! (protected keyboard/system-control usages). The Attack Shark X11 is one of -//! them: everything a page needs is hidden or blocked. A native HID handle is -//! not subject to that block, so the Bridge can talk to interface 2 directly. +//! Some mice keep their configuration channel where the browser cannot reach +//! it. The Attack Shark X11 is the hard case: its HID descriptor declares no +//! feature reports, so neither WebHID nor the OS HID API can move config data +//! to it (Windows' `HidD_SetFeature` returns ERROR_INVALID_FUNCTION). The +//! reference driver (dressedinblack5/attack-shark-x11-electron) works around +//! this by bypassing HID entirely: it claims USB interface 2 and sends raw +//! control transfers. We do the same here with `nusb`. //! -//! hidapi is blocking, and its handles are not `Sync`, so all device I/O runs -//! on one dedicated OS thread. The async world talks to it through a command -//! channel and never touches a raw handle. +//! Platform note: claiming a USB interface for raw access needs a suitable +//! kernel driver. On Linux the kernel HID driver is detached automatically; on +//! Windows interface 2 must be bound to WinUSB (via Zadig) or the claim fails — +//! in which case the device is still listed, with a note explaining why it is +//! not yet controllable. +//! +//! `nusb` is async, so the worker is a tokio task and talks to the HTTP side +//! through a command channel. pub mod attackshark; -use std::{ - sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}, - thread, - time::{Duration, Instant}, -}; +use std::{collections::HashSet, time::Duration}; use anyhow::{Result, anyhow}; -use hidapi::{HidApi, HidDevice}; +use nusb::transfer::{ControlOut, ControlType, Recipient, RequestBuffer}; use serde::Serialize; -use tokio::{runtime::Handle, sync::oneshot}; +use tokio::sync::{mpsc, oneshot}; use crate::service::{BatteryReading, BridgeService}; -/// How often the worker polls for plug/unplug and drains battery reports. +/// How often the worker re-enumerates and samples battery. const POLL_INTERVAL: Duration = Duration::from_secs(2); -/// Feature-report buffers are 65 bytes (id + 64) — the largest report here. -const REPORT_BUFFER: usize = 65; +/// Upper bound on a single control transfer. +const CONTROL_TIMEOUT: Duration = Duration::from_millis(1000); +/// How long to wait for a battery packet each cycle before giving up. +const BATTERY_READ_WINDOW: Duration = Duration::from_millis(250); +/// Battery interrupt reads use a 64-byte buffer (the endpoint's packet size). +const BATTERY_BUFFER: usize = 64; /// A mouse the Bridge can see natively, as reported to the web app. #[derive(Clone, Debug, Serialize)] @@ -41,14 +48,16 @@ pub struct DeviceInfo { pub product_id: u16, /// `"wired"` or `"wireless"`. pub connection: &'static str, + /// True when interface 2 was claimed and commands can be sent. + pub controllable: bool, pub battery_percent: Option, pub polling_rate_hz: Option, pub supported_polling_rates: Vec, - /// Why settings may be limited, when they are. + /// User-facing explanation of the device's current state. pub note: &'static str, } -/// Commands the async side sends to the blocking worker. +/// Commands the async HTTP side sends to the device worker. enum Command { List(oneshot::Sender>), SetPolling { @@ -58,258 +67,224 @@ enum Command { }, } -/// Handle to the device worker thread. Cloneable and cheap. +/// Handle to the device worker. Cloneable and cheap. #[derive(Clone)] pub struct DeviceManager { - commands: Sender, + commands: mpsc::Sender, } impl DeviceManager { - /// Start the worker. Battery readings are forwarded into `service` so they - /// reuse the existing low-battery notification pipeline. Returns `None` - /// when hidapi is unavailable, so the Bridge still runs without it. + /// Start the worker as a tokio task. Battery readings are forwarded into + /// `service` so they reuse the existing low-battery notifier. Returns + /// `None` if called outside a tokio runtime. pub fn start(service: BridgeService) -> Option { - // Captured here (inside the async runtime) so the blocking worker can - // hand battery readings back to the runtime without owning one. - let runtime = match Handle::try_current() { - Ok(handle) => handle, - Err(_) => { - tracing::error!("device worker needs a tokio runtime; native support is off"); - return None; - } - }; - let (tx, rx) = mpsc::channel(); - let spawned = thread::Builder::new() - .name("openmouse-devices".into()) - .spawn(move || worker(rx, service, runtime)); - match spawned { - Ok(_) => Some(Self { commands: tx }), - Err(error) => { - tracing::error!(%error, "could not start the device worker"); - None - } + if tokio::runtime::Handle::try_current().is_err() { + tracing::error!("device worker needs a tokio runtime; native support is off"); + return None; } + let (tx, rx) = mpsc::channel(16); + tokio::spawn(worker(rx, service)); + Some(Self { commands: tx }) } /// List every X11-family mouse currently attached. pub async fn list(&self) -> Vec { let (tx, rx) = oneshot::channel(); - if self.commands.send(Command::List(tx)).is_err() { + if self.commands.send(Command::List(tx)).await.is_err() { return Vec::new(); } rx.await.unwrap_or_default() } - /// Set the polling rate on one device and return the value it confirmed. + /// Set the polling rate on one device and return the value written. pub async fn set_polling(&self, id: String, hz: u16) -> Result { let (tx, rx) = oneshot::channel(); self.commands .send(Command::SetPolling { id, hz, reply: tx }) + .await .map_err(|_| anyhow!("the device worker is not running"))?; rx.await .map_err(|_| anyhow!("the device worker dropped the request"))? } } -/// One attached, opened control interface. +/// One attached device. `interface` is `None` when interface 2 could not be +/// claimed (e.g. not yet bound to WinUSB on Windows) — the device is still +/// listed so the UI can explain the situation. struct OpenDevice { info: DeviceInfo, - handle: HidDevice, + interface: Option, } -fn worker(commands: Receiver, service: BridgeService, runtime: Handle) { - let mut api = match HidApi::new() { - Ok(api) => api, - Err(error) => { - tracing::error!(%error, "hidapi is unavailable; native device support is off"); - return; - } - }; +async fn worker(mut commands: mpsc::Receiver, service: BridgeService) { let mut devices: Vec = Vec::new(); - - // Discover once up front so an already-attached mouse is ready immediately. - refresh(&mut api, &mut devices); - let mut last_refresh = Instant::now(); + refresh(&mut devices); + let mut ticker = tokio::time::interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - match commands.recv_timeout(POLL_INTERVAL) { - Ok(Command::List(reply)) => { - let _ = reply.send(devices.iter().map(|device| device.info.clone()).collect()); - } - Ok(Command::SetPolling { id, hz, reply }) => { - let _ = reply.send(set_polling(&mut devices, &id, hz)); + tokio::select! { + command = commands.recv() => match command { + Some(Command::List(reply)) => { + let _ = reply.send(devices.iter().map(|device| device.info.clone()).collect()); + } + Some(Command::SetPolling { id, hz, reply }) => { + let _ = reply.send(set_polling(&mut devices, &id, hz).await); + } + None => break, + }, + _ = ticker.tick() => { + poll_battery(&mut devices, &service).await; + refresh(&mut devices); } - Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => break, - } - - // Battery is drained on every wake so a fresh command still refreshes - // it, but enumeration is throttled so a busy command stream cannot - // spin hidapi. - drain_battery(&mut devices, &service, &runtime); - if last_refresh.elapsed() >= POLL_INTERVAL { - refresh(&mut api, &mut devices); - last_refresh = Instant::now(); } } } /// Re-enumerate and reconcile the open-device list with what is attached now. -fn refresh(api: &mut HidApi, devices: &mut Vec) { - if let Err(error) = api.refresh_devices() { - tracing::debug!(%error, "hidapi refresh failed"); - return; - } +fn refresh(devices: &mut Vec) { + let list = match nusb::list_devices() { + Ok(list) => list, + Err(error) => { + tracing::debug!(%error, "nusb enumeration failed"); + return; + } + }; - // Drop devices that are no longer present. - let present: Vec<(u16, u16)> = api - .device_list() + let entries: Vec = list .filter(|entry| attackshark::is_x11(entry.vendor_id(), entry.product_id())) + .collect(); + + // Drop devices that are no longer present. + let present: HashSet<(u16, u16)> = entries + .iter() .map(|entry| (entry.vendor_id(), entry.product_id())) .collect(); devices.retain(|device| present.contains(&(device.info.vendor_id, device.info.product_id))); - for entry in api.device_list() { + for entry in entries { let (vid, pid) = (entry.vendor_id(), entry.product_id()); - if !attackshark::is_x11(vid, pid) { - continue; - } - // Only the control interface answers; the boot mouse/keyboard entries - // are the wrong endpoint. hidapi reports -1 when it cannot tell, in - // which case we fall back to the system-control usage page (0x0c). - let is_control = entry.interface_number() == attackshark::CONTROL_INTERFACE - || (entry.interface_number() < 0 && entry.usage_page() == 0x000c); - if !is_control { - continue; - } if devices .iter() .any(|device| device.info.vendor_id == vid && device.info.product_id == pid) { continue; } - match entry.open_device(api) { - Ok(handle) => { - // Battery arrives unprompted; never block waiting for it. - let _ = handle.set_blocking_mode(false); - let info = DeviceInfo { - id: format!("{vid:04x}:{pid:04x}"), - name: attackshark::model_name(pid).to_owned(), - vendor_id: vid, - product_id: pid, - connection: if attackshark::is_wireless(pid) { - "wireless" - } else { - "wired" - }, - battery_percent: None, - polling_rate_hz: read_polling(&handle), - supported_polling_rates: attackshark::supported_polling_rates(), - note: "Battery and polling rate are read natively; other settings still require the desktop driver.", - }; - tracing::info!( - device = %info.id, - interface = entry.interface_number(), - usage_page = format_args!("{:#06x}", entry.usage_page()), - usage = format_args!("{:#06x}", entry.usage()), - "opened Attack Shark control interface", - ); - devices.push(OpenDevice { info, handle }); + + let (interface, controllable, note) = match claim(&entry) { + Ok(interface) => { + tracing::info!(device = %format!("{vid:04x}:{pid:04x}"), "claimed Attack Shark interface 2 for native control"); + ( + Some(interface), + true, + "Connected. Polling rate is set natively over USB.", + ) } Err(error) => { - tracing::debug!(%error, vid, pid, "could not open Attack Shark control interface"); + tracing::warn!(%error, vid, pid, "could not claim interface 2; on Windows bind it to WinUSB with Zadig"); + ( + None, + false, + "Detected, but interface 2 is not claimable. On Windows, bind it to WinUSB with Zadig, then reconnect.", + ) } - } + }; + + devices.push(OpenDevice { + info: DeviceInfo { + id: format!("{vid:04x}:{pid:04x}"), + name: attackshark::model_name(pid).to_owned(), + vendor_id: vid, + product_id: pid, + connection: if attackshark::is_wireless(pid) { + "wireless" + } else { + "wired" + }, + controllable, + battery_percent: None, + polling_rate_hz: None, + supported_polling_rates: attackshark::supported_polling_rates(), + note, + }, + interface, + }); } } -/// Ask the mouse for its polling rate and decode the reply. Best-effort. -/// -/// The X11's HID descriptor declares no feature reports, so on Windows the HID -/// API can reject these calls outright (HidD_Set/GetFeature validate the buffer -/// length against the descriptor). We log the exact error so a failing unit -/// tells us whether the HID path is viable or whether raw USB (WinUSB/Zadig) is -/// required, rather than silently reporting "unknown". -fn read_polling(handle: &HidDevice) -> Option { - if let Err(error) = handle.send_feature_report(&attackshark::polling_read_request()) { - tracing::warn!(%error, "polling read-request (feature report 0xa0) was refused"); - return None; - } - let mut buffer = [0u8; REPORT_BUFFER]; - buffer[0] = attackshark::POLLING_REPORT_ID; - let read = match handle.get_feature_report(&mut buffer) { - Ok(read) => read, - Err(error) => { - tracing::warn!(%error, "polling read-back (feature report 0x06) was refused"); - return None; - } - }; - let parsed = attackshark::parse_polling_reply(&buffer[..read]); - if parsed.is_none() { - tracing::warn!(bytes = ?&buffer[..read], "polling read-back did not decode"); - } - parsed +/// Open the device and claim interface 2 for raw control transfers. On Linux +/// this detaches the kernel HID driver first; on Windows it requires WinUSB. +fn claim(entry: &nusb::DeviceInfo) -> Result { + let device = entry.open()?; + let interface = device.detach_and_claim_interface(attackshark::CONTROL_INTERFACE)?; + Ok(interface) } -fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { +/// Send the polling-rate command as a HID SET_REPORT control transfer, exactly +/// as the reference driver does (bmRequestType 0x21, bRequest 0x09, wValue +/// 0x0306, wIndex 2). A completed transfer is the mouse acknowledging it. +async fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { let device = devices .iter_mut() .find(|device| device.info.id == id) .ok_or_else(|| anyhow!("no attached device with id {id}"))?; + let interface = device.interface.as_ref().ok_or_else(|| { + anyhow!( + "this mouse is detected but interface 2 is not claimable; on Windows bind it to \ + WinUSB with Zadig first" + ) + })?; let packet = attackshark::polling_packet(hz) .ok_or_else(|| anyhow!("{hz} Hz is not a supported polling rate"))?; - device - .handle - .send_feature_report(&packet) - .map_err(|error| { - anyhow!( - "the mouse refused the polling command (HID feature report 0x06): {error}. \ - This mouse declares no HID feature reports, so Windows likely needs the \ - interface bound to WinUSB (Zadig) and a raw-USB transport." - ) - })?; - // Try to confirm the mouse kept the new rate. The read-back can fail on - // this device even when the write lands, so distinguish the two: a hard - // mismatch is an error, but an unreadable rate is reported as "sent, - // unverified" rather than a false success. - match read_polling(&device.handle) { - Some(confirmed) if confirmed == hz => { - device.info.polling_rate_hz = Some(confirmed); - Ok(confirmed) - } - Some(other) => Err(anyhow!("the mouse kept {other} Hz instead of {hz} Hz")), - None => { - // Write was accepted but the rate is not read-backable here. - device.info.polling_rate_hz = Some(hz); - tracing::info!( - hz, - "polling command sent; rate is not read-backable on this unit" - ); - Ok(hz) - } - } + let transfer = interface.control_out(ControlOut { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: attackshark::SET_REPORT_REQUEST, + value: attackshark::POLLING_WVALUE, + index: u16::from(attackshark::CONTROL_INTERFACE), + data: &packet, + }); + let completion = tokio::time::timeout(CONTROL_TIMEOUT, transfer) + .await + .map_err(|_| anyhow!("the polling command timed out"))?; + completion + .status + .map_err(|error| anyhow!("the mouse rejected the polling command: {error}"))?; + + device.info.polling_rate_hz = Some(hz); + tracing::info!(device = %device.info.id, hz, "set polling rate over USB"); + Ok(hz) } -/// Drain any pending battery input reports and push them into the service so -/// the existing low-battery notifier fires. Non-blocking; skips quiet devices. -fn drain_battery(devices: &mut [OpenDevice], service: &BridgeService, runtime: &Handle) { +/// Best-effort battery sample: read one interrupt packet from the battery +/// endpoint, and push a change into the service's low-battery notifier. +async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { for device in devices.iter_mut() { - let mut buffer = [0u8; REPORT_BUFFER]; - // Read until the queue is empty so we always keep the freshest value. - let mut latest: Option = None; - while let Ok(read) = device.handle.read_timeout(&mut buffer, 0) { - if read == 0 { - break; - } - if let Some(percent) = attackshark::parse_battery(&buffer[..read]) { - latest = Some(percent); - } + if !attackshark::is_wireless(device.info.product_id) { + continue; + } + let Some(interface) = device.interface.as_ref() else { + continue; + }; + + let transfer = interface.interrupt_in( + attackshark::BATTERY_ENDPOINT, + RequestBuffer::new(BATTERY_BUFFER), + ); + let Ok(completion) = tokio::time::timeout(BATTERY_READ_WINDOW, transfer).await else { + continue; // No battery packet this cycle. + }; + if completion.status.is_err() { + continue; } - let Some(percent) = latest else { continue }; + let Some(percent) = attackshark::parse_battery(&completion.data) else { + continue; + }; if device.info.battery_percent == Some(percent) { continue; } + device.info.battery_percent = Some(percent); let reading = BatteryReading { device_id: device.info.id.clone(), @@ -317,12 +292,8 @@ fn drain_battery(devices: &mut [OpenDevice], service: &BridgeService, runtime: & percent, charging: false, }; - let service = service.clone(); - // record_battery is async; hop onto the runtime without blocking here. - runtime.spawn(async move { - if let Err(error) = service.record_battery(reading).await { - tracing::debug!(%error, "could not record native battery reading"); - } - }); + if let Err(error) = service.record_battery(reading).await { + tracing::debug!(%error, "could not record native battery reading"); + } } } diff --git a/src/driver.rs b/src/driver.rs new file mode 100644 index 0000000..f658882 --- /dev/null +++ b/src/driver.rs @@ -0,0 +1,166 @@ +//! One-click WinUSB driver install for the Attack Shark X11 config interface. +//! +//! On Windows the mouse's config interface (interface 2) is owned by the HID +//! driver, which refuses raw config traffic. To reach it the interface must be +//! bound to WinUSB — the same thing Zadig does, but here we ship our own scoped +//! driver package (`driver/OpenMouse-AttackShark-X11.inf`) and install it with +//! `pnputil` behind a single UAC prompt. +//! +//! Safety: the package matches only interface 2 of the three known product IDs, +//! writes no firmware, and is fully reversible (`uninstall`). See +//! `driver/README.md`. +//! +//! Other platforms don't need this — the Bridge claims the interface directly — +//! so the calls there just explain that. + +use anyhow::Result; + +/// Whether a driver install is meaningful on this platform (Windows only). +pub fn is_supported() -> bool { + cfg!(target_os = "windows") +} + +#[cfg(target_os = "windows")] +pub fn install() -> Result<()> { + windows_impl::install() +} + +#[cfg(target_os = "windows")] +pub fn uninstall() -> Result<()> { + windows_impl::uninstall() +} + +#[cfg(not(target_os = "windows"))] +pub fn install() -> Result<()> { + anyhow::bail!( + "the WinUSB driver install is only needed on Windows; elsewhere the Bridge claims the \ + device directly (Linux needs only a udev rule)" + ) +} + +#[cfg(not(target_os = "windows"))] +pub fn uninstall() -> Result<()> { + anyhow::bail!("no OpenMouse driver is installed on this platform") +} + +#[cfg(target_os = "windows")] +mod windows_impl { + use std::{env, ffi::OsStr, mem::zeroed, os::windows::ffi::OsStrExt, path::PathBuf}; + + use anyhow::{Context, Result, bail}; + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject}, + UI::{ + Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW}, + WindowsAndMessaging::SW_HIDE, + }, + }; + + const INSTALL_SCRIPT: &str = "sign-and-install.ps1"; + const UNINSTALL_SCRIPT: &str = "uninstall.ps1"; + /// Name of the transcript the scripts write in %TEMP% (same user, so the + /// non-elevated Bridge can read it back after an elevated run). + const TRANSCRIPT: &str = "openmouse-driver.log"; + + /// Locate a shipped driver-folder file. Next to the executable in a release + /// install; the repo's `driver/` folder as a fallback for `cargo run`. + fn driver_file(name: &str) -> Result { + let exe = env::current_exe().context("could not locate the Bridge executable")?; + let dir = exe + .parent() + .context("the Bridge executable has no parent directory")?; + let dev_fallback = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("driver"); + for base in [dir.join("driver"), dir.to_path_buf(), dev_fallback] { + let candidate = base.join(name); + if candidate.exists() { + return Ok(candidate); + } + } + bail!( + "could not find {name}. Ship the driver folder next to the Bridge executable \ + (see driver/README.md)." + ) + } + + pub fn install() -> Result<()> { + run_script(INSTALL_SCRIPT).context( + "enabling native control failed. This self-signs and installs the WinUSB driver for \ + interface 2 of the Attack Shark; it needs the UAC prompt to be approved.", + ) + } + + pub fn uninstall() -> Result<()> { + run_script(UNINSTALL_SCRIPT) + } + + /// Run one of the bundled driver scripts elevated, returning a rich error + /// (including the script transcript) when it fails. + fn run_script(name: &str) -> Result<()> { + let script = driver_file(name)?; + let args = format!( + "-NoProfile -ExecutionPolicy Bypass -File \"{}\"", + script.display() + ); + match run_elevated("powershell.exe", &args)? { + 0 => Ok(()), + other => { + let detail = transcript_tail().unwrap_or_default(); + if detail.is_empty() { + bail!("{name} exited with code {other}") + } else { + bail!("{name} exited with code {other}:\n{detail}") + } + } + } + } + + /// Read the tail of the script transcript for error reporting. + fn transcript_tail() -> Option { + let path = env::var_os("TEMP").map(PathBuf::from)?.join(TRANSCRIPT); + let text = std::fs::read_to_string(path).ok()?; + let tail: Vec<&str> = text.lines().rev().take(20).collect(); + Some(tail.into_iter().rev().collect::>().join("\n")) + } + + /// Run a program elevated (UAC "runas") and return its exit code, waiting + /// for it to finish. + fn run_elevated(program: &str, args: &str) -> Result { + let verb = wide("runas"); + let file = wide(program); + let params = wide(args); + + let mut info: SHELLEXECUTEINFOW = unsafe { zeroed() }; + info.cbSize = size_of::() as u32; + info.fMask = SEE_MASK_NOCLOSEPROCESS; + info.lpVerb = verb.as_ptr(); + info.lpFile = file.as_ptr(); + info.lpParameters = params.as_ptr(); + info.nShow = SW_HIDE; + + let launched = unsafe { ShellExecuteExW(&mut info) }; + if launched == 0 { + return Err(std::io::Error::last_os_error()).context( + "could not start the elevated installer (the UAC prompt may have been declined)", + ); + } + if info.hProcess.is_null() { + bail!("the elevated installer did not start"); + } + + unsafe { + WaitForSingleObject(info.hProcess, INFINITE); + let mut code = 0u32; + let read = GetExitCodeProcess(info.hProcess, &mut code); + CloseHandle(info.hProcess); + if read == 0 { + bail!("could not read the installer exit code"); + } + Ok(code) + } + } + + fn wide(value: impl AsRef) -> Vec { + value.as_ref().encode_wide().chain(Some(0)).collect() + } +} diff --git a/src/lib.rs b/src/lib.rs index 65cceaa..5dfaa6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod applications; pub mod config; pub mod devices; +pub mod driver; pub mod games; pub mod logging; pub mod platform; From 571e75369435a9952144e365d683d590ad3c7c71 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:04:43 +0300 Subject: [PATCH 07/15] feat(bridge): add the driver to the build --- .github/workflows/ci.yml | 8 ++------ .github/workflows/release.yml | 2 +- .gitignore | 4 ++++ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af46d25..d7b07d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Package shell: pwsh run: | - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md -DestinationPath openmouse-bridge-windows-x64.zip + Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,driver -DestinationPath openmouse-bridge-windows-x64.zip (Get-FileHash openmouse-bridge-windows-x64.zip -Algorithm SHA256).Hash.ToLower() + " openmouse-bridge-windows-x64.zip" | Set-Content openmouse-bridge-windows-x64.zip.sha256 - uses: actions/upload-artifact@v4 with: @@ -56,11 +56,7 @@ jobs: GH_TOKEN: ${{ github.token }} shell: pwsh run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if (gh release view dev-build 2>$null) { gh release delete dev-build --cleanup-tag --yes } - git tag -f dev-build - git push origin dev-build --force - gh release create dev-build --prerelease --title "Development build" --notes "Automatic Windows build from main at $env:GITHUB_SHA." openmouse-bridge-windows-x64.zip openmouse-bridge-windows-x64.zip.sha256 + gh release create dev-build --target $env:GITHUB_SHA --prerelease --title "Development build" --notes "Automatic Windows build from main at $env:GITHUB_SHA." openmouse-bridge-windows-x64.zip openmouse-bridge-windows-x64.zip.sha256 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae80051..791fd83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Package shell: pwsh run: | - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md -DestinationPath openmouse-bridge-windows-x64.zip + Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,driver -DestinationPath openmouse-bridge-windows-x64.zip (Get-FileHash openmouse-bridge-windows-x64.zip -Algorithm SHA256).Hash.ToLower() + " openmouse-bridge-windows-x64.zip" | Set-Content openmouse-bridge-windows-x64.zip.sha256 - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index ea8c4bf..c5231e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /target + +# The driver catalog is generated and signed per-machine by the install script; +# it must never be committed or shipped (a self-signed .cat is useless elsewhere). +*.cat From 9a2a7b8e3f542ce508f76a8a86e88c8e1373d7b1 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:37:01 +0300 Subject: [PATCH 08/15] =?UTF-8?q?fix(desktop):=20show=20=E2=80=94=20instea?= =?UTF-8?q?d=20of=200=20for=20unknown=20DPI/polling=20in=20profile=20summa?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/desktop.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/desktop.rs b/src/desktop.rs index 3741693..5c1e368 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -823,6 +823,7 @@ fn profile_summary( egui::Align2::CENTER_BOTTOM, profile .and_then(|profile| profile.settings.dpi) + .filter(|&dpi| dpi > 0) .map_or_else(|| "—".to_owned(), |dpi| dpi.to_string()), value_font.clone(), TEXT, @@ -840,6 +841,7 @@ fn profile_summary( egui::Align2::RIGHT_BOTTOM, profile .and_then(|profile| profile.settings.polling_rate_hz) + .filter(|&rate| rate > 0) .map_or_else(|| "—".to_owned(), |rate| format!("{rate} Hz")), value_font, TEXT, From 191aa8db97045b095040246535d2d3f6b47bbc19 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:02:20 +0300 Subject: [PATCH 09/15] feat(AttackShark): X11 DPI stage control via the Bridge --- src/api.rs | 24 ++ src/devices/attackshark.rs | 455 +++++++++++++++++++++++++++++++++++++ src/devices/mod.rs | 102 +++++++++ 3 files changed, 581 insertions(+) diff --git a/src/api.rs b/src/api.rs index 9d084dc..aecd551 100644 --- a/src/api.rs +++ b/src/api.rs @@ -69,6 +69,13 @@ struct PollingResult { polling_rate_hz: u16, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DpiPayload { + stages: Vec, + active_stage: u8, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DriverPayload { @@ -109,6 +116,7 @@ pub fn router( .route("/v1/autostart", put(set_autostart)) .route("/v1/devices", get(list_devices)) .route("/v1/devices/{id}/polling", put(set_device_polling)) + .route("/v1/devices/{id}/dpi", put(set_device_dpi)) .route("/v1/driver", put(driver_action)) .layer(cors) .layer(TraceLayer::new_for_http()) @@ -141,6 +149,22 @@ async fn set_device_polling( })) } +async fn set_device_dpi( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let manager = state.devices.ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "native device support is unavailable".to_owned(), + ))?; + manager + .set_dpi(id, payload.stages, payload.active_stage) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(ApiResult { ok: true })) +} + /// Install or remove the WinUSB driver package so the config interface becomes /// reachable (Windows only). Runs behind a UAC prompt and may block, so it hops /// to a blocking thread. diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs index aab17a3..1fb9432 100644 --- a/src/devices/attackshark.rs +++ b/src/devices/attackshark.rs @@ -42,11 +42,21 @@ pub const FEATURE_REPORT_TYPE: u16 = 0x03; pub const POLLING_REPORT_ID: u8 = 0x06; /// wValue for the polling SET_REPORT: (Feature << 8) | report id = 0x0306. pub const POLLING_WVALUE: u16 = (FEATURE_REPORT_TYPE << 8) | POLLING_REPORT_ID as u16; +/// Feature report id the DPI/stage command is written on. +pub const DPI_REPORT_ID: u8 = 0x04; +/// wValue for the DPI SET_REPORT: (Feature << 8) | report id = 0x0304. +pub const DPI_WVALUE: u16 = (FEATURE_REPORT_TYPE << 8) | DPI_REPORT_ID as u16; /// Interrupt IN endpoint that streams battery packets on the wireless receiver. pub const BATTERY_ENDPOINT: u8 = 0x83; /// Input report id the autonomous battery packet arrives on. pub const BATTERY_REPORT_ID: u8 = 0x03; +/// The X11 has six DPI stages; DPI runs 50–22000 in 50-step increments. +pub const DPI_STAGE_COUNT: usize = 6; +pub const DPI_MIN: u16 = 50; +pub const DPI_MAX: u16 = 22_000; +pub const DPI_STEP: u16 = 50; + /// Battery input report signature; byte 4 is the percentage. const BATTERY_SIGNATURE: [u8; 4] = [0x03, 0x55, 0x40, 0x01]; @@ -99,6 +109,76 @@ pub fn polling_packet(hz: u16) -> Option<[u8; 9]> { ]) } +/// The fixed 56-byte DPI template. Bytes the firmware expects verbatim are +/// pre-set here (matching the reference driver's DpiBuilder); the dynamic +/// fields — angle snap, rippler, the six stage bytes, the stage masks, the +/// current stage and the checksum — are written by `dpi_packet`. Byte 0 is the +/// report id. +#[rustfmt::skip] +const DPI_TEMPLATE: [u8; 56] = [ + 0x04, 0x38, 0x01, 0x00, 0x01, 0x3f, 0x20, 0x20, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + 0x02, + 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x00, 0xff, 0xff, 0x40, 0x00, 0xff, 0xff, 0xff, + 0x02, + 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +]; + +/// Encode a DPI value to its sensor byte: the smallest supported step >= dpi. +/// `None` when dpi exceeds the sensor maximum. +pub fn encode_dpi(dpi: u16) -> Option { + DPI_STEP_MAP + .iter() + .find(|&&(step, _)| step >= dpi) + .map(|&(_, code)| code) +} + +/// Build the 56-byte DPI feature report for the six stage values and the active +/// stage (1-based). `None` if a stage DPI is unsupported or the active stage is +/// out of range. Byte 0 is the report id. +/// +/// Mirrors the reference DpiBuilder.build(): stage mask bit set per stage above +/// 12000 DPI; high-stage flag set when a stage lands in an upper register page +/// ([10100,12000] or [20100,22000]); checksum = sum(bytes 3..=49) & 0xffff, +/// stored big-endian at bytes 50-51. +pub fn dpi_packet( + stages: [u16; DPI_STAGE_COUNT], + active_stage: u8, + angle_snap: bool, + rippler: bool, +) -> Option<[u8; 56]> { + if !(1..=DPI_STAGE_COUNT as u8).contains(&active_stage) { + return None; + } + let mut buf = DPI_TEMPLATE; + buf[3] = angle_snap as u8; + buf[4] = rippler as u8; + buf[24] = active_stage; + + let mut mask = 0u8; + for (i, &dpi) in stages.iter().enumerate() { + buf[8 + i] = encode_dpi(dpi)?; + if dpi > 12_000 { + mask |= 1 << i; + } + let high = (10_100..=12_000).contains(&dpi) || (20_100..=22_000).contains(&dpi); + buf[16 + i] = high as u8; + } + buf[6] = mask; + buf[7] = mask; + + let checksum = buf[3..=49].iter().map(|&b| b as u32).sum::() & 0xffff; + buf[50] = (checksum >> 8) as u8; + buf[51] = (checksum & 0xff) as u8; + Some(buf) +} + /// Decode a battery input report. `report` includes the leading report id, so /// the signature occupies bytes 0..4 and the percentage byte 4. Returns the /// percentage (0..=100) or `None` for a non-battery or out-of-range report. @@ -110,6 +190,333 @@ pub fn parse_battery(report: &[u8]) -> Option { (percent <= 100).then_some(percent) } +/// DPI value → sensor byte, ascending by DPI. From the reference driver's +/// dpi-map.ts. Codes above 10000 intentionally repeat lower codes; the +/// stage mask and high-stage flags select the sensor's register page. +#[rustfmt::skip] +static DPI_STEP_MAP: &[(u16, u8)] = &[ + (50, 0x01), + (100, 0x02), + (150, 0x03), + (200, 0x04), + (250, 0x05), + (300, 0x06), + (350, 0x08), + (400, 0x09), + (450, 0x0a), + (500, 0x0b), + (550, 0x0c), + (600, 0x0e), + (650, 0x0f), + (700, 0x10), + (750, 0x11), + (800, 0x12), + (850, 0x13), + (900, 0x15), + (950, 0x16), + (1000, 0x17), + (1050, 0x18), + (1100, 0x19), + (1150, 0x1b), + (1200, 0x1c), + (1250, 0x1d), + (1300, 0x1e), + (1350, 0x1f), + (1400, 0x20), + (1450, 0x22), + (1500, 0x23), + (1550, 0x24), + (1600, 0x25), + (1650, 0x26), + (1700, 0x27), + (1750, 0x29), + (1800, 0x2a), + (1850, 0x2b), + (1900, 0x2c), + (1950, 0x2d), + (2000, 0x2f), + (2050, 0x30), + (2100, 0x31), + (2150, 0x32), + (2200, 0x33), + (2250, 0x34), + (2300, 0x36), + (2350, 0x37), + (2400, 0x38), + (2450, 0x39), + (2500, 0x3a), + (2550, 0x3b), + (2600, 0x3d), + (2650, 0x3e), + (2700, 0x3f), + (2750, 0x40), + (2800, 0x41), + (2850, 0x43), + (2900, 0x44), + (2950, 0x45), + (3000, 0x46), + (3050, 0x47), + (3100, 0x48), + (3150, 0x4a), + (3200, 0x4b), + (3250, 0x4c), + (3300, 0x4d), + (3350, 0x4e), + (3400, 0x4f), + (3450, 0x51), + (3500, 0x52), + (3550, 0x53), + (3600, 0x54), + (3650, 0x55), + (3700, 0x57), + (3750, 0x58), + (3800, 0x59), + (3850, 0x5a), + (3900, 0x5b), + (3950, 0x5c), + (4000, 0x5e), + (4050, 0x5f), + (4100, 0x60), + (4150, 0x61), + (4200, 0x62), + (4250, 0x63), + (4300, 0x65), + (4350, 0x66), + (4400, 0x67), + (4450, 0x68), + (4500, 0x69), + (4550, 0x6b), + (4600, 0x6c), + (4650, 0x6d), + (4700, 0x6e), + (4750, 0x6f), + (4800, 0x70), + (4850, 0x72), + (4900, 0x73), + (4950, 0x74), + (5000, 0x75), + (5050, 0x76), + (5100, 0x77), + (5150, 0x79), + (5200, 0x7a), + (5250, 0x7b), + (5300, 0x7c), + (5350, 0x7d), + (5400, 0x7f), + (5450, 0x80), + (5500, 0x81), + (5550, 0x82), + (5600, 0x83), + (5650, 0x84), + (5700, 0x86), + (5750, 0x87), + (5800, 0x88), + (5850, 0x89), + (5900, 0x8a), + (5950, 0x8b), + (6000, 0x8d), + (6050, 0x8e), + (6100, 0x8f), + (6150, 0x90), + (6200, 0x91), + (6250, 0x93), + (6300, 0x94), + (6350, 0x95), + (6400, 0x96), + (6450, 0x97), + (6500, 0x98), + (6550, 0x9a), + (6600, 0x9b), + (6650, 0x9c), + (6700, 0x9d), + (6750, 0x9e), + (6800, 0x9f), + (6850, 0xa1), + (6900, 0xa2), + (6950, 0xa3), + (7000, 0xa4), + (7050, 0xa5), + (7100, 0xa7), + (7150, 0xa8), + (7200, 0xa9), + (7250, 0xaa), + (7300, 0xab), + (7350, 0xac), + (7400, 0xae), + (7450, 0xaf), + (7500, 0xb0), + (7550, 0xb1), + (7600, 0xb2), + (7650, 0xb3), + (7700, 0xb5), + (7750, 0xb6), + (7800, 0xb7), + (7850, 0xb8), + (7900, 0xb9), + (7950, 0xbb), + (8000, 0xbc), + (8050, 0xbd), + (8100, 0xbe), + (8150, 0xbf), + (8200, 0xc0), + (8250, 0xc2), + (8300, 0xc3), + (8350, 0xc4), + (8400, 0xc5), + (8450, 0xc6), + (8500, 0xc7), + (8550, 0xc9), + (8600, 0xca), + (8650, 0xcb), + (8700, 0xcc), + (8750, 0xcd), + (8800, 0xcf), + (8850, 0xd0), + (8900, 0xd1), + (8950, 0xd2), + (9000, 0xd3), + (9050, 0xd4), + (9100, 0xd6), + (9150, 0xd7), + (9200, 0xd8), + (9250, 0xd9), + (9300, 0xda), + (9350, 0xdb), + (9400, 0xdd), + (9450, 0xde), + (9500, 0xdf), + (9550, 0xe0), + (9600, 0xe1), + (9650, 0xe3), + (9700, 0xe4), + (9750, 0xe5), + (9800, 0xe6), + (9850, 0xe7), + (9900, 0xe8), + (9950, 0xea), + (10000, 0xeb), + (10100, 0x76), + (10200, 0x77), + (10300, 0x79), + (10400, 0x7a), + (10500, 0x7b), + (10600, 0x7c), + (10700, 0x7d), + (10800, 0x7f), + (10900, 0x80), + (11000, 0x81), + (11100, 0x82), + (11200, 0x83), + (11300, 0x84), + (11400, 0x86), + (11500, 0x87), + (11600, 0x88), + (11700, 0x89), + (11800, 0x8a), + (11900, 0x8b), + (12000, 0x8d), + (12100, 0x8e), + (12200, 0x8f), + (12300, 0x90), + (12400, 0x91), + (12500, 0x93), + (12600, 0x94), + (12700, 0x95), + (12800, 0x96), + (12900, 0x97), + (13000, 0x98), + (13100, 0x9a), + (13200, 0x9b), + (13300, 0x9c), + (13400, 0x9d), + (13500, 0x9e), + (13600, 0x9f), + (13700, 0xa1), + (13800, 0xa2), + (13900, 0xa3), + (14000, 0xa4), + (14100, 0xa5), + (14200, 0xa7), + (14300, 0xa8), + (14400, 0xa9), + (14500, 0xaa), + (14600, 0xab), + (14700, 0xac), + (14800, 0xae), + (14900, 0xaf), + (15000, 0xb0), + (15100, 0xb1), + (15200, 0xb2), + (15300, 0xb3), + (15400, 0xb5), + (15500, 0xb6), + (15600, 0xb7), + (15700, 0xb8), + (15800, 0xb9), + (15900, 0xbb), + (16000, 0xbc), + (16100, 0xbd), + (16200, 0xbe), + (16300, 0xbf), + (16400, 0xc0), + (16500, 0xc2), + (16600, 0xc3), + (16700, 0xc4), + (16800, 0xc5), + (16900, 0xc6), + (17000, 0xc7), + (17100, 0xc9), + (17200, 0xca), + (17300, 0xcb), + (17400, 0xcc), + (17500, 0xcd), + (17600, 0xcf), + (17700, 0xd0), + (17800, 0xd1), + (17900, 0xd2), + (18000, 0xd3), + (18100, 0xd4), + (18200, 0xd6), + (18300, 0xd7), + (18400, 0xd8), + (18500, 0xd9), + (18600, 0xda), + (18700, 0xdb), + (18800, 0xdd), + (18900, 0xde), + (19000, 0xdf), + (19100, 0xe0), + (19200, 0xe1), + (19300, 0xe3), + (19400, 0xe4), + (19500, 0xe5), + (19600, 0xe6), + (19700, 0xe7), + (19800, 0xe8), + (19900, 0xea), + (20000, 0xeb), + (20100, 0x76), + (20200, 0x77), + (20300, 0x79), + (20400, 0x7a), + (20500, 0x7b), + (20600, 0x7c), + (20700, 0x7d), + (20800, 0x7f), + (20900, 0x80), + (21000, 0x81), + (21100, 0x82), + (21200, 0x83), + (21300, 0x84), + (21400, 0x86), + (21500, 0x87), + (21600, 0x88), + (21700, 0x89), + (21800, 0x8a), + (21900, 0x8b), + (22000, 0x8d), +]; + #[cfg(test)] mod tests { use super::*; @@ -151,6 +558,54 @@ mod tests { assert_eq!(parse_battery(&[0x03, 0x55, 0x40, 0x01]), None); } + #[test] + fn dpi_encoding_matches_reference_map() { + assert_eq!(encode_dpi(800), Some(0x12)); + assert_eq!(encode_dpi(1600), Some(0x25)); + assert_eq!(encode_dpi(5000), Some(0x75)); + assert_eq!(encode_dpi(22000), Some(0x8d)); + // Rounds up to the next supported step, clamps below the minimum. + assert_eq!(encode_dpi(30), Some(0x01)); // -> 50 + assert_eq!(encode_dpi(801), Some(0x13)); // -> 850 + assert_eq!(encode_dpi(22001), None); + } + + #[test] + fn dpi_packet_matches_reference_default_vector() { + // Reference DpiBuilder default (stages 800/1600/2400/3200/5000/22000, + // active stage 2, angle-snap off, rippler on) after build(): identical + // to the DpiBuilder.test.ts golden buffer, but with the real computed + // checksum (0x0f74) in place of the pre-build placeholder (0x0f68). + let packet = dpi_packet([800, 1600, 2400, 3200, 5000, 22000], 2, false, true).unwrap(); + assert_eq!( + hex(&packet), + "04380100013f20201225384b758d0000000000000001000002ff000000ff000000\ + ffffff0000ffffff00ffff4000ffffff020f7400000000" + ); + } + + #[test] + fn dpi_packet_sets_masks_flags_and_rejects_bad_input() { + // A stage above 12000 sets its stage-mask bit; a stage in the upper + // register window sets its high-stage flag. + let packet = dpi_packet([1600, 1600, 1600, 1600, 1600, 16000], 1, false, true).unwrap(); + assert_eq!(packet[6], 0x20); // stage-mask bit 5 for the >12000 stage + assert_eq!(packet[7], 0x20); + assert_eq!(packet[16 + 5], 0x00); // 16000 is not in an upper-page window + let paged = dpi_packet([11000, 1600, 1600, 1600, 1600, 1600], 1, false, true).unwrap(); + assert_eq!(paged[16], 0x01); // 11000 is in [10100,12000] + assert_eq!(paged[6], 0x00); // but not >12000, so no stage-mask bit + + assert!(dpi_packet([800, 800, 800, 800, 800, 800], 0, false, true).is_none()); + assert!(dpi_packet([800, 800, 800, 800, 800, 800], 7, false, true).is_none()); + assert!(dpi_packet([800, 800, 800, 800, 800, 30000], 1, false, true).is_none()); + } + + #[test] + fn dpi_control_transfer_wvalue_matches_reference() { + assert_eq!(DPI_WVALUE, 0x0304); + } + #[test] fn family_recognition_covers_documented_pids() { assert!(is_x11(0x1d57, 0xfa55)); diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 793a9d0..1d73a78 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -36,6 +36,8 @@ const CONTROL_TIMEOUT: Duration = Duration::from_millis(1000); const BATTERY_READ_WINDOW: Duration = Duration::from_millis(250); /// Battery interrupt reads use a 64-byte buffer (the endpoint's packet size). const BATTERY_BUFFER: usize = 64; +/// Default DPI stages shown before the user sets their own. +const DEFAULT_DPI_STAGES: [u16; attackshark::DPI_STAGE_COUNT] = [400, 800, 1600, 3200, 6400, 12000]; /// A mouse the Bridge can see natively, as reported to the web app. #[derive(Clone, Debug, Serialize)] @@ -53,6 +55,14 @@ pub struct DeviceInfo { pub battery_percent: Option, pub polling_rate_hz: Option, pub supported_polling_rates: Vec, + /// The six DPI stages, as last set through the Bridge. The mouse does not + /// report its own, so these start at defaults and track what we write. + pub dpi_stages: Vec, + /// Active DPI stage, 1-based. + pub active_dpi_stage: u8, + pub dpi_min: u16, + pub dpi_max: u16, + pub dpi_step: u16, /// User-facing explanation of the device's current state. pub note: &'static str, } @@ -65,6 +75,12 @@ enum Command { hz: u16, reply: oneshot::Sender>, }, + SetDpi { + id: String, + stages: Vec, + active_stage: u8, + reply: oneshot::Sender>, + }, } /// Handle to the device worker. Cloneable and cheap. @@ -106,6 +122,22 @@ impl DeviceManager { rx.await .map_err(|_| anyhow!("the device worker dropped the request"))? } + + /// Set the six DPI stages and active stage on one device. + pub async fn set_dpi(&self, id: String, stages: Vec, active_stage: u8) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.commands + .send(Command::SetDpi { + id, + stages, + active_stage, + reply: tx, + }) + .await + .map_err(|_| anyhow!("the device worker is not running"))?; + rx.await + .map_err(|_| anyhow!("the device worker dropped the request"))? + } } /// One attached device. `interface` is `None` when interface 2 could not be @@ -131,6 +163,9 @@ async fn worker(mut commands: mpsc::Receiver, service: BridgeService) { Some(Command::SetPolling { id, hz, reply }) => { let _ = reply.send(set_polling(&mut devices, &id, hz).await); } + Some(Command::SetDpi { id, stages, active_stage, reply }) => { + let _ = reply.send(set_dpi(&mut devices, &id, &stages, active_stage).await); + } None => break, }, _ = ticker.tick() => { @@ -205,6 +240,13 @@ fn refresh(devices: &mut Vec) { battery_percent: None, polling_rate_hz: None, supported_polling_rates: attackshark::supported_polling_rates(), + // The mouse does not report its stages, so start from sensible + // defaults; they update as the user writes new ones. + dpi_stages: DEFAULT_DPI_STAGES.to_vec(), + active_dpi_stage: 2, + dpi_min: attackshark::DPI_MIN, + dpi_max: attackshark::DPI_MAX, + dpi_step: attackshark::DPI_STEP, note, }, interface, @@ -257,6 +299,66 @@ async fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result Result<()> { + let device = devices + .iter_mut() + .find(|device| device.info.id == id) + .ok_or_else(|| anyhow!("no attached device with id {id}"))?; + let interface = device.interface.as_ref().ok_or_else(|| { + anyhow!( + "this mouse is detected but interface 2 is not claimable; on Windows bind it to \ + WinUSB with Zadig first" + ) + })?; + + let stage_array: [u16; attackshark::DPI_STAGE_COUNT] = stages + .try_into() + .map_err(|_| anyhow!("expected {} DPI stages", attackshark::DPI_STAGE_COUNT))?; + let packet = + attackshark::dpi_packet(stage_array, active_stage, false, true).ok_or_else(|| { + anyhow!( + "invalid DPI request: stages must be {}–{} and the active stage 1–{}", + attackshark::DPI_MIN, + attackshark::DPI_MAX, + attackshark::DPI_STAGE_COUNT + ) + })?; + // Wired variants expect the shorter, checksum-terminated report. + let data: &[u8] = if attackshark::is_wireless(device.info.product_id) { + &packet + } else { + &packet[..52] + }; + + let transfer = interface.control_out(ControlOut { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: attackshark::SET_REPORT_REQUEST, + value: attackshark::DPI_WVALUE, + index: u16::from(attackshark::CONTROL_INTERFACE), + data, + }); + let completion = tokio::time::timeout(CONTROL_TIMEOUT, transfer) + .await + .map_err(|_| anyhow!("the DPI command timed out"))?; + completion + .status + .map_err(|error| anyhow!("the mouse rejected the DPI command: {error}"))?; + + device.info.dpi_stages = stage_array.to_vec(); + device.info.active_dpi_stage = active_stage; + tracing::info!(device = %device.info.id, ?stage_array, active_stage, "set DPI over USB"); + Ok(()) +} + /// Best-effort battery sample: read one interrupt packet from the battery /// endpoint, and push a change into the service's low-battery notifier. async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { From 09be517fddcfad7f82b7e38dd5c39da2bf8782cb Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:03:20 +0300 Subject: [PATCH 10/15] feat(AttackShark): X11 DPI/polling/battery in the main control panel --- src/devices/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 1d73a78..ba79024 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -206,9 +206,10 @@ fn refresh(devices: &mut Vec) { continue; } + let id = format!("{vid:04x}:{pid:04x}"); let (interface, controllable, note) = match claim(&entry) { Ok(interface) => { - tracing::info!(device = %format!("{vid:04x}:{pid:04x}"), "claimed Attack Shark interface 2 for native control"); + tracing::info!(device = %id, "claimed Attack Shark interface 2 for native control"); ( Some(interface), true, @@ -227,7 +228,7 @@ fn refresh(devices: &mut Vec) { devices.push(OpenDevice { info: DeviceInfo { - id: format!("{vid:04x}:{pid:04x}"), + id, name: attackshark::model_name(pid).to_owned(), vendor_id: vid, product_id: pid, From 067993bb212727563a690889bf4e5f2fa31e931e Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:54:23 +0300 Subject: [PATCH 11/15] ci(added): linux & mac readded, readme edited --- .github/workflows/ci.yml | 80 +++++++++++++++++++++++++++--- .github/workflows/release.yml | 22 ++++++++- README.md | 93 ++++++++++++++++++++++++++++++++--- 3 files changed, 180 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7b07d4..b906f1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,13 +50,79 @@ jobs: openmouse-bridge-windows-x64.zip openmouse-bridge-windows-x64.zip.sha256 if-no-files-found: error - - name: Publish rolling development release - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + linux-build: + name: Linux release artifact + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build + run: cargo build --release --locked + # The WinUSB driver folder is Windows-only, so it is not bundled here. + - name: Package + run: | + tar -czf openmouse-bridge-linux-x64.tar.gz README.md -C target/release openmouse-bridge + sha256sum openmouse-bridge-linux-x64.tar.gz > openmouse-bridge-linux-x64.tar.gz.sha256 + - uses: actions/upload-artifact@v4 + with: + name: openmouse-bridge-linux-x64 + path: | + openmouse-bridge-linux-x64.tar.gz + openmouse-bridge-linux-x64.tar.gz.sha256 + if-no-files-found: error + + macos-build: + name: macOS release artifact + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - name: Build universal binary + run: | + cargo build --release --locked --target aarch64-apple-darwin + cargo build --release --locked --target x86_64-apple-darwin + lipo -create \ + target/aarch64-apple-darwin/release/openmouse-bridge \ + target/x86_64-apple-darwin/release/openmouse-bridge \ + -output openmouse-bridge + chmod +x openmouse-bridge + # The WinUSB driver folder is Windows-only, so it is not bundled here. + - name: Package + run: | + zip openmouse-bridge-macos-universal.zip openmouse-bridge README.md + shasum -a 256 openmouse-bridge-macos-universal.zip > openmouse-bridge-macos-universal.zip.sha256 + - uses: actions/upload-artifact@v4 + with: + name: openmouse-bridge-macos-universal + path: | + openmouse-bridge-macos-universal.zip + openmouse-bridge-macos-universal.zip.sha256 + if-no-files-found: error + + dev-release: + name: Publish rolling development release + needs: [windows-build, linux-build, macos-build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Publish dev-build env: GH_TOKEN: ${{ github.token }} - shell: pwsh run: | - if (gh release view dev-build 2>$null) { - gh release delete dev-build --cleanup-tag --yes - } - gh release create dev-build --target $env:GITHUB_SHA --prerelease --title "Development build" --notes "Automatic Windows build from main at $env:GITHUB_SHA." openmouse-bridge-windows-x64.zip openmouse-bridge-windows-x64.zip.sha256 + if gh release view dev-build --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release delete dev-build --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes + fi + gh release create dev-build \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --prerelease \ + --title "Development build" \ + --notes "Automatic Windows, Linux, and macOS build from main at $GITHUB_SHA." \ + artifacts/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 791fd83..13e720a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,26 @@ jobs: openmouse-bridge-windows-x64.zip openmouse-bridge-windows-x64.zip.sha256 + linux: + name: Linux x64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --locked + - run: cargo build --release --locked + # The WinUSB driver folder is Windows-only, so it is not bundled here. + - name: Package + run: | + tar -czf openmouse-bridge-linux-x64.tar.gz README.md -C target/release openmouse-bridge + sha256sum openmouse-bridge-linux-x64.tar.gz > openmouse-bridge-linux-x64.tar.gz.sha256 + - uses: actions/upload-artifact@v4 + with: + name: bridge-linux + path: | + openmouse-bridge-linux-x64.tar.gz + openmouse-bridge-linux-x64.tar.gz.sha256 + macos: name: macOS universal runs-on: macos-14 @@ -67,7 +87,7 @@ jobs: release: name: Publish stable release - needs: [windows, macos] + needs: [windows, linux, macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 18960e1..523d514 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,95 @@ explicit configuration file. Only configured web origins receive CORS access. The listener never binds to a LAN or public interface. -## Attack Shark - Beta Bridge (Under Testing) -- `GET /v1/devices` returns the devices -- `PUT /v1/devices/{id}/polling` changes polling rate +## Attack Shark — Beta Bridge (Under Testing) + +> ⚠️ **Beta.** Native device control is new and still being validated on +> hardware. It changes DPI and polling rate only, never touches firmware, and is +> fully reversible — but treat it as experimental. + +Some mice keep their configuration channel on a USB interface the browser is not +allowed to reach. The **Attack Shark X11** is the hard case: its HID descriptor +declares no feature reports, so neither WebHID nor the OS HID API can configure +it (on Windows, `HidD_SetFeature` returns `ERROR_INVALID_FUNCTION`). Bridge +solves this the same way the reference driver does — it claims **USB interface +2** and sends raw control transfers with [`nusb`](https://crates.io/crates/nusb), +bypassing HID entirely. + +**Supported devices** (VID `0x1d57`): Attack Shark X11 wireless receiver +(`0xfa60`) and wired (`0xfa55`), and the Attack Shark R1 (`0xfa61`). + +**What works today:** six-stage DPI (50–22000, in 50 steps), polling rate +(125/250/500/1000 Hz), and battery on the wireless receiver. Lighting and macros +are not implemented yet. + +### Setup is required per platform + +Reaching interface 2 raw needs a suitable driver bound to it. This is a one-time +step; the mouse keeps working as a normal mouse throughout (pointing and +clicking are on a different interface and are never touched). + +#### Windows — bind interface 2 to WinUSB + +Windows must hand interface 2 to the **WinUSB** driver. The easiest path is in +OpenMouse itself: open **Interface settings → Bridge → Native devices** and +click **“Enable native control”**. Bridge installs a small, scoped WinUSB driver +package for interface 2 of the Attack Shark (self-signed and trusted on your +machine, behind one Windows admin prompt). **Remove driver** in the same place +reverts it. + +The driver package and its scripts live in [`driver/`](driver). To install it +manually instead, run [`driver/sign-and-install.ps1`](driver/sign-and-install.ps1) +from an **administrator** PowerShell, or use [Zadig](https://zadig.akeo.ie) to +assign **WinUSB** to the device's **Interface 2**. See +[`driver/README.md`](driver/README.md) for details, safety notes, and how to +sign for distribution. Nothing here writes firmware, and only interface 2 of the +three known product IDs is ever affected. + +#### Linux — add a udev rule + +`nusb` detaches the kernel HID driver from interface 2 automatically; it only +needs permission to open the device. Create a udev rule granting the logged-in +user access to the Attack Shark: + +```sh +sudo tee /etc/udev/rules.d/70-openmouse-attackshark.rules >/dev/null <<'RULE' +# Attack Shark X11 / R1 (VID 1d57) — allow the local user to configure it. +SUBSYSTEM=="usb", ATTRS{idVendor}=="1d57", MODE="0660", TAG+="uaccess" +RULE +sudo udevadm control --reload-rules && sudo udevadm trigger +``` + +Then unplug and replug the mouse. No Zadig or driver swap is needed on Linux. + +#### macOS + +Untested. `nusb` can claim interfaces on macOS, but the Attack Shark path has not +been validated there yet. + +### Device API + +- `GET /v1/devices` — list attached Attack Shark devices with their current + state: `id` (`"1d57:fa60"`), `name`, `connection` (`"wired"`/`"wireless"`), + `controllable` (true once the driver is bound), `batteryPercent`, + `pollingRateHz`, `supportedPollingRates`, `dpiStages`, `activeDpiStage`, and + the DPI range (`dpiMin`/`dpiMax`/`dpiStep`). +- `PUT /v1/devices/{id}/polling` — `{ hz }`. Set the polling rate. +- `PUT /v1/devices/{id}/dpi` — `{ stages, activeStage }`. Write all six DPI + stages (an array) and the active stage (1-based). +- `PUT /v1/driver` — `{ action: "install" | "uninstall" }` (Windows only). Runs + the WinUSB driver install/removal behind a UAC prompt. + +A device is only `controllable` after its interface 2 is bound (WinUSB on +Windows, or the udev rule on Linux); until then it is still listed so the UI can +explain what is needed. ## 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. +For most mice, battery readings come from the connected OpenMouse control panel +via `PUT /v1/battery`. Natively supported devices (currently the Attack Shark, +see above) are read directly over USB, so their low-battery alerts fire even +while the browser is closed. Extending native battery and control to more mice +is ongoing. Game detection already runs independently in the background. ## Verify From bad7757a1e46f89940bdf0568ac2e135a1dcea69 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:34:06 +0300 Subject: [PATCH 12/15] feat(AttackShark): Detect DPI changes by mouse --- src/devices/mod.rs | 51 ++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/devices/mod.rs b/src/devices/mod.rs index ba79024..2fd7759 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -360,34 +360,49 @@ async fn set_dpi( Ok(()) } -/// Best-effort battery sample: read one interrupt packet from the battery -/// endpoint, and push a change into the service's low-battery notifier. +/// Drain the interface-2 interrupt endpoint each cycle: record battery, and +/// log every packet so we can discover what else the mouse reports (e.g. a +/// notification when the physical DPI button is pressed). Draining catches +/// packets that queued since the last poll, not just a single snapshot. +/// +/// Runs for wired units too — only the battery signature is wireless-specific; +/// a DPI-change notification (if any) could arrive on either. async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { for device in devices.iter_mut() { - if !attackshark::is_wireless(device.info.product_id) { - continue; - } let Some(interface) = device.interface.as_ref() else { continue; }; - let transfer = interface.interrupt_in( - attackshark::BATTERY_ENDPOINT, - RequestBuffer::new(BATTERY_BUFFER), - ); - let Ok(completion) = tokio::time::timeout(BATTERY_READ_WINDOW, transfer).await else { - continue; // No battery packet this cycle. - }; - if completion.status.is_err() { - continue; + let mut latest_battery: Option = None; + // Cap the drain so a chatty device cannot stall the worker. + for _ in 0..8 { + let transfer = interface.interrupt_in( + attackshark::BATTERY_ENDPOINT, + RequestBuffer::new(BATTERY_BUFFER), + ); + let Ok(completion) = tokio::time::timeout(BATTERY_READ_WINDOW, transfer).await else { + break; // Nothing more queued this cycle. + }; + if completion.status.is_err() || completion.data.is_empty() { + break; + } + + // Diagnostic: log the raw packet so DPI-button (and other) reports + // can be identified. `battery` is set when it matches the known + // battery signature; anything else is a candidate to decode. + let hex: String = completion.data.iter().map(|byte| format!("{byte:02x}")).collect(); + let battery = attackshark::parse_battery(&completion.data); + tracing::info!(device = %device.info.id, packet = %hex, ?battery, "interrupt IN packet"); + + if let Some(percent) = battery { + latest_battery = Some(percent); + } } - let Some(percent) = attackshark::parse_battery(&completion.data) else { - continue; - }; + + let Some(percent) = latest_battery else { continue }; if device.info.battery_percent == Some(percent) { continue; } - device.info.battery_percent = Some(percent); let reading = BatteryReading { device_id: device.info.id.clone(), From b4eca93b0aea7e1edd58d3dd4619dfd00f3b25b0 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:39:05 +0300 Subject: [PATCH 13/15] ci(fix): Fixing the Ubuntu fail --- src/devices/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 2fd7759..d30a611 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -390,7 +390,11 @@ async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { // Diagnostic: log the raw packet so DPI-button (and other) reports // can be identified. `battery` is set when it matches the known // battery signature; anything else is a candidate to decode. - let hex: String = completion.data.iter().map(|byte| format!("{byte:02x}")).collect(); + let hex: String = completion + .data + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); let battery = attackshark::parse_battery(&completion.data); tracing::info!(device = %device.info.id, packet = %hex, ?battery, "interrupt IN packet"); @@ -399,7 +403,9 @@ async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { } } - let Some(percent) = latest_battery else { continue }; + let Some(percent) = latest_battery else { + continue; + }; if device.info.battery_percent == Some(percent) { continue; } From 3b1fcaeb1240be70f613149c4fec0d6c52b5d5c3 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:49:06 +0300 Subject: [PATCH 14/15] feat(AttackShark): detect physical DPI-button stage changes --- src/devices/attackshark.rs | 31 +++++++++++++++++++++ src/devices/mod.rs | 55 ++++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs index 1fb9432..0f810e1 100644 --- a/src/devices/attackshark.rs +++ b/src/devices/attackshark.rs @@ -190,6 +190,24 @@ pub fn parse_battery(report: &[u8]) -> Option { (percent <= 100).then_some(percent) } +/// Prefix of the DPI-button notification. When the physical DPI button cycles +/// the active stage, the mouse pushes `03 55 10 00` on the same +/// interrupt endpoint as battery; byte 3 is the new active stage (1-based). +/// (Battery uses `03 55 40 01 …`; byte 2 — 0x10 vs 0x40 — tells them apart.) +const DPI_NOTIFY_PREFIX: [u8; 3] = [0x03, 0x55, 0x10]; + +/// Decode a DPI-button notification, returning the new active stage (1..=6), or +/// `None` for any other packet. +pub fn parse_dpi_stage(report: &[u8]) -> Option { + if report.len() < 4 || report[..3] != DPI_NOTIFY_PREFIX { + return None; + } + let stage = report[3]; + (1..=DPI_STAGE_COUNT as u8) + .contains(&stage) + .then_some(stage) +} + /// DPI value → sensor byte, ascending by DPI. From the reference driver's /// dpi-map.ts. Codes above 10000 intentionally repeat lower codes; the /// stage mask and high-stage flags select the sensor's register page. @@ -606,6 +624,19 @@ mod tests { assert_eq!(DPI_WVALUE, 0x0304); } + #[test] + fn dpi_button_notification_reports_active_stage() { + // Captured from hardware: cycling the DPI button pushes 03 55 10 00. + assert_eq!(parse_dpi_stage(&[0x03, 0x55, 0x10, 0x01, 0x00]), Some(1)); + assert_eq!(parse_dpi_stage(&[0x03, 0x55, 0x10, 0x06, 0x00]), Some(6)); + // Battery packets and out-of-range stages are not DPI notifications. + assert_eq!(parse_dpi_stage(&[0x03, 0x55, 0x40, 0x01, 0x64]), None); + assert_eq!(parse_dpi_stage(&[0x03, 0x55, 0x10, 0x07, 0x00]), None); + assert_eq!(parse_dpi_stage(&[0x03, 0x55, 0x10]), None); + // ...and battery decoding still ignores a DPI packet. + assert_eq!(parse_battery(&[0x03, 0x55, 0x10, 0x03, 0x00]), None); + } + #[test] fn family_recognition_covers_documented_pids() { assert!(is_x11(0x1d57, 0xfa55)); diff --git a/src/devices/mod.rs b/src/devices/mod.rs index d30a611..dade4a8 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -374,6 +374,7 @@ async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { }; let mut latest_battery: Option = None; + let mut latest_stage: Option = None; // Cap the drain so a chatty device cannot stall the worker. for _ in 0..8 { let transfer = interface.interrupt_in( @@ -387,37 +388,39 @@ async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { break; } - // Diagnostic: log the raw packet so DPI-button (and other) reports - // can be identified. `battery` is set when it matches the known - // battery signature; anything else is a candidate to decode. - let hex: String = completion - .data - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - let battery = attackshark::parse_battery(&completion.data); - tracing::info!(device = %device.info.id, packet = %hex, ?battery, "interrupt IN packet"); - - if let Some(percent) = battery { + let data = &completion.data; + if let Some(percent) = attackshark::parse_battery(data) { latest_battery = Some(percent); + } else if let Some(stage) = attackshark::parse_dpi_stage(data) { + latest_stage = Some(stage); + } else { + // Unrecognized report — log it for future decoding. + let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); + tracing::debug!(device = %device.info.id, packet = %hex, "unknown interrupt IN packet"); } } - let Some(percent) = latest_battery else { - continue; - }; - if device.info.battery_percent == Some(percent) { - continue; + // The physical DPI button changed the active stage; reflect it. + if let Some(stage) = latest_stage + && device.info.active_dpi_stage != stage + { + device.info.active_dpi_stage = stage; + tracing::info!(device = %device.info.id, stage, "DPI stage changed on the mouse"); } - device.info.battery_percent = Some(percent); - let reading = BatteryReading { - device_id: device.info.id.clone(), - device_name: device.info.name.clone(), - percent, - charging: false, - }; - if let Err(error) = service.record_battery(reading).await { - tracing::debug!(%error, "could not record native battery reading"); + + if let Some(percent) = latest_battery + && device.info.battery_percent != Some(percent) + { + device.info.battery_percent = Some(percent); + let reading = BatteryReading { + device_id: device.info.id.clone(), + device_name: device.info.name.clone(), + percent, + charging: false, + }; + if let Err(error) = service.record_battery(reading).await { + tracing::debug!(%error, "could not record native battery reading"); + } } } } From cc209ad8f5f12494ca842e5a4236a5e1be64de72 Mon Sep 17 00:00:00 2001 From: Youssef <151364628+viix0dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:09:24 +0300 Subject: [PATCH 15/15] feat(AttackShark): persist DPI/polling and read DPI from the mouse --- src/config.rs | 17 +++++- src/devices/attackshark.rs | 56 ++++++++++++++++++++ src/devices/mod.rs | 103 ++++++++++++++++++++++++++++++++----- src/service.rs | 25 +++++++++ 4 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src/config.rs b/src/config.rs index 057dce4..cd5ef27 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, path::PathBuf}; +use std::{collections::HashMap, env, fs, path::PathBuf}; use anyhow::{Context, Result}; use directories::ProjectDirs; @@ -28,6 +28,19 @@ pub struct BridgeConfig { pub default_profile: Option, #[serde(default = "default_origins")] pub allowed_origins: Vec, + /// Persisted per-device settings (e.g. Attack Shark DPI/polling), keyed by + /// device id like `"1d57:fa60"`, so they survive Bridge restarts. + #[serde(default)] + pub device_settings: HashMap, +} + +/// Native device settings the Bridge remembers across restarts. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DeviceSettings { + pub dpi_stages: Vec, + pub active_dpi_stage: u8, + pub polling_rate_hz: Option, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -76,6 +89,7 @@ impl Default for BridgeConfig { profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), + device_settings: HashMap::new(), } } } @@ -213,6 +227,7 @@ mod tests { profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), + device_settings: HashMap::new(), } .normalized(); assert_eq!(config.battery_threshold_percent, 100); diff --git a/src/devices/attackshark.rs b/src/devices/attackshark.rs index 0f810e1..8eaa499 100644 --- a/src/devices/attackshark.rs +++ b/src/devices/attackshark.rs @@ -36,6 +36,8 @@ pub const CONTROL_INTERFACE: u8 = 2; /// /// bmRequestType 0x21 = Host->Device | Class | Interface. pub const SET_REPORT_REQUEST: u8 = 0x09; +/// HID GET_REPORT (bRequest 0x01), to read a feature report back over raw USB. +pub const GET_REPORT_REQUEST: u8 = 0x01; /// wValue high byte: HID report type 0x03 = Feature. pub const FEATURE_REPORT_TYPE: u16 = 0x03; /// Feature report id the polling-rate command is written on. @@ -179,6 +181,44 @@ pub fn dpi_packet( Some(buf) } +/// Decode one stage's DPI byte back to a DPI value. Codes repeat across the +/// sensor's register pages, so the stage-mask bit (DPI > 12000) and the +/// high-stage flag (upper page) select which page to look in. +fn decode_dpi(code: u8, mask_bit: bool, high_flag: bool) -> Option { + let (lo, hi) = match (mask_bit, high_flag) { + (false, false) => (DPI_MIN, 10_000), + (false, true) => (10_100, 12_000), + (true, false) => (12_100, 20_000), + (true, true) => (20_100, DPI_MAX), + }; + DPI_STEP_MAP + .iter() + .find(|&&(dpi, c)| c == code && (lo..=hi).contains(&dpi)) + .map(|&(dpi, _)| dpi) +} + +/// Decode a DPI feature report read back from the mouse into the six stage +/// values and the active stage (1-based). Returns `None` if the report does not +/// look like the DPI packet (`04 38 01 …`) or any stage byte fails to decode — +/// so a garbled or unsupported read is rejected rather than trusted. +pub fn decode_dpi_packet(report: &[u8]) -> Option<([u16; DPI_STAGE_COUNT], u8)> { + if report.len() < 25 || report[0] != DPI_REPORT_ID || report[1] != 0x38 || report[2] != 0x01 { + return None; + } + let active = report[24]; + if !(1..=DPI_STAGE_COUNT as u8).contains(&active) { + return None; + } + let mut stages = [0u16; DPI_STAGE_COUNT]; + for (i, stage) in stages.iter_mut().enumerate() { + let code = report[8 + i]; + let mask_bit = report[6] & (1 << i) != 0; + let high_flag = report[16 + i] != 0; + *stage = decode_dpi(code, mask_bit, high_flag)?; + } + Some((stages, active)) +} + /// Decode a battery input report. `report` includes the leading report id, so /// the signature occupies bytes 0..4 and the percentage byte 4. Returns the /// percentage (0..=100) or `None` for a non-battery or out-of-range report. @@ -624,6 +664,22 @@ mod tests { assert_eq!(DPI_WVALUE, 0x0304); } + #[test] + fn dpi_packet_round_trips_through_decode() { + // What we write, we can read back — including upper-register-page stages + // whose codes collide with lower ones. + let stages = [800u16, 1600, 2400, 3200, 5000, 22000]; + let packet = dpi_packet(stages, 2, false, true).unwrap(); + assert_eq!(decode_dpi_packet(&packet), Some((stages, 2))); + + let paged = [400u16, 12000, 16000, 20000, 22000, 800]; + let packet = dpi_packet(paged, 4, false, true).unwrap(); + assert_eq!(decode_dpi_packet(&packet), Some((paged, 4))); + + // A non-DPI report is rejected. + assert_eq!(decode_dpi_packet(&[0x03, 0x55, 0x40, 0x01, 0x64]), None); + } + #[test] fn dpi_button_notification_reports_active_stage() { // Captured from hardware: cycling the DPI button pushes 03 55 10 00. diff --git a/src/devices/mod.rs b/src/devices/mod.rs index dade4a8..65e6ba0 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -22,11 +22,14 @@ pub mod attackshark; use std::{collections::HashSet, time::Duration}; use anyhow::{Result, anyhow}; -use nusb::transfer::{ControlOut, ControlType, Recipient, RequestBuffer}; +use nusb::transfer::{ControlIn, ControlOut, ControlType, Recipient, RequestBuffer}; use serde::Serialize; use tokio::sync::{mpsc, oneshot}; -use crate::service::{BatteryReading, BridgeService}; +use crate::{ + config::DeviceSettings, + service::{BatteryReading, BridgeService}, +}; /// How often the worker re-enumerates and samples battery. const POLL_INTERVAL: Duration = Duration::from_secs(2); @@ -150,7 +153,7 @@ struct OpenDevice { async fn worker(mut commands: mpsc::Receiver, service: BridgeService) { let mut devices: Vec = Vec::new(); - refresh(&mut devices); + refresh(&mut devices, &service).await; let mut ticker = tokio::time::interval(POLL_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -161,23 +164,81 @@ async fn worker(mut commands: mpsc::Receiver, service: BridgeService) { let _ = reply.send(devices.iter().map(|device| device.info.clone()).collect()); } Some(Command::SetPolling { id, hz, reply }) => { - let _ = reply.send(set_polling(&mut devices, &id, hz).await); + let _ = reply.send(set_polling(&mut devices, &id, hz, &service).await); } Some(Command::SetDpi { id, stages, active_stage, reply }) => { - let _ = reply.send(set_dpi(&mut devices, &id, &stages, active_stage).await); + let _ = reply.send(set_dpi(&mut devices, &id, &stages, active_stage, &service).await); } None => break, }, _ = ticker.tick() => { poll_battery(&mut devices, &service).await; - refresh(&mut devices); + refresh(&mut devices, &service).await; } } } } +/// Read the DPI stages and active stage directly from the mouse over raw USB +/// (HID GET_REPORT for the DPI feature report). Best-effort — many units may +/// not answer, in which case we fall back to remembered settings. +async fn read_dpi( + interface: &nusb::Interface, +) -> Option<([u16; attackshark::DPI_STAGE_COUNT], u8)> { + let transfer = interface.control_in(ControlIn { + control_type: ControlType::Class, + recipient: Recipient::Interface, + request: attackshark::GET_REPORT_REQUEST, + value: attackshark::DPI_WVALUE, + index: u16::from(attackshark::CONTROL_INTERFACE), + length: 64, + }); + let completion = tokio::time::timeout(CONTROL_TIMEOUT, transfer).await.ok()?; + completion.status.ok()?; + attackshark::decode_dpi_packet(&completion.data) +} + +/// Decide a freshly-claimed device's starting DPI/polling: read from the mouse +/// if it answers, else the settings we persisted, else defaults. +async fn resolve_settings( + interface: &nusb::Interface, + id: &str, + service: &BridgeService, +) -> (Vec, u8, Option) { + let persisted = service.device_settings(id).await; + let (stages, active) = match read_dpi(interface).await { + Some((stages, active)) => { + tracing::info!(device = %id, "read DPI from the mouse"); + (stages.to_vec(), active) + } + None => match &persisted { + Some(saved) if !saved.dpi_stages.is_empty() => { + (saved.dpi_stages.clone(), saved.active_dpi_stage) + } + _ => (DEFAULT_DPI_STAGES.to_vec(), 2), + }, + }; + let polling = persisted.and_then(|saved| saved.polling_rate_hz); + (stages, active, polling) +} + +/// Persist a device's current DPI/polling so they survive a Bridge restart. +async fn persist(device: &OpenDevice, service: &BridgeService) { + let settings = DeviceSettings { + dpi_stages: device.info.dpi_stages.clone(), + active_dpi_stage: device.info.active_dpi_stage, + polling_rate_hz: device.info.polling_rate_hz, + }; + if let Err(error) = service + .save_device_settings(device.info.id.clone(), settings) + .await + { + tracing::debug!(%error, "could not persist device settings"); + } +} + /// Re-enumerate and reconcile the open-device list with what is attached now. -fn refresh(devices: &mut Vec) { +async fn refresh(devices: &mut Vec, service: &BridgeService) { let list = match nusb::list_devices() { Ok(list) => list, Err(error) => { @@ -226,6 +287,13 @@ fn refresh(devices: &mut Vec) { } }; + // Start DPI/polling from the mouse (if it answers), else remembered + // settings, else defaults — so values are not lost across restarts. + let (dpi_stages, active_dpi_stage, polling_rate_hz) = match &interface { + Some(interface) => resolve_settings(interface, &id, service).await, + None => (DEFAULT_DPI_STAGES.to_vec(), 2, None), + }; + devices.push(OpenDevice { info: DeviceInfo { id, @@ -239,12 +307,10 @@ fn refresh(devices: &mut Vec) { }, controllable, battery_percent: None, - polling_rate_hz: None, + polling_rate_hz, supported_polling_rates: attackshark::supported_polling_rates(), - // The mouse does not report its stages, so start from sensible - // defaults; they update as the user writes new ones. - dpi_stages: DEFAULT_DPI_STAGES.to_vec(), - active_dpi_stage: 2, + dpi_stages, + active_dpi_stage, dpi_min: attackshark::DPI_MIN, dpi_max: attackshark::DPI_MAX, dpi_step: attackshark::DPI_STEP, @@ -266,7 +332,12 @@ fn claim(entry: &nusb::DeviceInfo) -> Result { /// Send the polling-rate command as a HID SET_REPORT control transfer, exactly /// as the reference driver does (bmRequestType 0x21, bRequest 0x09, wValue /// 0x0306, wIndex 2). A completed transfer is the mouse acknowledging it. -async fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result { +async fn set_polling( + devices: &mut [OpenDevice], + id: &str, + hz: u16, + service: &BridgeService, +) -> Result { let device = devices .iter_mut() .find(|device| device.info.id == id) @@ -297,6 +368,7 @@ async fn set_polling(devices: &mut [OpenDevice], id: &str, hz: u16) -> Result Result<()> { let device = devices .iter_mut() @@ -357,6 +430,7 @@ async fn set_dpi( device.info.dpi_stages = stage_array.to_vec(); device.info.active_dpi_stage = active_stage; tracing::info!(device = %device.info.id, ?stage_array, active_stage, "set DPI over USB"); + persist(device, service).await; Ok(()) } @@ -400,12 +474,13 @@ async fn poll_battery(devices: &mut [OpenDevice], service: &BridgeService) { } } - // The physical DPI button changed the active stage; reflect it. + // The physical DPI button changed the active stage; reflect and remember it. if let Some(stage) = latest_stage && device.info.active_dpi_stage != stage { device.info.active_dpi_stage = stage; tracing::info!(device = %device.info.id, stage, "DPI stage changed on the mouse"); + persist(device, service).await; } if let Some(percent) = latest_battery diff --git a/src/service.rs b/src/service.rs index 3e49ac3..487628b 100644 --- a/src/service.rs +++ b/src/service.rs @@ -178,6 +178,31 @@ impl BridgeService { self.inner.read().await.config.clone() } + /// Persisted settings for one native device, if any. + pub async fn device_settings(&self, id: &str) -> Option { + self.inner + .read() + .await + .config + .device_settings + .get(id) + .cloned() + } + + /// Remember a native device's settings across restarts. + pub async fn save_device_settings( + &self, + id: String, + settings: config::DeviceSettings, + ) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.device_settings.insert(id, settings); + state.config.clone() + }; + config::save(&self.config_path, &config) + } + pub async fn applications(&self) -> Vec { self.inner.read().await.applications.clone() }