diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af46d25..b906f1c 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: @@ -50,17 +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: | - 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 + 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 ae80051..13e720a 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: @@ -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/.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 diff --git a/Cargo.lock b/Cargo.lock index 23baba1..a192272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1773,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" @@ -2036,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" @@ -2238,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" @@ -2548,6 +2586,7 @@ dependencies = [ "directories", "eframe", "notify-rust", + "nusb", "objc2-app-kit 0.3.2", "png", "serde", @@ -2556,6 +2595,7 @@ dependencies = [ "tokio", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "tray-icon", "windows-sys 0.61.2", @@ -3324,6 +3364,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" @@ -3493,6 +3539,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3501,6 +3548,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" @@ -3679,6 +3736,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" @@ -4289,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" @@ -4325,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" @@ -4385,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" @@ -4397,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" @@ -4409,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" @@ -4433,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" @@ -4445,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" @@ -4457,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" @@ -4469,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 1963dd8..fd7043d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,15 @@ winresource = "0.1" anyhow = "1.0" axum = "0.8" directories = "6.0" +nusb = "0.1" notify-rust = "4.11" 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", "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/README.md b/README.md index 4b998e0..523d514 100644 --- a/README.md +++ b/README.md @@ -83,12 +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) + +> ⚠️ **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 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 00acfa4..aecd551 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,19 +3,35 @@ 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}, }; 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}, + 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,15 +56,53 @@ 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, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DpiPayload { + stages: Vec, + active_stage: u8, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DriverPayload { + /// "install" or "uninstall". + action: String, +} + +pub fn router( + service: BridgeService, + devices: Option, + origins: &[String], +) -> Router { let allowed: Vec = origins .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)) @@ -60,13 +114,72 @@ 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)) - .layer(SetResponseHeaderLayer::if_not_present( - axum::http::HeaderName::from_static("access-control-allow-private-network"), - HeaderValue::from_static("true"), - )) + .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()) - .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 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. +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 { diff --git a/src/config.rs b/src/config.rs index 5023d68..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; @@ -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", ]; @@ -27,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)] @@ -75,6 +89,7 @@ impl Default for BridgeConfig { profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), + device_settings: HashMap::new(), } } } @@ -190,6 +205,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(), ] @@ -211,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/desktop.rs b/src/desktop.rs index a2cd52b..5c1e368 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,9 +15,12 @@ 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, + logging, platform, service::{BridgeService, BridgeSnapshot}, }; +use std::path::Path; #[cfg(target_os = "windows")] use std::ptr::null_mut; use tokio::{net::TcpListener, sync::oneshot}; @@ -129,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), @@ -160,6 +163,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 +193,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; }) @@ -435,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); }); } @@ -519,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), + }); }); }); } @@ -787,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, @@ -804,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, @@ -842,6 +880,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/attackshark.rs b/src/devices/attackshark.rs new file mode 100644 index 0000000..8eaa499 --- /dev/null +++ b/src/devices/attackshark.rs @@ -0,0 +1,707 @@ +//! 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 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; +/// 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. +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]; + +/// 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, + ]) +} + +/// 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 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. +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) +} + +/// 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. +#[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::*; + + 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_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] + 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 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 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. + 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)); + 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..65e6ba0 --- /dev/null +++ b/src/devices/mod.rs @@ -0,0 +1,501 @@ +//! Native mouse access for the Bridge. +//! +//! 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`. +//! +//! 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::{collections::HashSet, time::Duration}; + +use anyhow::{Result, anyhow}; +use nusb::transfer::{ControlIn, ControlOut, ControlType, Recipient, RequestBuffer}; +use serde::Serialize; +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + config::DeviceSettings, + service::{BatteryReading, BridgeService}, +}; + +/// How often the worker re-enumerates and samples battery. +const POLL_INTERVAL: Duration = Duration::from_secs(2); +/// 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; +/// 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)] +#[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, + /// 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, + /// 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, +} + +/// Commands the async HTTP side sends to the device worker. +enum Command { + List(oneshot::Sender>), + SetPolling { + id: String, + hz: u16, + reply: oneshot::Sender>, + }, + SetDpi { + id: String, + stages: Vec, + active_stage: u8, + reply: oneshot::Sender>, + }, +} + +/// Handle to the device worker. Cloneable and cheap. +#[derive(Clone)] +pub struct DeviceManager { + commands: mpsc::Sender, +} + +impl DeviceManager { + /// 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 { + 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)).await.is_err() { + return Vec::new(); + } + rx.await.unwrap_or_default() + } + + /// 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"))? + } + + /// 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 +/// 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, + interface: Option, +} + +async fn worker(mut commands: mpsc::Receiver, service: BridgeService) { + let mut devices: Vec = Vec::new(); + refresh(&mut devices, &service).await; + let mut ticker = tokio::time::interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + 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, &service).await); + } + Some(Command::SetDpi { id, stages, active_stage, reply }) => { + 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, &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. +async fn refresh(devices: &mut Vec, service: &BridgeService) { + let list = match nusb::list_devices() { + Ok(list) => list, + Err(error) => { + tracing::debug!(%error, "nusb enumeration failed"); + return; + } + }; + + 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 entries { + let (vid, pid) = (entry.vendor_id(), entry.product_id()); + if devices + .iter() + .any(|device| device.info.vendor_id == vid && device.info.product_id == pid) + { + continue; + } + + let id = format!("{vid:04x}:{pid:04x}"); + let (interface, controllable, note) = match claim(&entry) { + Ok(interface) => { + tracing::info!(device = %id, "claimed Attack Shark interface 2 for native control"); + ( + Some(interface), + true, + "Connected. Polling rate is set natively over USB.", + ) + } + Err(error) => { + 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.", + ) + } + }; + + // 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, + 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, + supported_polling_rates: attackshark::supported_polling_rates(), + dpi_stages, + active_dpi_stage, + dpi_min: attackshark::DPI_MIN, + dpi_max: attackshark::DPI_MAX, + dpi_step: attackshark::DPI_STEP, + note, + }, + interface, + }); + } +} + +/// 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) +} + +/// 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, + service: &BridgeService, +) -> 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"))?; + + 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"); + persist(device, service).await; + Ok(hz) +} + +/// Send the DPI/stage command as a HID SET_REPORT control transfer (wValue +/// 0x0304). The wireless adapter takes the full 56-byte report; wired X11/R1 +/// take the 52-byte form (checksum-terminated), matching the reference driver. +async fn set_dpi( + devices: &mut [OpenDevice], + id: &str, + stages: &[u16], + active_stage: u8, + service: &BridgeService, +) -> 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"); + persist(device, service).await; + Ok(()) +} + +/// 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() { + let Some(interface) = device.interface.as_ref() else { + continue; + }; + + 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( + 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; + } + + 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"); + } + } + + // 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 + && 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"); + } + } + } +} 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 a39f76b..5dfaa6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,10 @@ pub mod api; pub mod applications; pub mod config; +pub mod devices; +pub mod driver; 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 5828137..766d6c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,14 +12,16 @@ 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; + +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); @@ -42,26 +44,18 @@ 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(()) } -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; 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() }