diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30ae1d9..a1cd6c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,8 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} - # Builds and tests run dependency code; only windows-build needs write access. - permissions: - contents: read steps: - uses: actions/checkout@v4 - with: - persist-credentials: false - name: Install Linux HID and GUI dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libudev-dev libgtk-3-dev libayatana-appindicator3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev pkg-config @@ -38,21 +33,15 @@ jobs: - name: Check formatting if: runner.os == 'Linux' run: cargo fmt --check - - name: Test - run: cargo test --locked - - name: Clippy - run: cargo clippy --locked --all-targets -- -D warnings - name: Test installer script if: runner.os == 'Linux' run: | bash -n install-linux.sh - bash tests/linux_startup.sh "${{ runner.temp }}" bash install-linux.sh --help bash -c 'export HOME=/root OPENMOUSE_INSTALL_SOURCED=1; source ./install-linux.sh; D=$(mktemp -d); write_udev_rule "$D/rule"; bodies=$(grep -c "SUBSYSTEM" "$D/rule"); vids=$(printf "%s" "$VENDOR_IDS" | wc -w); [ "$bodies" = "$vids" ] || exit 1; write_autostart "$D/a.desktop" "/opt/x/b"; grep -q "Exec=\"/opt/x/b\"" "$D/a.desktop"; write_launcher "$D/l.desktop" "/opt/x/b" "/opt/x/i.png"; grep -q "^Name=OpenMouse Bridge$" "$D/l.desktop"; grep -q "^Terminal=false$" "$D/l.desktop"; for pm in apt dnf pacman zypper apk; do pm_packages "$pm" | grep -q curl || exit 1; done; rm -rf "$D"' windows-build: name: Windows release artifact - needs: check runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -82,8 +71,7 @@ jobs: - name: Package shell: pwsh run: | - Copy-Item assets/fonts/DM-Sans-OFL.txt DM-Sans-OFL.txt - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,DM-Sans-OFL.txt,native-hid -DestinationPath openmouse-bridge-windows-x64.zip + Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,native-hid -DestinationPath openmouse-bridge-windows-x64.zip (Get-FileHash openmouse-bridge-windows-x64.zip -Algorithm SHA256).Hash.ToLower() + " openmouse-bridge-windows-x64.zip" | Set-Content openmouse-bridge-windows-x64.zip.sha256 - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c4b3b..1fb9423 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,17 +23,13 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 with: node-version: 24 cache: npm cache-dependency-path: native-hid/package-lock.json - - run: cargo fmt --check - run: cargo test --locked - - run: cargo clippy --locked --all-targets -- -D warnings - run: cargo build --release --locked - name: Sign executable if: env.WINDOWS_CERTIFICATE_BASE64 != '' @@ -59,8 +55,7 @@ jobs: - name: Package shell: pwsh run: | - Copy-Item assets/fonts/DM-Sans-OFL.txt DM-Sans-OFL.txt - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,DM-Sans-OFL.txt,native-hid -DestinationPath openmouse-bridge-windows-x64.zip + Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,native-hid -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: @@ -76,7 +71,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt targets: aarch64-apple-darwin,x86_64-apple-darwin - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 @@ -84,9 +78,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: native-hid/package-lock.json - - run: cargo fmt --check - run: cargo test --locked - - run: cargo clippy --locked --all-targets -- -D warnings - run: cargo build --release --locked --target aarch64-apple-darwin - run: cargo build --release --locked --target x86_64-apple-darwin - name: Prepare native-hid (deps + bundled Node.js runtime) @@ -112,8 +104,7 @@ jobs: target/x86_64-apple-darwin/release/openmouse-bridge \ -output openmouse-bridge chmod +x openmouse-bridge - cp assets/fonts/DM-Sans-OFL.txt ./DM-Sans-OFL.txt - zip -r openmouse-bridge-macos-universal.zip openmouse-bridge README.md DM-Sans-OFL.txt native-hid + zip -r openmouse-bridge-macos-universal.zip openmouse-bridge README.md native-hid shasum -a 256 openmouse-bridge-macos-universal.zip > openmouse-bridge-macos-universal.zip.sha256 - uses: actions/upload-artifact@v4 with: @@ -129,17 +120,13 @@ jobs: - name: Install Linux HID and GUI dependencies run: sudo apt-get update && sudo apt-get install -y libudev-dev libgtk-3-dev libayatana-appindicator3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev pkg-config - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 with: node-version: 24 cache: npm cache-dependency-path: native-hid/package-lock.json - - run: cargo fmt --check - run: cargo test --locked - - run: cargo clippy --locked --all-targets -- -D warnings - run: cargo build --release --locked - name: Prepare native-hid (deps + bundled Node.js runtime) run: | @@ -158,8 +145,7 @@ jobs: cp target/release/openmouse-bridge ./openmouse-bridge chmod +x openmouse-bridge cp assets/openmouse-app-icon.png ./openmouse-app-icon.png - cp assets/fonts/DM-Sans-OFL.txt ./DM-Sans-OFL.txt - zip -r openmouse-bridge-linux-x64.zip openmouse-bridge openmouse-app-icon.png README.md DM-Sans-OFL.txt native-hid + zip -r openmouse-bridge-linux-x64.zip openmouse-bridge openmouse-app-icon.png README.md native-hid rm openmouse-bridge openmouse-app-icon.png sha256sum openmouse-bridge-linux-x64.zip > openmouse-bridge-linux-x64.zip.sha256 - uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index e39e436..5bc9681 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -875,6 +884,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -2529,18 +2549,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2920,7 +2928,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openmouse-bridge" -version = "1.0.0-beta.19" +version = "1.0.0-beta.18" dependencies = [ "anyhow", "axum", @@ -2929,12 +2937,9 @@ dependencies = [ "epaint_default_fonts", "gtk", "hidapi", - "libc", "notify-rust", "objc2 0.6.4", "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", "objc2-foundation 0.3.2", "png", "reqwest", @@ -2945,7 +2950,6 @@ dependencies = [ "skrifa", "sysinfo", "tokio", - "tower", "tower-http", "tracing", "tracing-appender", @@ -3623,9 +3627,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.46" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -4111,7 +4115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -4228,9 +4232,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.3" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4545,12 +4549,6 @@ dependencies = [ "rustc-hash 2.1.3", ] -[[package]] -name = "typed-path" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" - [[package]] name = "typenum" version = "1.20.1" @@ -5821,16 +5819,15 @@ dependencies = [ [[package]] name = "zip" -version = "9.0.5" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ + "arbitrary", "crc32fast", "flate2", "indexmap", "memchr", - "nix", - "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 4028620..e67c750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openmouse-bridge" -version = "1.0.0-beta.19" +version = "1.0.0-beta.18" edition = "2024" description = "Local native HID and desktop companion for the OpenMouse control panel" license = "MIT" @@ -21,12 +21,12 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sysinfo = "0.37" -tokio = { version = "1.47", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower-http = { version = "0.6", features = ["cors", "set-header", "trace"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -zip = { version = "9.0", default-features = false, features = ["deflate"] } +zip = { version = "4.6", default-features = false, features = ["deflate"] } [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] eframe = { version = "0.36.1", default-features = false, features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] } @@ -48,8 +48,6 @@ gtk = "0.18" # X11 overlay banner and foreground-window lookup. Pure Rust; no system # libraries beyond what eframe/tray-icon already require. x11rb = "0.13" -# Virtual keyboard and mouse for the button listener (/dev/uinput). -libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" @@ -72,9 +70,6 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSWorkspace", ] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSArray", "NSData", "NSGeometry", "NSString"] } -# Posts the key and button events the button listener presses. -objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFCGTypes"] } -objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEvent", "CGEventTypes", "CGRemoteOperation"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61", features = [ @@ -87,10 +82,6 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_HiDpi", - "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } - -[dev-dependencies] -tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index 0c203ad..8e11ba9 100644 --- a/README.md +++ b/README.md @@ -6,33 +6,8 @@ portable so additional desktop adapters do not require changes to the web app. The initial service provides: -- a compact tray panel for status, startup, battery, and update controls, - with a device summary row set showing the mouse's current DPI, polling - rate, and switch mode as read from its onboard memory; -- a live tray icon that shows the connected mouse's battery, drawn square at - the taskbar's real pixel size with either the native Segoe Fluent Icons - battery glyph (Segoe MDL2 Assets on Windows 10) or the percentage number in - bold text — a control-panel choice, since the Windows 11 taskbar only - renders square tray icons faithfully — tinted by charge state: green while - charging (the variant with the built-in bolt) or above half, amber through - the mid band, alert red at or below the low-battery threshold, gray when - the reading is stale, and a level glyph for every 10% — with a tooltip that - spells everything out — `Device — 87% · Charging · full in 45 m` — - including the estimated time remaining from the recent trend; +- a compact tray panel for status, startup, battery, and update controls; - process-based detection for configured game executables; -- startup discovery: Bridge scans the HID bus for mice whose brands it has - drivers for (the same vendor table the native-hid helper drives — Pulsar, - Logitech, Razer, Delux, VGN, and more), so the tray shows the battery from - the first second — no web panel connection and no saved profile required; -- hybrid device mode (toggle in the panel): while the OpenMouse web app is - disconnected, Bridge drives the mouse itself in onboard memory mode — the - active profile's stored DPI, polling rate, and switch mode are pushed - natively (switch mode wherever a driver supports it, e.g. Logitech - LightForce), and the battery is read directly (including a native Pulsar - battery reader) — and the moment the web app connects, it takes the mouse - over live, with Bridge restoring the stored settings and reading the - battery again on disconnect; without a saved profile Bridge leaves the - mouse's own onboard memory untouched and just monitors it; - discovery of visible applications and the foreground application (on Linux, foreground detection needs X11; on Wayland every entry reports `foreground: false`); - persistent application profiles tied to a specific mouse; - low-battery notifications with a configurable threshold and cooldown, from @@ -106,31 +81,6 @@ explicit configuration file, `OPENMOUSE_BRIDGE_GAMES_URL` can override the catalog endpoint, and `OPENMOUSE_BRIDGE_UPDATE_API_URL` can override the stable release API endpoint. -## Local games and apps - -OpenMouse presets remain the centrally maintained catalog. Personal targets live -separately in `localApplications` in Bridge's local config, so catalog refreshes -cannot replace them. Open Games & apps in the control panel, choose Add game or -app, then pick a running app or enter its executable name/full path. No request, -Discord account, approval or upload is involved. A connected mouse is not needed -to add a target; select one later to save its mouse settings. - -Use Game to retain its profile while the matching process is running, including -when you alt-tab away. Use App to apply only while the app is in front. A full -executable path restricts matching to that installation; it never falls back to -another process with the same filename. With only a filename, all matching -executables are eligible. Windows paths ignore case and separator differences; -POSIX paths remain case-sensitive. Bridge detects processes; it does not launch -or execute the saved inputs. Foreground-only apps on Linux require X11. - -Each local target has a stable `local:` ID, referenced by a mouse profile's -`application.localId`. Renaming/editing the target preserves linked mouse -settings. Removing it removes only profiles linked to that ID, not a same-name -OpenMouse preset or another target. Clearing a target's mouse settings does not -remove the target. Local data persists across Bridge restarts and is separate -from browser storage. Use the updated control panel and Bridge together; older -Bridges do not implement the local-target endpoints. - ## Updates The tray panel can check for a stable Bridge release on demand. Automatic @@ -162,28 +112,11 @@ enumeration details. OpenMouse client has completed a recent handshake. - `PUT /v1/handshake` renews OpenMouse's 20-second connection lease. The client sends this heartbeat every five seconds while connected. -- `GET /v1/games` returns presets (`source: "preset"`), the user's custom - games (`source: "custom"`) and local targets (`source: "local"`, with `id`, - `path` and `kind`). -- `GET /v1/custom-games` returns the games the user added by hand; - `PUT /v1/custom-games` replaces them (`[{ name, executables }]`), persists - them under `customGames` in `config.json` and answers with the saved list. - They live apart from the catalog, which is replaced on every start. -- `GET /v1/custom-games/image?name=…` returns the image saved for a custom - game (404 when it has none); `PUT` with a PNG, JPEG or WebP body (at most - 512 KB) saves one. Images live in a `custom-game-images` folder beside - `config.json` and go away with the game. -- `GET /v1/local-applications` reads personal targets. `PUT` accepts - `{ applications: [{ id, name, executable, path, kind }] }` and atomically - validates/persists the complete local list. Limit: 256 targets, names up to 80 - characters, executable leaf names up to 255, full paths up to 1024. Invalid - input returns 400; failed persistence returns 500 without changing live state. -- `GET /v1/applications` lists relevant visible applications from the preset - catalog, custom games or local targets and identifies the foreground - application. Each item includes an `iconId`; requesting +- `GET /v1/games` returns the full executable catalog currently being tracked. +- `GET /v1/applications` lists running games and identifies the foreground + game. Only applications from the registered catalog are + returned. Each item includes an `iconId`; requesting `GET /v1/applications/{iconId}/icon` returns its extracted icon as a PNG. -- `GET /v1/running-applications` lists visible running apps for the local picker, - including apps outside the preset catalog. - `PUT /v1/default-profile` keeps Bridge synchronized with the mouse and settings currently selected in OpenMouse. Bridge shows this profile whenever no game-specific profile is active. @@ -191,8 +124,6 @@ enumeration details. replaces and persists them. - `PUT /v1/battery` accepts `{ deviceId, deviceName, percent, charging }` and applies the notification threshold and cooldown. -- `DELETE /v1/battery/{deviceId}` forgets a cached battery report. Stale reports - can also be removed from the native panel's connection details. - `GET /v1/hid` upgrades to the native HID WebSocket transport. It enumerates only vendor IDs requested by OpenMouse, reports parsed HID collections, and supports open, close, input, output, and feature-report operations. This lets @@ -204,40 +135,10 @@ The listener binds only to loopback, never to a LAN or public interface. ## Current boundary -The HID WebSocket is active only while the control panel is connected. Bridge -also reads battery levels every five minutes for devices in saved profiles when -no native HID client session is active. This requires a supported native battery -reader; Pulsar currently has no background battery reader. Readings older than -twelve minutes are marked as last known rather than live. - -Game detection and saved profile switching run independently in the background. -Native profile application writes DPI and polling rate for supported devices. -Full settings snapshots are stored by Bridge but applied and restored by an open -control panel. A selected profile is not proof that a hardware write succeeded. - -## Tray panel - -The native tray panel uses OpenMouse's lit surfaces, inset controls, raised -actions, trailing switches, Emerald palette and embedded DM Sans. Home starts at -360 by 280 logical points; Settings is 360 by 420. Real content can grow Home up -to 340 points high. Extended connection and battery reports live behind the info -controls rather than filling the default view. -Home separates the Bridge service, browser connection, live HID sessions, saved -profile selection, and battery readings instead of treating them as one status. -Settings keeps startup, battery alerts, notification sound, updates, and logs in -one scrollable view. - -Drag the header beside the OpenMouse name, or beside the Settings title, to move -the borderless window. Settings and Back leave its position unchanged. Clicking -the tray icon opens the panel near that icon, constrained to the monitor work -area on Windows. The titlebar minimize and close controls hide to the tray; -background operation continues. Escape also dismisses the panel. Quit Bridge -stops the process. - -`OPENMOUSE_BRIDGE_TEST_POSITION=x,y` starts the panel at an explicit logical -position without requesting focus, for isolated secondary-monitor UI checks. -Use `OPENMOUSE_BRIDGE_CONFIG` with a disposable configuration and set -`autostartConfigured` to `true` to avoid first-run startup registration in tests. +The HID WebSocket is active only while the control panel is connected. Battery +readings still come from that panel, so true battery alerts while the browser is +closed require a background native protocol reader. Game detection and saved +profile switching already run independently in the background. ## Verify diff --git a/assets/fonts/DM-Sans-OFL.txt b/assets/fonts/DM-Sans-OFL.txt deleted file mode 100644 index 441171d..0000000 --- a/assets/fonts/DM-Sans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/DM-Sans-source.json b/assets/fonts/DM-Sans-source.json deleted file mode 100644 index 7717e47..0000000 --- a/assets/fonts/DM-Sans-source.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "revision": "5b35b7208dd4100571326fdf37f030b32a524232", - "files": [ - { - "file": "DMSans-Variable.ttf", - "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/DMSans%5Bopsz%2Cwght%5D.ttf", - "sha256": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d", - "bytes": 240164 - }, - { - "file": "DM-Sans-OFL.txt", - "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/OFL.txt", - "sha256": "9af36190332437f5ecd09974de43c1f7c77a310a996cdd8ceb25628b458840e1", - "bytes": 4482 - } - ] -} diff --git a/assets/fonts/DMSans-Variable.ttf b/assets/fonts/DMSans-Variable.ttf deleted file mode 100644 index c672f98..0000000 Binary files a/assets/fonts/DMSans-Variable.ttf and /dev/null differ diff --git a/install-linux.sh b/install-linux.sh index 6c77711..2313a57 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -197,37 +197,12 @@ write_udev_rule() { for vid in $VENDOR_IDS; do printf 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="%s", MODE="0666", TAG+="uaccess"\n' "$vid" done - echo "# The button listener presses keys and side buttons through a virtual keyboard and mouse." - # No SUBSYSTEM match here: the installer test counts those, one per vendor id. - echo 'KERNEL=="uinput", MODE="0660", TAG+="uaccess", OPTIONS+="static_node=uinput"' } >"$path" } # Writes the XDG autostart entry Bridge itself uses (see src/platform/linux.rs). -desktop_exec() { - local value="$1" character index - [[ "$value" != *[=$'\001'-$'\037'$'\177']* ]] || return 1 - for ((index=0; index<${#value}; index++)); do - character="${value:index:1}" - case "$character" in - '%') printf '%s' '%%' ;; - '\') printf '%s' '\\\\' ;; - '"'|'$'|'`') printf '%s%s' '\\' "$character" ;; - *) printf '%s' "$character" ;; - esac - done -} - -startup_config_home() { - case "${XDG_CONFIG_HOME:-}" in - /*) printf '%s' "$XDG_CONFIG_HOME" ;; - *) printf '%s' "$HOME/.config" ;; - esac -} - write_autostart() { - local path="$1" exec - exec=$(desktop_exec "$2") || return 1 + local path="$1" exec="$2" mkdir -p "$(dirname "$path")" cat >"$path" <"$path" </dev/null || true fi update_desktop_database @@ -329,7 +301,7 @@ main() { fi local binary="$INSTALL_DIR/openmouse-bridge" local link="$BIN_LINK_DIR/openmouse-bridge" - local desktop="$(startup_config_home)/autostart/io.openmouse.bridge.desktop" + local desktop="$HOME/.config/autostart/io.openmouse.bridge.desktop" # GNOME/KDE launcher: user-local always, so it never needs root and # --uninstall can remove it without sudo. Icon path is absolute because # launchers do not resolve relative to the .desktop location. @@ -447,15 +419,8 @@ main() { log "Installing hidraw udev rule..." write_udev_rule "$tmp/69-openmouse-bridge.rules" $sudo cp "$tmp/69-openmouse-bridge.rules" "$rule" - # /dev/uinput only exists once the uinput module is loaded. - # Best effort: button assignments need it, the rest of Bridge does not. - if ! { $sudo mkdir -p /etc/modules-load.d && echo uinput | $sudo tee /etc/modules-load.d/openmouse-bridge.conf >/dev/null; }; then - warn "could not configure uinput to load at boot; button assignments need /dev/uinput" - fi - $sudo modprobe uinput 2>/dev/null || true $sudo udevadm control --reload-rules 2>/dev/null || true $sudo udevadm trigger --subsystem-match=hidraw --action=change 2>/dev/null || true - $sudo udevadm trigger --subsystem-match=misc --sysname-match=uinput --action=change 2>/dev/null || true log "Unplug and replug the mouse (or reboot) so the new rule applies." fi else @@ -464,14 +429,14 @@ main() { # 6. Autostart. if [ "$WITH_AUTOSTART" = true ]; then - write_autostart "$desktop" "$binary" || die "could not write autostart entry: invalid path or write failure" + write_autostart "$desktop" "$binary" log "Login autostart enabled." fi # 7. App launcher entry + icon refresh. The zip is expected to ship # openmouse-app-icon.png next to the binary (see release.yml Package). if [ "$WITH_LAUNCHER" = true ]; then - write_launcher "$launcher" "$binary" "$icon" || die "could not write launcher entry: invalid path or write failure" + write_launcher "$launcher" "$binary" "$icon" update_desktop_database log "Launcher entry installed." else diff --git a/native-hid/README.md b/native-hid/README.md index fa406c3..45d8c8e 100644 --- a/native-hid/README.md +++ b/native-hid/README.md @@ -48,16 +48,6 @@ fall through to the generic `AttackSharkHidClient` path, which drives the 0x04 six-stage table for model `0x55` (mouse-protocol #174), preserving the stages the request does not touch. -## Button listener - -`button-listener.mjs` is the one long-running helper. While a game's profile assigns -the side buttons of a Logitech mouse, Bridge (`src/buttons.rs`) starts it with -`{"brand": "Logitech"}` on stdin. It puts the mouse on the Host profile if it was -on an onboard one, turns the mouse's button events on, and prints -`{"event": "buttons", "mask": 16}` lines; Bridge presses the assigned key. Closing -stdin stops it: the events go off and the mouse returns to the mode it was in. -It needs an `@openmouse/protocol` release that has `startButtonListener`. - ## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`) - `0` — a device was found and the requested settings were applied. In diff --git a/native-hid/package-lock.json b/native-hid/package-lock.json index d7d8df2..c2b03b3 100644 --- a/native-hid/package-lock.json +++ b/native-hid/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.48.0", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.2", "node-hid": "^3.1.0" }, "engines": { @@ -26,7 +26,7 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#326f292bbe29ea73801cda1b2f0434f4fea62d18", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#e53af6d57197074bb8afd31f0fc1522ece68c890", "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" diff --git a/native-hid/package.json b/native-hid/package.json index 054a2ed..953b873 100644 --- a/native-hid/package.json +++ b/native-hid/package.json @@ -12,18 +12,17 @@ }, "dependencies": { "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.48.0", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.2", "node-hid": "^3.1.0" }, "allowScripts": { "github:OpenMouse-Project/keyboard-protocol#v1.1.0": true, "github:OpenMouse-Project/keyboard-protocol#cae3d7a74e8256a9a3cc5c7fe2311db1425bb977": true, - "github:OpenMouse-Project/mouse-protocol#v0.48.0": true, + "github:OpenMouse-Project/mouse-protocol#v0.42.2": true, "node-hid@3.4.0": true, "github:OpenMouse-Project/mouse-protocol#b69cd44b2498dcaaa65fd6f77ba10bf109e49322": true, "github:OpenMouse-Project/mouse-protocol#de188133bace18b1f75cd7bfd8360f17197222a5": true, "github:OpenMouse-Project/mouse-protocol#c3b1bc20866fbef3a27b37ad020887607d6dbbf2": true, - "github:OpenMouse-Project/mouse-protocol#e53af6d57197074bb8afd31f0fc1522ece68c890": true, - "github:OpenMouse-Project/mouse-protocol#326f292bbe29ea73801cda1b2f0434f4fea62d18": true + "github:OpenMouse-Project/mouse-protocol#e53af6d57197074bb8afd31f0fc1522ece68c890": true } } diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 4e3b592..2d591e9 100644 --- a/native-hid/src/apply.mjs +++ b/native-hid/src/apply.mjs @@ -6,14 +6,9 @@ // Usage: node apply.mjs < request.json // stdin: {"brand": "Razer", "dpi": 800, "pollingRateHz": 1000} // (dpi / pollingRateHz are each optional — omit to leave that setting alone) -// stdin: {"brand": "Razer", "dpi": 800, "settings": {"lightforceSwitchMode": "Optical"}} -// (settings carries the panel's stored MouseStatus fields; ones the driver -// supports are applied, the rest are reported and skipped) // stdin: {"brand": "Razer", "action": "status"} -// reads the mouse's status instead and prints it as JSON on stdout: -// {"batteryPercent": 41, "batteryState": "Discharging", -// "deviceName": "G Pro X Superlight 2", "dpi": 800, "pollingRateHz": 1000, -// "switchMode": "Optical"} (settings fields null when unreadable) +// reads the mouse's status instead and prints its battery fields as JSON on +// stdout: {"batteryPercent": 41, "batteryState": "Discharging"} // // Exit codes (Bridge's src/drivers/native_hid.rs depends on this contract): // 0 — a device was found and the requested settings were applied (or, in @@ -39,9 +34,7 @@ globalThis.window ??= globalThis; import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; import { applyX11PollingRate, x11DeviceInfos } from "./attack-shark-x11.mjs"; -import { candidateDevices, probeVerifiedDevice } from "./hid-device-adapter.mjs"; -import { devices } from "node-hid"; -import { automate } from "./automation.mjs"; +import { candidateDevices } from "./hid-device-adapter.mjs"; const EXIT_APPLIED = 0; const EXIT_FAILED = 1; @@ -89,14 +82,6 @@ async function main() { const brand = typeof input.brand === "string" ? input.brand : ""; const normalizedBrand = brandKey(brand); const entry = BRAND_DRIVERS[normalizedBrand]; - if (input.action === "automatic-read" || input.action === "automatic-apply") { - const reply = await automate(input, { - list: () => devices(), - probe: (info, identity) => probeVerifiedDevice(info, identity, (device) => probe(device, entry.classes[0])), - }); - process.stdout.write(JSON.stringify(reply)); - process.exit(EXIT_APPLIED); - } if (!entry) { console.error(`[native-hid] no driver registered for brand "${brand}"`); process.exit(EXIT_NO_DRIVER); @@ -143,16 +128,6 @@ async function main() { JSON.stringify({ batteryPercent: status?.batteryPercent ?? null, batteryState: status?.batteryState ?? null, - // The driver's own model name (MouseStatus.name) — better than the - // receiver-level name Bridge's discovery gets from the HID - // descriptor (a LIGHTSPEED receiver reports "USB Receiver"). - deviceName: status?.name ?? null, - // The settings currently in the device's onboard memory, for the - // tray panel's device summary. MouseStatus reports them whenever - // the driver can read them. - dpi: status?.dpi ?? null, - pollingRateHz: status?.pollingRateHz ?? null, - switchMode: status?.lightforceSwitchMode ?? null, }), ); await client.close().catch(() => undefined); @@ -220,43 +195,6 @@ async function main() { process.exit(EXIT_FAILED); } - // ── Panel snapshot fields ──────────────────────────────────────────── - // Bridge stores the panel's opaque MouseStatus fields alongside the - // numeric settings and hands them back here for onboard-memory mode. - // Only fields with a feature-detected driver method are applied; the - // rest are reported and skipped. - if (input.settings && typeof input.settings === "object" && !Array.isArray(input.settings)) { - const settings = input.settings; - const skipped = []; - if ( - typeof settings.lightforceSwitchMode === "string" && - typeof client.setLightforceSwitchMode === "function" - ) { - try { - await withTimeout( - client.setLightforceSwitchMode(settings.lightforceSwitchMode), - PROBE_TIMEOUT_MS, - "setLightforceSwitchMode", - ); - } catch (error) { - console.error(`[native-hid] could not apply lightforceSwitchMode: ${error.message}`); - await client.close().catch(() => undefined); - process.exit(EXIT_FAILED); - } - } else if (typeof settings.lightforceSwitchMode === "string") { - skipped.push("lightforceSwitchMode"); - } - const known = ["lightforceSwitchMode"]; - for (const field of Object.keys(settings)) { - if (!known.includes(field) && settings[field] !== null && settings[field] !== undefined) { - skipped.push(field); - } - } - if (skipped.length > 0) { - console.error(`[native-hid] no native support for: ${skipped.join(", ")} — skipped.`); - } - } - await client.close().catch(() => undefined); process.exit(EXIT_APPLIED); } diff --git a/native-hid/src/automation.mjs b/native-hid/src/automation.mjs deleted file mode 100644 index 331a04c..0000000 --- a/native-hid/src/automation.mjs +++ /dev/null @@ -1,37 +0,0 @@ -const identityFields = ["vendorId", "productId", "path", "serialNumber", "interface"]; - -export async function automate(input, io) { - if (input.brand?.trim().toLowerCase() !== "zaunkoenig") throw new Error("unsupported acknowledged native automation readback for this driver"); - const infos = io.list(); - if (infos.length > 1024) throw new Error("native identity enumeration limit exceeded"); - const endpoints = new Map(); - for (const info of infos) { - if (info.vendorId !== 0x0483 || (info.usagePage === 1 && [1, 2, 6].includes(info.usage))) continue; - const identity = Object.fromEntries(identityFields.map((field) => [field, info[field] ?? null])); - endpoints.set(JSON.stringify(identity), { info, identity }); - } - let selected; - if (input.identity) { - selected = [...endpoints.values()].find(({ identity }) => identityFields.every((field) => identity[field] === input.identity[field])); - } else if (endpoints.size === 1) { - selected = endpoints.values().next().value; - } - if (!selected || !Number.isInteger(selected.identity.interface) || !Number.isInteger(selected.identity.productId) || typeof selected.identity.path !== "string" || !selected.identity.path || selected.identity.path.length > 2048 || typeof selected.identity.serialNumber !== "string" || !selected.identity.serialNumber || selected.identity.serialNumber.length > 256) throw new Error("native device identity is missing, ambiguous, or changed"); - if (input.action === "automatic-apply" && !input.identity) throw new Error("native apply requires captured identity"); - const { client, status } = await io.probe(selected.info, selected.identity); - try { - if (`${status.brand}:${status.name}` !== input.deviceId) throw new Error("native profile device ownership does not match acknowledged status"); - if (!Number.isSafeInteger(status.dpi) || status.dpi <= 0 || !Number.isSafeInteger(status.pollingRateHz) || status.pollingRateHz <= 0 || (status.dpiY !== undefined && status.dpiY !== status.dpi)) throw new Error("unsupported acknowledged DPI/polling readback"); - if (input.action === "automatic-read") return { identity: selected.identity, dpi: status.dpi, pollingRateHz: status.pollingRateHz }; - if (input.action !== "automatic-apply") throw new Error("unsupported automatic action"); - for (const value of [input.dpi, input.pollingRateHz]) { - if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new Error("invalid automatic settings"); - } - if (input.pollingRateHz !== undefined && (!["Full", "High"].includes(status.usbSpeed) || ![1000, 2000, 4000, 8000].includes(input.pollingRateHz) || (input.pollingRateHz > 1000 && status.usbSpeed !== "High"))) throw new Error("automatic polling cannot change USB speed or use unsupported rates"); - if (input.dpi !== undefined && await client.setDpi(input.dpi) !== input.dpi) throw new Error("driver did not acknowledge requested DPI"); - if (input.pollingRateHz !== undefined && input.pollingRateHz !== status.pollingRateHz && await client.setPollingRate(input.pollingRateHz) !== input.pollingRateHz) throw new Error("driver did not acknowledge requested polling rate"); - return { identity: selected.identity }; - } finally { - await client.close(); - } -} diff --git a/native-hid/src/automation.test.mjs b/native-hid/src/automation.test.mjs deleted file mode 100644 index 0a9b0ef..0000000 --- a/native-hid/src/automation.test.mjs +++ /dev/null @@ -1,211 +0,0 @@ -import test from "node:test"; -import { HidDeviceAdapter, probeVerifiedDevice } from "./hid-device-adapter.mjs"; -import assert from "node:assert/strict"; -import { automate } from "./automation.mjs"; -import { ZaunkoenigHidClient } from "@openmouse/protocol/drivers/zaunkoenig/hid"; -import { ZAUNKOENIG_M3K_PRODUCT_ID, ZAUNKOENIG_VERSION_REPORT_ID, zaunkoenigBuildConfigPayload } from "@openmouse/protocol/zaunkoenig"; - -test("installed protocol driver reads, acknowledges and restores over fake feature I/O", async () => { - const info = { vendorId: 0x0483, productId: ZAUNKOENIG_M3K_PRODUCT_ID, path: "fake-only", serialNumber: "fake-serial", interface: 2 }; - let config = zaunkoenigBuildConfigPayload({ dpi: 800, pollingRateHz: 1000, pollingRateCode: 3, usbSpeed: "High", liftOffDistanceMm: 1, angleSnapping: false, primaryButton: "Left" }, info.productId); - let writes = 0; - const device = { - vendorId: info.vendorId, productId: info.productId, productName: "M3K", opened: false, - open: async () => { device.opened = true; }, close: async () => { device.opened = false; }, - receiveFeatureReport: async (id) => id === ZAUNKOENIG_VERSION_REPORT_ID ? new TextEncoder().encode("parawizard new v0.8") : config, - sendFeatureReport: async (_id, data) => { config = new Uint8Array(data); writes++; }, - }; - const io = { list: () => [info], probe: async () => { const client = new ZaunkoenigHidClient(device); return { client, status: await client.readStatus() }; } }; - const request = { brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K" }; - const baseline = await automate({ ...request, action: "automatic-read" }, io); - assert.equal(writes, 0); - await automate({ ...request, action: "automatic-apply", identity: baseline.identity, dpi: 1600, pollingRateHz: 2000 }, io); - const changed = await automate({ ...request, action: "automatic-read" }, io); - assert.equal(changed.dpi, 1600); - assert.equal(changed.pollingRateHz, 2000); - await automate({ ...request, action: "automatic-apply", ...baseline }, io); - const restored = await automate({ ...request, action: "automatic-read" }, io); - assert.deepEqual(restored, baseline); - assert.equal(writes, 4); - assert.equal(device.opened, false); -}); - -test("production verified probe retains the same fake NodeHID handle through installed driver read/apply/restore", async () => { - const info = { vendorId: 0x0483, productId: ZAUNKOENIG_M3K_PRODUCT_ID, product: "M3K", path: "node-fake", serialNumber: "node-serial", interface: 2 }; - let config = zaunkoenigBuildConfigPayload({ dpi: 800, pollingRateHz: 1000, pollingRateCode: 3, usbSpeed: "High", liftOffDistanceMm: 1, angleSnapping: false, primaryButton: "Left" }, info.productId); - const calls = []; - class FakeHID { - constructor(path) { assert.equal(path, info.path); this.verified = false; calls.push("open"); } - getDeviceInfo() { this.verified = true; calls.push("identity"); return { ...info }; } - on() {} - removeAllListeners() {} - close() { calls.push("close"); } - getFeatureReport(id) { - assert.equal(this.verified, true); - calls.push("report"); - return id === ZAUNKOENIG_VERSION_REPORT_ID ? [id, ...new TextEncoder().encode("parawizard new v0.8")] : [id, ...config]; - } - sendFeatureReport(frame) { assert.equal(this.verified, true); config = Uint8Array.from(frame.slice(1)); } - } - const io = { list: () => [info], probe: (info, identity) => probeVerifiedDevice(info, identity, async (device) => { - const client = new ZaunkoenigHidClient(device); - return { client, status: await client.readStatus() }; - }, FakeHID) }; - const request = { brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K" }; - const baseline = await automate({ ...request, action: "automatic-read" }, io); - await automate({ ...request, action: "automatic-apply", identity: baseline.identity, dpi: 1600, pollingRateHz: 2000 }, io); - await automate({ ...request, action: "automatic-apply", ...baseline }, io); - for (const name of ["open", "identity", "close"]) assert.equal(calls.filter((call) => call === name).length, 3); - assert.deepEqual(calls.slice(0, 2), ["open", "identity"]); -}); - -test("verified probe closes its fake NodeHID handle when protocol probing fails", async () => { - let closed = 0; - class FakeHID { - getDeviceInfo() { return endpoint; } - on() {} - removeAllListeners() {} - close() { closed++; } - } - await assert.rejects(probeVerifiedDevice(endpoint, endpoint, async () => { throw new Error("protocol failed"); }, FakeHID), /protocol failed/); - assert.equal(closed, 1); -}); - -test("opened NodeHID replacement is rejected before protocol probe and every handle closes", async () => { - for (const action of ["automatic-read", "automatic-apply"]) { - for (const field of ["vendorId", "productId", "path", "serialNumber", "interface", "missing", "throws", "noMethod"]) { - const calls = []; - class FakeHID { - constructor(path) { calls.push(["open", path]); } - getDeviceInfo() { - calls.push("identity"); - if (field === "throws") throw new Error("identity unavailable"); - if (field === "missing") return {}; - return { ...endpoint, [field]: typeof endpoint[field] === "number" ? endpoint[field] + 1 : "replacement" }; - } - on() { calls.push("listen"); } - removeAllListeners() { calls.push("unlisten"); } - close() { calls.push("close"); } - } - if (field === "noMethod") FakeHID.prototype.getDeviceInfo = undefined; - const io = { - list: () => [endpoint], - probe: (info, identity) => probeVerifiedDevice(info, identity, async (device) => { - calls.push("protocol-probe"); - return { client: { close: () => device.close() }, status }; - }, FakeHID), - }; - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action, identity: action === "automatic-apply" ? endpoint : undefined, dpi: 1600 }, io), /identity/); - assert.equal(calls.includes("protocol-probe"), false); - assert.equal(calls.filter((value) => value === "close").length, 1); - } - } -}); - -test("Full-speed automation and restoration preserve latent High-speed interval bits", async () => { - const info = { vendorId: 0x0483, productId: ZAUNKOENIG_M3K_PRODUCT_ID, path: "full-fake", serialNumber: "full-serial", interface: 2 }; - let config = zaunkoenigBuildConfigPayload({ dpi: 800, pollingRateHz: 1000, pollingRateCode: 0, usbSpeed: "Full", liftOffDistanceMm: 1, angleSnapping: false, primaryButton: "Left" }, info.productId); - const original = Uint8Array.from(config); - let pollingCalls = 0; - const device = { - vendorId: info.vendorId, productId: info.productId, productName: "M3K", opened: false, - open: async () => { device.opened = true; }, close: async () => { device.opened = false; }, - receiveFeatureReport: async (id) => id === ZAUNKOENIG_VERSION_REPORT_ID ? new TextEncoder().encode("parawizard new v0.8") : config, - sendFeatureReport: async (_id, data) => { config = new Uint8Array(data); }, - }; - const io = { list: () => [info], probe: async () => { - const client = new ZaunkoenigHidClient(device); - const set = client.setPollingRate.bind(client); - client.setPollingRate = async (rate) => { pollingCalls++; return await set(rate); }; - return { client, status: await client.readStatus() }; - } }; - const request = { brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K" }; - const baseline = await automate({ ...request, action: "automatic-read" }, io); - await automate({ ...request, action: "automatic-apply", identity: baseline.identity, dpi: 1600, pollingRateHz: 1000 }, io); - await automate({ ...request, action: "automatic-apply", ...baseline }, io); - assert.equal(pollingCalls, 0); - assert.deepEqual(config, original); - assert.equal(device.opened, false); -}); - -const endpoint = { vendorId: 0x0483, productId: 1, path: "exact-path", serialNumber: "serial", interface: 2 }; -const status = { brand: "Zaunkoenig", name: "M3K", dpi: 800, pollingRateHz: 1000, usbSpeed: "High" }; -function fixture(infos = [endpoint], value = status) { - const calls = []; - const client = { readStatus: async () => { calls.push("read"); return value; }, close: async () => calls.push("close"), setDpi: async (dpi) => { calls.push(["dpi", dpi]); return dpi; }, setPollingRate: async (hz) => { calls.push(["hz", hz]); return hz; } }; - return { calls, io: { list: () => infos, probe: async () => ({ client, status: value }) } }; -} -test("captures acknowledged values and exact endpoint without setting hardware", async () => { - const { calls, io } = fixture(); - const result = await automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-read" }, io); - assert.equal(result.dpi, 800); - assert.equal(result.pollingRateHz, 1000); - assert.deepEqual(result.identity, endpoint); - assert.deepEqual(calls, ["close"]); -}); -test("ambiguous endpoints and missing serial fail before probing", async () => { - for (const infos of [[endpoint, { ...endpoint, path: "other" }], [{ ...endpoint, serialNumber: "" }], [{ ...endpoint, interface: undefined }]]) { - const { calls, io } = fixture(infos); - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-read" }, io), /identity/); - assert.deepEqual(calls, []); - } -}); -test("restores only exact captured endpoint and serial", async () => { - const { calls, io } = fixture([{ ...endpoint, serialNumber: "replacement" }]); - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, dpi: 800 }, io), /identity/); - assert.deepEqual(calls, []); -}); -test("unsupported readback and mismatched profile ownership fail without setters", async () => { - for (const value of [{ ...status, dpi: null }, { ...status, name: "Other" }, { ...status, dpiY: 900 }]) { - const { calls, io } = fixture([endpoint], value); - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, dpi: 1600 }, io)); - assert.deepEqual(calls, ["close"]); - } - const { calls, io } = fixture(); - await assert.rejects(automate({ brand: "Pulsar", deviceId: "Pulsar:Mouse", action: "automatic-read" }, io), /unsupported.*readback/); - assert.deepEqual(calls, []); -}); -test("setter acknowledgements are required and client closes on failure", async () => { - const { calls, io } = fixture(); - const open = io.probe; - io.probe = async () => { const result = await open(); result.client.setDpi = async () => 1200; return result; }; - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, dpi: 1600 }, io), /acknowledge/); - assert.equal(calls.at(-1), "close"); -}); -test("polling that would change USB speed fails before any DPI setter", async () => { - const { calls, io } = fixture([endpoint], { ...status, usbSpeed: "Full" }); - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, dpi: 1600, pollingRateHz: 8000 }, io), /USB/); - assert.deepEqual(calls, ["close"]); -}); - -test("equal effective polling never calls the setter at either supported USB speed", async () => { - for (const usbSpeed of ["Full", "High"]) { - const { calls, io } = fixture([endpoint], { ...status, usbSpeed }); - await automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, pollingRateHz: 1000 }, io); - assert.deepEqual(calls, ["close"]); - } -}); - -test("unsupported USB speed/readback or rate fails before any write", async () => { - for (const [usbSpeed, pollingRateHz] of [["Unknown", 1000], [undefined, 1000], ["High", 500], ["Full", 2000]]) { - const { calls, io } = fixture([endpoint], { ...status, usbSpeed }); - await assert.rejects(automate({ brand: "Zaunkoenig", deviceId: "Zaunkoenig:M3K", action: "automatic-apply", identity: endpoint, dpi: 1600, pollingRateHz }, io), /USB/); - assert.deepEqual(calls, ["close"]); - } -}); - -test("manual adapter keeps its existing multi-path behavior without a live identity requirement", async () => { - const calls = []; - class FakeHID { - constructor(path) { calls.push(["open", path]); } - getDeviceInfo() { throw new Error("manual behavior must not gain automatic identity gating"); } - on() {} - removeAllListeners() {} - close() { calls.push("close"); } - } - const device = new HidDeviceAdapter([endpoint, { ...endpoint, path: "manual-second" }], null, FakeHID); - await device.open(); - assert.equal(device.opened, true); - await device.close(); - assert.deepEqual(calls, [["open", endpoint.path], ["open", "manual-second"], "close", "close"]); -}); diff --git a/native-hid/src/button-listener.mjs b/native-hid/src/button-listener.mjs deleted file mode 100644 index 1cb60fd..0000000 --- a/native-hid/src/button-listener.mjs +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node -// Long-running helper Bridge spawns while a game profile has button -// assignments for a Logitech mouse running from software (Host mode). -// -// stdin: one JSON line to start, e.g. {"brand": "Logitech", "device": "PRO X 3 SUPERSTRIKE"} -// ("device" picks that mouse when several of the brand are connected); add -// "requireHost": true to listen only to a mouse already on Host. Closing stdin (or -// SIGTERM/SIGINT) stops it: the listener is switched off and the mouse goes back -// to the mode it was in, if this helper changed it. -// stdout: one JSON object per line. -// {"event": "ready", "priorMode": "Onboard", "buttons": [1, 2, 3, 4, 5]} -// {"event": "buttons", "mask": 16} — the buttons held right now -// {"event": "error", "message": "..."} -// {"event": "stopped"} -// -// Exit codes: 0 stopped cleanly, 3 no driver for the brand, 1 anything else. -// See apply.mjs for why `window` is aliased before any driver is imported. -globalThis.window ??= globalThis; - -import { createInterface } from "node:readline"; - -import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; -import { candidateDevices } from "./hid-device-adapter.mjs"; - -const EXIT_OK = 0; -const EXIT_FAILED = 1; -const EXIT_NO_DRIVER = 3; -const STEP_TIMEOUT_MS = 3000; - -const emit = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); - -function withTimeout(promise, ms, label) { - let timer; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); -} - -/** Opens a candidate class on one device and reads its status, or rejects and closes it. */ -async function probe(device, candidate) { - const module = await import(candidate.module); - const client = new module[candidate.exportName](device); - try { - await withTimeout(client.open(), STEP_TIMEOUT_MS, `${candidate.exportName}.open()`); - const status = await withTimeout(client.readStatus(), STEP_TIMEOUT_MS, `${candidate.exportName}.readStatus()`); - return { client, status }; - } catch (error) { - await device.close().catch(() => undefined); - throw error; - } -} - -// `wantedName` is the mouse the profile names. With several mice of the brand -// connected only that one is used; with a single one it is used whatever its -// name, so a name spelled differently never leaves it without a listener. -async function findClient(entry, wantedName) { - const attempts = []; - const others = []; - const closeOthers = () => Promise.all(others.map((other) => other.client.close().catch(() => undefined))); - for (const vendorId of entry.vendorIds) { - for (const device of candidateDevices(vendorId)) { - for (const candidate of entry.classes) { - try { - const found = await probe(device, candidate); - // The listener only exists on a client built from a protocol release that has it. - if (typeof found.client.startButtonListener !== "function") { - await found.client.close().catch(() => undefined); - attempts.push(`${candidate.exportName}: this protocol version has no button listener`); - continue; - } - if (!wantedName || String(found.status?.name ?? "").toLowerCase() === wantedName.toLowerCase()) { - await closeOthers(); - return found; - } - others.push(found); - break; - } catch (error) { - attempts.push(`${candidate.exportName} on ${device.productName || "unknown device"}: ${error.message}`); - } - } - } - } - if (others.length === 1) return others[0]; - await closeOthers(); - if (others.length > 1) throw new Error(`none of the ${others.length} connected mice is named "${wantedName}"`); - throw new Error(`no mouse answered. ${attempts.join("; ")}`); -} - -async function main(input) { - const entry = BRAND_DRIVERS[brandKey(typeof input.brand === "string" ? input.brand : "")]; - if (!entry) { - emit({ event: "error", message: `no driver registered for brand "${input.brand}"` }); - process.exit(EXIT_NO_DRIVER); - } - - // Listening from the start: Bridge may close stdin while discovery is still - // probing, and an `end` emitted with no listener is not emitted again. - let stopRequested = false; - let stop = () => { - stopRequested = true; - }; - const onStop = () => stop(); - process.stdin.on("end", onStop); - process.stdin.on("close", onStop); - process.on("SIGTERM", onStop); - process.on("SIGINT", onStop); - - const { client, status } = await findClient(entry, typeof input.device === "string" ? input.device.trim() : ""); - if (stopRequested || process.stdin.readableEnded) { - // Nothing has been changed on the mouse yet. - await client.close().catch(() => undefined); - emit({ event: "stopped" }); - process.exit(EXIT_OK); - } - const priorMode = status?.deviceMode ?? "Unknown"; - // The default profile only listens to a mouse already on Host; it never - // switches one over, so a mouse on an onboard profile is left alone. - if (input.requireHost === true && priorMode !== "Host") { - emit({ event: "error", message: "the mouse is not on the Host profile" }); - await client.close().catch(() => undefined); - process.exit(EXIT_FAILED); - } - let changedMode = false; - let stopping = false; - - // `code` is how the helper ends: a failed start is a failure, not a clean stop. - async function shutdown(code = EXIT_OK) { - if (stopping) return; - stopping = true; - await withTimeout(client.stopButtonListener(), STEP_TIMEOUT_MS, "stopButtonListener").catch(() => undefined); - if (changedMode) { - await withTimeout(client.setOnboardMode(priorMode), STEP_TIMEOUT_MS, "setOnboardMode").catch(() => undefined); - } - await client.close().catch(() => undefined); - emit({ event: "stopped" }); - process.exit(code); - } - - // Event handlers pass their own arguments (a signal name), which are not an exit code. - stop = () => void shutdown(); - - try { - // The events only flow while the mouse runs from software; an unknown mode - // is left alone and the listener is tried as it is. - if (priorMode === "Onboard") { - // Marked first: the mouse may switch and then fail to confirm it, and it still has to go back. - changedMode = true; - await withTimeout(client.setOnboardMode("Host"), STEP_TIMEOUT_MS, "setOnboardMode"); - } - client.onButtonChange((mask) => emit({ event: "buttons", mask })); - const buttons = await withTimeout(client.startButtonListener(), STEP_TIMEOUT_MS, "startButtonListener"); - // A stop that came in while switching modes: put the mouse back now. - if (stopRequested || process.stdin.readableEnded) { - await shutdown(); - return; - } - emit({ event: "ready", priorMode, buttons }); - } catch (error) { - emit({ event: "error", message: error.message }); - await shutdown(EXIT_FAILED); - } -} - -const lines = createInterface({ input: process.stdin }); -lines.once("line", (line) => { - let input; - try { - input = JSON.parse(line); - } catch { - emit({ event: "error", message: "the first line on stdin must be JSON" }); - process.exit(EXIT_FAILED); - } - main(input).catch((error) => { - emit({ event: "error", message: error.message }); - process.exit(EXIT_FAILED); - }); -}); diff --git a/native-hid/src/hid-device-adapter.mjs b/native-hid/src/hid-device-adapter.mjs index 58af888..51fef83 100644 --- a/native-hid/src/hid-device-adapter.mjs +++ b/native-hid/src/hid-device-adapter.mjs @@ -32,7 +32,7 @@ const REPLAY_ID_MAX = 255; * splits off the same underlying USB/BT interface (see module docs above). */ export class HidDeviceAdapter { - constructor(infos, identity = null, Handle = HID) { + constructor(infos) { const [primary] = infos; this.vendorId = primary.vendorId; this.productId = primary.productId; @@ -45,8 +45,6 @@ export class HidDeviceAdapter { this.opened = false; this._infos = infos; - this._identity = identity; - this._Handle = Handle; this._devices = []; this._listeners = new Set(); this._onData = this._onData.bind(this); @@ -55,32 +53,11 @@ export class HidDeviceAdapter { async open() { if (this.opened) return; - if (this._identity) { - let device; - try { - if (this._infos.length !== 1) throw new Error("native live identity requires one endpoint"); - device = new this._Handle(this._infos[0].path); - const live = device.getDeviceInfo(); - const fields = ["vendorId", "productId", "path", "serialNumber", "interface"]; - if (!live || fields.some((field) => live[field] === undefined || live[field] !== this._identity[field])) throw new Error("native live handle identity is missing or changed"); - device.on("data", this._onData); - device.on("error", this._onError); - this._devices = [device]; - this.opened = true; - return; - } catch (error) { - if (device) { - try { device.removeAllListeners(); } catch {} - try { device.close(); } catch {} - } - throw new Error(`native live handle identity verification failed: ${error.message}`); - } - } const openedDevices = []; let lastError = null; for (const info of this._infos) { try { - const device = new this._Handle(info.path); + const device = new HID(info.path); device.on("data", this._onData); device.on("error", this._onError); openedDevices.push(device); @@ -268,14 +245,3 @@ export function candidateDevices(vendorId) { return new HidDeviceAdapter(unique); }); } - -export async function probeVerifiedDevice(info, identity, probe, Handle = HID) { - const device = new HidDeviceAdapter([info], identity, Handle); - try { - await device.open(); - return await probe(device); - } catch (error) { - await device.close().catch(() => undefined); - throw error; - } -} diff --git a/src/api.rs b/src/api.rs index cf70ed8..e92039f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,23 +1,17 @@ -use std::{ - collections::HashSet, - sync::{Arc, OnceLock}, - time::Duration, -}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use axum::{ Json, Router, - body::{Body, Bytes}, - extract::{Path, Query, Request, State, rejection::JsonRejection, ws::WebSocketUpgrade}, + body::Body, + extract::{Path, State, ws::WebSocketUpgrade}, http::{HeaderMap, HeaderValue, Method, Response, StatusCode, header}, - middleware::{self, Next}, - response::IntoResponse, routing::{delete, get, put}, }; use serde::{Deserialize, Serialize}; use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer}; use crate::{ - config::{ApplicationProfile, GameConfig, LocalApplication}, + config::{ApplicationProfile, GameConfig}, drivers, platform, service::{BatteryReading, BridgeService}, }; @@ -39,11 +33,6 @@ struct ProfilesPayload { profiles: Vec, } -#[derive(Debug, Deserialize)] -struct LocalApplicationsPayload { - applications: Vec, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct NativeSettingsPayload { @@ -57,8 +46,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .iter() .filter_map(|origin| origin.parse().ok()) .collect(); - let request_origins = Arc::new(origins.iter().cloned().collect::>()); - let hid_origins = Arc::clone(&request_origins); + let hid_origins = Arc::new(origins.iter().cloned().collect::>()); let hid_route = Router::new().route( "/v1/hid", get(move |websocket, headers| hid_upgrade(websocket, headers, Arc::clone(&hid_origins))), @@ -72,27 +60,11 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/status", get(status)) .route("/v1/handshake", put(handshake)) .route("/v1/games", get(games)) - .route( - "/v1/custom-games", - get(custom_games).put(replace_custom_games), - ) - .route( - "/v1/custom-games/image", - get(custom_game_image).put(set_custom_game_image), - ) - .route("/v1/custom-games/image-from-url", get(image_from_link)) - .route( - "/v1/local-applications", - get(local_applications).put(replace_local_applications), - ) .route("/v1/applications", get(applications)) .route("/v1/applications/{icon_id}/icon", get(application_icon)) .route("/v1/running-applications", get(running_applications)) .route("/v1/profiles", get(profiles).put(replace_profiles)) - .route( - "/v1/default-profile", - get(default_profile).put(set_default_profile), - ) + .route("/v1/default-profile", put(set_default_profile)) .route("/v1/native/settings", put(apply_native_settings)) .route("/v1/battery", put(record_battery)) .route("/v1/battery/{device_id}", delete(forget_battery)) @@ -104,70 +76,10 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { HeaderValue::from_static("true"), )) .layer(cors) - .layer(middleware::from_fn(move |request: Request, next: Next| { - request_gate(request, next, Arc::clone(&request_origins)) - })) .layer(TraceLayer::new_for_http()) .with_state(service) } -fn single_header(headers: &HeaderMap, name: header::HeaderName) -> Option<&str> { - let mut values = headers.get_all(name).iter(); - let value = values.next()?.to_str().ok()?; - if values.next().is_some() { - return None; - } - Some(value) -} - -fn loopback_authority(host: &str) -> bool { - let Ok(authority) = host.parse::() else { - return false; - }; - let name = authority.host(); - if !matches!(name, "localhost" | "127.0.0.1" | "[::1]") { - return false; - } - if host == name { - return true; - } - let Some(port) = host - .strip_prefix(name) - .and_then(|value| value.strip_prefix(':')) - else { - return false; - }; - !port.is_empty() - && port.bytes().all(|value| value.is_ascii_digit()) - && port.parse::().is_ok_and(|value| value != 0) -} - -async fn request_gate( - request: Request, - next: Next, - allowed: Arc>, -) -> axum::response::Response { - let headers = request.headers(); - let Some(host) = single_header(headers, header::HOST).filter(|host| loopback_authority(host)) - else { - return StatusCode::FORBIDDEN.into_response(); - }; - if request - .uri() - .authority() - .is_some_and(|authority| authority.as_str() != host) - { - return StatusCode::FORBIDDEN.into_response(); - } - if headers.contains_key(header::ORIGIN) - && !single_header(headers, header::ORIGIN) - .is_some_and(|origin| origin != "null" && allowed.contains(origin)) - { - return StatusCode::FORBIDDEN.into_response(); - } - next.run(request).await -} - async fn hid_upgrade( websocket: WebSocketUpgrade, headers: HeaderMap, @@ -184,10 +96,7 @@ async fn hid_upgrade( tracing::warn!(%origin, "Rejected native HID WebSocket from an unapproved origin"); return Err(StatusCode::FORBIDDEN); } - Ok(websocket - .max_message_size(64 * 1024) - .max_frame_size(64 * 1024) - .on_upgrade(crate::hid::serve)) + Ok(websocket.on_upgrade(crate::hid::serve)) } async fn status(State(service): State) -> Json { @@ -235,121 +144,29 @@ async fn profiles(State(service): State) -> Json) -> Json> { +async fn games(State(service): State) -> Json> { Json(service.games().await) } -async fn custom_games(State(service): State) -> Json> { - Json(service.custom_games().await) -} - -async fn replace_custom_games( - State(service): State, - Json(games): Json>, -) -> Result>, (StatusCode, String)> { - service - .set_custom_games(games) - .await - .map(Json) - .map_err(internal_error) -} - -#[derive(Debug, Deserialize)] -struct GameImageQuery { - name: String, -} - -async fn custom_game_image( - State(service): State, - Query(query): Query, -) -> Result, StatusCode> { - let (image, kind) = service - .custom_game_image(&query.name) - .await - .ok_or(StatusCode::NOT_FOUND)?; - Response::builder() - .header(header::CONTENT_TYPE, kind) - // Revalidated every time: an image can be replaced under the same name. - .header(header::CACHE_CONTROL, "private, no-cache") - .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") - .body(Body::from(image)) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} - -#[derive(Debug, Deserialize)] -struct ImageLinkQuery { - url: String, -} - -async fn image_from_link( - Query(query): Query, -) -> Result, (StatusCode, String)> { - let (image, kind) = crate::remote_image::fetch(&query.url) - .await - .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; - Response::builder() - .header(header::CONTENT_TYPE, kind) - .header(header::CACHE_CONTROL, "no-store") - .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") - .body(Body::from(image)) - .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string())) -} - -async fn set_custom_game_image( - State(service): State, - Query(query): Query, - body: Bytes, -) -> Result, (StatusCode, String)> { - service - .set_custom_game_image(&query.name, &body) - .await - .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; - Ok(Json(ApiResult { ok: true })) -} - -async fn local_applications(State(service): State) -> Json> { - Json(service.local_applications().await) -} - -async fn replace_local_applications( - State(service): State, - payload: Result, JsonRejection>, -) -> Result, (StatusCode, String)> { - let Json(payload) = payload.map_err(invalid_json)?; - let applications = service - .replace_local_applications(payload.applications) - .await - .map_err(profile_error)?; - Ok(Json( - serde_json::json!({"ok":true,"applications":applications}), - )) -} - async fn replace_profiles( State(service): State, - payload: Result, JsonRejection>, + Json(payload): Json, ) -> Result, (StatusCode, String)> { - let Json(payload) = payload.map_err(invalid_json)?; service .replace_profiles(payload.profiles) .await - .map_err(profile_error)?; + .map_err(internal_error)?; Ok(Json(ApiResult { ok: true })) } -async fn default_profile(State(service): State) -> Json> { - Json(service.default_profile().await) -} - async fn set_default_profile( State(service): State, - payload: Result, JsonRejection>, + Json(profile): Json, ) -> Result, (StatusCode, String)> { - let Json(profile) = payload.map_err(invalid_json)?; service .set_default_profile(profile) .await - .map_err(profile_error)?; + .map_err(internal_error)?; Ok(Json(ApiResult { ok: true })) } @@ -379,8 +196,6 @@ async fn set_autostart( Ok(Json(ApiResult { ok: true })) } -static NATIVE_SETTINGS_ADMISSION: OnceLock> = OnceLock::new(); - async fn apply_native_settings( Json(payload): Json, ) -> Result, (StatusCode, String)> { @@ -391,44 +206,17 @@ async fn apply_native_settings( )); } - let permit = NATIVE_SETTINGS_ADMISSION - .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(1))) - .clone() - .try_acquire_owned() - .map_err(|_| { - ( - StatusCode::TOO_MANY_REQUESTS, - "native settings are busy".into(), - ) - })?; - if crate::hid::client_session_active() { - return Err(( - StatusCode::TOO_MANY_REQUESTS, - "native HID is owned by an active WebSocket session".into(), - )); - } let NativeSettingsPayload { brand, dpi, polling_rate_hz, } = payload; let error_brand = brand.clone(); - let applied = tokio::task::spawn_blocking(move || { - let _permit = permit; - drivers::apply_settings(&brand, dpi, polling_rate_hz) - }) - .await - .map_err(|error| internal_error(anyhow::anyhow!(error)))? - .map_err(|error| { - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == std::io::ErrorKind::WouldBlock) - { - (StatusCode::TOO_MANY_REQUESTS, error.to_string()) - } else { - internal_error(error) - } - })?; + let applied = + tokio::task::spawn_blocking(move || drivers::apply_settings(&brand, dpi, polling_rate_hz)) + .await + .map_err(|error| internal_error(anyhow::anyhow!(error)))? + .map_err(internal_error)?; if !applied { return Err(( @@ -439,176 +227,7 @@ async fn apply_native_settings( Ok(Json(ApiResult { ok: true })) } -fn invalid_json(error: JsonRejection) -> (StatusCode, String) { - (StatusCode::BAD_REQUEST, error.body_text()) -} - -fn profile_error(error: anyhow::Error) -> (StatusCode, String) { - if error - .downcast_ref::() - .is_some() - { - (StatusCode::BAD_REQUEST, error.to_string()) - } else { - internal_error(error) - } -} - fn internal_error(error: anyhow::Error) -> (StatusCode, String) { tracing::error!(%error, "Bridge request failed"); (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::to_bytes, - http::{Method, Request}, - }; - use tower::ServiceExt; - - use super::*; - use crate::{config::BridgeConfig, remote_image::PNG_SIGNATURE}; - - fn app() -> (Router, std::path::PathBuf) { - let dir = std::env::temp_dir().join(format!( - "bridge-api-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).expect("temp dir"); - let service = BridgeService::new(BridgeConfig::default(), dir.join("config.json")); - (router(service, &[]), dir) - } - - async fn call( - app: &Router, - method: Method, - uri: &str, - content_type: &str, - body: Vec, - ) -> (StatusCode, Vec, Option) { - let request = Request::builder() - .method(method) - .uri(uri) - // The request gate only serves loopback hosts. - .header(header::HOST, "127.0.0.1:43821") - .header(header::CONTENT_TYPE, content_type) - .body(Body::from(body)) - .expect("request"); - let response = app.clone().oneshot(request).await.expect("response"); - let status = response.status(); - let kind = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let bytes = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body") - .to_vec(); - (status, bytes, kind) - } - - #[tokio::test] - async fn a_custom_game_keeps_the_image_saved_for_it() { - let (app, dir) = app(); - let games = br#"[{"name":"My Game","executables":["MyGame.exe"]}]"#.to_vec(); - let (status, saved, _) = call( - &app, - Method::PUT, - "/v1/custom-games", - "application/json", - games, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert!( - String::from_utf8_lossy(&saved).contains("mygame.exe"), - "the list comes back normalized" - ); - - let mut png = PNG_SIGNATURE.to_vec(); - png.extend_from_slice(&[1, 2, 3]); - let (status, ..) = call( - &app, - Method::PUT, - "/v1/custom-games/image?name=My%20Game", - "image/png", - png.clone(), - ) - .await; - assert_eq!(status, StatusCode::OK); - let (status, bytes, kind) = call( - &app, - Method::GET, - "/v1/custom-games/image?name=my%20game", - "", - vec![], - ) - .await; - assert_eq!( - (status, bytes, kind.as_deref()), - (StatusCode::OK, png, Some("image/png")) - ); - std::fs::remove_dir_all(dir).expect("cleanup"); - } - - #[tokio::test] - async fn an_image_is_refused_for_an_unknown_game_or_a_file_that_is_not_an_image() { - let (app, dir) = app(); - let (status, ..) = call( - &app, - Method::PUT, - "/v1/custom-games/image?name=Nope", - "image/png", - PNG_SIGNATURE.to_vec(), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "the game must exist"); - let games = br#"[{"name":"Game","executables":["game.exe"]}]"#.to_vec(); - call( - &app, - Method::PUT, - "/v1/custom-games", - "application/json", - games, - ) - .await; - let (status, ..) = call( - &app, - Method::PUT, - "/v1/custom-games/image?name=Game", - "image/png", - b"".to_vec(), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "only real images are kept"); - let (status, ..) = call( - &app, - Method::GET, - "/v1/custom-games/image?name=Game", - "", - vec![], - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); - std::fs::remove_dir_all(dir).expect("cleanup"); - } - - #[tokio::test] - async fn a_link_into_the_local_network_is_refused() { - let (app, dir) = app(); - let (status, message, _) = call( - &app, - Method::GET, - "/v1/custom-games/image-from-url?url=http%3A%2F%2F192.168.1.1%2Flogo.png", - "", - vec![], - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); - assert!(String::from_utf8_lossy(&message).contains("cannot be reached")); - std::fs::remove_dir_all(dir).expect("cleanup"); - } -} diff --git a/src/applications.rs b/src/applications.rs index 848697b..dd2826a 100644 --- a/src/applications.rs +++ b/src/applications.rs @@ -21,7 +21,7 @@ pub struct ApplicationInfo { )] fn icon_id(path: &str) -> String { let mut hasher = DefaultHasher::new(); - crate::games::path_key(path).hash(&mut hasher); + path.to_ascii_lowercase().hash(&mut hasher); format!("{:016x}", hasher.finish()) } @@ -273,7 +273,7 @@ mod imp { if name.is_empty() || executable.is_empty() { continue; } - let key = path.clone(); + let key = path.to_lowercase(); applications.entry(key).or_insert(ApplicationInfo { name, executable, @@ -335,7 +335,7 @@ mod imp { continue; } let foreground = foreground_pid.is_some_and(|pid| Pid::from_u32(pid) == process.pid()); - let key = path.clone(); + let key = path.to_lowercase(); applications .entry(key) .and_modify(|application: &mut ApplicationInfo| { @@ -428,14 +428,6 @@ pub use imp::{application_icon, visible_applications}; mod tests { use super::icon_id; - #[test] - fn posix_path_identity_preserves_case() { - assert_ne!( - icon_id("/Applications/Editor"), - icon_id("/Applications/editor") - ); - } - #[test] fn icon_ids_are_case_insensitive_like_application_paths() { assert_eq!( diff --git a/src/buttons.rs b/src/buttons.rs deleted file mode 100644 index d809a17..0000000 --- a/src/buttons.rs +++ /dev/null @@ -1,1007 +0,0 @@ -//! Button assignments for a Logitech mouse running from software (Host mode). -//! -//! A mouse on the Host profile stops sending its side buttons as ordinary -//! clicks; it reports them as events instead, and something on the PC has to -//! act on them (G HUB does). While a game's profile has assignments, Bridge -//! runs `native-hid/src/button-listener.mjs`, which puts the mouse on Host, turns -//! those events on and prints them, and then presses the assigned key, or the -//! side button's normal Back/Forward click when nothing is assigned. When the -//! game ends the helper stops the events and puts the mouse back in the mode -//! it was in. -//! -//! The assignments live in a game profile's `snapshot.hostButtons`: -//! `{"8": {"key": 58, "modifiers": 0}, "16": {...}}`, keyed by the button's bit -//! in the mouse's report (8 the back side button, 16 the front one), each a HID -//! keyboard usage and a modifier mask (1 ctrl, 2 shift, 4 alt, 8 win). - -use std::{ - collections::BTreeMap, - io::{BufRead, BufReader, Write}, - process::{Child, ChildStdin}, - sync::{Arc, Mutex, PoisonError, TryLockError, mpsc}, - thread::{self, JoinHandle}, - time::{Duration, Instant}, -}; - -use anyhow::{Context, Result}; -use serde_json::Value; - -use crate::config::ApplicationProfile; - -/// The back side button's bit in the mouse's button report. -pub const BACK: u8 = 0x08; -/// The front side button's bit. -pub const FRONT: u8 = 0x10; -/// The only buttons a Host mode mouse stops sending as normal clicks. -const SIDE_BUTTONS: [u8; 2] = [BACK, FRONT]; - -/// How long the helper gets to stop the events and restore the mouse's mode: it -/// allows itself three seconds for each of the two, then closes the mouse. -const STOP_TIMEOUT: Duration = Duration::from_secs(8); -/// How long a starting helper may hold the mouse before Bridge stops waiting. -const START_TIMEOUT: Duration = Duration::from_secs(12); -/// How long to leave a mouse alone after the helper could not start or died. -const RETRY_DELAY: Duration = Duration::from_secs(15); - -/// A keyboard key and its modifiers, as a profile stores them (HID usage). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct KeyAction { - pub usage: u8, - pub modifiers: u8, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Action { - Key(KeyAction), - /// A media key, by its HID consumer usage (volume, play/pause, ...). - Media(u16), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Step { - Down(Action), - Up(Action), - /// Press the side button's normal click: `BACK` is Back, `FRONT` is Forward. - SideDown(u8), - SideUp(u8), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Assignments { - /// The brand the profile names ("Logitech"), which the helper picks its driver by. - pub brand: String, - /// The mouse's name as the profile stores it ("PRO X 3 SUPERSTRIKE"), so the - /// helper listens to that mouse when several of the brand are connected. - pub device: String, - pub actions: BTreeMap, - /// Set for the default profile: it only listens to a mouse that is already - /// on the Host profile, and never switches one to it. - pub require_host: bool, -} - -impl Assignments { - pub fn from_profile(profile: &ApplicationProfile, is_default: bool) -> Option { - let (brand, device) = profile - .device - .id - .split_once(':') - .unwrap_or((profile.device.id.as_str(), "")); - if !brand.eq_ignore_ascii_case("logitech") { - return None; - } - let buttons = profile - .settings - .snapshot - .as_ref()? - .get("hostButtons")? - .as_object()?; - let mut actions = BTreeMap::new(); - for (bit, action) in buttons { - let bit: u8 = bit.parse().ok().filter(|bit| SIDE_BUTTONS.contains(bit))?; - let action = if let Some(usage) = action.get("consumer") { - let usage = u16::try_from(usage.as_u64()?).ok()?; - media_virtual_key(usage)?; - Action::Media(usage) - } else { - let usage = u8::try_from(action.get("key")?.as_u64()?).ok()?; - // A key this system cannot press would also swallow the button's normal click. - can_press(usage).then_some(())?; - let modifiers = - u8::try_from(action.get("modifiers").and_then(Value::as_u64).unwrap_or(0)) - .ok() - .filter(|modifiers| *modifiers <= 0x0f)?; - Action::Key(KeyAction { usage, modifiers }) - }; - actions.insert(bit, action); - } - (!actions.is_empty()).then(|| Self { - brand: brand.to_owned(), - device: device.to_owned(), - actions, - require_host: is_default, - }) - } -} - -/// What each held side button does, as it was when it went down: `None` is its -/// normal click. A press uses the assignments of that moment and its release the -/// same action, even if the profile changed while the button was held. -pub type HeldButtons = BTreeMap>; - -pub fn transitions( - held: &mut HeldButtons, - assignments: &Assignments, - previous: u8, - now: u8, -) -> Vec { - let mut steps = Vec::new(); - for bit in SIDE_BUTTONS { - let (was, is) = (previous & bit != 0, now & bit != 0); - if was == is { - continue; - } - if is { - let action = assignments.actions.get(&bit).copied(); - held.insert(bit, action); - steps.push(action.map_or(Step::SideDown(bit), Step::Down)); - } else { - let action = held - .remove(&bit) - .unwrap_or_else(|| assignments.actions.get(&bit).copied()); - steps.push(action.map_or(Step::SideUp(bit), Step::Up)); - } - } - steps -} - -/// The steps for a change of the held buttons, all under one set of assignments. -pub fn steps(assignments: &Assignments, previous: u8, now: u8) -> Vec { - let mut held = HeldButtons::new(); - for bit in SIDE_BUTTONS { - if previous & bit != 0 { - held.insert(bit, assignments.actions.get(&bit).copied()); - } - } - transitions(&mut held, assignments, previous, now) -} - -/// How many held assignments share each key code. A shared modifier goes down -/// once and comes up with the last assignment holding it, not the first. -#[derive(Default)] -pub struct HeldKeys(BTreeMap); - -impl HeldKeys { - pub const fn new() -> Self { - Self(BTreeMap::new()) - } - - /// True when the key was not down yet, so it has to be pressed. - pub fn press(&mut self, key: u16) -> bool { - let count = self.0.entry(key).or_default(); - *count += 1; - *count == 1 - } - - /// True when no other assignment still holds the key, so it has to be released. - pub fn release(&mut self, key: u16) -> bool { - match self.0.get_mut(&key) { - Some(count) if *count > 1 => { - *count -= 1; - false - } - Some(_) => { - self.0.remove(&key); - true - } - None => true, - } - } -} - -/// Whether Bridge can press the key on this system. A Mac has no key for Print -/// Screen, Scroll Lock, Pause or F21 to F24, which the other systems do. -fn can_press(usage: u8) -> bool { - if cfg!(target_os = "macos") { - keymap::mac_key(usage).is_some() - } else { - virtual_key(usage).is_some() - } -} - -/// The Windows virtual-key code for a HID keyboard usage, for the keys a -/// profile can hold. -pub fn virtual_key(usage: u8) -> Option { - Some(match usage { - 0x04..=0x1d => 0x41 + u16::from(usage - 0x04), // A-Z - 0x1e..=0x26 => 0x31 + u16::from(usage - 0x1e), // 1-9 - 0x27 => 0x30, // 0 - 0x28 => 0x0d, // Enter - 0x29 => 0x1b, // Escape - 0x2a => 0x08, // Backspace - 0x2b => 0x09, // Tab - 0x2c => 0x20, // Space - 0x2d => 0xbd, // - - 0x2e => 0xbb, // = - 0x2f => 0xdb, // [ - 0x30 => 0xdd, // ] - 0x31 => 0xdc, // \ - 0x33 => 0xba, // ; - 0x34 => 0xde, // ' - 0x35 => 0xc0, // ` - 0x36 => 0xbc, // , - 0x37 => 0xbe, // . - 0x38 => 0xbf, // / - 0x3a..=0x45 => 0x70 + u16::from(usage - 0x3a), // F1-F12 - 0x46 => 0x2c, // Print Screen - 0x47 => 0x91, // Scroll Lock - 0x48 => 0x13, // Pause - 0x49 => 0x2d, // Insert - 0x4a => 0x24, // Home - 0x4b => 0x21, // Page Up - 0x4c => 0x2e, // Delete - 0x4d => 0x23, // End - 0x4e => 0x22, // Page Down - 0x4f => 0x27, // Right - 0x50 => 0x25, // Left - 0x51 => 0x28, // Down - 0x52 => 0x26, // Up - 0x68..=0x73 => 0x7c + u16::from(usage - 0x68), // F13-F24 - _ => return None, - }) -} - -/// The Windows virtual-key code for a HID consumer usage (media key). -pub fn media_virtual_key(usage: u16) -> Option { - Some(match usage { - 0xe2 => 0xad, // Mute - 0xe9 => 0xaf, // Volume up - 0xea => 0xae, // Volume down - 0xcd => 0xb3, // Play / pause - 0xb5 => 0xb0, // Next track - 0xb6 => 0xb1, // Previous track - 0xb7 => 0xb2, // Stop - _ => return None, - }) -} - -/// The modifier keys a mask names, in the order they are pressed. -pub fn modifier_keys(modifiers: u8) -> Vec { - [(0x01, 0x11), (0x02, 0x10), (0x04, 0x12), (0x08, 0x5b)] // ctrl, shift, alt, win - .into_iter() - .filter(|(bit, _)| modifiers & bit != 0) - .map(|(_, vk)| vk) - .collect() -} - -#[derive(Default)] -pub struct ButtonListener { - running: Mutex>, - /// Not before this moment is a helper that failed started again. - retry_after: Mutex>, -} - -struct Running { - /// Shared with the listener thread, so new assignments reach it without a restart. - assignments: Arc>, - stop: mpsc::Sender<()>, - thread: JoinHandle<()>, -} - -impl Running { - /// Asks the helper to stop and waits for it to restore the mouse. - fn stop(self) { - let _ = self.stop.send(()); - let _ = self.thread.join(); - } - - fn matches(&self, desired: Option<&Assignments>) -> bool { - let mine = self - .assignments - .lock() - .unwrap_or_else(PoisonError::into_inner); - Some(&*mine) == desired && !self.thread.is_finished() - } - - fn retarget(&self, wanted: &Assignments) -> bool { - if self.thread.is_finished() { - return false; - } - let mut mine = self - .assignments - .lock() - .unwrap_or_else(PoisonError::into_inner); - if mine.brand != wanted.brand - || mine.device != wanted.device - || mine.require_host != wanted.require_host - { - return false; - } - if *mine != *wanted { - *mine = wanted.clone(); - tracing::info!("Updated the button assignments"); - } - true - } -} - -impl ButtonListener { - /// Makes the helper match `desired`: started for new assignments, stopped - /// when there are none. `may_start` is false while a control panel holds - /// the mouse, so a new helper does not open it underneath. Stopping and - /// starting wait on the helper, so they run off the caller's thread. - pub fn update(self: &Arc, desired: Option, may_start: bool) { - // New keys for a listener that is already running need no helper at - // all, so they are applied at once even while a panel holds the mouse. - // The caller is the async monitor loop, and a helper starting or stopping - // holds `running` for seconds, so a busy lock means "ask again next tick". - let Some(running) = self.try_running() else { - return; - }; - if let Some(wanted) = desired.as_ref() - && running - .as_ref() - .is_some_and(|current| current.retarget(wanted)) - { - return; - } - let in_step = running.as_ref().map_or(desired.is_none(), |current| { - current.matches(desired.as_ref()) - }); - let holds_old = running.is_some(); - drop(running); - if in_step { - return; - } - // A panel holding the mouse keeps a new helper from starting. One for a profile - // that is no longer the active one must not keep pressing keys meanwhile, so it - // is stopped, and the new one starts once the panel lets go. - let desired = if desired.is_some() && !may_start { - if !holds_old { - return; - } - None - } else { - desired - }; - let waiting = *self - .retry_after - .lock() - .unwrap_or_else(PoisonError::into_inner); - if desired.is_some() && waiting.is_some_and(|until| Instant::now() < until) { - return; - } - let listener = Arc::clone(self); - thread::spawn(move || listener.apply(desired)); - } - - /// The running helper, or `None` while another thread is starting or stopping one. - fn try_running(&self) -> Option>> { - match self.running.try_lock() { - Ok(guard) => Some(guard), - Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), - Err(TryLockError::WouldBlock) => None, - } - } - - fn apply(&self, desired: Option) { - let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); - if running.as_ref().map_or(desired.is_none(), |current| { - current.matches(desired.as_ref()) - }) { - return; - } - let died = running - .as_ref() - .is_some_and(|current| current.thread.is_finished()); - if let Some(old) = running.take() { - old.stop(); - } - // The lock is let go before the helper starts: starting can take seconds, and the - // monitor loop reads this deadline to decide whether to ask again. - *self - .retry_after - .lock() - .unwrap_or_else(PoisonError::into_inner) = died.then(|| Instant::now() + RETRY_DELAY); - // A listener that just died waits out the cooldown before the mouse is probed again. - if died && desired.is_some() { - return; - } - if let Some(assignments) = desired { - match start(assignments) { - Ok(started) => *running = Some(started), - Err(error) => { - tracing::warn!(%error, "Could not start the button listener"); - *self - .retry_after - .lock() - .unwrap_or_else(PoisonError::into_inner) = - Some(Instant::now() + RETRY_DELAY); - } - } - } - } -} - -impl Drop for ButtonListener { - fn drop(&mut self) { - let running = self - .running - .get_mut() - .unwrap_or_else(PoisonError::into_inner); - if let Some(old) = running.take() { - old.stop(); - } - } -} - -enum Event { - Buttons(u8), - Ready, - Failed(String), - Closed, -} - -fn start(assignments: Assignments) -> Result { - // The helper talks to the mouse while it starts, and so does a profile - // apply, so they take turns: this is held until the helper is listening. - inject::prepare(); - let access = crate::drivers::device_access(); - let mut child = crate::drivers::spawn_button_listener(&assignments.brand)?; - let stdin = child - .stdin - .take() - .context("the button helper's stdin was unavailable")?; - let stdout = child - .stdout - .take() - .context("the button helper's stdout was unavailable")?; - let (stop, stopped) = mpsc::channel(); - let (ready, started) = mpsc::channel(); - let shared = Arc::new(Mutex::new(assignments)); - let for_thread = Arc::clone(&shared); - let thread = thread::Builder::new() - .name("openmouse-bridge-buttons".into()) - .spawn(move || run(child, stdin, stdout, &for_thread, &stopped, &ready)) - .context("could not start the button listener thread")?; - let _ = started.recv_timeout(START_TIMEOUT); - drop(access); - Ok(Running { - assignments: shared, - stop, - thread, - }) -} - -fn run( - mut child: Child, - mut stdin: ChildStdin, - stdout: std::process::ChildStdout, - shared: &Mutex, - stopped: &mpsc::Receiver<()>, - ready: &mpsc::Sender<()>, -) { - let current = |shared: &Mutex| { - shared - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() - }; - let first = current(shared); - let request = format!( - "{{\"brand\":{},\"device\":{},\"requireHost\":{}}}\n", - Value::from(first.brand.as_str()), - Value::from(first.device.as_str()), - first.require_host - ); - if stdin - .write_all(request.as_bytes()) - .and_then(|()| stdin.flush()) - .is_err() - { - tracing::warn!("Could not start the button helper"); - } - - let (events, incoming) = mpsc::channel(); - thread::spawn(move || { - for line in BufReader::new(stdout).lines().map_while(Result::ok) { - let message: Value = serde_json::from_str(&line).unwrap_or(Value::Null); - let event = match message.get("event").and_then(Value::as_str) { - Some("buttons") => message - .get("mask") - .and_then(Value::as_u64) - .and_then(|mask| u8::try_from(mask).ok()) - .map(Event::Buttons), - Some("ready") => Some(Event::Ready), - Some("error") => Some(Event::Failed( - message - .get("message") - .and_then(Value::as_str) - .unwrap_or("unknown error") - .to_owned(), - )), - _ => None, - }; - if let Some(event) = event - && events.send(event).is_err() - { - return; - } - } - let _ = events.send(Event::Closed); - }); - - let mut held = 0u8; - // What each held button does, from the moment it went down, so a key that was - // pressed is the key that is released even if the profile changed while it was held. - let mut in_use = HeldButtons::new(); - loop { - if !matches!(stopped.try_recv(), Err(mpsc::TryRecvError::Empty)) { - break; - } - match incoming.recv_timeout(Duration::from_millis(100)) { - Ok(Event::Buttons(mask)) => { - for step in transitions(&mut in_use, ¤t(shared), held, mask) { - tracing::info!(mask, ?step, "Acting on a mouse button"); - execute(step); - } - held = mask; - } - Ok(Event::Ready) => { - tracing::info!("The mouse's buttons are being listened to"); - let _ = ready.send(()); - } - Ok(Event::Failed(message)) => { - tracing::warn!(%message, "The button helper reported a problem"); - let _ = ready.send(()); - } - Ok(Event::Closed) | Err(mpsc::RecvTimeoutError::Disconnected) => { - let _ = ready.send(()); - break; - } - Err(mpsc::RecvTimeoutError::Timeout) => {} - } - } - - // Let go of anything still held, then close stdin: the helper stops the - // events and puts the mouse back in the mode it was in. - for step in transitions(&mut in_use, ¤t(shared), held, 0) { - execute(step); - } - drop(stdin); - let deadline = Instant::now() + STOP_TIMEOUT; - while Instant::now() < deadline { - if matches!(child.try_wait(), Ok(Some(_))) { - return; - } - thread::sleep(Duration::from_millis(50)); - } - tracing::warn!("The button helper did not stop in time; ending it"); - // Collected as well: a killed child nobody waits for stays a zombie on Unix. - if child.kill().is_ok() { - let _ = child.wait(); - } -} - -mod keymap; - -#[cfg(target_os = "linux")] -#[path = "buttons/linux.rs"] -mod inject; - -#[cfg(target_os = "macos")] -#[path = "buttons/macos.rs"] -mod inject; - -/// No other system presses keys yet. -#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] -mod inject { - use super::Step; - - pub fn prepare() {} - - pub fn execute(step: Step) { - tracing::debug!( - ?step, - "Button actions are not carried out on this system yet" - ); - } -} - -fn execute(step: Step) { - inject::execute(step); -} - -#[cfg(target_os = "windows")] -mod inject { - use std::{ - mem::size_of, - sync::{Mutex, PoisonError}, - }; - - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, - KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_XDOWN, - MOUSEEVENTF_XUP, MOUSEINPUT, MapVirtualKeyW, SendInput, - }; - use windows_sys::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; - - use super::{ - Action, BACK, HeldKeys, KeyAction, Step, media_virtual_key, modifier_keys, virtual_key, - }; - - static HELD: Mutex = Mutex::new(HeldKeys::new()); - - /// `SendInput` needs no setup. - pub fn prepare() {} - - pub fn execute(step: Step) { - match step { - Step::Down(Action::Key(action)) => keys(action, true), - Step::Up(Action::Key(action)) => keys(action, false), - Step::Down(Action::Media(usage)) => media(usage, true), - Step::Up(Action::Media(usage)) => media(usage, false), - Step::SideDown(bit) => side(bit, true), - Step::SideUp(bit) => side(bit, false), - } - } - - /// Media keys have no scan code of their own, so they go by virtual key. - fn media(usage: u16, down: bool) { - let Some(vk) = media_virtual_key(usage) else { - return; - }; - // Two buttons on the same media key: it comes up with the last of them. - let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); - if !(if down { - held.press(vk) - } else { - held.release(vk) - }) { - return; - } - let flags = KEYEVENTF_EXTENDEDKEY | if down { 0 } else { KEYEVENTF_KEYUP }; - send(&[INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: vk, - wScan: 0, - dwFlags: flags, - time: 0, - dwExtraInfo: 0, - }, - }, - }]); - } - - /// Modifiers go down before the key and come up after it. - fn keys(action: KeyAction, down: bool) { - let Some(key) = virtual_key(action.usage) else { - tracing::warn!(usage = action.usage, "That key cannot be pressed by Bridge"); - return; - }; - let mut order = modifier_keys(action.modifiers); - order.push(key); - if !down { - order.reverse(); - } - let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); - for vk in order { - // A modifier two held buttons share stays down until both are let go. - if if down { - held.press(vk) - } else { - held.release(vk) - } { - send(&[key_input(vk, down)]); - } - } - } - - fn key_input(vk: u16, down: bool) -> INPUT { - // The scan code lets games that read it see the key; the high byte - // 0xE0 marks an extended key (arrows, Insert, Home, ...). - let scan = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC_EX) }; - let mut flags = if down { 0 } else { KEYEVENTF_KEYUP }; - if scan != 0 { - flags |= KEYEVENTF_SCANCODE; - } - if scan >> 8 == 0xe0 { - flags |= KEYEVENTF_EXTENDEDKEY; - } - INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: if scan != 0 { 0 } else { vk }, - wScan: (scan & 0xff) as u16, - dwFlags: flags, - time: 0, - dwExtraInfo: 0, - }, - }, - } - } - - fn side(bit: u8, down: bool) { - let button = if bit == BACK { XBUTTON1 } else { XBUTTON2 }; - send(&[INPUT { - r#type: INPUT_MOUSE, - Anonymous: INPUT_0 { - mi: MOUSEINPUT { - dx: 0, - dy: 0, - mouseData: u32::from(button), - dwFlags: if down { - MOUSEEVENTF_XDOWN - } else { - MOUSEEVENTF_XUP - }, - time: 0, - dwExtraInfo: 0, - }, - }, - }]); - } - - fn send(inputs: &[INPUT]) { - let sent = unsafe { - SendInput( - inputs.len() as u32, - inputs.as_ptr(), - size_of::() as i32, - ) - }; - if sent as usize != inputs.len() { - tracing::warn!("Windows did not accept the button input"); - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - use crate::config::{ProfileApplication, ProfileDevice, ProfileSettings}; - - fn profile(device: &str, snapshot: Value) -> ApplicationProfile { - ApplicationProfile { - application: ProfileApplication { - name: "Game".into(), - executable: String::new(), - path: String::new(), - local_id: None, - activation: None, - bundle_path: None, - bundle_identifier: None, - }, - device: ProfileDevice { - id: device.into(), - name: "Mouse".into(), - }, - settings: ProfileSettings { - dpi: None, - polling_rate_hz: None, - snapshot: Some(snapshot), - }, - enabled: true, - } - } - - fn assignments(keys: &[(u8, u8, u8)]) -> Assignments { - Assignments { - brand: "Logitech".into(), - device: "Mouse".into(), - actions: keys - .iter() - .map(|&(bit, usage, modifiers)| (bit, Action::Key(KeyAction { usage, modifiers }))) - .collect(), - require_host: false, - } - } - - #[test] - fn a_profile_names_the_mouse_it_is_for() { - let parsed = Assignments::from_profile( - &profile( - "Logitech:PRO X 3 SUPERSTRIKE", - json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}}}), - ), - false, - ) - .expect("assignments"); - assert_eq!(parsed.device, "PRO X 3 SUPERSTRIKE"); - } - - #[test] - fn a_held_button_keeps_its_action_while_another_follows_the_new_profile() { - let old = assignments(&[(BACK, 0x04, 0)]); - let new = assignments(&[(BACK, 0x05, 0), (FRONT, 0x06, 0)]); - let key = |usage| { - Action::Key(KeyAction { - usage, - modifiers: 0, - }) - }; - let mut held = HeldButtons::new(); - assert_eq!( - transitions(&mut held, &old, 0, BACK), - [Step::Down(key(0x04))] - ); - // The profile changes while Back is held: Forward uses the new one, Back is let go as pressed. - assert_eq!( - transitions(&mut held, &new, BACK, BACK | FRONT), - [Step::Down(key(0x06))] - ); - assert_eq!( - transitions(&mut held, &new, BACK | FRONT, FRONT), - [Step::Up(key(0x04))] - ); - assert_eq!( - transitions(&mut held, &new, FRONT, 0), - [Step::Up(key(0x06))] - ); - } - - #[test] - fn a_key_two_held_buttons_share_is_released_by_the_last_of_them() { - let mut keys = HeldKeys::new(); - assert!(keys.press(0x11), "the first Ctrl goes down"); - assert!(!keys.press(0x11), "a second one is already down"); - assert!(!keys.release(0x11), "letting one go keeps Ctrl down"); - assert!(keys.release(0x11), "the last one releases it"); - assert!(keys.release(0x11), "a key nobody holds is released anyway"); - } - - #[test] - fn a_profile_names_its_assignments_by_button_bit() { - let parsed = Assignments::from_profile(&profile( - "Logitech:PRO X3 SUPERSTRIKE", - json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}, "8": {"key": 4, "modifiers": 3}}}), - ), false) - .expect("assignments"); - assert_eq!(parsed.brand, "Logitech"); - assert_eq!( - parsed.actions[&FRONT], - Action::Key(KeyAction { - usage: 104, - modifiers: 0 - }) - ); - assert_eq!( - parsed.actions[&BACK], - Action::Key(KeyAction { - usage: 4, - modifiers: 3 - }) - ); - } - - #[test] - fn a_profile_can_assign_a_media_key() { - let parsed = Assignments::from_profile( - &profile( - "Logitech:Mouse", - json!({"hostButtons": {"16": {"consumer": 233}}}), - ), - true, - ) - .expect("assignments"); - assert_eq!(parsed.actions[&FRONT], Action::Media(233)); - assert!( - parsed.require_host, - "the default profile only listens to a mouse already on Host" - ); - let unknown = Assignments::from_profile( - &profile( - "Logitech:Mouse", - json!({"hostButtons": {"16": {"consumer": 1}}}), - ), - false, - ); - assert_eq!( - unknown, None, - "a media key Bridge cannot press is not accepted" - ); - } - - #[test] - fn profiles_without_usable_assignments_start_nothing() { - let logitech = - |snapshot| Assignments::from_profile(&profile("Logitech:Mouse", snapshot), false); - assert_eq!(logitech(json!({})), None); - assert_eq!(logitech(json!({"hostButtons": {}})), None); - assert_eq!( - logitech(json!({"hostButtons": {"4": {"key": 4}}})), - None, - "the middle button is not assignable" - ); - assert_eq!(logitech(json!({"hostButtons": {"8": {"key": 999}}})), None); - assert_eq!( - logitech(json!({"hostButtons": {"8": {"key": 0}}})), - None, - "a key Bridge cannot press would swallow the button's normal click" - ); - assert_eq!( - logitech(json!({"hostButtons": {"8": {"key": 0x46}}})).is_some(), - !cfg!(target_os = "macos"), - "Print Screen has no key on a Mac" - ); - assert_eq!( - logitech(json!({"hostButtons": {"8": {"key": 4, "modifiers": 99}}})), - None - ); - let other = profile("Razer:Viper", json!({"hostButtons": {"8": {"key": 4}}})); - assert_eq!( - Assignments::from_profile(&other, false), - None, - "only Logitech has the button events" - ); - } - - #[test] - fn a_press_and_release_become_one_key_down_and_up() { - let assigned = assignments(&[(FRONT, 0x68, 0)]); - let action = Action::Key(KeyAction { - usage: 0x68, - modifiers: 0, - }); - assert_eq!(steps(&assigned, 0, FRONT), [Step::Down(action)]); - assert_eq!( - steps(&assigned, FRONT, FRONT), - [], - "holding changes nothing" - ); - assert_eq!(steps(&assigned, FRONT, 0), [Step::Up(action)]); - } - - #[test] - fn an_unassigned_side_button_keeps_its_normal_click() { - let assigned = assignments(&[(FRONT, 0x68, 0)]); - assert_eq!(steps(&assigned, 0, BACK), [Step::SideDown(BACK)]); - assert_eq!(steps(&assigned, BACK, 0), [Step::SideUp(BACK)]); - } - - #[test] - fn the_other_buttons_are_left_to_the_mouse() { - let assigned = assignments(&[(FRONT, 0x68, 0)]); - // left, right and middle already click on their own - assert_eq!(steps(&assigned, 0, 0x01 | 0x02 | 0x04), []); - } - - #[test] - fn two_buttons_pressed_together_are_both_handled() { - let assigned = assignments(&[(FRONT, 0x68, 0)]); - assert_eq!( - steps(&assigned, 0, BACK | FRONT), - [ - Step::SideDown(BACK), - Step::Down(Action::Key(KeyAction { - usage: 0x68, - modifiers: 0 - })) - ] - ); - } - - #[test] - fn keys_map_to_windows_virtual_keys() { - assert_eq!(virtual_key(0x04), Some(0x41), "A"); - assert_eq!(virtual_key(0x1d), Some(0x5a), "Z"); - assert_eq!(virtual_key(0x1e), Some(0x31), "1"); - assert_eq!(virtual_key(0x27), Some(0x30), "0"); - assert_eq!(virtual_key(0x3a), Some(0x70), "F1"); - assert_eq!(virtual_key(0x45), Some(0x7b), "F12"); - assert_eq!(virtual_key(0x68), Some(0x7c), "F13"); - assert_eq!(virtual_key(0x73), Some(0x87), "F24"); - assert_eq!(virtual_key(0x2c), Some(0x20), "Space"); - assert_eq!(virtual_key(0x52), Some(0x26), "Up"); - assert_eq!(virtual_key(0x01), None); - assert_eq!(media_virtual_key(0xe9), Some(0xaf), "volume up"); - assert_eq!(media_virtual_key(0xcd), Some(0xb3), "play / pause"); - assert_eq!(media_virtual_key(0x01), None); - assert_eq!(modifier_keys(0b0101), [0x11, 0x12], "ctrl and alt"); - assert_eq!(modifier_keys(0), Vec::::new()); - } -} diff --git a/src/buttons/keymap.rs b/src/buttons/keymap.rs deleted file mode 100644 index a3d4494..0000000 --- a/src/buttons/keymap.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! HID keyboard and consumer usages as the key codes of Linux (evdev) and macOS -//! (virtual keycodes), for the keys a profile can hold. `buttons::virtual_key` -//! is the same list for Windows, so the three cover the same keys. -//! -//! Plain functions with no system calls, so they are tested on every platform -//! even though each is only used on one. -#![allow(dead_code)] - -/// Linux `KEY_A` to `KEY_Z`, in HID order (usage 0x04 is A). -const LINUX_LETTERS: [u16; 26] = [ - 30, 48, 46, 32, 18, 33, 34, 35, 23, 36, 37, 38, 50, 49, 24, 25, 16, 19, 31, 20, 22, 47, 17, 45, - 21, 44, -]; - -/// macOS `kVK_ANSI_A` to `kVK_ANSI_Z`, in HID order. -const MAC_LETTERS: [u16; 26] = [ - 0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6, -]; - -/// macOS keycodes of the digits 1 to 9 and 0, in HID order (usage 0x1e is 1). -const MAC_DIGITS: [u16; 10] = [18, 19, 20, 21, 23, 22, 26, 28, 25, 29]; - -/// The Linux `KEY_*` code for a HID keyboard usage. -pub fn linux_key(usage: u8) -> Option { - Some(match usage { - 0x04..=0x1d => LINUX_LETTERS[usize::from(usage - 0x04)], - 0x1e..=0x27 => 2 + u16::from(usage - 0x1e), // 1-9, then 0 (KEY_0 is 11) - 0x28 => 28, // Enter - 0x29 => 1, // Escape - 0x2a => 14, // Backspace - 0x2b => 15, // Tab - 0x2c => 57, // Space - 0x2d => 12, // - - 0x2e => 13, // = - 0x2f => 26, // [ - 0x30 => 27, // ] - 0x31 => 43, // \ - 0x33 => 39, // ; - 0x34 => 40, // ' - 0x35 => 41, // ` - 0x36 => 51, // , - 0x37 => 52, // . - 0x38 => 53, // / - 0x3a..=0x43 => 59 + u16::from(usage - 0x3a), // F1-F10 - 0x44 => 87, // F11 - 0x45 => 88, // F12 - 0x46 => 99, // Print Screen (KEY_SYSRQ) - 0x47 => 70, // Scroll Lock - 0x48 => 119, // Pause - 0x49 => 110, // Insert - 0x4a => 102, // Home - 0x4b => 104, // Page Up - 0x4c => 111, // Delete - 0x4d => 107, // End - 0x4e => 109, // Page Down - 0x4f => 106, // Right - 0x50 => 105, // Left - 0x51 => 108, // Down - 0x52 => 103, // Up - 0x68..=0x73 => 183 + u16::from(usage - 0x68), // F13-F24 - _ => return None, - }) -} - -/// The Linux `KEY_*` code for a HID consumer usage (media key). -pub fn linux_media_key(usage: u16) -> Option { - Some(match usage { - 0xe2 => 113, // Mute - 0xe9 => 115, // Volume up - 0xea => 114, // Volume down - 0xcd => 164, // Play / pause - 0xb5 => 163, // Next track - 0xb6 => 165, // Previous track - 0xb7 => 166, // Stop - _ => return None, - }) -} - -/// The Linux modifier keys a mask names (ctrl, shift, alt, meta), in the order they are pressed. -pub fn linux_modifier_keys(modifiers: u8) -> Vec { - modifier_codes(modifiers, [29, 42, 56, 125]) -} - -/// The macOS virtual keycode for a HID keyboard usage. Print Screen, Scroll -/// Lock, Pause and F21 to F24 have no key on a Mac. -pub fn mac_key(usage: u8) -> Option { - Some(match usage { - 0x04..=0x1d => MAC_LETTERS[usize::from(usage - 0x04)], - 0x1e..=0x27 => MAC_DIGITS[usize::from(usage - 0x1e)], - 0x28 => 36, // Return - 0x29 => 53, // Escape - 0x2a => 51, // Delete (backspace) - 0x2b => 48, // Tab - 0x2c => 49, // Space - 0x2d => 27, // - - 0x2e => 24, // = - 0x2f => 33, // [ - 0x30 => 30, // ] - 0x31 => 42, // \ - 0x33 => 41, // ; - 0x34 => 39, // ' - 0x35 => 50, // ` - 0x36 => 43, // , - 0x37 => 47, // . - 0x38 => 44, // / - 0x3a => 122, // F1 - 0x3b => 120, - 0x3c => 99, - 0x3d => 118, - 0x3e => 96, - 0x3f => 97, - 0x40 => 98, - 0x41 => 100, - 0x42 => 101, - 0x43 => 109, // F10 - 0x44 => 103, // F11 - 0x45 => 111, // F12 - 0x49 => 114, // Insert (kVK_Help) - 0x4a => 115, // Home - 0x4b => 116, // Page Up - 0x4c => 117, // Forward delete - 0x4d => 119, // End - 0x4e => 121, // Page Down - 0x4f => 124, // Right - 0x50 => 123, // Left - 0x51 => 125, // Down - 0x52 => 126, // Up - 0x68 => 105, // F13 - 0x69 => 107, // F14 - 0x6a => 113, // F15 - 0x6b => 106, // F16 - 0x6c => 64, // F17 - 0x6d => 79, // F18 - 0x6e => 80, // F19 - 0x6f => 90, // F20 - _ => return None, - }) -} - -/// The macOS modifier keys a mask names (control, shift, option, command), in the order they are pressed. -pub fn mac_modifier_keys(modifiers: u8) -> Vec { - modifier_codes(modifiers, [59, 56, 58, 55]) -} - -fn modifier_codes(modifiers: u8, codes: [u16; 4]) -> Vec { - codes - .into_iter() - .enumerate() - .filter(|(bit, _)| modifiers & (1 << bit) != 0) - .map(|(_, code)| code) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::buttons::virtual_key; - - #[test] - fn linux_keys_follow_the_evdev_codes() { - assert_eq!(linux_key(0x04), Some(30), "A"); - assert_eq!(linux_key(0x1d), Some(44), "Z"); - assert_eq!(linux_key(0x10), Some(50), "M"); - assert_eq!(linux_key(0x1e), Some(2), "1"); - assert_eq!(linux_key(0x26), Some(10), "9"); - assert_eq!(linux_key(0x27), Some(11), "0"); - assert_eq!(linux_key(0x3a), Some(59), "F1"); - assert_eq!(linux_key(0x43), Some(68), "F10"); - assert_eq!(linux_key(0x44), Some(87), "F11"); - assert_eq!(linux_key(0x68), Some(183), "F13"); - assert_eq!(linux_key(0x73), Some(194), "F24"); - assert_eq!(linux_key(0x2c), Some(57), "Space"); - assert_eq!(linux_key(0x52), Some(103), "Up"); - assert_eq!(linux_key(0x01), None); - assert_eq!(linux_media_key(0xe9), Some(115), "volume up"); - assert_eq!(linux_media_key(0x01), None); - assert_eq!(linux_modifier_keys(0b0101), [29, 56], "ctrl and alt"); - assert_eq!(linux_modifier_keys(0b1010), [42, 125], "shift and meta"); - } - - #[test] - fn mac_keys_follow_the_virtual_keycodes() { - assert_eq!(mac_key(0x04), Some(0), "A"); - assert_eq!(mac_key(0x1d), Some(6), "Z"); - assert_eq!(mac_key(0x0b), Some(4), "H"); - assert_eq!(mac_key(0x1e), Some(18), "1"); - assert_eq!(mac_key(0x22), Some(23), "5"); - assert_eq!(mac_key(0x27), Some(29), "0"); - assert_eq!(mac_key(0x3a), Some(122), "F1"); - assert_eq!(mac_key(0x45), Some(111), "F12"); - assert_eq!(mac_key(0x68), Some(105), "F13"); - assert_eq!(mac_key(0x2c), Some(49), "Space"); - assert_eq!(mac_key(0x52), Some(126), "Up"); - assert_eq!(mac_key(0x46), None, "Print Screen has no Mac key"); - assert_eq!(mac_modifier_keys(0b1001), [59, 55], "control and command"); - assert_eq!(mac_modifier_keys(0), Vec::::new()); - } - - #[test] - fn linux_covers_every_key_windows_does() { - for usage in 0..=u8::MAX { - assert_eq!( - linux_key(usage).is_some(), - virtual_key(usage).is_some(), - "usage {usage:#x}" - ); - } - } - - #[test] - fn keys_do_not_share_a_code() { - let mut linux: Vec = (0..=u8::MAX).filter_map(linux_key).collect(); - let mut mac: Vec = (0..=u8::MAX).filter_map(mac_key).collect(); - for codes in [&mut linux, &mut mac] { - let before = codes.len(); - codes.sort_unstable(); - codes.dedup(); - assert_eq!(codes.len(), before, "two usages map to one key code"); - } - } -} diff --git a/src/buttons/linux.rs b/src/buttons/linux.rs deleted file mode 100644 index a62a8e6..0000000 --- a/src/buttons/linux.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! Presses keys and side buttons on Linux through virtual devices in `/dev/uinput`. -//! -//! uinput works the same on X11 and Wayland. The keyboard and the mouse are two -//! devices because a desktop only treats a device as a pointer when it has -//! relative axes; a button on a keyboard-only device would be ignored. - -use std::{ - fs::{File, OpenOptions}, - io::{Error, Write}, - mem::{size_of_val, zeroed}, - os::fd::AsRawFd, - sync::{Mutex, PoisonError}, - thread, - time::Duration, -}; - -use anyhow::{Context, Result}; - -use super::{Action, BACK, HeldKeys, KeyAction, Step, keymap}; - -const EV_SYN: u16 = 0x00; -const EV_KEY: u16 = 0x01; -const EV_REL: u16 = 0x02; -const REL_X: u16 = 0x00; -const REL_Y: u16 = 0x01; -const BTN_LEFT: u16 = 0x110; -const BTN_SIDE: u16 = 0x113; -const BTN_EXTRA: u16 = 0x114; -const BUS_VIRTUAL: u16 = 0x06; -/// Keyboard codes the keymap can produce all sit below this. -const KEY_LIMIT: u16 = 256; - -// ioctl requests from : UI_SET_EVBIT, UI_SET_KEYBIT, UI_SET_RELBIT, UI_DEV_SETUP, UI_DEV_CREATE. -const UI_SET_EVBIT: u32 = 0x4004_5564; -const UI_SET_KEYBIT: u32 = 0x4004_5565; -const UI_SET_RELBIT: u32 = 0x4004_5566; -const UI_DEV_SETUP: u32 = 0x405c_5503; -const UI_DEV_CREATE: u32 = 0x5501; - -#[repr(C)] -struct UinputSetup { - bustype: u16, - vendor: u16, - product: u16, - version: u16, - name: [u8; 80], - ff_effects_max: u32, -} - -struct Devices { - keyboard: File, - mouse: File, - /// The keys held down, counted, so a modifier two buttons share is not let go early. - held: HeldKeys, -} - -/// Made on first use and kept until Bridge exits; closing the files removes the devices. -static DEVICES: Mutex> = Mutex::new(None); - -/// Creates the virtual devices before the first button press, which the desktop -/// needs a moment to notice. -pub fn prepare() { - let mut devices = DEVICES.lock().unwrap_or_else(PoisonError::into_inner); - if devices.is_some() { - return; - } - match create() { - Ok(created) => *devices = Some(created), - Err(error) => tracing::warn!( - error = format!("{error:#}"), - "Could not create the virtual keyboard and mouse, so button assignments do nothing. \ - Bridge needs write access to /dev/uinput; install-linux.sh adds a udev rule for it" - ), - } -} - -fn create() -> Result { - let keyboard = device("OpenMouse Bridge keyboard", &[], 1..KEY_LIMIT)?; - let mouse = device( - "OpenMouse Bridge mouse", - &[REL_X, REL_Y], - [BTN_LEFT, BTN_SIDE, BTN_EXTRA], - )?; - thread::sleep(Duration::from_millis(200)); - Ok(Devices { - keyboard, - mouse, - held: HeldKeys::new(), - }) -} - -fn device(name: &str, axes: &[u16], keys: impl IntoIterator) -> Result { - let file = OpenOptions::new() - .write(true) - .open("/dev/uinput") - .context("could not open /dev/uinput")?; - let fd = file.as_raw_fd(); - set(fd, UI_SET_EVBIT, EV_KEY)?; - for key in keys { - set(fd, UI_SET_KEYBIT, key)?; - } - if !axes.is_empty() { - set(fd, UI_SET_EVBIT, EV_REL)?; - for axis in axes { - set(fd, UI_SET_RELBIT, *axis)?; - } - } - let mut setup = UinputSetup { - bustype: BUS_VIRTUAL, - vendor: 0, - product: 0, - version: 1, - name: [0; 80], - ff_effects_max: 0, - }; - let bytes = name.as_bytes(); - setup.name[..bytes.len().min(79)].copy_from_slice(&bytes[..bytes.len().min(79)]); - // SAFETY: `setup` is the struct UI_DEV_SETUP expects and lives through the call. - check(unsafe { libc::ioctl(fd, UI_DEV_SETUP as _, &setup) })?; - // SAFETY: UI_DEV_CREATE takes no argument. - check(unsafe { libc::ioctl(fd, UI_DEV_CREATE as _) })?; - Ok(file) -} - -fn set(fd: i32, request: u32, value: u16) -> Result<()> { - // SAFETY: these requests take their argument by value. - check(unsafe { libc::ioctl(fd, request as _, libc::c_int::from(value)) }) -} - -fn check(result: libc::c_int) -> Result<()> { - if result < 0 { - return Err(Error::last_os_error()).context("a /dev/uinput request failed"); - } - Ok(()) -} - -pub fn execute(step: Step) { - let mut devices = DEVICES.lock().unwrap_or_else(PoisonError::into_inner); - let Some(devices) = devices.as_mut() else { - return; - }; - match step { - Step::Down(action) => press(devices, action, true), - Step::Up(action) => press(devices, action, false), - Step::SideDown(bit) => write_key(&devices.mouse, side(bit), true), - Step::SideUp(bit) => write_key(&devices.mouse, side(bit), false), - } -} - -fn side(bit: u8) -> u16 { - if bit == BACK { BTN_SIDE } else { BTN_EXTRA } -} - -/// Modifiers go down before the key and come up after it. -fn press(devices: &mut Devices, action: Action, down: bool) { - let mut order = match action { - Action::Key(KeyAction { usage, modifiers }) => { - let Some(key) = keymap::linux_key(usage) else { - tracing::warn!(usage, "That key cannot be pressed by Bridge"); - return; - }; - let mut order = keymap::linux_modifier_keys(modifiers); - order.push(key); - order - } - Action::Media(usage) => keymap::linux_media_key(usage).into_iter().collect(), - }; - if !down { - order.reverse(); - } - for code in order { - let changes = if down { - devices.held.press(code) - } else { - devices.held.release(code) - }; - if changes { - write_key(&devices.keyboard, code, down); - } - } -} - -fn write_key(mut device: &File, code: u16, down: bool) { - let events = [event(EV_KEY, code, i32::from(down)), event(EV_SYN, 0, 0)]; - // SAFETY: `input_event` is plain integers, so its bytes are what the kernel reads. - let bytes = - unsafe { std::slice::from_raw_parts(events.as_ptr().cast::(), size_of_val(&events)) }; - if let Err(error) = device.write_all(bytes) { - tracing::warn!(%error, "Linux did not accept the button input"); - } -} - -fn event(kind: u16, code: u16, value: i32) -> libc::input_event { - // SAFETY: an all-zero `input_event` is valid (integers only); the timestamp is filled in by the kernel. - let mut event: libc::input_event = unsafe { zeroed() }; - event.type_ = kind; - event.code = code; - event.value = value; - event -} diff --git a/src/buttons/macos.rs b/src/buttons/macos.rs deleted file mode 100644 index bf3c7ea..0000000 --- a/src/buttons/macos.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Presses keys and side buttons on macOS by posting CoreGraphics events. -//! -//! macOS only lets a program post input once the user has allowed it under -//! System Settings > Privacy & Security > Accessibility; without that the -//! events are dropped silently, so `prepare` checks and asks for it. - -use std::sync::{Mutex, PoisonError}; - -use objc2_core_foundation::CGPoint; -use objc2_core_graphics::{ - CGEvent, CGEventField, CGEventTapLocation, CGEventType, CGMouseButton, - CGPreflightPostEventAccess, CGRequestPostEventAccess, -}; - -use super::{Action, BACK, HeldKeys, KeyAction, Step, keymap}; - -/// The keys held down, counted, so a modifier two buttons share is not let go early. -static HELD: Mutex = Mutex::new(HeldKeys::new()); - -/// Asks macOS to let Bridge post input, which shows its permission prompt the first time. -pub fn prepare() { - if !CGPreflightPostEventAccess() { - CGRequestPostEventAccess(); - tracing::warn!( - "macOS has not allowed Bridge to press keys, so button assignments do nothing until it is \ - allowed under System Settings > Privacy & Security > Accessibility" - ); - } -} - -pub fn execute(step: Step) { - match step { - Step::Down(action) => press(action, true), - Step::Up(action) => press(action, false), - Step::SideDown(bit) => side(bit, true), - Step::SideUp(bit) => side(bit, false), - } -} - -/// Modifiers go down before the key and come up after it. -fn press(action: Action, down: bool) { - let Action::Key(KeyAction { usage, modifiers }) = action else { - tracing::warn!("Media keys cannot be pressed by Bridge on macOS yet"); - return; - }; - let Some(key) = keymap::mac_key(usage) else { - tracing::warn!(usage, "That key cannot be pressed by Bridge"); - return; - }; - let mut order = keymap::mac_modifier_keys(modifiers); - order.push(key); - if !down { - order.reverse(); - } - let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); - for code in order { - let changes = if down { - held.press(code) - } else { - held.release(code) - }; - if !changes { - continue; - } - match CGEvent::new_keyboard_event(None, code, down) { - Some(event) => CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event)), - None => tracing::warn!("macOS did not create the key event"), - } - } -} - -/// The side buttons are the mouse's fourth (Back) and fifth (Forward) buttons. -fn side(bit: u8, down: bool) { - let button: u32 = if bit == BACK { 3 } else { 4 }; - let position = - CGEvent::new(None).map_or(CGPoint::ZERO, |event| CGEvent::location(Some(&event))); - let kind = if down { - CGEventType::OtherMouseDown - } else { - CGEventType::OtherMouseUp - }; - match CGEvent::new_mouse_event(None, kind, position, CGMouseButton::Center) { - Some(event) => { - CGEvent::set_integer_value_field( - Some(&event), - CGEventField::MouseEventButtonNumber, - i64::from(button), - ); - CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event)); - } - None => tracing::warn!("macOS did not create the mouse event"), - } -} diff --git a/src/config.rs b/src/config.rs index a46b273..781e2b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,25 +28,8 @@ pub struct BridgeConfig { pub notification_sound: bool, #[serde(default = "default_notification_volume")] pub notification_volume: u8, - /// What the tray battery icon shows: `"glyph"` (the native battery icon) - /// or `"percentage"` (just the number). Other values read as `"glyph"`. - #[serde(default = "default_tray_battery_style")] - pub tray_battery_style: String, - /// Hybrid device mode: while the OpenMouse web panel is disconnected, - /// Bridge drives the mouse itself — the active profile's stored settings - /// (DPI, polling rate, switch mode) go to the onboard memory and the - /// battery is read natively — and hands the mouse over to the panel the - /// moment it connects, applying the stored settings again on disconnect. - #[serde(default = "enabled_by_default")] - pub hybrid_mode: bool, #[serde(default)] pub games: Vec, - /// Games the user added by hand. Kept apart from `games` because the - /// catalog replaces that list on every start. - #[serde(default)] - pub custom_games: Vec, - #[serde(default)] - pub local_applications: Vec, #[serde(default)] pub profiles: Vec, #[serde(default)] @@ -70,45 +53,6 @@ pub struct GameConfig { pub executables: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct LocalApplication { - pub id: String, - pub name: String, - pub executable: String, - pub path: String, - pub kind: LocalApplicationKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub activation: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_identifier: Option, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum LocalApplicationActivation { - Running, - Focused, -} - -impl LocalApplication { - pub fn effective_activation(&self) -> LocalApplicationActivation { - self.activation.unwrap_or(match self.kind { - LocalApplicationKind::Game => LocalApplicationActivation::Running, - LocalApplicationKind::App => LocalApplicationActivation::Focused, - }) - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum LocalApplicationKind { - Game, - App, -} - #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ApplicationProfile { @@ -133,12 +77,6 @@ impl ApplicationProfile { /// which only know a game's executables). Lowercased, as matching is. pub fn application_key(&self) -> String { let application = &self.application; - if let Some(id) = &application.local_id { - return id.clone(); - } - if !application.path.is_empty() { - return crate::games::path_key(&application.path); - } [ &application.path, &application.executable, @@ -157,14 +95,6 @@ pub struct ProfileApplication { pub name: String, pub executable: String, pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub activation: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_identifier: Option, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -195,11 +125,7 @@ impl Default for BridgeConfig { automatic_updates: false, notification_sound: true, notification_volume: DEFAULT_NOTIFICATION_VOLUME, - tray_battery_style: default_tray_battery_style(), - hybrid_mode: true, games: Vec::new(), - custom_games: Vec::new(), - local_applications: Vec::new(), profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), @@ -214,15 +140,22 @@ impl BridgeConfig { self.battery_threshold_percent = self.battery_threshold_percent.min(100); self.notification_volume = self.notification_volume.min(100); self.alert_cooldown_minutes = self.alert_cooldown_minutes.max(1); - // Only the two known tray styles are honored; anything else (a - // hand-edited config, an older panel) reads as the default glyph. - self.tray_battery_style = match self.tray_battery_style.trim().to_ascii_lowercase().as_str() - { - "percentage" => "percentage".to_owned(), - _ => "glyph".to_owned(), - }; - normalize_games(&mut self.games, &CATALOG_LIMITS); - normalize_games(&mut self.custom_games, &CUSTOM_GAME_LIMITS); + for game in &mut self.games { + game.name = game.name.trim().to_owned(); + game.executables = game + .executables + .iter() + .map(|entry| entry.trim().to_ascii_lowercase()) + .filter(|entry| !entry.is_empty()) + .collect(); + game.executables.sort(); + game.executables.dedup(); + } + self.games + .retain(|game| !game.name.is_empty() && !game.executables.is_empty()); + self.games.sort_by(|left, right| left.name.cmp(&right.name)); + self.games + .dedup_by(|left, right| left.name.eq_ignore_ascii_case(&right.name)); for profile in &mut self.profiles { profile.application.name = profile.application.name.trim().to_owned(); profile.application.executable = profile.application.executable.trim().to_owned(); @@ -230,21 +163,15 @@ impl BridgeConfig { profile.device.id = profile.device.id.trim().to_owned(); profile.device.name = profile.device.name.trim().to_owned(); } - for profile in self - .profiles - .iter_mut() - .chain(self.default_profile.iter_mut()) - { - let _ = crate::local_targets::canonicalize_profile(profile, &self.local_applications); - } // Game profiles from the control panel carry no path (a game is known // by its executables), so a profile only needs something to match on. self.profiles.retain(|profile| { !profile.application_key().is_empty() && !profile.device.id.is_empty() }); - let mut identities = std::collections::HashSet::new(); - self.profiles.retain(|profile| { - identities.insert((profile.application_key(), profile.device.id.clone())) + self.profiles + .sort_by_cached_key(|profile| (profile.application_key(), profile.device.id.clone())); + self.profiles.dedup_by(|left, right| { + left.application_key() == right.application_key() && left.device.id == right.device.id }); if let Some(profile) = &mut self.default_profile { profile.application.name = profile.application.name.trim().to_owned(); @@ -266,70 +193,6 @@ impl BridgeConfig { self.allowed_origins.dedup(); self } - - pub fn tracked_games(&self) -> Vec { - let mut games = self.games.clone(); - games.extend( - self.custom_games - .iter() - .filter(|custom| { - !self - .games - .iter() - .any(|game| game.name.eq_ignore_ascii_case(&custom.name)) - }) - .cloned(), - ); - games - } -} - -const MAX_GAMES: usize = 200; -const MAX_NAME_CHARS: usize = 80; -const MAX_EXECUTABLES: usize = 20; -const MAX_EXECUTABLE_CHARS: usize = 260; - -struct GameLimits { - games: usize, - name_chars: usize, - executables: usize, - executable_chars: usize, -} - -/// What a user may add by hand. -const CUSTOM_GAME_LIMITS: GameLimits = GameLimits { - games: MAX_GAMES, - name_chars: MAX_NAME_CHARS, - executables: MAX_EXECUTABLES, - executable_chars: MAX_EXECUTABLE_CHARS, -}; - -/// The catalog is already bounded when it is fetched; these match that parser. -const CATALOG_LIMITS: GameLimits = GameLimits { - games: crate::games::MAX_CATALOG_GAMES, - name_chars: crate::games::MAX_CATALOG_STRING, - executables: crate::games::MAX_EXECUTABLES, - executable_chars: crate::games::MAX_CATALOG_STRING, -}; - -fn normalize_games(games: &mut Vec, limits: &GameLimits) { - for game in games.iter_mut() { - game.name = game.name.trim().chars().take(limits.name_chars).collect(); - game.executables = game - .executables - .iter() - .map(|entry| entry.trim().to_ascii_lowercase()) - .filter(|entry| !entry.is_empty() && entry.chars().count() <= limits.executable_chars) - .collect(); - game.executables.sort(); - game.executables.dedup(); - game.executables.truncate(limits.executables); - } - games.retain(|game| !game.name.is_empty() && !game.executables.is_empty()); - // Sorted by the folded name, so names that differ only in case sit side by side for the dedup. - games.sort_by_key(|game| game.name.to_ascii_lowercase()); - games.dedup_by(|left, right| left.name.eq_ignore_ascii_case(&right.name)); - games.truncate(limits.games); } pub fn config_path() -> Result { @@ -393,42 +256,12 @@ pub async fn load_with_catalog() -> Result<(BridgeConfig, PathBuf)> { } pub fn save(path: &PathBuf, config: &BridgeConfig) -> Result<()> { - use std::{ - io::Write, - sync::atomic::{AtomicU64, Ordering}, - }; - static NEXT_SAVE: AtomicU64 = AtomicU64::new(0); - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); - fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?; - let json = serde_json::to_vec_pretty(config)?; - let name = path - .file_name() - .context("config path has no filename")? - .to_string_lossy(); - let temporary = parent.join(format!( - ".{name}.{}.{}.tmp", - std::process::id(), - NEXT_SAVE.fetch_add(1, Ordering::Relaxed) - )); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) - .with_context(|| format!("could not create {}", temporary.display()))?; - let result: std::io::Result<()> = (|| { - file.write_all(&json)?; - file.sync_all()?; - drop(file); - fs::rename(&temporary, path)?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; } - result.with_context(|| format!("could not write {}", path.display())) + let json = serde_json::to_vec_pretty(config)?; + fs::write(path, json).with_context(|| format!("could not write {}", path.display())) } const fn default_battery_threshold() -> u8 { @@ -443,10 +276,6 @@ const fn default_notification_volume() -> u8 { DEFAULT_NOTIFICATION_VOLUME } -fn default_tray_battery_style() -> String { - "glyph".to_owned() -} - fn default_origins() -> Vec { vec![ "https://control.openmouse.app".to_owned(), @@ -462,136 +291,6 @@ fn default_origins() -> Vec { mod tests { use super::*; - #[test] - fn names_that_differ_only_in_case_are_one_game_wherever_they_sort() { - let game = |name: &str| GameConfig { - name: name.into(), - executables: vec!["g.exe".into()], - }; - // Sorted by plain bytes these would be Alpha, Beta, alpha: the two Alphas apart. - let mut games = vec![game("alpha"), game("Beta"), game("Alpha")]; - normalize_games(&mut games, &CUSTOM_GAME_LIMITS); - assert_eq!(games.len(), 2); - } - - #[test] - fn game_lists_have_a_size_limit() { - let many = |count: usize| -> Vec { - (0..count) - .map(|i| GameConfig { - name: format!("game {i:04}"), - executables: vec![format!("g{i}.exe")], - }) - .collect() - }; - let mut games = many(MAX_GAMES + 50); - normalize_games(&mut games, &CUSTOM_GAME_LIMITS); - assert_eq!(games.len(), MAX_GAMES); - - let mut one = vec![GameConfig { - name: "n".repeat(500), - executables: (0..40) - .map(|i| format!("{i}.exe")) - .chain([format!("{}.exe", "x".repeat(300))]) - .collect(), - }]; - normalize_games(&mut one, &CUSTOM_GAME_LIMITS); - assert_eq!(one[0].name.chars().count(), MAX_NAME_CHARS); - assert_eq!(one[0].executables.len(), MAX_EXECUTABLES); - assert!( - one[0] - .executables - .iter() - .all(|entry| entry.chars().count() <= MAX_EXECUTABLE_CHARS) - ); - } - - #[test] - fn the_catalog_is_not_held_to_the_custom_game_limits() { - let config = BridgeConfig { - games: (0..MAX_GAMES + 50) - .map(|i| GameConfig { - name: format!("game {i:04}"), - executables: (0..MAX_EXECUTABLES + 5) - .map(|e| format!("g{i}-{e}.exe")) - .collect(), - }) - .collect(), - ..BridgeConfig::default() - } - .normalized(); - assert_eq!(config.games.len(), MAX_GAMES + 50); - assert_eq!(config.games[0].executables.len(), MAX_EXECUTABLES + 5); - } - - #[test] - fn saving_replaces_the_file_instead_of_truncating_an_existing_reader() { - use std::io::Read; - let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join(format!("atomic-config-test-{}", std::process::id())); - fs::create_dir_all(&directory).unwrap(); - let path = directory.join("config.json"); - fs::write(&path, b"old configuration").unwrap(); - let mut original = fs::File::open(&path).unwrap(); - save(&path, &BridgeConfig::default()).unwrap(); - let mut bytes = Vec::new(); - original.read_to_end(&mut bytes).unwrap(); - assert_eq!(bytes, b"old configuration"); - assert!(serde_json::from_slice::(&fs::read(&path).unwrap()).is_ok()); - drop(original); - fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn local_profile_metadata_is_canonical_after_config_reload() { - let json = serde_json::json!({"localApplications":[{"id":"local:editor","name":" Editor ","executable":"editor.exe","path":"C:/Tools/editor.exe","kind":"app"}],"profiles":[{"application":{"localId":"local:editor","name":"Old name","executable":"old.exe","path":"C:/Old/old.exe"},"device":{"id":"d","name":"Mouse"},"settings":{"dpi":800}}]}); - let config: BridgeConfig = serde_json::from_value(json).unwrap(); - let config = config.normalized(); - assert_eq!( - config.profiles[0].application.name, - config.local_applications[0].name - ); - assert_eq!(config.profiles[0].application.executable, "editor.exe"); - assert_eq!(config.profiles[0].application.path, "C:/Tools/editor.exe"); - } - - #[test] - fn local_profile_identity_is_not_its_display_name_or_executable() { - let json = serde_json::json!({"profiles": [ - {"application":{"name":"Editor","executable":"editor.exe","path":"","localId":"local:one"}, "device":{"id":"d","name":"Mouse"},"settings":{}}, - {"application":{"name":"Editor","executable":"editor.exe","path":"","localId":"local:two"}, "device":{"id":"d","name":"Mouse"},"settings":{}}, - {"application":{"name":"Editor","executable":"editor.exe","path":""}, "device":{"id":"d","name":"Mouse"},"settings":{}} - ]}); - let config: BridgeConfig = serde_json::from_value(json).unwrap(); - let saved = serde_json::to_value(config.normalized()).unwrap(); - assert_eq!(saved["profiles"].as_array().unwrap().len(), 3); - assert!( - saved["profiles"] - .as_array() - .unwrap() - .iter() - .any(|p| p["application"]["localId"] == "local:one") - ); - } - - #[test] - fn local_targets_survive_config_roundtrip_and_catalog_refresh() { - let json = serde_json::json!({"localApplications": [{"id":"local:editor", "name":"Editor", "executable":"editor.exe", "path":"C:/Tools/editor.exe", "kind":"app"}]}); - let mut config: BridgeConfig = serde_json::from_value(json.clone()).unwrap(); - config.games = vec![GameConfig { - name: "Preset".into(), - executables: vec!["preset.exe".into()], - }]; - let saved = serde_json::to_value(config.normalized()).unwrap(); - assert_eq!(saved["localApplications"], json["localApplications"]); - let legacy: BridgeConfig = serde_json::from_str("{}").unwrap(); - assert_eq!( - serde_json::to_value(legacy).unwrap()["localApplications"], - serde_json::json!([]) - ); - } - #[test] fn normalization_clamps_threshold_and_cleans_executables() { let config = BridgeConfig { @@ -604,13 +303,9 @@ mod tests { automatic_updates: false, notification_sound: true, notification_volume: 180, - custom_games: Vec::new(), - tray_battery_style: " percentage ".to_owned(), - hybrid_mode: true, profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), - local_applications: Vec::new(), autostart_configured: false, tray_hint_shown: false, } @@ -618,7 +313,6 @@ mod tests { assert_eq!(config.battery_threshold_percent, 100); assert_eq!(config.alert_cooldown_minutes, 1); assert_eq!(config.notification_volume, 100); - assert_eq!(config.tray_battery_style, "percentage"); let valorant = config .games .iter() @@ -633,39 +327,6 @@ mod tests { ); } - #[test] - fn custom_games_are_tracked_beside_the_catalog_and_the_catalog_wins_name_clashes() { - let game = |name: &str, exe: &str| GameConfig { - name: name.into(), - executables: vec![exe.into()], - }; - let config = BridgeConfig { - games: vec![game("Valorant", "valorant.exe")], - custom_games: vec![ - game("My Game", " MyGame.EXE "), - game("valorant", "other.exe"), - ], - ..BridgeConfig::default() - } - .normalized(); - let names: Vec<_> = config - .tracked_games() - .into_iter() - .map(|game| game.name) - .collect(); - assert_eq!(names, ["Valorant", "My Game"]); - assert_eq!(config.custom_games[0].executables, ["mygame.exe"]); - } - - #[test] - fn hybrid_mode_defaults_to_on_and_normalizes_to_a_bool() { - let config: BridgeConfig = serde_json::from_str("{}").expect("empty config parses"); - assert!(config.hybrid_mode); - let config: BridgeConfig = - serde_json::from_str(r#"{"hybridMode": false}"#).expect("off config parses"); - assert!(!config.hybrid_mode); - } - #[test] fn profile_settings_keep_the_snapshot_and_accept_profiles_without_one() { let json = @@ -690,10 +351,6 @@ mod tests { name: name.into(), executable: executable.into(), path: String::new(), - local_id: None, - activation: None, - bundle_path: None, - bundle_identifier: None, }, device: ProfileDevice { id: device.into(), diff --git a/src/desktop.rs b/src/desktop.rs index a334e0a..7cd238f 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -20,7 +20,7 @@ use eframe::egui::{ }; use openmouse_bridge::{ BRIDGE_PORT, api, config, platform, - service::{BridgeService, BridgeSnapshot, DeviceBattery, Notice}, + service::{BridgeService, BridgeSnapshot, Notice}, updater::{self, UpdateInfo}, }; #[cfg(target_os = "windows")] @@ -123,20 +123,20 @@ fn run_headless( } } -#[cfg(any(target_os = "linux", test))] -fn drain_tray_events(mut pending: impl FnMut() -> bool, mut iterate: impl FnMut()) { - for _ in 0..32 { - if !pending() { - break; - } - iterate(); - } -} - -const WINDOW_WIDTH: f32 = 360.0; -const WINDOW_HEIGHT: f32 = 280.0; - -use crate::native_ui::{ACCENT, BACKGROUND, DANGER, DIVIDER, MUTED, ROW_HEIGHT, TEXT, WARNING}; +const WINDOW_WIDTH: f32 = 320.0; +const WINDOW_HEIGHT: f32 = 306.0; +/// Settings holds more rows than home, so the panel grows while it is open. +const SETTINGS_HEIGHT: f32 = 452.0; +const BACKGROUND: Color32 = Color32::from_rgb(16, 17, 19); +const SURFACE: Color32 = Color32::from_rgb(26, 28, 31); +const SURFACE_HOVER: Color32 = Color32::from_rgb(36, 39, 43); +const DIVIDER: Color32 = Color32::from_rgb(34, 36, 40); +const TEXT: Color32 = Color32::from_rgb(236, 238, 240); +const MUTED: Color32 = Color32::from_rgb(132, 138, 145); +const ACCENT: Color32 = Color32::from_rgb(93, 222, 137); +const DANGER: Color32 = Color32::from_rgb(248, 113, 113); +const ROW_HEIGHT: f32 = 34.0; +const MAX_BATTERY_ROWS: usize = 3; const BATTERY_THRESHOLDS: [u8; 7] = [10, 15, 20, 25, 30, 40, 50]; /// Glow/winit often tear down after Windows hibernate. If the tray loop ends /// without an Exit or in-app update, spawn a replacement (issue #16). @@ -146,73 +146,59 @@ const GUI_STABLE_AFTER: Duration = Duration::from_secs(30); const MAX_QUICK_RELAUNCHES: u32 = 5; const RELAUNCH_DELAY: Duration = Duration::from_secs(2); -fn clamp_panel(position: Pos2, size: Vec2, work_area: egui::Rect) -> Pos2 { - let min = work_area.min + Vec2::splat(8.0); - let max = (work_area.max - size - Vec2::splat(8.0)).max(min); - Pos2::new( - position.x.clamp(min.x, max.x), - position.y.clamp(min.y, max.y), - ) -} - -fn test_position() -> Option { - env::var("OPENMOUSE_BRIDGE_TEST_POSITION") - .ok() - .and_then(|value| parse_position(&value)) -} - -fn parse_position(value: &str) -> Option { - let (x, y) = value.split_once(',')?; - let position = Pos2::new(x.trim().parse().ok()?, y.trim().parse().ok()?); - position.is_finite().then_some(position) -} - -#[cfg(target_os = "windows")] -fn tray_work_area(rect: TrayRect, scale: f64) -> Option { - use windows_sys::Win32::{ - Foundation::RECT, - Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect}, - }; - let native_rect = RECT { - left: rect.position.x as i32, - top: rect.position.y as i32, - right: (rect.position.x + f64::from(rect.size.width)) as i32, - bottom: (rect.position.y + f64::from(rect.size.height)) as i32, - }; - let monitor = unsafe { MonitorFromRect(&native_rect, MONITOR_DEFAULTTONEAREST) }; - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() +fn toggle_control(ui: &mut egui::Ui, enabled: bool) -> bool { + let (rect, response) = ui.allocate_exact_size(Vec2::new(28.0, 16.0), Sense::click()); + let track = if enabled { ACCENT } else { SURFACE_HOVER }; + let knob = if enabled { BACKGROUND } else { MUTED }; + ui.painter().rect_filled(rect, 8.0, track); + let knob_x = if enabled { + rect.right() - 8.0 + } else { + rect.left() + 8.0 }; - if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { - return None; - } - let work = info.rcWork; - Some(egui::Rect::from_min_max( - Pos2::new( - (f64::from(work.left) / scale) as f32, - (f64::from(work.top) / scale) as f32, - ), - Pos2::new( - (f64::from(work.right) / scale) as f32, - (f64::from(work.bottom) / scale) as f32, - ), - )) + ui.painter() + .circle_filled(Pos2::new(knob_x, rect.center().y), 5.0, knob); + response.clicked() } -#[cfg(not(target_os = "windows"))] -fn tray_work_area(_rect: TrayRect, _scale: f64) -> Option { - None +#[derive(Clone, Copy)] +enum Glyph { + Gear, + Back, } -fn drag_header(ui: &mut egui::Ui, rect: egui::Rect, id: &'static str) { - let response = ui - .interact(rect, ui.id().with(id), Sense::drag()) - .on_hover_cursor(egui::CursorIcon::Grab) - .on_hover_text("Drag to move"); - if response.drag_started_by(egui::PointerButton::Primary) { - ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); +fn icon_button(ui: &mut egui::Ui, glyph: Glyph) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(Vec2::splat(24.0), Sense::click()); + let painter = ui.painter(); + if response.hovered() { + painter.rect_filled(rect, 6.0, SURFACE_HOVER); + } + let color = if response.hovered() { TEXT } else { MUTED }; + let center = rect.center(); + match glyph { + Glyph::Gear => { + painter.circle_stroke(center, 3.5, Stroke::new(1.5, color)); + for tooth in 0..8 { + let angle = tooth as f32 * std::f32::consts::FRAC_PI_4; + let direction = Vec2::angled(angle); + painter.line_segment( + [center + direction * 5.5, center + direction * 7.5], + Stroke::new(2.0, color), + ); + } + } + Glyph::Back => { + painter.line( + vec![ + center + Vec2::new(2.5, -5.0), + center + Vec2::new(-2.5, 0.0), + center + Vec2::new(2.5, 5.0), + ], + Stroke::new(1.5, color), + ); + } } + response } fn divider(ui: &mut egui::Ui) { @@ -221,73 +207,37 @@ fn divider(ui: &mut egui::Ui) { .hline(rect.x_range(), rect.center().y, Stroke::new(1.0, DIVIDER)); } +fn section_label(ui: &mut egui::Ui, label: &str) { + ui.add_space(14.0); + ui.label( + RichText::new(label.to_uppercase()) + .size(10.0) + .strong() + .color(MUTED), + ); + ui.add_space(2.0); +} + fn row(ui: &mut egui::Ui, label: &str, trailing: impl FnOnce(&mut egui::Ui)) { ui.allocate_ui_with_layout( Vec2::new(ui.available_width(), ROW_HEIGHT), Layout::left_to_right(Align::Center), |ui| { - ui.label(RichText::new(label).size(13.0).color(TEXT)); + ui.label(RichText::new(label).size(12.0).color(TEXT)); ui.with_layout(Layout::right_to_left(Align::Center), trailing); }, ); } -fn service_label(snapshot: Option<&BridgeSnapshot>) -> &'static str { - if snapshot.is_some() { - "Bridge service ready" - } else { - "Starting Bridge…" - } +fn value_row(ui: &mut egui::Ui, label: &str, value: &str, color: Color32) { + row(ui, label, |ui| { + ui.add(egui::Label::new(RichText::new(value).size(12.0).color(color)).truncate()); + }); } -fn profile_label(snapshot: Option<&BridgeSnapshot>) -> String { - if let Some(snapshot) = snapshot { - if snapshot.pending_native_restores > 0 { - let mut label = format!( - "Native restore pending ({})", - snapshot.pending_native_restores - ); - if let Some(error) = &snapshot.native_automation_error { - label.push_str(": "); - label.push_str(error); - } - return label; - } - if let Some(error) = &snapshot.native_automation_error { - return format!("Native settings not applied: {error}"); - } - } - snapshot - .and_then(|snapshot| { - snapshot - .active_profile - .as_ref() - .map(|profile| (snapshot, profile)) - }) - .map(|(snapshot, profile)| { - if snapshot.active_profile_is_default { - "Default profile selected".into() - } else { - format!("{} selected", profile.application.name) - } - }) - .unwrap_or_else(|| "No game profile active".into()) -} - -fn device_label(snapshot: Option<&BridgeSnapshot>) -> String { - snapshot - .and_then(|snapshot| snapshot.active_devices.first()) - .map(|device| device.name.clone()) - .unwrap_or_else(|| "No mouse session".into()) -} - -fn battery_text(battery: &openmouse_bridge::service::DeviceBattery) -> String { - match (battery.stale, battery.charging) { - (true, true) => format!("{}% · Last known (was charging)", battery.percent), - (true, false) => format!("{}% · Last known", battery.percent), - (false, true) => format!("{}% · Charging", battery.percent), - (false, false) => format!("{}%", battery.percent), - } +fn link_button(ui: &mut egui::Ui, label: &str, color: Color32) -> bool { + ui.add(Button::new(RichText::new(label).size(12.0).strong().color(color)).frame(false)) + .clicked() } #[derive(Clone, Copy, PartialEq, Eq)] @@ -296,20 +246,11 @@ enum View { Settings, } -fn forget_battery_button(ui: &mut egui::Ui, stale: bool) -> Option { - stale.then(|| { - crate::native_ui::button(ui, "Remove", None, false, 60.0) - .on_hover_text("Forget this stale battery report") - }) -} - enum BridgeCommand { SetBatteryThreshold(u8), SetNotificationSound(bool), SetNotificationVolume(u8), SetAutomaticUpdates(bool), - SetHybridMode(bool), - SetTrayBatteryStyle(platform::TrayBatteryStyle), CheckForUpdates, InstallUpdate(UpdateInfo), AcknowledgeTrayHint, @@ -340,158 +281,18 @@ enum UpdateState { Failed(String), } -fn update_is_notice(state: &UpdateState) -> bool { - matches!( - state, - UpdateState::Available(_) - | UpdateState::Downloading(_) - | UpdateState::Restarting(_) - | UpdateState::Failed(_) - ) -} - -/// Height added to the Home view while a device-summary row is showing -/// (the mouse's live onboard-memory settings under the profile panel). -const DEVICE_SUMMARY_HEIGHT: f32 = 22.0; - -fn panel_height( - view: View, - details: bool, - saved_values: bool, - notice: bool, - device_summary: bool, -) -> f32 { - if view == View::Settings { - return 420.0; - } - if details { - return 340.0; - } - let base = - (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) - .min(340.0); - base + if device_summary { - DEVICE_SUMMARY_HEIGHT - } else { - 0.0 - } -} - -/// The battery entry whose onboard-memory settings the Home view renders -/// under the profile panel — the freshest non-stale one carrying any. Both -/// the renderer and the window sizing go through this so they stay in step. -fn device_summary_battery(snapshot: Option<&BridgeSnapshot>) -> Option<&DeviceBattery> { - let snapshot = snapshot?; - snapshot - .batteries - .iter() - .find(|battery| !battery.stale) - .filter(|battery| { - battery.dpi.is_some() - || battery.polling_rate_hz.is_some() - || battery.switch_mode.is_some() - }) +enum UpdateUiAction { + Check, + Install(UpdateInfo), } #[derive(Clone, Copy)] enum TrayAction { - ShowWindow, ToggleWindow { rect: TrayRect }, Restart, Exit, } -/// The battery display currently swapped into the tray icon: `None` keeps -/// the static OpenMouse logo. Every field here feeds the drawing or the -/// tooltip, so comparing against the next snapshot redraws exactly when -/// the level, charge state, estimate, or size changed — and stops the -/// per-second updates from churning the icon when nothing did. -#[derive(Clone, PartialEq, Eq)] -struct TrayBattery { - device_name: String, - percent: u8, - charging: bool, - stale: bool, - /// The Segoe glyph's ink, from `battery_ink` (green charged/charging, - /// amber mid, alert red when low, gray when stale). - ink: Color32, - minutes_remaining: Option, - /// The notification area's icon size in physical pixels at the current - /// DPI; Windows reports it, so a display-scaling change redraws. - size: u32, - /// What fills the square: the battery glyph or the percentage number - /// (the control panel's tray-icon style). - style: platform::TrayBatteryStyle, -} - -/// The battery glyph's ink, by charge state: green while charging or above -/// half, amber through the mid band, the alert red at or below the -/// low-battery threshold, and gray when the reading is stale. -fn battery_ink(percent: u8, charging: bool, stale: bool, threshold: u8) -> Color32 { - use crate::native_ui::{ACCENT, DANGER, MUTED, WARNING}; - if stale { - MUTED - } else if charging { - ACCENT - } else if percent <= threshold { - DANGER - } else if percent <= 50 { - WARNING - } else { - ACCENT - } -} - -/// Renders the live tray icon from a cached battery display: either the -/// Segoe Fluent battery glyph (the charging variant with the bolt when -/// plugged in) or the percentage number, per the panel's tray-icon style, -/// tinted by charge state — green charged/charging, amber mid, red low, -/// gray stale. -fn battery_tray_icon(battery: &TrayBattery) -> Result { - let (rgba, width, height) = platform::battery_icon_rgba( - battery.percent, - battery.charging, - battery.style, - battery.size, - (battery.ink.r(), battery.ink.g(), battery.ink.b()), - )?; - Icon::from_rgba(rgba, width, height).context("could not create the battery tray icon") -} - -/// Formats a rough duration for the tooltip: "45 m", "3 h 20 m", "1 d 4 h". -fn remaining_text(minutes: u32) -> String { - let days = minutes / (24 * 60); - let hours = minutes % (24 * 60) / 60; - let minutes = minutes % 60; - if days > 0 { - if hours > 0 { - format!("{days} d {hours} h") - } else { - format!("{days} d") - } - } else if hours > 0 { - if minutes > 0 { - format!("{hours} h {minutes} m") - } else { - format!("{hours} h") - } - } else { - format!("{minutes} m") - } -} - -fn tray_menu_action(open: bool, restart: bool, quit: bool) -> Option { - if open { - Some(TrayAction::ShowWindow) - } else if restart { - Some(TrayAction::Restart) - } else if quit { - Some(TrayAction::Exit) - } else { - None - } -} - struct TrayState { icon: TrayIcon, menu: Menu, @@ -511,12 +312,8 @@ fn build_tray_icon(menu: &Menu) -> Result { impl TrayState { fn new(context: &egui::Context) -> Result { let menu = Menu::new(); - let open_item = MenuItem::new("Open OpenMouse Bridge", true, None); - menu.append(&open_item) - .context("could not add Open to the tray menu")?; - let open_id = open_item.id().clone(); let restart_item = MenuItem::new("Restart", true, None); - let exit_item = MenuItem::new("Quit Bridge", true, None); + let exit_item = MenuItem::new("Exit", true, None); menu.append(&restart_item) .context("could not add Restart to the tray menu")?; menu.append(&exit_item) @@ -546,11 +343,11 @@ impl TrayState { let menu_repaint = context.clone(); MenuEvent::set_event_handler(Some(move |event: MenuEvent| { tracing::debug!(?event, "received tray menu event"); - let Some(action) = tray_menu_action( - event.id == open_id, - event.id == restart_id, - event.id == exit_id, - ) else { + let action = if event.id == restart_id { + TrayAction::Restart + } else if event.id == exit_id { + TrayAction::Exit + } else { return; }; let _ = event_tx.send(action); @@ -567,33 +364,6 @@ impl TrayState { Ok(()) } - /// Swaps the tray icon for the live battery display: the battery glyph - /// drawn from `battery`'s level, theme, and state colors, with a tooltip - /// that spells the percentage out on hover. The previous icon's HICON is - /// destroyed by the tray backend when the new one replaces it, so - /// redrawing never leaks GDI handles. - fn set_battery_display(&mut self, battery: &TrayBattery, tooltip: &str) -> Result<()> { - let icon = battery_tray_icon(battery)?; - self.icon - .set_icon(Some(icon)) - .context("could not swap in the battery tray icon")?; - self.icon - .set_tooltip(Some(tooltip.to_owned())) - .context("could not update the battery tooltip")?; - Ok(()) - } - - /// Restores the static OpenMouse logo and its plain tooltip. - fn set_default_display(&mut self) -> Result<()> { - self.icon - .set_icon(Some(tray_icon()?)) - .context("could not restore the OpenMouse tray icon")?; - self.icon - .set_tooltip(Some("OpenMouse Bridge")) - .context("could not restore the tray tooltip")?; - Ok(()) - } - fn next_action(&self) -> Option { self.events.try_recv().ok() } @@ -609,8 +379,6 @@ struct TrayApp { overlay: Option, snapshot: Option, view: View, - details_open: bool, - requested_height: f32, visible: bool, visibility_initialized: bool, panel_had_focus: bool, @@ -618,15 +386,13 @@ struct TrayApp { keep_running: Arc, autostart: bool, battery_threshold: u8, - tray_battery: Option, - tray_style: platform::TrayBatteryStyle, - hybrid_mode: bool, automatic_updates: bool, notification_sound: bool, notification_volume: u8, // While the volume slider is dragged, snapshots would snap it back to the // saved value; it is saved once released. adjusting_volume: bool, + tray_rect: Option, last_tick: SystemTime, panel_requests: Receiver<()>, update_state: UpdateState, @@ -658,7 +424,32 @@ impl TrayApp { let overlay = Overlay::new() .inspect_err(|error| tracing::warn!(%error, "Could not create the overlay")) .ok(); - crate::native_ui::install(context); + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = BACKGROUND; + visuals.window_fill = BACKGROUND; + visuals.override_text_color = Some(TEXT); + visuals.window_stroke = Stroke::NONE; + visuals.window_shadow = egui::Shadow::NONE; + visuals.popup_shadow = egui::Shadow::NONE; + visuals.window_corner_radius = 8.into(); + visuals.menu_corner_radius = 8.into(); + visuals.selection.bg_fill = SURFACE_HOVER; + visuals.selection.stroke = Stroke::new(1.0, TEXT); + for (widget, fill) in [ + (&mut visuals.widgets.noninteractive, BACKGROUND), + (&mut visuals.widgets.inactive, SURFACE), + (&mut visuals.widgets.hovered, SURFACE_HOVER), + (&mut visuals.widgets.active, SURFACE_HOVER), + (&mut visuals.widgets.open, SURFACE_HOVER), + ] { + widget.bg_fill = fill; + widget.weak_bg_fill = fill; + widget.bg_stroke = Stroke::NONE; + widget.corner_radius = 6.into(); + widget.expansion = 0.0; + } + visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, DIVIDER); + context.set_visuals(visuals); let (logo_pixels, logo_width, logo_height) = openmouse_icon_rgba()?; let logo = context.load_texture( "openmouse-logo", @@ -678,8 +469,6 @@ impl TrayApp { overlay, snapshot: None, view: View::Home, - details_open: false, - requested_height: WINDOW_HEIGHT, visible, visibility_initialized: false, panel_had_focus: false, @@ -687,13 +476,11 @@ impl TrayApp { keep_running, autostart: platform::autostart_enabled(), battery_threshold: 20, - tray_battery: None, - tray_style: platform::TrayBatteryStyle::Glyph, - hybrid_mode: true, automatic_updates: false, notification_sound: true, notification_volume: 60, adjusting_volume: false, + tray_rect: None, last_tick: SystemTime::now(), panel_requests, update_state: UpdateState::Idle, @@ -707,9 +494,7 @@ impl TrayApp { context.send_viewport_cmd(ViewportCommand::Visible(visible)); if visible { self.show_view(context, View::Home); - if test_position().is_none() { - context.send_viewport_cmd(ViewportCommand::Focus); - } + context.send_viewport_cmd(ViewportCommand::Focus); } } @@ -719,37 +504,38 @@ impl TrayApp { } fn show_near_tray(&mut self, context: &egui::Context, rect: TrayRect) { + self.tray_rect = Some(rect); self.set_visible(context, true); - let height = self.requested_height; - if let Some(position) = test_position() { - context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); + } + + /// Switches to `view`, sizing the panel for it and keeping it against the + /// tray icon: below it on macOS, above the taskbar on Windows. + fn show_view(&mut self, context: &egui::Context, view: View) { + self.view = view; + let height = match view { + View::Home => WINDOW_HEIGHT, + View::Settings => SETTINGS_HEIGHT, + }; + context.send_viewport_cmd(ViewportCommand::InnerSize(Vec2::new(WINDOW_WIDTH, height))); + let Some(rect) = self.tray_rect else { return; - } + }; let scale = f64::from(context.pixels_per_point()); let x = (rect.position.x + f64::from(rect.size.width)) / scale - f64::from(WINDOW_WIDTH); #[cfg(target_os = "macos")] let y = (rect.position.y + f64::from(rect.size.height)) / scale + 8.0; #[cfg(any(target_os = "windows", target_os = "linux"))] let y = rect.position.y / scale - f64::from(height) - 8.0; - let position = Pos2::new(x as f32, y as f32); - let position = tray_work_area(rect, scale) - .map(|work| clamp_panel(position, Vec2::new(WINDOW_WIDTH, height), work)) - .unwrap_or(position); - context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); - } - - fn show_view(&mut self, _context: &egui::Context, view: View) { - self.view = view; - self.resize_panel(_context); + context.send_viewport_cmd(ViewportCommand::OuterPosition(Pos2::new( + x as f32, + y.max(8.0) as f32, + ))); } fn refresh_snapshot(&mut self) { while let Ok(snapshot) = self.snapshots.try_recv() { self.autostart = snapshot.autostart_enabled; self.battery_threshold = snapshot.battery_threshold_percent; - self.tray_style = - platform::TrayBatteryStyle::from_config_str(&snapshot.tray_battery_style); - self.hybrid_mode = snapshot.hybrid_mode; self.automatic_updates = snapshot.automatic_updates; self.notification_sound = snapshot.notification_sound; if !self.adjusting_volume { @@ -757,74 +543,6 @@ impl TrayApp { } self.snapshot = Some(snapshot); } - self.refresh_tray_battery_display(false); - } - - /// Refreshes the tray icon: a live battery glyph showing the mouse's - /// charge (or the percentage number, per the panel's tray-icon style) - /// while any battery is known, the static OpenMouse logo otherwise. - /// Redraws only when the display would change unless `force` — used - /// after icon recreation, when the fresh icon is the plain logo again. - fn refresh_tray_battery_display(&mut self, force: bool) { - let battery = self.snapshot.as_ref().and_then(|snapshot| { - snapshot - .batteries - .iter() - .find(|battery| !battery.stale) - .or_else(|| snapshot.batteries.first()) - }); - let shown = battery.map(|battery| TrayBattery { - device_name: battery.device_name.clone(), - percent: battery.percent.min(100), - charging: battery.charging, - stale: battery.stale, - ink: battery_ink( - battery.percent.min(100), - battery.charging, - battery.stale, - self.battery_threshold, - ), - minutes_remaining: battery.minutes_remaining, - size: platform::tray_icon_size(), - style: self.tray_style, - }); - if !force && shown == self.tray_battery { - return; - } - let outcome = match &shown { - Some(battery) => { - let status = match (battery.charging, battery.stale) { - (true, _) => " · Charging", - (false, true) => " · Not updating", - (false, false) => "", - }; - // Windows' tooltip buffer tops out at 128 UTF-16 units, and - // device names can be long. - let eta = battery.minutes_remaining.map(|minutes| { - if battery.charging { - format!("full in {}", remaining_text(minutes)) - } else { - format!("{} left", remaining_text(minutes)) - } - }); - let tooltip = match eta { - Some(eta) => { - format!( - "{} — {}%{status} · {eta}", - battery.device_name, battery.percent - ) - } - None => format!("{} — {}%{status}", battery.device_name, battery.percent), - }; - self.tray - .set_battery_display(battery, &tooltip.chars().take(120).collect::()) - } - None => self.tray.set_default_display(), - }; - match outcome { - Ok(()) => self.tray_battery = shown, - Err(error) => tracing::warn!(%error, "Could not update the tray battery display"), - } } /// Shows pending notices and advances the banner's fade. @@ -880,21 +598,10 @@ impl TrayApp { } } -fn should_hide_on_escape(visible: bool, context: &egui::Context) -> bool { - visible - && context.input(|input| input.key_pressed(egui::Key::Escape)) - && !egui::Popup::is_any_open(context) -} - impl eframe::App for TrayApp { fn logic(&mut self, context: &egui::Context, _frame: &mut eframe::Frame) { - #[cfg(target_os = "linux")] - drain_tray_events(gtk::events_pending, || { - gtk::main_iteration_do(false); - }); self.restore_tray_after_resume(); self.refresh_snapshot(); - self.resize_panel(context); if self.refresh_updates() { // The updater has already spawned its own post-exit relaunch helper. self.keep_running.store(false, Ordering::Relaxed); @@ -904,7 +611,7 @@ impl eframe::App for TrayApp { if !self.visibility_initialized { self.visibility_initialized = true; context.send_viewport_cmd(ViewportCommand::Visible(self.visible)); - if self.visible && test_position().is_none() { + if self.visible { context.send_viewport_cmd(ViewportCommand::Focus); } } @@ -914,7 +621,6 @@ impl eframe::App for TrayApp { } while let Some(action) = self.tray.next_action() { match action { - TrayAction::ShowWindow => self.set_visible(context, true), TrayAction::ToggleWindow { rect } => { tracing::debug!(?rect, visible = self.visible, "handling tray toggle"); if self.visible { @@ -934,9 +640,6 @@ impl eframe::App for TrayApp { } } } - if should_hide_on_escape(self.visible, context) { - self.set_visible(context, false); - } if self.visible { match context.input(|input| input.viewport().focused) { Some(true) => self.panel_had_focus = true, @@ -956,19 +659,12 @@ impl eframe::App for TrayApp { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - ui.spacing_mut().item_spacing.y = 0.0; Frame::NONE .fill(BACKGROUND) - .stroke(Stroke::new(1.0, DIVIDER)) - .show(ui, |ui| { - self.titlebar(ui); - Frame::NONE.inner_margin(12).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - match self.view { - View::Home => self.home_view(ui), - View::Settings => self.settings_view(ui), - } - }); + .inner_margin(egui::Margin::symmetric(18, 16)) + .show(ui, |ui| match self.view { + View::Home => self.home_view(ui), + View::Settings => self.settings_view(ui), }); } } @@ -986,593 +682,369 @@ impl TrayApp { if let Err(error) = self.tray.recreate_icon() { tracing::error!(%error, "could not recreate the tray icon after resume"); } - // The fresh icon is the plain logo; put the battery display back. - self.refresh_tray_battery_display(true); } - fn titlebar(&mut self, ui: &mut egui::Ui) { - use crate::native_ui as n; - let mut controls = Vec::new(); - let header = Frame::NONE - .fill(n::CHROME) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.spacing_mut().item_spacing = Vec2::new(3.0, 0.0); - ui.horizontal(|ui| { - ui.set_height(28.0); - if self.view == View::Settings { - let back = n::icon_button(ui, n::Icon::Back, "Back to Home"); - controls.push(back.rect); - if back.clicked() { - self.show_view(ui.ctx(), View::Home); - } - ui.label(RichText::new("Settings").size(13.0).strong()); + fn home_view(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.set_height(24.0); + let (logo_rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover()); + ui.put( + logo_rect, + egui::Image::new((self.logo.id(), Vec2::new(13.0, 19.0))), + ); + ui.add_space(2.0); + ui.label(RichText::new("OpenMouse Bridge").size(13.0).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if icon_button(ui, Glyph::Gear) + .on_hover_text("Settings") + .clicked() + { + self.show_view(ui.ctx(), View::Settings); + } + }); + }); + + let snapshot = self.snapshot.as_ref(); + let connected = snapshot.is_some_and(|snapshot| snapshot.client_connected); + ui.add_space(22.0); + ui.horizontal(|ui| { + let (dot_rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().circle_filled( + dot_rect.center(), + 4.0, + if connected { ACCENT } else { MUTED }, + ); + ui.label( + RichText::new(if connected { "Connected" } else { "Ready" }) + .size(20.0) + .strong(), + ); + }); + let device_name = snapshot.and_then(|snapshot| { + snapshot + .active_devices + .first() + .map(|device| device.name.clone()) + .or_else(|| { + snapshot + .batteries + .first() + .map(|battery| battery.device_name.clone()) + }) + .or_else(|| { + snapshot + .active_profile + .as_ref() + .map(|profile| profile.device.name.clone()) + }) + }); + let subtitle = device_name.unwrap_or_else(|| { + if connected { + "The control panel is using Bridge".into() + } else { + "Open the control panel to connect".into() + } + }); + ui.add(egui::Label::new(RichText::new(subtitle).size(12.0).color(MUTED)).truncate()); + + ui.add_space(18.0); + // Name the profile that is actually applied: a game's while it runs, + // an application's while it is in front, otherwise Default. + let profile = snapshot + .filter(|snapshot| !snapshot.active_profile_is_default) + .and_then(|snapshot| snapshot.active_profile.as_ref()) + .map(|profile| profile.application.name.clone()) + .unwrap_or_else(|| "Default".into()); + divider(ui); + value_row(ui, "Profile", &profile, MUTED); + divider(ui); + match snapshot.map(|snapshot| snapshot.batteries.as_slice()) { + Some(batteries) if !batteries.is_empty() => { + for battery in batteries.iter().take(MAX_BATTERY_ROWS) { + let value = if battery.charging { + format!("{}% · Charging", battery.percent) } else { - ui.add(egui::Image::new((self.logo.id(), Vec2::new(12.0, 18.0)))); - ui.add_space(4.0); - ui.vertical(|ui| { - ui.label(RichText::new("OpenMouse Bridge").size(12.5).strong()); - ui.label( - RichText::new(format!("v{}", openmouse_bridge::BRIDGE_VERSION)) - .size(10.0) - .color(n::QUIET), + format!("{}%", battery.percent) + }; + let color = if battery.stale { + MUTED + } else if battery.percent <= self.battery_threshold && !battery.charging { + DANGER + } else { + TEXT + }; + // Battery history can belong to a different mouse than + // the active HID device, even when there is only one row. + if battery.stale { + row(ui, &battery.device_name, |ui| { + if link_button(ui, "Remove", MUTED) { + let _ = self + .commands + .send(BridgeCommand::ForgetBattery(battery.device_id.clone())); + } + ui.add( + egui::Label::new(RichText::new(&value).size(12.0).color(color)) + .truncate(), ); }); + } else { + value_row(ui, &battery.device_name, &value, color); } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let close = n::icon_button( - ui, - n::Icon::Close, - "Close to tray: Bridge keeps running", - ); - controls.push(close.rect); - if close.clicked() { - self.set_visible(ui.ctx(), false); - } - let minimize = n::icon_button( - ui, - n::Icon::Minimize, - "Minimize to tray: Bridge keeps running", - ); - controls.push(minimize.rect); - if minimize.clicked() { - self.set_visible(ui.ctx(), false); - } - if self.view == View::Home { - let settings = n::icon_button(ui, n::Icon::Gear, "Settings"); - controls.push(settings.rect); - if settings.clicked() { - self.show_view(ui.ctx(), View::Settings); - } - } - }); - }); - }); - controls.sort_by(|a, b| a.left().total_cmp(&b.left())); - let mut left = header.response.rect.left(); - for (index, control) in controls.iter().enumerate() { - let rect = egui::Rect::from_min_max( - Pos2::new(left, header.response.rect.top()), - Pos2::new(control.left(), header.response.rect.bottom()), - ); - if rect.width() > 1.0 { - ui.push_id(index, |ui| drag_header(ui, rect, "titlebar-drag")); + divider(ui); + } + } + _ => { + value_row(ui, "Battery", "Unknown", MUTED); + divider(ui); } - left = control.right(); - } - if left < header.response.rect.right() { - let rect = egui::Rect::from_min_max( - Pos2::new(left, header.response.rect.top()), - header.response.rect.max, - ); - ui.push_id("titlebar-end", |ui| drag_header(ui, rect, "titlebar-drag")); } - divider(ui); - } - fn home_view(&mut self, ui: &mut egui::Ui) { - use crate::native_ui as n; - let starting = self.snapshot.is_none(); - let connected = self.snapshot.as_ref().is_some_and(|s| s.client_connected); - let device = self - .snapshot - .as_ref() - .and_then(|s| s.active_devices.first()) - .cloned(); - let device_count = self.snapshot.as_ref().map_or(0, |s| s.active_devices.len()); - let profile = self - .snapshot - .as_ref() - .and_then(|s| s.active_profile.clone()); - let is_default = self - .snapshot - .as_ref() - .is_some_and(|s| s.active_profile_is_default); - let profile_count = self.snapshot.as_ref().map_or(0, |s| s.profile_count); - egui::ScrollArea::vertical().id_salt("home-content").auto_shrink([false, false]) - .max_height((ui.available_height() - 46.0).max(80.0)).show(ui, |ui| { - if let Some(error) = &self.last_error { ui.label(RichText::new(error).size(12.0).color(DANGER)); } - n::panel(ui, true, 12, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let (rect, _) = ui.allocate_exact_size(Vec2::splat(38.0), Sense::hover()); - ui.painter().rect_filled(rect, 8, n::CHROME); - ui.painter().rect_stroke(rect, 8, Stroke::new(1.0, DIVIDER), egui::StrokeKind::Inside); - n::icon(ui.painter(), rect, n::Icon::Mouse, if device.is_some() { ACCENT } else { MUTED }); - ui.add_space(4.0); - ui.vertical(|ui| { - ui.add(egui::Label::new(RichText::new(if starting { "Starting Bridge…" } else { device.as_ref().map_or("Ready to connect", |d| d.name.as_str()) }).size(17.0).strong()).truncate()); - ui.label(RichText::new(if starting { "Waiting for service" } else if device.is_some() { "Mouse session open" } else { "Connect a mouse in OpenMouse." }).size(12.0).color(MUTED)); - }); - }); - if device_count > 1 { - ui.label(RichText::new(format!("+{} other open sessions", device_count - 1)).size(11.0).color(MUTED)); - } - }); - ui.add_space(4.0); - n::panel(ui, false, 5, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover()); - ui.painter().circle_filled(dot.center(), 2.5, if starting { MUTED } else { ACCENT }); - ui.label(RichText::new(if starting { "Starting" } else { "Bridge ready" }).size(11.5).color(MUTED)); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if n::icon_button(ui, n::Icon::Info, "Connection details").clicked() { self.details_open = !self.details_open; } - ui.label(RichText::new(if connected { "Panel connected" } else { "Panel offline" }).size(11.5).color(if connected { TEXT } else { MUTED })); - }); - }); - }); - ui.add_space(4.0); - n::panel(ui, false, 10, |ui| { - ui.set_width(ui.available_width()); + let mut install = None; + let notice = match &self.update_state { + UpdateState::Available(update) => Some(format!("Update {} available", update.version)), + UpdateState::Downloading(version) => Some(format!("Downloading {version}…")), + UpdateState::Restarting(version) => Some(format!("Restarting into {version}…")), + _ => None, + }; + if let Some(notice) = notice { + ui.add_space(12.0); + Frame::NONE + .fill(SURFACE) + .corner_radius(8.0) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { ui.horizontal(|ui| { - let (icon, _) = ui.allocate_exact_size(Vec2::splat(16.0), Sense::hover()); - n::icon(ui.painter(), icon, n::Icon::Game, MUTED); - ui.label(RichText::new("Mouse profile").size(12.0).color(MUTED)); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.add(egui::Label::new(RichText::new(profile.as_ref().map_or("None selected", |p| if is_default { "Default profile" } else { p.application.name.as_str() })).size(12.5).strong()).truncate()) - .on_hover_text(profile.as_ref().map_or_else(|| format!("{profile_count} saved profiles. Profiles are configured in OpenMouse."), |p| format!("Selected for {}. Hardware application is not confirmed here.", p.device.name))); - }); - }); - if let Some(profile) = &profile { - let mut values = Vec::new(); - if let Some(dpi) = profile.settings.dpi { values.push(format!("{dpi} DPI")); } - if let Some(rate) = profile.settings.polling_rate_hz { values.push(format!("{rate} Hz")); } - if !values.is_empty() { ui.label(RichText::new(format!("Saved: {}", values.join(" · "))).size(12.0).color(MUTED)); } - } - // The mouse's own onboard-memory settings, read natively - // wherever its driver can see them — the details someone - // changing DPI or polling rate actually needs, profile or - // no profile. - if let Some(device) = device_summary_battery(self.snapshot.as_ref()) { - let mut values = Vec::new(); - if let Some(dpi) = device.dpi { values.push(format!("{dpi} DPI")); } - if let Some(rate) = device.polling_rate_hz { values.push(format!("{rate} Hz")); } - if let Some(mode) = &device.switch_mode { values.push(mode.clone()); } - if !values.is_empty() { - ui.label(RichText::new(format!("Onboard: {}", values.join(" · "))).size(12.0).color(MUTED)); + ui.label(RichText::new(notice).size(12.0)); + if let UpdateState::Available(update) = &self.update_state { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if link_button(ui, "Install", ACCENT) { + install = Some(update.clone()); + } + }); } - } - if self.snapshot.as_ref().is_some_and(|snapshot| snapshot.native_automation_error.is_some() || snapshot.pending_native_restores > 0) { - ui.label(RichText::new(profile_label(self.snapshot.as_ref())).size(12.0).color(DANGER)); - } + }); }); - if update_is_notice(&self.update_state) { ui.add_space(4.0); self.update_row(ui); } - if self.details_open { self.diagnostics(ui); } - }); - ui.add_space(8.0); - if n::button( - ui, - "Open control panel", - Some(n::Icon::External), - true, - ui.available_width(), - ) - .clicked() - { - self.last_error = open_openmouse().err().map(|error| error.to_string()); } - } + if let Some(update) = install { + self.send_command( + BridgeCommand::InstallUpdate(update), + "Bridge update service is unavailable", + ); + } - fn settings_view(&mut self, ui: &mut egui::Ui) { - use crate::native_ui as n; - egui::ScrollArea::vertical() - .id_salt("settings-content") - .auto_shrink([false, false]) - .max_height((ui.available_height() - 36.0).max(80.0)) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - if let Some(error) = &self.last_error { - ui.label(RichText::new(error).size(12.0).color(DANGER)); - } - n::panel(ui, false, 8, |ui| { - ui.set_width(ui.available_width()); - let mut enabled = self.autostart; - if n::switch_row(ui, "Launch at login", &mut enabled) - .on_hover_text("Keep Bridge in the tray when you sign in.") - .changed() - { - match platform::set_autostart(enabled) { - Ok(()) => { - self.autostart = enabled; - self.last_error = None; - } - Err(error) => self.last_error = Some(error.to_string()), - } - } - }); - ui.add_space(6.0); - n::panel(ui, false, 8, |ui| { - ui.set_width(ui.available_width()); - ui.label( - RichText::new("Device mode") + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + if ui + .add_sized( + [ui.available_width(), 36.0], + Button::new( + RichText::new("Open control panel") .size(12.0) .strong() - .color(MUTED), - ); - let mut enabled = self.hybrid_mode; - if n::switch_row(ui, "Hybrid", &mut enabled) - .on_hover_text( - "While the web panel is closed, Bridge applies the mouse's stored settings and reads the battery itself.", - ) - .changed() - && self.send_command( - BridgeCommand::SetHybridMode(enabled), - "Bridge settings service is unavailable", - ) - { - self.hybrid_mode = enabled; - } - ui.label( - RichText::new(if self.hybrid_mode { - "While the web panel is closed, Bridge applies the mouse's \ - stored settings and reads the battery itself" - } else { - "Only the web panel changes the mouse" - }) - .size(11.0) - .color(MUTED), - ); - divider(ui); - let mut selected_style = self.tray_style; - row(ui, "Battery display", |ui| { - let choice = egui::ComboBox::from_id_salt("tray-battery-style") - .width(110.0) - .selected_text( - RichText::new(match self.tray_style { - platform::TrayBatteryStyle::Glyph => "Battery glyph", - platform::TrayBatteryStyle::Percentage => "Percentage", - }) - .size(12.0), - ) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut selected_style, - platform::TrayBatteryStyle::Glyph, - "Battery glyph", - ); - ui.selectable_value( - &mut selected_style, - platform::TrayBatteryStyle::Percentage, - "Percentage", - ); - }); - choice.response.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::ComboBox, - ui.is_enabled(), - "Battery display", - ) - }); - }); - if selected_style != self.tray_style - && self.send_command( - BridgeCommand::SetTrayBatteryStyle(selected_style), - "Bridge settings service is unavailable", - ) - { - self.tray_style = selected_style; - // Redraw the tray at once; the service's next snapshot - // only confirms what is already on screen. - self.refresh_tray_battery_display(true); - } - }); - ui.add_space(6.0); - n::panel(ui, false, 8, |ui| { - ui.set_width(ui.available_width()); - ui.label( - RichText::new("Notifications") - .size(12.0) - .strong() - .color(MUTED), - ); - let mut threshold = self.battery_threshold; - row(ui, "Low battery alert", |ui| { - let choice = egui::ComboBox::from_id_salt("battery-threshold") - .width(62.0) - .selected_text(format!("{threshold}%")) - .show_ui(ui, |ui| { - for percent in BATTERY_THRESHOLDS { - if ui - .selectable_value( - &mut threshold, - percent, - format!("{percent}%"), - ) - .clicked() - { - ui.close(); - } - } - }); - choice.response.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::ComboBox, - ui.is_enabled(), - "Low battery alert", - ) - }); - choice - .response - .on_hover_text("Notify at or below this reported charge."); - }); - if threshold != self.battery_threshold - && self.send_command( - BridgeCommand::SetBatteryThreshold(threshold), - "Battery alert setting could not be sent", - ) - { - self.battery_threshold = threshold; - } - divider(ui); - let mut sound = self.notification_sound; - if n::switch_row(ui, "Notification sound", &mut sound) - .on_hover_text("Play a chime with profile and battery notices.") - .changed() - && self.send_command( - BridgeCommand::SetNotificationSound(sound), - "Sound setting could not be sent", - ) - { - self.notification_sound = sound; - } - divider(ui); - let mut released = false; - row(ui, "Volume", |ui| { - ui.add_enabled_ui(self.notification_sound, |ui| { - ui.label( - RichText::new(format!("{}%", self.notification_volume)) - .size(12.0) - .color(MUTED), - ); - ui.visuals_mut().selection.bg_fill = ACCENT; - ui.spacing_mut().slider_width = 108.0; - let response = ui.add( - egui::Slider::new(&mut self.notification_volume, 0..=100) - .show_value(false) - .trailing_fill(true), - ); - response.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Slider, - ui.is_enabled(), - "Notification volume", - ) - }); - self.adjusting_volume = response.is_pointer_button_down_on(); - released = response.drag_stopped() - || (response.changed() && !self.adjusting_volume); - }); - }); - if released { - self.send_command( - BridgeCommand::SetNotificationVolume(self.notification_volume), - "Volume setting could not be sent", - ); - } - divider(ui); - let mut test = false; - row(ui, "Test notification", |ui| { - test = n::button(ui, "Test", None, false, 48.0).clicked(); - }); - if test { - self.present(&Notice { - title: "OpenMouse Bridge".into(), - detail: "This is how notifications look".into(), - }); - ui.ctx().request_repaint(); - } - }); + .color(BACKGROUND), + ) + .fill(ACCENT) + .stroke(Stroke::NONE) + .corner_radius(8.0), + ) + .clicked() + && let Err(error) = open_openmouse() + { + self.last_error = Some(error.to_string()); + } + if let Some(error) = &self.last_error { ui.add_space(6.0); - n::panel(ui, false, 8, |ui| { - ui.set_width(ui.available_width()); - ui.label(RichText::new("Updates").size(12.0).strong().color(MUTED)); - let mut automatic = self.automatic_updates; - if n::switch_row(ui, "Automatic updates", &mut automatic) - .on_hover_text( - "Download, install and restart Bridge when an update is found.", - ) - .changed() - && self.send_command( - BridgeCommand::SetAutomaticUpdates(automatic), - "Update preference could not be sent", - ) - { - self.automatic_updates = automatic; - } - divider(ui); - self.update_row(ui); - }); - if self.details_open { - self.diagnostics(ui); - } - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - if n::button(ui, "Open logs", Some(n::Icon::Logs), false, 94.0).clicked() { - self.last_error = open_logs().err().map(|e| e.to_string()); + ui.label(RichText::new(error).size(11.0).color(DANGER)); } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui - .add( - Button::new(RichText::new("Quit Bridge").size(12.0).color(DANGER)) - .min_size(Vec2::new(84.0, 26.0)), - ) - .clicked() - { - self.keep_running.store(false, Ordering::Relaxed); - self.request_exit(ui.ctx()); - } - if n::icon_button(ui, n::Icon::Info, "Connection details").clicked() { - self.details_open = !self.details_open; - } - }); }); } - fn update_row(&mut self, ui: &mut egui::Ui) { - use crate::native_ui as n; - let (label, action): (String, Option<&str>) = match &self.update_state { - UpdateState::Idle => ("Not checked yet".into(), Some("Check")), - UpdateState::Checking => ("Checking…".into(), None), - UpdateState::UpToDate => ("Up to date".into(), Some("Check")), - UpdateState::Available(update) => { - (format!("{} available", update.version), Some("Install")) + fn settings_view(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.set_height(24.0); + if icon_button(ui, Glyph::Back).on_hover_text("Back").clicked() { + self.show_view(ui.ctx(), View::Home); } - UpdateState::Downloading(version) => (format!("Downloading {version}…"), None), - UpdateState::Restarting(version) => (format!("Restarting {version}…"), None), - UpdateState::Failed(_) => ("Update check failed".into(), Some("Retry")), - }; - let mut clicked = false; - row(ui, &label, |ui| { - if let Some(action) = action { - clicked = n::button(ui, action, None, false, 60.0).clicked(); + ui.label(RichText::new("Settings").size(13.0).strong()); + }); + + section_label(ui, "General"); + let mut toggle_autostart = false; + row(ui, "Launch at login", |ui| { + toggle_autostart = toggle_control(ui, self.autostart); + }); + if toggle_autostart { + let enabled = !self.autostart; + match platform::set_autostart(enabled) { + Ok(()) => { + self.autostart = enabled; + self.last_error = None; + } + Err(error) => self.last_error = Some(error.to_string()), } + } + + section_label(ui, "Notifications"); + let mut selected_threshold = self.battery_threshold; + row(ui, "Low battery alert", |ui| { + egui::ComboBox::from_id_salt("battery-threshold") + .width(64.0) + .selected_text(RichText::new(format!("{}%", self.battery_threshold)).size(12.0)) + .show_ui(ui, |ui| { + for percent in BATTERY_THRESHOLDS { + ui.selectable_value( + &mut selected_threshold, + percent, + format!("{percent}%"), + ); + } + }); }); - if let UpdateState::Failed(error) = &self.update_state { - ui.add(egui::Label::new(RichText::new(error).size(11.0).color(DANGER)).truncate()) - .on_hover_text(error); + if selected_threshold != self.battery_threshold + && self.send_command( + BridgeCommand::SetBatteryThreshold(selected_threshold), + "Bridge settings service is unavailable", + ) + { + self.battery_threshold = selected_threshold; } - if clicked { - let command = match &self.update_state { - UpdateState::Available(update) => BridgeCommand::InstallUpdate(update.clone()), - _ => BridgeCommand::CheckForUpdates, - }; - self.send_command(command, "Bridge update service is unavailable"); + divider(ui); + let mut toggle_sound = false; + row(ui, "Sound", |ui| { + toggle_sound = toggle_control(ui, self.notification_sound); + }); + if toggle_sound { + let enabled = !self.notification_sound; + if self.send_command( + BridgeCommand::SetNotificationSound(enabled), + "Bridge settings service is unavailable", + ) { + self.notification_sound = enabled; + } } - } - - fn diagnostics(&self, ui: &mut egui::Ui) { - use crate::native_ui as n; - ui.add_space(6.0); - n::panel(ui, false, 8, |ui| { - ui.set_width(ui.available_width()); - ui.label(RichText::new("Connection details").size(12.0).strong()); - ui.label( - RichText::new(service_label(self.snapshot.as_ref())) - .size(11.5) - .color(MUTED), - ); - ui.label( - RichText::new(format!( - "{} · 127.0.0.1:{BRIDGE_PORT}", - platform::platform_name() - )) - .size(11.0) - .color(MUTED), - ); - if let Some(snapshot) = &self.snapshot { + divider(ui); + let mut volume_released = false; + row(ui, "Volume", |ui| { + ui.add_enabled_ui(self.notification_sound, |ui| { ui.label( - RichText::new(device_label(Some(snapshot))) - .size(11.5) + RichText::new(format!("{}%", self.notification_volume)) + .size(12.0) .color(MUTED), ); - ui.label( - RichText::new(profile_label(Some(snapshot))) - .size(11.5) - .color(MUTED), + ui.spacing_mut().slider_width = 120.0; + ui.visuals_mut().selection.bg_fill = ACCENT; + let response = ui.add( + egui::Slider::new(&mut self.notification_volume, 0..=100) + .show_value(false) + .trailing_fill(true), ); - for device in &snapshot.active_devices { - ui.label( - RichText::new(format!( - "{} · {:04x}:{:04x}", - device.name, device.vendor_id, device.product_id - )) - .size(11.0) - .color(MUTED), - ); + self.adjusting_volume = response.is_pointer_button_down_on(); + volume_released = + response.drag_stopped() || (response.changed() && !self.adjusting_volume); + }); + }); + if volume_released { + self.send_command( + BridgeCommand::SetNotificationVolume(self.notification_volume), + "Bridge settings service is unavailable", + ); + } + divider(ui); + let mut test = false; + row(ui, "Test notification", |ui| { + test = link_button(ui, "Test", ACCENT); + }); + if test { + self.present(&Notice { + title: "OpenMouse Bridge".into(), + detail: "This is how notifications look".into(), + }); + ui.ctx().request_repaint(); + } + + section_label(ui, "Updates"); + let mut toggle_automatic_updates = false; + row(ui, "Automatic updates", |ui| { + toggle_automatic_updates = toggle_control(ui, self.automatic_updates); + }); + if toggle_automatic_updates { + let enabled = !self.automatic_updates; + if self.send_command( + BridgeCommand::SetAutomaticUpdates(enabled), + "Bridge settings service is unavailable", + ) { + self.automatic_updates = enabled; + } + } + divider(ui); + let (status, color, action_label) = match &self.update_state { + UpdateState::Idle => ("Not checked yet".to_owned(), MUTED, Some("Check")), + UpdateState::Checking => ("Checking…".to_owned(), MUTED, None), + UpdateState::UpToDate => ("Up to date".to_owned(), MUTED, Some("Check")), + UpdateState::Available(update) => ( + format!("{} available", update.version), + ACCENT, + Some("Install"), + ), + UpdateState::Downloading(version) => (format!("Downloading {version}…"), MUTED, None), + UpdateState::Restarting(version) => { + (format!("Restarting into {version}…"), ACCENT, None) + } + UpdateState::Failed(_) => ("Check failed".to_owned(), DANGER, Some("Retry")), + }; + let mut update_action = None; + ui.allocate_ui_with_layout( + Vec2::new(ui.available_width(), ROW_HEIGHT), + Layout::left_to_right(Align::Center), + |ui| { + let response = ui.label(RichText::new(status).size(12.0).color(color)); + if let UpdateState::Failed(error) = &self.update_state { + response.on_hover_text(error); } - for battery in &snapshot.batteries { - let color = if !battery.stale - && !battery.charging - && battery.percent <= self.battery_threshold - { - WARNING - } else { - MUTED - }; - ui.horizontal_wrapped(|ui| { - if forget_battery_button(ui, battery.stale) - .is_some_and(|response| response.clicked()) - { - let _ = self - .commands - .send(BridgeCommand::ForgetBattery(battery.device_id.clone())); + if let Some(action_label) = action_label { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if link_button(ui, action_label, ACCENT) { + update_action = Some(match &self.update_state { + UpdateState::Available(update) => { + UpdateUiAction::Install(update.clone()) + } + _ => UpdateUiAction::Check, + }); } - ui.label( - RichText::new(format!( - "{}: {}", - battery.device_name, - battery_text(battery) - )) - .size(11.5) - .color(color), - ); }); } - if !snapshot.batteries.is_empty() { - ui.label( - RichText::new("Battery reports are not proof of an open device session.") - .size(11.0) - .color(MUTED), - ); - } - } - }); - } - - fn resize_panel(&mut self, context: &egui::Context) { - let has_values = self - .snapshot - .as_ref() - .and_then(|s| s.active_profile.as_ref()) - .is_some_and(|p| p.settings.dpi.is_some() || p.settings.polling_rate_hz.is_some()); - let has_summary = device_summary_battery(self.snapshot.as_ref()).is_some(); - let height = panel_height( - self.view, - self.details_open, - has_values, - update_is_notice(&self.update_state), - has_summary, + }, ); - if height != self.requested_height { - let origin = context - .input(|i| i.viewport().outer_rect.map(|rect| rect.min)) - .or_else(test_position); - context.send_viewport_cmd(ViewportCommand::InnerSize(Vec2::new(WINDOW_WIDTH, height))); - if let Some(origin) = origin { - let scale = f64::from(context.pixels_per_point()); - let anchor = TrayRect { - position: (f64::from(origin.x) * scale, f64::from(origin.y) * scale).into(), - size: (1, 1).into(), - }; - let position = tray_work_area(anchor, scale).map_or(origin, |work| { - clamp_panel(origin, Vec2::new(WINDOW_WIDTH, height), work) + if let Some(action) = update_action { + let command = match action { + UpdateUiAction::Check => BridgeCommand::CheckForUpdates, + UpdateUiAction::Install(update) => BridgeCommand::InstallUpdate(update), + }; + self.send_command(command, "Bridge update service is unavailable"); + } + + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "v{} · {} · 127.0.0.1:{BRIDGE_PORT}", + openmouse_bridge::BRIDGE_VERSION, + platform::platform_name() + )) + .size(10.0) + .color(MUTED), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if link_button(ui, "Quit", DANGER) { + self.keep_running.store(false, Ordering::Relaxed); + self.request_exit(ui.ctx()); + } }); - context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); + }); + if let Some(error) = &self.last_error { + ui.add_space(6.0); + ui.label(RichText::new(error).size(11.0).color(DANGER)); } - self.requested_height = height; - } + }); } fn send_command(&mut self, command: BridgeCommand, unavailable: &str) -> bool { @@ -1706,7 +1178,7 @@ pub fn run() -> Result<()> { // like nothing happened, and an elevated process's tray icon does not // show in the normal notification area. let visible = show_panel || env::var_os("OPENMOUSE_BRIDGE_SHOW_WINDOW").is_some(); - let mut options = eframe::NativeOptions { + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT]) .with_decorations(false) @@ -1717,9 +1189,6 @@ pub fn run() -> Result<()> { renderer: eframe::Renderer::Glow, ..Default::default() }; - if let Some(position) = test_position() { - options.viewport = options.viewport.with_position(position).with_active(false); - } let gui_started = Instant::now(); let tray_result = match panic::catch_unwind(AssertUnwindSafe(|| { eframe::run_native( @@ -1938,19 +1407,6 @@ fn run_server( } } } - BridgeCommand::SetHybridMode(enabled) => { - if let Err(error) = command_service.set_hybrid_mode(enabled).await { - tracing::error!(%error, "could not save Bridge settings"); - } - } - BridgeCommand::SetTrayBatteryStyle(style) => { - if let Err(error) = command_service - .set_tray_battery_style(style.as_config_str()) - .await - { - tracing::error!(%error, "could not save Bridge settings"); - } - } BridgeCommand::CheckForUpdates => { check_for_updates(false, &update_events).await; } @@ -2082,21 +1538,6 @@ fn tray_icon() -> Result { Icon::from_rgba(rgba, width, height).context("could not create the OpenMouse tray icon") } -fn open_logs() -> Result<()> { - let directory = config::log_dir()?; - #[cfg(target_os = "windows")] - let program = "explorer.exe"; - #[cfg(target_os = "macos")] - let program = "open"; - #[cfg(target_os = "linux")] - let program = "xdg-open"; - std::process::Command::new(program) - .arg(directory) - .spawn() - .context("Could not open the Bridge log folder")?; - Ok(()) -} - fn open_openmouse() -> Result<()> { open_url(OPENMOUSE_URL) } @@ -2154,370 +1595,6 @@ fn wide(value: &str) -> Vec { mod tests { use super::*; - #[test] - fn escape_keeps_panel_open_until_popup_closes() { - let context = egui::Context::default(); - context - .run_ui( - egui::RawInput { - events: vec![egui::Event::Key { - key: egui::Key::Escape, - physical_key: Some(egui::Key::Escape), - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }], - ..Default::default() - }, - |ui| { - let popup = egui::Id::new("escape-review-popup"); - egui::Popup::open_id(ui.ctx(), popup); - assert!(egui::Popup::is_any_open(ui.ctx())); - assert!(!should_hide_on_escape(true, ui.ctx())); - assert!( - egui::Popup::new(popup, ui.ctx().clone(), egui::Pos2::ZERO, ui.layer_id(),) - .open_memory(None) - .show(|ui| ui.label("Popup")) - .is_some() - ); - assert!(!egui::Popup::is_any_open(ui.ctx())); - }, - ) - .drop_without_applying_deltas(); - } - - #[test] - fn escape_requires_visible_panel_and_keypress() { - for (visible, pressed, expected) in [ - (true, true, true), - (false, true, false), - (true, false, false), - (false, false, false), - ] { - egui::Context::default() - .run_ui( - egui::RawInput { - events: if pressed { - vec![egui::Event::Key { - key: egui::Key::Escape, - physical_key: Some(egui::Key::Escape), - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }] - } else { - vec![] - }, - ..Default::default() - }, - |ui| assert_eq!(should_hide_on_escape(visible, ui.ctx()), expected), - ) - .drop_without_applying_deltas(); - } - } - - fn simulated_header_drag(start: Pos2, end: Pos2) -> bool { - let context = egui::Context::default(); - let rect = egui::Rect::from_min_max(Pos2::new(20.0, 20.0), Pos2::new(260.0, 60.0)); - let frames = [ - vec![], - vec![ - egui::Event::PointerMoved(start), - egui::Event::PointerButton { - pos: start, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::default(), - }, - ], - vec![egui::Event::PointerMoved(end)], - ]; - let mut started = false; - for events in frames { - let output = context.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - Pos2::ZERO, - Vec2::new(372.0, 484.0), - )), - events, - ..Default::default() - }, - |ui| drag_header(ui, rect, "test-drag"), - ); - started |= output.viewport_output.values().any(|viewport| { - viewport - .commands - .iter() - .any(|command| matches!(command, ViewportCommand::StartDrag)) - }); - output.drop_without_applying_deltas(); - } - started - } - - #[test] - fn tray_menu_can_recover_the_window_without_click_coordinates() { - assert!(matches!( - tray_menu_action(true, false, false), - Some(TrayAction::ShowWindow) - )); - assert!(tray_menu_action(false, false, false).is_none()); - } - - #[test] - fn tray_event_pump_is_bounded_and_drains_pending_events() { - let remaining = std::cell::Cell::new(40); - drain_tray_events( - || remaining.get() > 0, - || remaining.set(remaining.get() - 1), - ); - assert_eq!(remaining.get(), 8); - drain_tray_events( - || remaining.get() > 0, - || remaining.set(remaining.get() - 1), - ); - assert_eq!(remaining.get(), 0); - } - - #[test] - fn header_drag_requests_a_native_window_move() { - assert!(simulated_header_drag( - Pos2::new(80.0, 35.0), - Pos2::new(140.0, 75.0) - )); - } - - #[test] - fn dragging_outside_header_leaves_window_in_place() { - assert!(!simulated_header_drag( - Pos2::new(300.0, 35.0), - Pos2::new(340.0, 75.0) - )); - } - - #[test] - fn content_growth_stays_bounded_and_settings_has_its_own_budget() { - assert_eq!(panel_height(View::Home, false, false, false, false), 280.0); - assert_eq!(panel_height(View::Home, false, true, false, false), 298.0); - assert_eq!(panel_height(View::Home, false, true, true, false), 338.0); - assert_eq!(panel_height(View::Home, true, false, false, false), 340.0); - assert_eq!(panel_height(View::Settings, true, true, true, false), 420.0); - } - - #[test] - fn device_summary_rows_grow_the_home_view() { - assert_eq!(panel_height(View::Home, false, false, false, true), 302.0); - assert_eq!(panel_height(View::Home, false, true, false, true), 320.0); - // The details view scrolls, so its budget stays fixed. - assert_eq!(panel_height(View::Home, true, false, false, true), 340.0); - } - - #[test] - fn idle_window_is_a_compact_companion_not_a_settings_sheet() { - assert_eq!(WINDOW_WIDTH, 360.0); - assert!(panel_height(View::Home, false, false, false, false) <= 340.0); - } - - #[test] - fn missing_snapshot_is_starting_not_ready() { - assert_eq!(service_label(None), "Starting Bridge…"); - } - - #[test] - fn native_failure_and_pending_restore_are_visible_without_an_applied_claim() { - let mut snapshot = empty_snapshot(); - snapshot.native_automation_error = - Some("manual intent superseded automatic settings".into()); - assert_eq!( - profile_label(Some(&snapshot)), - "Native settings not applied: manual intent superseded automatic settings" - ); - snapshot.pending_native_restores = 1; - assert_eq!( - profile_label(Some(&snapshot)), - "Native restore pending (1): manual intent superseded automatic settings" - ); - snapshot.native_automation_error = None; - assert_eq!(profile_label(Some(&snapshot)), "Native restore pending (1)"); - } - - #[test] - fn missing_profile_is_not_an_invented_default() { - assert_eq!(profile_label(None), "No game profile active"); - } - - fn empty_snapshot() -> BridgeSnapshot { - BridgeSnapshot { - version: "test", - platform: "test", - linux_distribution: None, - uptime_seconds: 0, - active_games: vec![], - games: vec![], - tracked_game_count: 0, - battery_threshold_percent: 20, - tray_battery_style: "glyph".to_owned(), - hybrid_mode: true, - device_mode: "onboard".to_owned(), - autostart_enabled: false, - automatic_updates: false, - notification_sound: true, - notification_volume: 60, - foreground_application: None, - active_profile: None, - active_profile_is_default: false, - native_automation_error: None, - pending_native_restores: 0, - visible_application_count: 0, - profile_count: 0, - client_connected: false, - active_devices: vec![], - batteries: vec![], - } - } - - #[test] - fn remembered_battery_does_not_become_a_live_mouse() { - let mut snapshot = empty_snapshot(); - snapshot - .batteries - .push(openmouse_bridge::service::DeviceBattery { - device_id: "old".into(), - device_name: "Remembered mouse".into(), - percent: 60, - charging: false, - stale: true, - minutes_remaining: None, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - }); - assert_eq!(device_label(Some(&snapshot)), "No mouse session"); - } - - #[test] - fn stale_battery_removal_remains_available_in_compact_details() { - let context = egui::Context::default(); - let mut shown = Vec::new(); - for stale in [false, true] { - context - .run_ui(egui::RawInput::default(), |ui| { - shown.push(forget_battery_button(ui, stale).is_some()); - }) - .drop_without_applying_deltas(); - } - assert_eq!(shown, vec![false, true]); - } - - #[test] - fn stale_battery_explicitly_says_last_known_without_current_charging_claim() { - let battery = openmouse_bridge::service::DeviceBattery { - device_id: "old".into(), - device_name: "Remembered mouse".into(), - percent: 60, - charging: true, - stale: true, - minutes_remaining: None, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - }; - assert_eq!(battery_text(&battery), "60% · Last known (was charging)"); - } - - #[test] - fn panel_anchor_respects_negative_monitor_work_area() { - let work = egui::Rect::from_min_max(Pos2::new(-1920.0, -1080.0), Pos2::new(0.0, -40.0)); - assert_eq!( - clamp_panel(Pos2::new(-2000.0, -1200.0), Vec2::new(372.0, 484.0), work), - Pos2::new(-1912.0, -1072.0) - ); - assert_eq!( - clamp_panel(Pos2::new(100.0, 100.0), Vec2::new(372.0, 484.0), work), - Pos2::new(-380.0, -532.0) - ); - } - - #[test] - fn explicit_test_position_accepts_secondary_monitor_coordinates() { - assert_eq!(parse_position("-1500,40"), Some(Pos2::new(-1500.0, 40.0))); - for value in ["", "1", "1,2,3", "NaN,2", "inf,0"] { - assert_eq!(parse_position(value), None); - } - } - - #[test] - fn ready_service_does_not_claim_panel_connection_or_a_default() { - let mut snapshot = empty_snapshot(); - assert_eq!(service_label(Some(&snapshot)), "Bridge service ready"); - assert_eq!(profile_label(Some(&snapshot)), "No game profile active"); - snapshot.client_connected = true; - assert_eq!(service_label(Some(&snapshot)), "Bridge service ready"); - assert_eq!(device_label(Some(&snapshot)), "No mouse session"); - } - - #[test] - fn profile_target_is_remembered_and_selected_not_live_or_applied() { - let mut snapshot = empty_snapshot(); - snapshot.active_profile = Some(config::ApplicationProfile { - application: config::ProfileApplication { - name: "Game".into(), - executable: "game.exe".into(), - path: String::new(), - local_id: None, - activation: None, - bundle_path: None, - bundle_identifier: None, - }, - device: config::ProfileDevice { - id: "saved".into(), - name: "Saved mouse".into(), - }, - settings: config::ProfileSettings { - dpi: Some(800), - polling_rate_hz: Some(1000), - snapshot: None, - }, - enabled: true, - }); - assert_eq!(profile_label(Some(&snapshot)), "Game selected"); - assert_eq!(device_label(Some(&snapshot)), "No mouse session"); - snapshot.active_profile_is_default = true; - assert_eq!(profile_label(Some(&snapshot)), "Default profile selected"); - snapshot - .active_devices - .push(openmouse_bridge::service::ActiveDevice { - key: "live".into(), - name: "Wired mouse".into(), - vendor_id: 1, - product_id: 2, - }); - assert_eq!(device_label(Some(&snapshot)), "Wired mouse"); - assert!(snapshot.batteries.is_empty()); - } - - #[test] - fn fresh_battery_keeps_reported_charge_and_charging_state() { - let mut battery = openmouse_bridge::service::DeviceBattery { - device_id: "live".into(), - device_name: "Mouse".into(), - percent: 42, - charging: false, - stale: false, - minutes_remaining: None, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - }; - assert_eq!(battery_text(&battery), "42%"); - battery.charging = true; - assert_eq!(battery_text(&battery), "42% · Charging"); - battery.stale = true; - battery.charging = false; - assert_eq!(battery_text(&battery), "42% · Last known"); - } - #[test] fn user_exit_does_not_relaunch() { assert_eq!( diff --git a/src/drivers/automation.rs b/src/drivers/automation.rs deleted file mode 100644 index 02ce0b6..0000000 --- a/src/drivers/automation.rs +++ /dev/null @@ -1,342 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -use crate::config::{ApplicationProfile, ProfileSettings}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DeviceIdentity { - vendor_id: u16, - product_id: u16, - path: String, - serial_number: String, - interface: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct Baseline { - pub identity: DeviceIdentity, - pub dpi: u32, - pub polling_rate_hz: u32, -} - -pub(crate) trait AutomationIo { - fn read(&mut self, device_id: &str) -> Result; - fn write( - &mut self, - device_id: &str, - baseline: &Baseline, - settings: &ProfileSettings, - ) -> Result<()>; -} - -struct Entry { - baseline: Baseline, - dpi: bool, - polling: bool, - failed: bool, - restore_after: Option, - restore_delay: Duration, -} - -#[derive(Clone, Copy, Default)] -pub(crate) struct ManualIntent { - dpi: u64, - polling: u64, -} - -impl ManualIntent { - pub const fn new() -> Self { - Self { dpi: 0, polling: 0 } - } - - pub fn invalidate(&mut self, brand: &str, dpi: bool, polling: bool) -> Result<()> { - if brand - .trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}') - .eq_ignore_ascii_case("Zaunkoenig") - { - if dpi { - self.dpi = self - .dpi - .checked_add(1) - .ok_or_else(|| anyhow::anyhow!("manual DPI ownership generation exhausted"))?; - } - if polling { - self.polling = self.polling.checked_add(1).ok_or_else(|| { - anyhow::anyhow!("manual polling ownership generation exhausted") - })?; - } - } - Ok(()) - } -} - -#[derive(Default)] -pub(crate) struct NativeAutomation { - entries: HashMap, - attempted: Option, - apply_deferred: bool, - apply_error: Option, - observed_manual: ManualIntent, -} - -impl NativeAutomation { - pub fn observe_manual_intent(&mut self, intent: &ManualIntent) { - let dpi = intent.dpi != self.observed_manual.dpi; - let polling = intent.polling != self.observed_manual.polling; - if dpi || polling { - let mut superseded = false; - self.entries.retain(|id, entry| { - if id - .split_once(':') - .is_some_and(|(brand, _)| brand.eq_ignore_ascii_case("Zaunkoenig")) - { - superseded |= (dpi && entry.dpi) || (polling && entry.polling); - entry.dpi &= !dpi; - entry.polling &= !polling; - } - entry.dpi || entry.polling - }); - if superseded { - self.apply_error = Some("native automatic settings superseded by manual intent; untouched fields remain owned".into()); - } - } - self.observed_manual = *intent; - } - - pub fn pending_restores_for(&self, desired: Option<&ApplicationProfile>) -> usize { - self.entries - .iter() - .filter(|(id, entry)| { - entry.failed || desired.is_none_or(|profile| &profile.device.id != *id) - }) - .count() - } - - pub fn reconcile( - &mut self, - desired: Option<&ApplicationProfile>, - io: &mut impl AutomationIo, - ) -> Result<()> { - self.reconcile_at(desired, io, Instant::now()) - } - - fn reconcile_at( - &mut self, - desired: Option<&ApplicationProfile>, - io: &mut impl AutomationIo, - now: Instant, - ) -> Result<()> { - let mut errors = Vec::new(); - let mut removed: Vec<_> = self - .entries - .keys() - .filter(|id| { - desired.is_none_or(|profile| &profile.device.id != *id) || self.entries[*id].failed - }) - .cloned() - .collect(); - removed.sort(); - for id in removed { - let entry = self.entries.get_mut(&id).unwrap(); - entry.failed = true; - if entry.restore_after.is_some_and(|deadline| now < deadline) { - errors.push(format!( - "{id}: native restoration pending; retry delayed, original baseline retained" - )); - continue; - } - let settings = ProfileSettings { - dpi: entry.dpi.then_some(entry.baseline.dpi), - polling_rate_hz: entry.polling.then_some(entry.baseline.polling_rate_hz), - snapshot: None, - }; - match io.write(&id, &entry.baseline, &settings) { - Ok(()) => { - self.entries.remove(&id); - } - Err(error) => { - entry.restore_after = Some(now + entry.restore_delay); - entry.restore_delay = entry - .restore_delay - .saturating_mul(2) - .min(Duration::from_secs(60)); - errors.push(format!("{id}: native restoration pending: {error:#}")); - } - } - } - let needs_apply = desired != self.attempted.as_ref() || self.apply_deferred; - let blocked = needs_apply - && desired.is_some_and(|profile| match self.entries.get(&profile.device.id) { - Some(entry) => entry.failed, - None => self.entries.len() >= 64, - }); - if blocked { - self.apply_deferred = true; - self.apply_error = Some("native apply deferred until restoration completes".into()); - } else if needs_apply { - self.apply_deferred = false; - self.attempted = desired.cloned(); - self.apply_error = None; - if let Some(profile) = desired { - let result = self.apply(profile, io); - if let Err(error) = result { - self.apply_error = Some(format!("{}: {error:#}", profile.device.id)); - } - } - } - if let Some(error) = &self.apply_error { - errors.push(error.clone()); - } - if !errors.is_empty() { - anyhow::bail!("{}", errors.join("; ")); - } - Ok(()) - } - - fn apply(&mut self, profile: &ApplicationProfile, io: &mut impl AutomationIo) -> Result<()> { - let id = &profile.device.id; - if self.entries.get(id).is_some_and(|entry| entry.failed) { - anyhow::bail!("previous native restoration must finish before another write"); - } - if profile.settings.dpi.is_none() - && profile.settings.polling_rate_hz.is_none() - && !self.entries.contains_key(id) - { - return Ok(()); - } - if let Some(entry) = self.entries.get_mut(id) { - let acquire_dpi = profile.settings.dpi.is_some() && !entry.dpi; - let acquire_polling = profile.settings.polling_rate_hz.is_some() && !entry.polling; - if acquire_dpi || acquire_polling { - let fresh = io.read(id)?; - if fresh.identity != entry.baseline.identity - || fresh.dpi == 0 - || fresh.polling_rate_hz == 0 - { - anyhow::bail!("fresh native baseline identity/readback is missing or changed"); - } - if acquire_dpi { - entry.baseline.dpi = fresh.dpi; - } - if acquire_polling { - entry.baseline.polling_rate_hz = fresh.polling_rate_hz; - } - } - } - if !self.entries.contains_key(id) { - if self.entries.len() >= 64 { - anyhow::bail!( - "native restoration journal is full; refusing another hardware change" - ); - } - let baseline = io.read(id)?; - if baseline.dpi == 0 - || baseline.polling_rate_hz == 0 - || baseline.identity.path.is_empty() - || baseline.identity.path.len() > 2048 - || baseline.identity.serial_number.is_empty() - || baseline.identity.serial_number.len() > 256 - { - anyhow::bail!( - "unsupported or invalid acknowledged native baseline identity/readback" - ); - } - if self - .entries - .values() - .any(|entry| entry.baseline.identity == baseline.identity) - { - anyhow::bail!( - "resolved native device is already owned by another profile device identity" - ); - } - self.entries.insert( - id.clone(), - Entry { - baseline, - dpi: false, - polling: false, - failed: false, - restore_after: None, - restore_delay: Duration::from_secs(1), - }, - ); - } - let entry = self.entries.get_mut(id).unwrap(); - entry.dpi |= profile.settings.dpi.is_some(); - entry.polling |= profile.settings.polling_rate_hz.is_some(); - let settings = ProfileSettings { - dpi: profile - .settings - .dpi - .or_else(|| entry.dpi.then_some(entry.baseline.dpi)), - polling_rate_hz: profile - .settings - .polling_rate_hz - .or_else(|| entry.polling.then_some(entry.baseline.polling_rate_hz)), - snapshot: None, - }; - entry.failed = true; - io.write(id, &entry.baseline, &settings)?; - entry.failed = false; - Ok(()) - } -} - -pub(crate) struct HelperIo(pub F); - -impl Result>>> AutomationIo for HelperIo { - fn read(&mut self, device_id: &str) -> Result { - let brand = supported_brand(device_id)?; - let request = - serde_json::json!({"brand":brand,"deviceId":device_id,"action":"automatic-read"}); - let bytes = - (self.0)(&request)?.ok_or_else(|| anyhow::anyhow!("unsupported native readback"))?; - Ok(serde_json::from_slice(&bytes)?) - } - fn write( - &mut self, - device_id: &str, - baseline: &Baseline, - settings: &ProfileSettings, - ) -> Result<()> { - let brand = supported_brand(device_id)?; - let mut request = serde_json::json!({"brand":brand,"deviceId":device_id,"action":"automatic-apply","identity":baseline.identity}); - if let Some(dpi) = settings.dpi { - request["dpi"] = dpi.into(); - } - if let Some(hz) = settings.polling_rate_hz { - request["pollingRateHz"] = hz.into(); - } - let bytes = - (self.0)(&request)?.ok_or_else(|| anyhow::anyhow!("unsupported native restoration"))?; - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct Reply { - identity: DeviceIdentity, - } - let reply: Reply = serde_json::from_slice(&bytes)?; - if reply.identity != baseline.identity { - anyhow::bail!("native acknowledgement identity changed"); - } - Ok(()) - } -} - -fn supported_brand(device_id: &str) -> Result<&str> { - let (brand, name) = device_id - .split_once(':') - .ok_or_else(|| anyhow::anyhow!("unsupported native profile device identity"))?; - if !brand.eq_ignore_ascii_case("Zaunkoenig") || name.is_empty() || device_id.len() > 512 { - anyhow::bail!("unsupported acknowledged native automation readback for {device_id}"); - } - Ok(brand) -} - -#[cfg(test)] -mod tests; diff --git a/src/drivers/automation/tests.rs b/src/drivers/automation/tests.rs deleted file mode 100644 index 7313fdd..0000000 --- a/src/drivers/automation/tests.rs +++ /dev/null @@ -1,498 +0,0 @@ -use super::*; - -#[derive(Default)] -struct FakeIo { - calls: Vec, - fail_reads: bool, - fail_writes: usize, - invalid_baseline: bool, -} - -impl AutomationIo for FakeIo { - fn read(&mut self, id: &str) -> Result { - self.calls.push(format!("read:{id}")); - if self.fail_reads { - anyhow::bail!("unsupported readback"); - } - Ok(Baseline { - identity: DeviceIdentity { - vendor_id: 1, - product_id: 2, - path: id.into(), - serial_number: "serial".into(), - interface: Some(0), - }, - dpi: if self.invalid_baseline { 0 } else { 800 }, - polling_rate_hz: 1000, - }) - } - fn write(&mut self, id: &str, baseline: &Baseline, settings: &ProfileSettings) -> Result<()> { - assert_eq!(baseline.identity.path, id); - self.calls.push(format!( - "write:{id}:{:?}:{:?}", - settings.dpi, settings.polling_rate_hz - )); - if self.fail_writes > 0 { - self.fail_writes -= 1; - anyhow::bail!("write failed after possible partial hardware change"); - } - Ok(()) - } -} - -fn profile(id: &str, dpi: Option, hz: Option) -> ApplicationProfile { - serde_json::from_value(serde_json::json!({"application":{"name":"Game","path":"/game","executable":"game"},"device":{"id":id,"name":"Mouse"},"settings":{"dpi":dpi,"pollingRateHz":hz},"enabled":true})).unwrap() -} - -#[test] -fn unchanged_request_does_not_rewrite_and_empty_profile_does_not_read() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let game = profile("a", Some(1600), None); - automation - .reconcile(Some(&profile("a", None, None)), &mut io) - .unwrap(); - assert!(io.calls.is_empty()); - automation.reconcile(Some(&game), &mut io).unwrap(); - automation.reconcile(Some(&game), &mut io).unwrap(); - assert_eq!(io.calls, ["read:a", "write:a:Some(1600):None"]); -} - -#[test] -fn invalid_or_unreadable_baseline_never_writes() { - for invalid in [false, true] { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo { - fail_reads: !invalid, - invalid_baseline: invalid, - ..FakeIo::default() - }; - assert!( - automation - .reconcile(Some(&profile("a", Some(1600), None)), &mut io) - .is_err() - ); - assert_eq!(io.calls, ["read:a"]); - assert!(automation.entries.is_empty()); - } -} - -#[test] -fn failed_partial_apply_is_restored_even_if_same_request_remains() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo { - fail_writes: 1, - ..FakeIo::default() - }; - let game = profile("a", Some(1600), Some(2000)); - assert!(automation.reconcile(Some(&game), &mut io).is_err()); - let original_error = automation.apply_error.clone(); - assert_eq!(automation.attempted.as_ref(), Some(&game)); - let _ = automation.reconcile(Some(&game), &mut io); - let _ = automation.reconcile(Some(&game), &mut io); - assert_eq!(automation.apply_error, original_error); - assert_eq!( - io.calls, - [ - "read:a", - "write:a:Some(1600):Some(2000)", - "write:a:Some(800):Some(1000)" - ] - ); - assert!(automation.entries.is_empty()); -} - -#[test] -fn owned_baseline_is_pending_when_final_target_ends_before_hid_access() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let game = profile("a", Some(1600), None); - automation.reconcile(Some(&game), &mut io).unwrap(); - assert_eq!(automation.pending_restores_for(Some(&game)), 0); - assert_eq!(automation.pending_restores_for(None), 1); - assert_eq!(io.calls.len(), 2); -} - -#[test] -fn failed_restoration_retains_baseline_and_waits_before_retry() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - automation - .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) - .unwrap(); - io.fail_writes = 20; - assert!(automation.reconcile_at(None, &mut io, now).is_err()); - for millis in [0, 1, 500, 999] { - assert!( - automation - .reconcile_at(None, &mut io, now + Duration::from_millis(millis)) - .is_err() - ); - } - assert_eq!(io.calls.len(), 3); - assert_eq!(automation.entries["a"].baseline.dpi, 800); - assert_eq!(automation.pending_restores_for(None), 1); - io.fail_writes = 0; - automation - .reconcile_at(None, &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!(io.calls.len(), 4); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); - assert!(automation.entries.is_empty()); -} - -#[test] -fn restoration_failure_delays_increase_to_cap_without_abandoning_baseline() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let mut now = Instant::now(); - automation - .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) - .unwrap(); - io.fail_writes = 10; - for (index, seconds) in [1, 2, 4, 8, 16, 32, 60, 60, 60, 60].into_iter().enumerate() { - assert!(automation.reconcile_at(None, &mut io, now).is_err()); - let calls = io.calls.len(); - assert_eq!(calls, index + 3); - assert_eq!(automation.entries["a"].baseline.dpi, 800); - assert_eq!( - automation.entries["a"].baseline.identity.serial_number, - "serial" - ); - assert_eq!(automation.pending_restores_for(None), 1); - now += Duration::from_secs(seconds); - assert!( - automation - .reconcile_at(None, &mut io, now - Duration::from_nanos(1)) - .is_err() - ); - assert_eq!(io.calls.len(), calls, "retry before {seconds}s deadline"); - } - automation.reconcile_at(None, &mut io, now).unwrap(); - assert_eq!(io.calls.len(), 13); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); - assert!(automation.entries.is_empty()); -} - -#[test] -fn restoration_deadlines_are_independent_per_device() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - automation - .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) - .unwrap(); - io.fail_writes = 1; - assert!( - automation - .reconcile_at(Some(&profile("b", None, Some(2000))), &mut io, now) - .is_err() - ); - assert_eq!(io.calls.len(), 5); - assert!( - automation - .reconcile_at(None, &mut io, now + Duration::from_millis(100)) - .is_err() - ); - assert_eq!(io.calls.len(), 6); - assert_eq!(io.calls.last().unwrap(), "write:b:None:Some(1000)"); - assert_eq!(automation.entries.len(), 1); - assert_eq!(automation.entries["a"].baseline.dpi, 800); - automation - .reconcile_at(None, &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); - assert!(automation.entries.is_empty()); -} - -#[test] -fn delayed_restoration_preserves_manual_override_and_untouched_ownership() { - for (dpi, polling, expected) in [ - (true, false, Some("write:Zaunkoenig:M3K:None:Some(1000)")), - (false, true, Some("write:Zaunkoenig:M3K:Some(800):None")), - (true, true, None), - ] { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - automation - .reconcile_at( - Some(&profile("Zaunkoenig:M3K", Some(1600), Some(2000))), - &mut io, - now, - ) - .unwrap(); - io.fail_writes = 1; - assert!(automation.reconcile_at(None, &mut io, now).is_err()); - let mut intent = ManualIntent::new(); - intent.invalidate("Zaunkoenig", dpi, polling).unwrap(); - automation.observe_manual_intent(&intent); - let _ = automation.reconcile_at(None, &mut io, now + Duration::from_millis(999)); - assert_eq!(io.calls.len(), 3); - let _ = automation.reconcile_at(None, &mut io, now + Duration::from_secs(1)); - if let Some(expected) = expected { - assert_eq!(io.calls.len(), 4); - assert_eq!(io.calls.last().unwrap(), expected); - } else { - assert_eq!(io.calls.len(), 3); - } - assert!(automation.entries.is_empty()); - } -} - -#[test] -fn delayed_restoration_retains_all_64_baselines_and_refuses_new_capture() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - let desired = profile("Zaunkoenig:new", Some(1600), None); - for index in 0..64 { - automation - .apply( - &profile(&format!("Zaunkoenig:{index}"), Some(1600), None), - &mut io, - ) - .unwrap(); - } - io.fail_writes = 64; - assert!(automation.reconcile_at(None, &mut io, now).is_err()); - let calls = io.calls.len(); - assert_eq!(automation.pending_restores_for(None), 64); - assert!( - automation - .reconcile_at(Some(&desired), &mut io, now + Duration::from_millis(999),) - .is_err() - ); - assert_eq!(io.calls.len(), calls); - assert!(automation.attempted.is_none()); - assert_eq!(automation.entries.len(), 64); - assert!( - automation - .entries - .values() - .all(|entry| entry.baseline.dpi == 800) - ); - automation - .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!(io.calls.len(), calls + 66); - assert_eq!( - io.calls.last().unwrap(), - "write:Zaunkoenig:new:Some(1600):None" - ); - assert_eq!(automation.attempted.as_ref(), Some(&desired)); - assert!(automation.apply_error.is_none()); - automation - .reconcile_at(None, &mut io, now + Duration::from_secs(2)) - .unwrap(); - assert!(automation.entries.is_empty()); -} - -#[test] -fn delayed_same_device_restoration_retries_unchanged_desired_profile() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - let desired = profile("a", Some(3200), None); - automation - .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) - .unwrap(); - io.fail_writes = 1; - assert!(automation.reconcile_at(None, &mut io, now).is_err()); - assert!( - automation - .reconcile_at(Some(&desired), &mut io, now + Duration::from_millis(999)) - .is_err() - ); - assert!(automation.attempted.is_none()); - assert_eq!(io.calls.len(), 3); - automation - .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!( - &io.calls[3..], - [ - "write:a:Some(800):None", - "read:a", - "write:a:Some(3200):None" - ] - ); - assert_eq!(automation.attempted.as_ref(), Some(&desired)); - assert!(automation.apply_error.is_none()); - automation - .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(2)) - .unwrap(); - assert_eq!(io.calls.len(), 6); -} - -#[test] -fn returning_to_previous_profile_while_deferred_retries_after_restoration() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo { - fail_writes: 1, - ..Default::default() - }; - let now = Instant::now(); - let original = profile("a", Some(1600), None); - assert!( - automation - .reconcile_at(Some(&original), &mut io, now) - .is_err() - ); - io.fail_writes = 1; - assert!( - automation - .reconcile_at(Some(&profile("a", Some(3200), None)), &mut io, now) - .is_err() - ); - assert_eq!(automation.attempted.as_ref(), Some(&original)); - assert!( - automation - .reconcile_at(Some(&original), &mut io, now + Duration::from_millis(999)) - .is_err() - ); - automation - .reconcile_at(Some(&original), &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(1600):None"); - assert!(automation.apply_error.is_none()); -} - -#[test] -fn restored_previous_device_profile_is_reapplied_after_blocked_switch() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - let now = Instant::now(); - let a = profile("a", Some(1600), None); - let b = profile("b", Some(3200), None); - automation.reconcile_at(Some(&b), &mut io, now).unwrap(); - io.fail_writes = 1; - assert!(automation.reconcile_at(Some(&a), &mut io, now).is_err()); - let waiting = now + Duration::from_millis(999); - assert!(automation.reconcile_at(Some(&b), &mut io, waiting).is_err()); - assert!(automation.reconcile_at(Some(&a), &mut io, waiting).is_err()); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(1600):None"); - assert!(automation.apply_error.is_none()); - let calls = io.calls.len(); - automation - .reconcile_at(Some(&a), &mut io, now + Duration::from_secs(1)) - .unwrap(); - assert_eq!(io.calls.len(), calls + 1); - assert_eq!(io.calls.last().unwrap(), "write:b:Some(800):None"); - assert!(automation.entries.contains_key("a")); - assert_eq!(automation.attempted.as_ref(), Some(&a)); - assert!(automation.apply_error.is_none()); -} - -#[test] -fn explicit_default_preserves_values_and_resets_only_omitted_owned_fields() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - automation - .reconcile(Some(&profile("a", Some(1600), Some(2000))), &mut io) - .unwrap(); - let mut default = profile("a", Some(400), None); - default.application.name = "Manual default".into(); - automation.reconcile(Some(&default), &mut io).unwrap(); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(400):Some(1000)"); - automation.reconcile(None, &mut io).unwrap(); - assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):Some(1000)"); -} - -#[test] -fn device_switch_restores_old_identity_before_capturing_new_device() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - automation - .reconcile(Some(&profile("a", Some(1600), None)), &mut io) - .unwrap(); - automation - .reconcile(Some(&profile("b", Some(3200), None)), &mut io) - .unwrap(); - automation.reconcile(None, &mut io).unwrap(); - assert_eq!( - io.calls, - [ - "read:a", - "write:a:Some(1600):None", - "write:a:Some(800):None", - "read:b", - "write:b:Some(3200):None", - "write:b:Some(800):None" - ] - ); -} - -#[test] -fn helper_adapter_binds_read_and_write_to_full_profile_and_captured_identity() { - let mut requests = Vec::new(); - let captured = serde_json::json!({"identity":{"vendorId":1155,"productId":1,"path":"exact","serialNumber":"serial","interface":2},"dpi":800,"pollingRateHz":1000}); - let mut io = HelperIo(|request: &serde_json::Value| { - requests.push(request.clone()); - Ok(Some(serde_json::to_vec(&captured).unwrap())) - }); - let baseline = io.read("Zaunkoenig:M3K").unwrap(); - assert_eq!(baseline.dpi, 800); - assert_eq!(requests[0]["deviceId"], "Zaunkoenig:M3K"); - assert_eq!(requests[0]["action"], "automatic-read"); - let mut io = HelperIo(|request: &serde_json::Value| { - assert_eq!(request["identity"]["path"], "exact"); - assert_eq!(request["identity"]["serialNumber"], "serial"); - assert_eq!(request["deviceId"], "Zaunkoenig:M3K"); - assert_eq!(request["action"], "automatic-apply"); - Ok(Some( - serde_json::to_vec(&serde_json::json!({"identity":captured["identity"]})).unwrap(), - )) - }); - io.write( - "Zaunkoenig:M3K", - &baseline, - &profile("a", Some(1600), None).settings, - ) - .unwrap(); - let mut io = HelperIo(|_: &serde_json::Value| -> Result>> { - panic!("unsupported driver must not launch a helper"); - }); - assert!(io.read("Pulsar:Mouse").is_err()); -} - -#[test] -fn helper_adapter_rejects_changed_acknowledgement_identity() { - let mut source = FakeIo::default(); - let baseline = source.read("a").unwrap(); - let mut io = HelperIo(|_: &serde_json::Value| { - Ok(Some(br#"{"identity":{"vendorId":1,"productId":2,"path":"wrong","serialNumber":"serial","interface":0}}"#.to_vec())) - }); - assert!( - io.write( - "Zaunkoenig:M3K", - &baseline, - &profile("a", Some(1600), None).settings - ) - .is_err() - ); -} - -#[test] -fn captures_before_write_and_restores_original_not_handoff() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - automation - .reconcile(Some(&profile("a", Some(1600), Some(2000))), &mut io) - .unwrap(); - automation - .reconcile(Some(&profile("a", Some(3200), Some(4000))), &mut io) - .unwrap(); - automation.reconcile(None, &mut io).unwrap(); - assert_eq!( - io.calls, - [ - "read:a", - "write:a:Some(1600):Some(2000)", - "write:a:Some(3200):Some(4000)", - "write:a:Some(800):Some(1000)" - ] - ); - assert!(automation.entries.is_empty()); -} diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index 3b10217..2b02005 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -18,187 +18,26 @@ use anyhow::Result; use crate::config::ApplicationProfile; -pub(crate) mod automation; pub mod delux; mod native_hid; pub mod pulsar; -pub use native_hid::{NativeBattery, NativeStatus, spawn_button_listener}; - -/// A mouse Bridge discovered by itself, by scanning the HID bus for vendor -/// ids its drivers recognize — no saved profile required. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DetectedMouse { - /// Stable profile-style id, `":"` — the same - /// `":"` prefix convention the OpenMouse web app writes. - pub device_id: String, - /// Human-readable product name from the HID descriptor. - pub name: String, -} - -/// Brand keys and vendor ids, mirroring the helper's `native-hid/src/brands.mjs` -/// (the same drivers the web panel drives, and the same normalization -/// `apply.mjs` gives `device.id` prefixes: trim + lowercase). When several -/// brands share a vendor id, the entry names the brand whose driver class -/// list covers the most sibling hardware — Delux's list includes the Attack -/// Shark and Lamzu clients — or `mouse-protocol`'s registry.ts first match; -/// the loser's devices merely fail their silent battery probe later. -const KNOWN_BRANDS: &[(&str, &[u16])] = &[ - ("Delux", &[0x1d57, 0x248a, 0x373e]), - ("Logitech", &[0x046d]), - ("Razer", &[0x1532]), - ("Zaunkoenig", &[0x0483]), - ("Finalmouse", &[0x361d]), - ("Endgame Gear", &[0x3367]), - ("Teevolution", &[0x3554]), - ("WLMouse", &[0x36a7]), - ("moddoMOUSE", &[0x2fe3]), - ("Ninjutso", &[0x093a, 0x1915]), - ("ATK", &[0x373b]), - ("Attack Shark", &[0x25a7]), - ("Keychron", &[0x3434]), - ("Fantech", &[0x3151]), - ("Wooting", &[0x31e3]), - ("Microsoft", &[0x045e]), - ("WALLHACK", &[0x3879, 0x1caa]), -]; - -/// Logitech is special: every Logitech-branded device (keyboards, headsets) -/// shares vendor id 0x046d and the HID++ transport (`mouse-protocol`'s -/// `drivers/logitech/hidpp.ts` has the same limitation — "whether it is a -/// *mouse* cannot be told from descriptors"), so discovery keeps the HID++ -/// collections — short/long reports on usage page 0xFF00, Bluetooth on -/// 0xFF43 — and a keyboard merely fails its battery probe silently later. -const LOGITECH_VENDOR_ID: u16 = 0x046d; -const HIDPP_USAGE_PAGE: u16 = 0xff00; -const HIDPP_BLUETOOTH_USAGE_PAGE: u16 = 0xff43; - -/// The brand a discovered HID interface belongs to, or `None` when the -/// interface should be skipped (other vendors' hardware, Logitech non-HID++ -/// interfaces, Noir's undriven receiver PID). -fn discovered_brand(info: &hidapi::DeviceInfo) -> Option<&'static str> { - let vendor_id = info.vendor_id(); - if vendor_id == pulsar::VENDOR_ID { - return Some("Pulsar"); - } - if vendor_id == LOGITECH_VENDOR_ID { - let usage_page = info.usage_page(); - let hidpp_transport = (usage_page == HIDPP_USAGE_PAGE - && matches!(info.usage(), 0x01 | 0x02)) - || usage_page == HIDPP_BLUETOOTH_USAGE_PAGE; - return hidpp_transport.then_some("Logitech"); - } - if vendor_id == 0x3554 { - // Teevolution and VGN share this vendor page; their product ids are - // disjoint (pulsar-hid.ts's CLAIMED_VGN_PRODUCT_IDS). Noir Gear's - // 0xf500 rides it too but has no native driver. - return match info.product_id() { - 0xfb56 | 0xfb57 => Some("VGN"), - 0xf500 => None, - _ => Some("Teevolution"), - }; - } - KNOWN_BRANDS - .iter() - .find(|(_, ids)| ids.contains(&vendor_id)) - .map(|(brand, _)| *brand) -} - -/// Every HID interface on the bus whose vendor id belongs to a brand with a -/// Bridge driver, deduplicated to one entry per physical mouse (several -/// interfaces share the same vendor/product/serial). -/// -/// Deliberately does not open any device — enumeration only — so it is safe -/// to call while a web panel session or another driver operation runs. -pub fn detect_mice() -> Vec { - let api = match hidapi::HidApi::new() { - Ok(api) => api, - Err(error) => { - tracing::warn!(%error, "could not enumerate HID devices"); - return Vec::new(); - } - }; - let mut mice: Vec = Vec::new(); - let mut seen: Vec<(u16, u16, String)> = Vec::new(); - for info in api.device_list() { - let vendor_id = info.vendor_id(); - let Some(brand) = discovered_brand(info) else { - continue; - }; - // One physical mouse exposes several HID interfaces (boot mouse, - // consumer control, vendor config, ...). Dedup on the identity - // triple so the same mouse is not listed three times. - let tail = identity_tail(&( - vendor_id, - info.product_id(), - info.serial_number().unwrap_or_default(), - )); - let identity = (vendor_id, info.product_id(), tail); - if seen.contains(&identity) { - continue; - } - seen.push(identity.clone()); - mice.push(DetectedMouse { - device_id: format!("{brand}:{}", identity.2), - name: product_name(info.product_string().unwrap_or_default(), brand), - }); - } - mice -} - -/// The stable per-mouse tail of a discovered device id: the serial number -/// when the device reports one, else `vid:pid`. -fn identity_tail(identity: &(u16, u16, &str)) -> String { - if identity.2.is_empty() { - format!("{:04x}:{:04x}", identity.0, identity.1) - } else { - identity.2.to_owned() - } -} - -/// The HID product string, or the brand name when the device reports none. -fn product_name(product_string: &str, brand: &str) -> String { - let trimmed = product_string.trim(); - if trimmed.is_empty() { - format!("{brand} Mouse") - } else { - trimmed.to_owned() - } -} +pub use native_hid::NativeBattery; /// Serializes Bridge's own device access. A profile switch and a battery read /// can start at the same moment (both run at startup), and on macOS the second /// open of a receiver fails with "exclusive access"; elsewhere the two would /// interleave requests on the same device. -static DEVICE_ACCESS: Mutex = Mutex::new(automation::ManualIntent::new()); +static DEVICE_ACCESS: Mutex<()> = Mutex::new(()); -pub(crate) fn device_access() -> MutexGuard<'static, automation::ManualIntent> { +fn device_access() -> MutexGuard<'static, ()> { + // The guarded value is (), so a panic while holding it leaves nothing + // inconsistent behind. DEVICE_ACCESS .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } -pub(crate) fn manual_report_access( - vendor_id: u16, -) -> Result> { - let mut access = device_access(); - if vendor_id == 0x0483 { - access.invalidate("Zaunkoenig", true, true)?; - } - Ok(access) -} - -fn ensure_native_ownership() -> Result<()> { - if crate::hid::client_session_active() { - return Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "native HID is owned by an active WebSocket session", - ) - .into()); - } - Ok(()) -} - /// Pushes a profile's DPI/polling rate to the mouse over native HID, if /// Bridge has a driver for that device. /// @@ -221,13 +60,7 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result { .next() .unwrap_or_default() .to_ascii_lowercase(); - let mut access = device_access(); - ensure_native_ownership()?; - access.invalidate( - &brand, - profile.settings.dpi.is_some(), - profile.settings.polling_rate_hz.is_some(), - )?; + let _access = device_access(); match brand.as_str() { pulsar::BRAND => { pulsar::apply(profile)?; @@ -244,41 +77,24 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result { } } -pub(crate) fn reconcile_automatic( - automation: &mut automation::NativeAutomation, - desired: Option<&ApplicationProfile>, -) -> Result<()> { - let access = device_access(); - automation.observe_manual_intent(&access); - ensure_native_ownership()?; - automation.reconcile( - desired, - &mut automation::HelperIo(|request: &serde_json::Value| native_hid::run_helper(request)), - ) -} - -/// Reads the onboard-memory state of the mouse `device_id` names -/// (`":"`) over native HID — battery, and the current DPI / -/// polling rate / switch mode wherever the driver can read them. -/// `Ok(None)` means Bridge has no reader for the brand or the mouse reports -/// no battery; `Err` means a driver exists but the mouse did not answer -/// (e.g. asleep or disconnected). -pub fn read_status(device_id: &str) -> Result> { +/// Reads the battery of the mouse `device_id` names (`":"`) over +/// native HID. `Ok(None)` means Bridge has no battery reader for the brand or +/// the mouse reports no battery; `Err` means a driver exists but the mouse +/// did not answer (e.g. asleep or disconnected). +/// +/// The Rust Pulsar driver only writes settings, so Pulsar has no reader yet. +pub fn read_battery(device_id: &str) -> Result> { let brand = device_id.split(':').next().unwrap_or_default(); - let _access = device_access(); - ensure_native_ownership()?; if brand.eq_ignore_ascii_case(pulsar::BRAND) { - return pulsar::read_status(); + return Ok(None); } - native_hid::read_status(brand) + let _access = device_access(); + native_hid::read_battery(brand) } /// local Bridge. Native-only devices use this path when WebHID cannot access /// their protected configuration interface. pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { - let mut access = device_access(); - ensure_native_ownership()?; - access.invalidate(brand, dpi.is_some(), polling_rate_hz.is_some())?; native_hid::apply_settings(brand, dpi, polling_rate_hz) } @@ -293,10 +109,6 @@ mod tests { name: "Test Game".into(), executable: "test.exe".into(), path: "/games/test".into(), - local_id: None, - activation: None, - bundle_path: None, - bundle_identifier: None, }, device: ProfileDevice { id: device_id.into(), @@ -311,255 +123,6 @@ mod tests { } } - static TEST_NATIVE: Mutex<()> = Mutex::new(()); - - struct FakeNative { - dpi: u32, - hz: u32, - fail_manual: bool, - calls: Vec, - } - - fn fake_native() -> (std::sync::Arc>, native_hid::TestHelper) { - let values = std::sync::Arc::new(Mutex::new(FakeNative { - dpi: 800, - hz: 1000, - fail_manual: false, - calls: Vec::new(), - })); - let device = values.clone(); - ( - values, - Box::new(move |request| { - let mut device = device.lock().unwrap(); - device.calls.push(request.clone()); - let identity = serde_json::json!({"vendorId":1155,"productId":42082,"path":"fake-a","serialNumber":"serial-a","interface":2}); - if request["action"] == "automatic-read" { - return Ok(Some(serde_json::to_vec( - &serde_json::json!({"identity":identity,"dpi":device.dpi,"pollingRateHz":device.hz}), - )?)); - } - if request["action"] == "automatic-apply" { - assert_eq!(request["identity"], identity); - } - if let Some(dpi) = request["dpi"].as_u64() { - device.dpi = dpi as u32; - } - if request["action"].is_null() && device.fail_manual { - anyhow::bail!("manual write failed after possible partial change"); - } - if let Some(hz) = request["pollingRateHz"].as_u64() { - device.hz = hz as u32; - } - Ok(Some(serde_json::to_vec( - &serde_json::json!({"identity":identity}), - )?)) - }), - ) - } - - #[test] - fn manual_settings_takeover_preserves_3200_on_automatic_exit() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = None; - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - assert_eq!(values.lock().unwrap().dpi, 1600); - assert!(apply_settings("Zaunkoenig", Some(3200), None).unwrap()); - reconcile_automatic(&mut automation, None).unwrap(); - assert_eq!(values.lock().unwrap().dpi, 3200); - }); - } - - #[test] - fn manual_profile_and_partial_failure_preserve_requested_fields_and_restore_untouched_fields() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - for fail in [false, true] { - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = Some(2000); - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - values.lock().unwrap().fail_manual = fail; - let mut manual = game.clone(); - manual.settings.dpi = Some(3200); - manual.settings.polling_rate_hz = None; - assert_eq!(apply_profile(&manual).is_err(), fail); - let error = reconcile_automatic(&mut automation, Some(&game)).unwrap_err(); - assert!(error.to_string().contains("manual intent")); - assert_eq!(values.lock().unwrap().dpi, 3200); - reconcile_automatic(&mut automation, None).unwrap(); - let device = values.lock().unwrap(); - assert_eq!((device.dpi, device.hz), (3200, 1000)); - assert!(device.calls.last().unwrap()["dpi"].is_null()); - }); - } - } - - #[test] - fn new_automatic_acquisition_captures_manual_baseline_without_losing_other_original_field() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = Some(2000); - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - apply_settings("zaunkoenig", Some(3200), None).unwrap(); - let mut next = game.clone(); - next.application.name = "Next".into(); - next.settings.dpi = Some(6400); - reconcile_automatic(&mut automation, Some(&next)).unwrap(); - assert_eq!(values.lock().unwrap().dpi, 6400); - reconcile_automatic(&mut automation, None).unwrap(); - let device = values.lock().unwrap(); - assert_eq!((device.dpi, device.hz), (3200, 1000)); - }); - } - - #[test] - fn browser_report_takeover_invalidates_unknown_fields_before_native_restore() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - { - let _access = manual_report_access(0x0483).unwrap(); - values.lock().unwrap().dpi = 3200; - } - reconcile_automatic(&mut automation, None).unwrap(); - assert_eq!(values.lock().unwrap().dpi, 3200); - }); - } - - #[test] - fn helper_accepted_brand_whitespace_cannot_bypass_manual_takeover() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = None; - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - let brand = " \u{feff}ZaUnKoEnIg \t"; - apply_settings(brand, Some(3200), None).unwrap(); - assert_eq!(values.lock().unwrap().calls.last().unwrap()["brand"], brand); - reconcile_automatic(&mut automation, None).unwrap(); - assert_eq!(values.lock().unwrap().dpi, 3200); - }); - } - - #[test] - fn polling_manual_intent_preserves_manual_rate_and_restores_only_dpi() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = Some(2000); - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - apply_settings("Zaunkoenig", None, Some(4000)).unwrap(); - reconcile_automatic(&mut automation, None).unwrap(); - let device = values.lock().unwrap(); - assert_eq!((device.dpi, device.hz), (800, 4000)); - assert!(device.calls.last().unwrap()["pollingRateHz"].is_null()); - }); - } - - #[test] - fn partially_failed_manual_request_relinquishes_every_requested_field() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - let (values, helper) = fake_native(); - native_hid::with_test_helper(helper, || { - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = Some(2000); - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - values.lock().unwrap().fail_manual = true; - assert!(apply_settings("Zaunkoenig", Some(3200), Some(4000)).is_err()); - reconcile_automatic(&mut automation, None).unwrap(); - { - let device = values.lock().unwrap(); - assert_eq!((device.dpi, device.hz), (3200, 2000)); - assert_eq!(device.calls.len(), 3); - } - reconcile_automatic(&mut automation, Some(&game)).unwrap(); - reconcile_automatic(&mut automation, None).unwrap(); - let device = values.lock().unwrap(); - assert_eq!((device.dpi, device.hz), (3200, 2000)); - }); - } - - #[test] - fn queued_automatic_exit_waits_for_manual_intent_and_partial_completion() { - let _serial = TEST_NATIVE.lock().unwrap_or_else(|e| e.into_inner()); - for fail in [false, true] { - let (values, helper) = fake_native(); - let mut automation = automation::NativeAutomation::default(); - let mut game = profile("Zaunkoenig:M3K"); - game.settings.dpi = Some(1600); - game.settings.polling_rate_hz = None; - native_hid::with_test_helper(helper, || { - reconcile_automatic(&mut automation, Some(&game)) - }) - .unwrap(); - values.lock().unwrap().fail_manual = fail; - let (started_tx, started_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let manual_values = values.clone(); - let manual = std::thread::spawn(move || { - native_hid::with_test_helper( - Box::new(move |request| { - manual_values.lock().unwrap().dpi = request["dpi"].as_u64().unwrap() as u32; - started_tx.send(()).unwrap(); - release_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - if fail { - anyhow::bail!("manual partial failure"); - } - Ok(Some(Vec::new())) - }), - || apply_settings("Zaunkoenig", Some(3200), None), - ) - }); - started_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - assert!(DEVICE_ACCESS.try_lock().is_err()); - let (queued_tx, queued_rx) = std::sync::mpsc::channel(); - let automatic = std::thread::spawn(move || { - native_hid::with_test_helper( - Box::new(|_| panic!("manual takeover leaves no automatic write to restore")), - || { - queued_tx.send(()).unwrap(); - reconcile_automatic(&mut automation, None) - }, - ) - }); - queued_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - release_tx.send(()).unwrap(); - assert_eq!(manual.join().unwrap().is_err(), fail); - automatic.join().unwrap().unwrap(); - assert_eq!(values.lock().unwrap().dpi, 3200); - } - } - /// Exercises the real Rust -> Node.js round trip, so it needs Node.js on /// PATH and the native-hid/ checkout present next to Cargo.toml — not /// guaranteed in every CI environment, so it's opt-in rather than part @@ -581,81 +144,4 @@ mod tests { "expected an error with no Razer mouse connected, got {outcome:?}" ); } - - /// Discovery must see every vendor id the helper can drive, and every - /// brand discovery writes into `device.id` must normalize to a helper - /// brand key — otherwise a mouse is discovered but its battery or - /// settings can never be read. Parses `native-hid/src/brands.mjs` (the - /// helper's own table) so the two cannot drift apart silently. - #[test] - fn discovery_table_matches_the_native_hid_helper_brand_table() { - let source = std::fs::read_to_string(concat!( - env!("CARGO_MANIFEST_DIR"), - "/native-hid/src/brands.mjs" - )) - .expect("the native-hid checkout ships next to Cargo.toml"); - let mut after_table = false; - let mut helper_keys = Vec::new(); - let mut helper_vendor_ids = Vec::new(); - for line in source.lines() { - if line.starts_with("export const BRAND_DRIVERS") { - after_table = true; - continue; - } - if !after_table { - continue; - } - if let Some(start) = line.find("vendorIds: [") { - let rest = &line[start + "vendorIds: [".len()..]; - let end = rest.find(']').expect("vendorIds lists close on their line"); - for value in rest[..end].split(',') { - let value = value.trim(); - if value.is_empty() { - continue; - } - helper_vendor_ids.push( - u16::from_str_radix(value.trim_start_matches("0x"), 16) - .expect("brands.mjs vendor ids are hex"), - ); - } - } else if let Some(key) = line.strip_suffix(": {") { - helper_keys.push(key.trim().trim_matches('"').to_owned()); - } - } - assert!( - !helper_vendor_ids.is_empty(), - "parsed no vendor ids — did brands.mjs's format change?" - ); - let discoverable = |id: u16| { - // The special-cased vendor ids in `discovered_brand`: the shared - // Teevolution/VGN page, Logitech's HID++ transport, and Pulsar's - // own Rust-driver vendor id. - [pulsar::VENDOR_ID, 0x3554, LOGITECH_VENDOR_ID].contains(&id) - || delux::SUPPORTED_VENDOR_IDS.contains(&id) - || KNOWN_BRANDS.iter().any(|(_, ids)| ids.contains(&id)) - }; - for id in helper_vendor_ids { - assert!( - discoverable(id), - "vendor id 0x{id:04x} is in brands.mjs but discovery cannot see it" - ); - } - for (brand, _) in KNOWN_BRANDS { - let key = brand.trim().to_lowercase(); - assert!( - helper_keys.contains(&key), - "discovery brand {brand:?} has no {key:?} key in brands.mjs" - ); - } - } - - #[test] - #[ignore = "prints what discovery sees on live hardware; needs a supported mouse connected"] - fn discovery_lists_the_connected_mice() { - let mice = detect_mice(); - for mouse in &mice { - println!("{mouse:?}"); - } - assert!(!mice.is_empty(), "discovery found no mice"); - } } diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index 8a0514f..af54ec1 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -24,8 +24,6 @@ use crate::config::ApplicationProfile; /// per-candidate probe timeouts (a few seconds each, for a handful of HID /// interfaces at most). const HELPER_TIMEOUT: Duration = Duration::from_secs(10); -const HELPER_INPUT_LIMIT: usize = 4096; -const HELPER_OUTPUT_LIMIT: usize = 64 * 1024; const EXIT_OK: i32 = 0; const EXIT_NO_DRIVER: i32 = 3; @@ -37,11 +35,6 @@ struct ApplyRequest<'a> { dpi: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "pollingRateHz")] polling_rate_hz: Option, - /// The profile's opaque panel settings (`ProfileSettings.snapshot`, - /// e.g. `{"lightforceSwitchMode":"Optical"}`). The helper applies the - /// fields its driver supports and skips the rest. - #[serde(skip_serializing_if = "Option::is_none")] - settings: Option<&'a serde_json::Value>, } #[derive(Serialize)] @@ -57,49 +50,13 @@ struct StatusRequest<'a> { struct StatusReply { battery_percent: Option, battery_state: Option, - /// The driver's own model name (`MouseStatus.name`), when the driver - /// reports one — better than the receiver-level name discovery gets from - /// the HID descriptor. - device_name: Option, - /// The settings currently in the device's onboard memory, when the - /// driver can read them. `dpi` is a number or a per-stage list in - /// `MouseStatus`; anything else parses as `None`. - dpi: Option, - polling_rate_hz: Option, - switch_mode: Option, } /// A battery level read from the mouse over native HID. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct NativeBattery { pub percent: u8, pub charging: bool, - /// The model name the driver itself reported, when it did. - pub reported_name: Option, -} - -/// Everything the mouse reports about its own onboard-memory state: the -/// battery plus the settings currently in effect. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NativeStatus { - pub battery: NativeBattery, - pub dpi: Option, - pub polling_rate_hz: Option, - /// Logitech's LightForce switch mode; `None` for brands without one. - pub switch_mode: Option, -} - -/// `MouseStatus.dpi` is either one number or a per-stage list; the stages -/// have no reliable "active" marker, so a list collapses to its first -/// stage — the one most drivers treat as the default. -fn coerce_dpi(value: Option<&serde_json::Value>) -> Option { - match value? { - serde_json::Value::Number(number) => number.as_u64().map(|dpi| dpi as u32), - serde_json::Value::Array(stages) => stages - .iter() - .find_map(|stage| stage.as_u64().map(|dpi| dpi as u32)), - _ => None, - } } /// Pushes a profile's DPI/polling rate to the mouse via the Node helper. @@ -109,11 +66,10 @@ fn coerce_dpi(value: Option<&serde_json::Value>) -> Option { /// apply itself failed (including the helper or Node.js being unavailable). pub fn apply(profile: &ApplicationProfile) -> Result { let brand = profile.device.id.split(':').next().unwrap_or_default(); - apply_settings_with_snapshot( + apply_settings( brand, profile.settings.dpi, profile.settings.polling_rate_hz, - profile.settings.snapshot.as_ref(), ) } @@ -121,28 +77,18 @@ pub fn apply(profile: &ApplicationProfile) -> Result { /// the exact helper path used by automatic profiles, so native device control /// and profile switching cannot drift into separate protocol implementations. pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { - apply_settings_with_snapshot(brand, dpi, polling_rate_hz, None) -} - -fn apply_settings_with_snapshot( - brand: &str, - dpi: Option, - polling_rate_hz: Option, - settings: Option<&serde_json::Value>, -) -> Result { let request = ApplyRequest { brand, dpi, polling_rate_hz, - settings, }; Ok(run_helper(&request)?.is_some()) } -/// Reads a `brand` mouse's status via the Node helper. `Ok(None)` means +/// Reads the battery of a `brand` mouse via the Node helper. `Ok(None)` means /// the helper has no driver for the brand or the mouse reports no battery /// (a wired mouse); `Err` means a driver exists but no mouse answered. -pub fn read_status(brand: &str) -> Result> { +pub fn read_battery(brand: &str) -> Result> { let request = StatusRequest { brand, action: "status", @@ -152,22 +98,10 @@ pub fn read_status(brand: &str) -> Result> { }; let reply: StatusReply = serde_json::from_slice(&stdout) .context("the native-hid helper returned an unreadable status")?; - Ok(parse_status(reply)) + Ok(parse_battery(reply)) } -fn parse_status(reply: StatusReply) -> Option { - let battery = parse_battery(&reply)?; - Some(NativeStatus { - battery, - dpi: coerce_dpi(reply.dpi.as_ref()), - polling_rate_hz: reply - .polling_rate_hz - .and_then(|rate| u32::try_from(rate.round() as i64).ok()), - switch_mode: reply.switch_mode.filter(|mode| !mode.trim().is_empty()), - }) -} - -fn parse_battery(reply: &StatusReply) -> Option { +fn parse_battery(reply: StatusReply) -> Option { let percent = reply.battery_percent?; if !percent.is_finite() { return None; @@ -176,56 +110,16 @@ fn parse_battery(reply: &StatusReply) -> Option { percent: percent.round().clamp(0.0, 100.0) as u8, charging: reply .battery_state - .as_ref() .is_some_and(|state| state.starts_with("Charging")), - reported_name: reply - .device_name - .clone() - .filter(|name| !name.trim().is_empty()), }) } -#[cfg(test)] -pub(super) type TestHelper = Box Result>>>; - -#[cfg(test)] -thread_local! { - static TEST_HELPER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; -} - -#[cfg(test)] -pub(super) fn with_test_helper(helper: TestHelper, operation: impl FnOnce() -> R) -> R { - struct Reset; - impl Drop for Reset { - fn drop(&mut self) { - TEST_HELPER.with(|helper| *helper.borrow_mut() = None); - } - } - TEST_HELPER.with(|slot| *slot.borrow_mut() = Some(helper)); - let _reset = Reset; - operation() -} - /// Runs `apply.mjs` with `request` on stdin. Returns its stdout on success, /// `None` when it has no driver for the brand, and `Err` otherwise. -pub(super) fn run_helper(request: &impl Serialize) -> Result>> { - #[cfg(test)] - if let Some(result) = TEST_HELPER.with(|helper| { - helper - .borrow_mut() - .as_mut() - .map(|helper| helper(&serde_json::to_value(request)?)) - }) { - return result; - } - +fn run_helper(request: &impl Serialize) -> Result>> { let script = locate_apply_script()?; let payload = serde_json::to_vec(request).context("could not encode the native-hid request")?; - if payload.len() > HELPER_INPUT_LIMIT { - bail!("native-hid request exceeds the input limit"); - } - let node = locate_node_binary(); let mut command = Command::new(node.as_deref().unwrap_or_else(|| Path::new("node"))); command @@ -235,7 +129,7 @@ pub(super) fn run_helper(request: &impl Serialize) -> Result>> { .stderr(Stdio::piped()); #[cfg(target_os = "windows")] command.creation_flags(CREATE_NO_WINDOW); - let child = command.spawn().with_context(|| match &node { + let mut child = command.spawn().with_context(|| match &node { Some(bundled) => format!( "could not start the bundled Node.js runtime at {}", bundled.display() @@ -246,7 +140,16 @@ pub(super) fn run_helper(request: &impl Serialize) -> Result>> { ), })?; - let output = wait_with_timeout(child, payload, HELPER_TIMEOUT)?; + // Dropping the returned handle after this write closes stdin, which is + // how apply.mjs's stdin-read loop knows the request is complete. + child + .stdin + .take() + .context("the native-hid helper's stdin was unavailable")? + .write_all(&payload) + .context("could not send the request to the native-hid helper")?; + + let output = wait_with_timeout(child, HELPER_TIMEOUT)?; match output.status.code() { Some(EXIT_OK) => Ok(Some(output.stdout)), Some(EXIT_NO_DRIVER) => Ok(None), @@ -258,25 +161,6 @@ pub(super) fn run_helper(request: &impl Serialize) -> Result>> { } } -pub fn spawn_button_listener(brand: &str) -> Result { - let script = locate_apply_script()?.with_file_name("button-listener.mjs"); - let node = locate_node_binary(); - let mut command = Command::new(node.as_deref().unwrap_or_else(|| Path::new("node"))); - command - .arg(&script) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - #[cfg(target_os = "windows")] - command.creation_flags(CREATE_NO_WINDOW); - command.spawn().with_context(|| { - format!( - "could not start {} for the {brand} button listener", - script.display() - ) - }) -} - /// Finds `native-hid/src/apply.mjs` next to the running executable — how a /// packaged Bridge release is expected to ship it — falling back to the /// checkout's own source tree so `cargo run`/`cargo test` work without a @@ -319,217 +203,75 @@ fn installed_native_hid_dir() -> Option { Some(std::env::current_exe().ok()?.parent()?.join("native-hid")) } -fn read_capped(pipe: impl Read) -> Result> { - let mut bytes = Vec::new(); - pipe.take((HELPER_OUTPUT_LIMIT + 1) as u64) - .read_to_end(&mut bytes)?; - if bytes.len() > HELPER_OUTPUT_LIMIT { - bail!("native-hid helper exceeded the output limit"); - } - Ok(bytes) -} - -fn wait_with_timeout(mut child: Child, payload: Vec, timeout: Duration) -> Result { +/// Waits for the child with a hard deadline, killing it if exceeded. Reads +/// stdout/stderr only after exit — safe here because the helper's own +/// output is a handful of short lines at most, never enough to fill a pipe +/// buffer and risk the classic write-blocks-because-nobody-is-reading +/// deadlock a long-running/chatty child could hit with this pattern. +fn wait_with_timeout(mut child: Child, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; - let outcome = (|| { - let stdin = child - .stdin - .take() - .context("native-hid helper stdin unavailable")?; - let stdout = child - .stdout - .take() - .context("native-hid helper stdout unavailable")?; - let stderr = child - .stderr - .take() - .context("native-hid helper stderr unavailable")?; - let (tx, rx) = std::sync::mpsc::channel::<(usize, Result>)>(); - for (index, pipe) in [ - (1, Box::new(stdout) as Box), - (2, Box::new(stderr) as Box), - ] { - let tx = tx.clone(); - std::thread::Builder::new() - .name(format!("openmouse-helper-output-{index}")) - .spawn(move || { - let _ = tx.send((index, read_capped(pipe))); - }) - .context("could not start native-hid output reader")?; - } - std::thread::Builder::new() - .name("openmouse-helper-input".into()) - .spawn(move || { - let mut stdin = stdin; - let result = stdin - .write_all(&payload) - .map(|()| Vec::new()) - .map_err(anyhow::Error::from); - drop(stdin); - let _ = tx.send((0, result)); - }) - .context("could not start native-hid input writer")?; - let mut received: [Option>; 3] = [None, None, None]; - let mut status = None; - loop { - if status.is_none() { - status = child - .try_wait() - .context("could not poll the native-hid helper")?; - } - if let Some(status) = status - && received.iter().all(Option::is_some) - { - return Ok(Output { - status, - stdout: received[1].take().unwrap(), - stderr: received[2].take().unwrap(), - }); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - bail!( - "the native-hid helper did not finish within {}s", - timeout.as_secs() - ); + loop { + if let Some(status) = child + .try_wait() + .context("could not poll the native-hid helper")? + { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); } - match rx.recv_timeout(remaining.min(Duration::from_millis(10))) { - Ok((index, result)) => received[index] = Some(result?), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - if received.iter().any(Option::is_none) { - bail!("native-hid I/O worker stopped"); - } - std::thread::sleep(remaining.min(Duration::from_millis(10))); - } + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_end(&mut stderr); } + return Ok(Output { + status, + stdout, + stderr, + }); } - })(); - if outcome.is_err() { - let _ = child.kill(); - let _ = child.wait(); + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "the native-hid helper did not finish within {}s", + timeout.as_secs() + ); + } + std::thread::sleep(Duration::from_millis(50)); } - outcome } #[cfg(test)] mod tests { use super::*; - #[test] - fn helper_output_is_bounded_before_json_decoding() { - assert_eq!( - read_capped(std::io::Cursor::new(vec![1; HELPER_OUTPUT_LIMIT])) - .unwrap() - .len(), - HELPER_OUTPUT_LIMIT - ); - assert!(read_capped(std::io::Cursor::new(vec![1; HELPER_OUTPUT_LIMIT + 1])).is_err()); - } - fn reply(percent: Option, state: Option<&str>) -> StatusReply { StatusReply { battery_percent: percent, battery_state: state.map(str::to_owned), - device_name: None, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - } - } - - fn named_reply(name: &str) -> StatusReply { - StatusReply { - battery_percent: Some(41.0), - battery_state: Some("Discharging".into()), - device_name: Some(name.to_owned()), - dpi: None, - polling_rate_hz: None, - switch_mode: None, } } #[test] fn parses_battery_from_status() { assert_eq!( - parse_status(reply(Some(41.0), Some("Discharging"))), - Some(NativeStatus { - battery: NativeBattery { - percent: 41, - charging: false, - reported_name: None, - }, - dpi: None, - polling_rate_hz: None, - switch_mode: None, + parse_battery(reply(Some(41.0), Some("Discharging"))), + Some(NativeBattery { + percent: 41, + charging: false }) ); assert_eq!( - parse_status(reply(Some(87.6), Some("Charging slowly"))), - Some(NativeStatus { - battery: NativeBattery { - percent: 88, - charging: true, - reported_name: None, - }, - dpi: None, - polling_rate_hz: None, - switch_mode: None, + parse_battery(reply(Some(87.6), Some("Charging slowly"))), + Some(NativeBattery { + percent: 88, + charging: true }) ); - assert_eq!(parse_status(reply(None, Some("Unknown"))), None); + assert_eq!(parse_battery(reply(None, Some("Unknown"))), None); assert_eq!( - parse_status(reply(Some(140.0), None)).map(|status| status.battery.percent), + parse_battery(reply(Some(140.0), None)).map(|battery| battery.percent), Some(100) ); - // The driver's model name rides along for better tray labels. - assert_eq!( - parse_status(named_reply("G Pro X Superlight 2")), - Some(NativeStatus { - battery: NativeBattery { - percent: 41, - charging: false, - reported_name: Some("G Pro X Superlight 2".into()), - }, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - }) - ); - // A blank name is treated as no name. - assert_eq!( - parse_status(named_reply(" ")).and_then(|status| status.battery.reported_name), - None - ); - } - - #[test] - fn parses_the_onboard_settings_from_status() { - let mut logitech = reply(Some(81.0), Some("Discharging")); - logitech.device_name = Some("G Pro X Superlight 2".into()); - logitech.dpi = Some(serde_json::json!(800)); - logitech.polling_rate_hz = Some(1000.0); - logitech.switch_mode = Some("Optical".into()); - assert_eq!( - parse_status(logitech), - Some(NativeStatus { - battery: NativeBattery { - percent: 81, - charging: false, - reported_name: Some("G Pro X Superlight 2".into()), - }, - dpi: Some(800), - polling_rate_hz: Some(1000), - switch_mode: Some("Optical".into()), - }) - ); - // Drivers that report a stage list collapse to the first stage, and - // blank switch modes read as none. - let mut staged = reply(Some(81.0), None); - staged.dpi = Some(serde_json::json!([400, 800, 1600])); - staged.switch_mode = Some(" ".into()); - let status = parse_status(staged).expect("staged status parses"); - assert_eq!(status.dpi, Some(400)); - assert_eq!(status.switch_mode, None); } } diff --git a/src/drivers/pulsar.rs b/src/drivers/pulsar.rs index 89a1843..a4f0b2e 100644 --- a/src/drivers/pulsar.rs +++ b/src/drivers/pulsar.rs @@ -17,7 +17,6 @@ use std::{ use anyhow::{Context, Result, anyhow, bail}; use hidapi::HidApi; -use super::{NativeBattery, NativeStatus}; use crate::config::ApplicationProfile; /// This driver's slot in `crate::drivers`' brand registry — matches the @@ -38,43 +37,6 @@ pub fn apply(profile: &ApplicationProfile) -> Result<()> { Ok(()) } -/// Opens the connected Pulsar mouse and reads its onboard-memory state — -/// the battery via the same `batteryLevel` query (command `0x04`, percent in -/// response byte 5, charge flag in byte 6) the WebHID driver's `readStatus` -/// uses, plus the active DPI stage and polling rate from flash, all inside -/// one host-control handshake. `Ok(None)` means the mouse answered but -/// reported no battery (byte 5 above 100). -pub fn read_status() -> Result> { - let device = PulsarDevice::open()?; - device.with_device_control(|| { - // The same flash reads `set_dpi`/`set_polling_rate` confirm their - // writes with, so the decode paths are hardware-verified. - let polling_rate_hz = decode_polling_rate(device.read_flash(flash::REPORT_RATE, 2)?[0]); - let stage = device.read_flash(flash::CURRENT_DPI, 2)?[0].min(7) as u16; - let dpi = decode_dpi(&device.read_flash(flash::DPI_VALUES + stage * 4, 4)?); - let response = device.query(command::BATTERY_LEVEL, &[])?; - assert_accepted(&response, "battery query")?; - let percent = response[5]; - if percent > 100 { - return Ok(None); - } - Ok(Some(NativeStatus { - battery: NativeBattery { - percent, - charging: response[6] == 1, - // The report-8 battery query carries no model name; the HID - // product string discovery already read is the best - // available. - reported_name: None, - }, - dpi, - polling_rate_hz, - // The report-8 protocol has no LightForce equivalent. - switch_mode: None, - })) - }) -} - /// Real Pulsar-branded mice and dongles. The Pulsar 4K Wireless Receiver /// shares a report-8 16-byte protocol but enumerates under a different, /// shared vendor id (0x3554) that other brands also use — out of scope here. @@ -87,7 +49,6 @@ const EXCHANGE_TIMEOUT: Duration = Duration::from_millis(1200); mod command { pub const ENCRYPTION_DATA: u8 = 0x01; pub const DEVICE_ONLINE: u8 = 0x03; - pub const BATTERY_LEVEL: u8 = 0x04; pub const WRITE_FLASH_DATA: u8 = 0x07; pub const READ_FLASH_DATA: u8 = 0x08; } diff --git a/src/games.rs b/src/games.rs index 0e13ab0..784ff6c 100644 --- a/src/games.rs +++ b/src/games.rs @@ -4,108 +4,18 @@ use anyhow::{Context, Result, ensure}; use serde::Deserialize; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; -#[cfg(test)] -use crate::config::LocalApplicationKind; -use crate::config::{GameConfig, LocalApplication}; +use crate::config::GameConfig; pub const GAMES_CDN_URL: &str = "https://cdn.jsdelivr.net/gh/OpenMouse-Project/Desktop@main/public/games.json"; const GAMES_URL_ENV: &str = "OPENMOUSE_BRIDGE_GAMES_URL"; const FETCH_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_CATALOG_BYTES: usize = 2 * 1024 * 1024; -pub(crate) const MAX_CATALOG_GAMES: usize = 10_000; -pub(crate) const MAX_EXECUTABLES: usize = 32; -pub(crate) const MAX_CATALOG_STRING: usize = 256; #[derive(Deserialize)] struct CatalogResponse { - #[serde(deserialize_with = "deserialize_games")] games: Vec, } -#[derive(Deserialize)] -struct CatalogGame { - name: String, - #[serde(deserialize_with = "deserialize_executables")] - executables: Vec, -} - -fn deserialize_executables<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - struct ExecutablesVisitor; - impl<'de> serde::de::Visitor<'de> for ExecutablesVisitor { - type Value = Vec; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a bounded array of executable names") - } - fn visit_seq>( - self, - mut seq: A, - ) -> std::result::Result { - let mut executables = Vec::new(); - while let Some(entry) = seq.next_element::()? { - let serde_json::Value::String(name) = entry else { - continue; - }; - if name.len() > MAX_CATALOG_STRING - || name.trim().is_empty() - || name.chars().any(char::is_control) - { - continue; - } - if executables.len() >= MAX_EXECUTABLES { - return Err(serde::de::Error::custom( - "catalog executable count exceeds limit", - )); - } - executables.push(name); - } - Ok(executables) - } - } - deserializer.deserialize_seq(ExecutablesVisitor) -} - -fn deserialize_games<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - struct GamesVisitor; - impl<'de> serde::de::Visitor<'de> for GamesVisitor { - type Value = Vec; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a bounded array of game entries") - } - fn visit_seq>( - self, - mut seq: A, - ) -> std::result::Result { - let mut games = Vec::new(); - let mut game_count = 0; - while let Some(game) = seq.next_element::()? { - if game_count >= MAX_CATALOG_GAMES - || game.name.len() > MAX_CATALOG_STRING - || game.name.trim().is_empty() - || game.name.chars().any(char::is_control) - { - return Err(serde::de::Error::custom( - "game catalog exceeds item limits or contains invalid strings", - )); - } - game_count += 1; - if !game.executables.is_empty() { - games.push(GameConfig { - name: game.name, - executables: game.executables, - }); - } - } - Ok(games) - } - } - deserializer.deserialize_seq(GamesVisitor) -} - pub fn catalog_url() -> Cow<'static, str> { env::var(GAMES_URL_ENV) .map(Cow::Owned) @@ -118,22 +28,20 @@ pub async fn fetch_catalog() -> Result> { .timeout(FETCH_TIMEOUT) .build() .context("could not create the game catalog client")?; - let response = client + let bytes = client .get(url.as_ref()) .send() .await .with_context(|| format!("could not download {url}"))? .error_for_status() - .with_context(|| format!("game catalog request failed for {url}"))?; - let bytes = crate::updater::read_bounded_response(response, MAX_CATALOG_BYTES).await?; + .with_context(|| format!("game catalog request failed for {url}"))? + .bytes() + .await + .with_context(|| format!("could not read game catalog from {url}"))?; parse_catalog(&bytes) } fn parse_catalog(bytes: &[u8]) -> Result> { - ensure!( - bytes.len() <= MAX_CATALOG_BYTES, - "game catalog exceeds byte limit" - ); let catalog: CatalogResponse = serde_json::from_slice(bytes).context("could not parse the game catalog")?; ensure!(!catalog.games.is_empty(), "game catalog contains no games"); @@ -144,65 +52,6 @@ pub struct GameDetector { system: System, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RunningProcess { - pub executable: String, - pub path: String, -} - -pub fn matches_local_games( - targets: &[LocalApplication], - processes: &[RunningProcess], -) -> Vec { - targets - .iter() - .filter(|target| { - target.effective_activation() == crate::config::LocalApplicationActivation::Running - && !target.path.is_empty() - && processes.iter().any(|process| { - matches_target( - &target.path, - &target.executable, - &process.path, - &process.executable, - ) - }) - }) - .map(|target| target.id.clone()) - .collect() -} - -pub fn path_key(path: &str) -> String { - if is_windows_path(path) { - path.replace('\\', "/").to_lowercase() - } else { - path.to_owned() - } -} - -pub fn is_windows_path(path: &str) -> bool { - let bytes = path.as_bytes(); - (bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'/' | b'\\')) - || path.starts_with("\\\\") - || path.starts_with("//") -} - -pub fn matches_target( - path: &str, - executable: &str, - running_path: &str, - running_executable: &str, -) -> bool { - if !path.is_empty() { - !running_path.is_empty() && path_key(path) == path_key(running_path) - } else { - !executable.is_empty() && executable.eq_ignore_ascii_case(running_executable) - } -} - impl Default for GameDetector { fn default() -> Self { Self { @@ -212,26 +61,11 @@ impl Default for GameDetector { } impl GameDetector { - pub fn running_processes(&self) -> Vec { - self.system - .processes() - .values() - .filter_map(|process| { - let path = process.exe()?; - let executable = path.file_name()?.to_string_lossy().into_owned(); - Some(RunningProcess { - executable, - path: path.to_string_lossy().into_owned(), - }) - }) - .collect() - } - pub fn detect(&mut self, games: &[GameConfig]) -> Vec { self.system.refresh_processes_specifics( ProcessesToUpdate::All, true, - ProcessRefreshKind::nothing().with_exe(UpdateKind::Always), + ProcessRefreshKind::nothing().with_exe(UpdateKind::OnlyIfNotSet), ); let running: BTreeSet = self .system @@ -279,122 +113,6 @@ where mod tests { use super::*; - #[test] - fn running_apps_use_inventory_and_missing_paths_fail_closed() { - let mut app: LocalApplication = serde_json::from_value(serde_json::json!({ - "id":"local:editor", "name":"Editor", "kind":"app", "activation":"running", - "executable":"editor", "path":"/opt/editor", "bundleIdentifier":"io.editor" - })) - .unwrap(); - let running = RunningProcess { - executable: "editor".into(), - path: "/opt/editor".into(), - }; - assert_eq!( - matches_local_games(std::slice::from_ref(&app), std::slice::from_ref(&running)), - ["local:editor"] - ); - app.path.clear(); - assert!( - matches_local_games(std::slice::from_ref(&app), std::slice::from_ref(&running)) - .is_empty() - ); - app.path = "/other/editor".into(); - assert!(matches_local_games(std::slice::from_ref(&app), &[running]).is_empty()); - app.path = "/opt/editor".into(); - app.activation = Some(crate::config::LocalApplicationActivation::Focused); - assert!( - matches_local_games( - &[app], - &[RunningProcess { - executable: "editor".into(), - path: "/opt/editor".into() - }] - ) - .is_empty() - ); - } - - #[test] - fn windows_paths_ignore_unicode_case_but_posix_paths_do_not() { - assert!(matches_target( - "C:/Tools/Éditor.exe", - "Éditor.exe", - r"c:\tools\éditor.exe", - "éditor.exe" - )); - assert!(!matches_target( - "/Tools/Éditor", - "Éditor", - "/Tools/éditor", - "éditor" - )); - } - - #[test] - fn local_games_detect_the_running_test_executable_without_window_enumeration() { - let path = std::env::current_exe().unwrap(); - let target = LocalApplication { - id: "local:test-process".into(), - name: "No Window Test".into(), - executable: path.file_name().unwrap().to_string_lossy().into_owned(), - path: path.to_string_lossy().into_owned(), - kind: LocalApplicationKind::Game, - activation: None, - bundle_path: None, - bundle_identifier: None, - }; - let mut detector = GameDetector::default(); - detector.detect(&[]); - assert_eq!( - matches_local_games(&[target], &detector.running_processes()), - ["local:test-process"] - ); - } - - #[test] - fn local_process_matching_is_path_strict_and_excludes_apps() { - let target = LocalApplication { - id: "local:game".into(), - name: "Custom".into(), - executable: "custom.exe".into(), - path: "C:/Trusted/custom.exe".into(), - kind: LocalApplicationKind::Game, - activation: None, - bundle_path: None, - bundle_identifier: None, - }; - let wrong = RunningProcess { - executable: "custom.exe".into(), - path: "C:/Other/custom.exe".into(), - }; - assert!(matches_local_games(std::slice::from_ref(&target), &[wrong]).is_empty()); - let running = RunningProcess { - executable: "CUSTOM.exe".into(), - path: r"c:\trusted\CUSTOM.exe".into(), - }; - assert_eq!( - matches_local_games( - std::slice::from_ref(&target), - std::slice::from_ref(&running) - ), - ["local:game"] - ); - let app = LocalApplication { - kind: LocalApplicationKind::App, - activation: None, - bundle_path: None, - bundle_identifier: None, - ..target.clone() - }; - assert!(matches_local_games(&[app], std::slice::from_ref(&running)).is_empty()); - let leaf = LocalApplication { - path: String::new(), - ..target - }; - assert!(matches_local_games(&[leaf], &[running]).is_empty()); - } - #[test] fn matching_is_case_insensitive_and_deduplicated_by_game() { let games = vec![GameConfig { @@ -430,84 +148,6 @@ mod tests { ); } - #[test] - fn catalog_skips_invalid_executable_entries() { - let catalog = serde_json::json!({"games":[{ - "name":"Mixed", - "executables":["ok.exe", "", " ", "bad\u{0001}.exe", "x".repeat(MAX_CATALOG_STRING + 1), null, false, 42, {}, ["nested"], "other.exe"] - }]}); - let games = parse_catalog(&serde_json::to_vec(&catalog).unwrap()).unwrap(); - assert_eq!(games[0].executables, ["ok.exe", "other.exe"]); - } - - #[test] - fn catalog_drops_games_without_valid_executables() { - let catalog = serde_json::json!({"games":[ - {"name":"Invalid", "executables":["", "\n", null]}, - {"name":"Empty", "executables":[]}, - {"name":"Valid", "executables":["valid.exe"]} - ]}); - let games = parse_catalog(&serde_json::to_vec(&catalog).unwrap()).unwrap(); - assert_eq!( - games, - [GameConfig { - name: "Valid".into(), - executables: vec!["valid.exe".into()] - }] - ); - } - - #[test] - fn catalog_game_limit_counts_discarded_entries() { - let mut entries = - vec![serde_json::json!({"name":"Invalid", "executables":[]}); MAX_CATALOG_GAMES]; - entries.push(serde_json::json!({"name":"Valid", "executables":["valid.exe"]})); - let error = - parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":entries})).unwrap()) - .unwrap_err(); - assert!(format!("{error:#}").contains("game catalog exceeds item limits")); - } - - #[test] - fn catalog_limits_reject_bytes_counts_and_strings() { - let oversized = serde_json::json!({"games":[{"name":"x","executables":["x"]}],"padding":"x".repeat(MAX_CATALOG_BYTES)}); - assert!(parse_catalog(&serde_json::to_vec(&oversized).unwrap()).is_err()); - for game in [ - serde_json::json!({"name":"x".repeat(257),"executables":["x"]}), - serde_json::json!({"name":"x","executables":["x".repeat(257)]}), - serde_json::json!({"name":"x","executables":vec!["x";33]}), - serde_json::json!({"name":" ","executables":["x"]}), - serde_json::json!({"name":"x","executables":[""]}), - ] { - assert!( - parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":[game]})).unwrap()) - .is_err() - ); - } - let boundary = - serde_json::json!({"name":"x".repeat(256),"executables":vec!["x".repeat(256);32]}); - assert!( - parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":[boundary]})).unwrap()) - .is_ok() - ); - let game = serde_json::json!({"name":"x","executables":["x"]}); - assert_eq!( - parse_catalog( - &serde_json::to_vec(&serde_json::json!({"games":vec![game.clone();10000]})) - .unwrap() - ) - .unwrap() - .len(), - 10000 - ); - assert!( - parse_catalog( - &serde_json::to_vec(&serde_json::json!({"games":vec![game;10001]})).unwrap() - ) - .is_err() - ); - } - #[test] fn empty_desktop_catalog_is_rejected() { assert!(parse_catalog(br#"{"games":[]}"#).is_err()); diff --git a/src/hid.rs b/src/hid.rs index 7858658..8884807 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -12,11 +12,9 @@ use std::{ use axum::extract::ws::{Message, WebSocket}; use hidapi::{HidApi, HidDevice, MAX_REPORT_DESCRIPTOR_SIZE}; use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, watch}; +use tokio::sync::mpsc; const MAX_REPORT_BYTES: usize = MAX_REPORT_DESCRIPTOR_SIZE; -const INPUT_QUEUE_CAPACITY: usize = 64; -const SOCKET_SEND_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(target_os = "windows")] const READ_TIMEOUT_MS: i32 = 100; /// How long a shared-handle reader waits between empty polls. The wait happens @@ -228,35 +226,6 @@ struct InputReport { data: Vec, } -#[derive(Clone)] -struct InputSender { - reports: mpsc::Sender, - failed: watch::Sender, -} - -impl InputSender { - fn send(&self, report: InputReport) -> Result<(), ()> { - if *self.failed.borrow() { - return Err(()); - } - if self.reports.try_send(report).is_err() { - self.failed.send_replace(true); - return Err(()); - } - Ok(()) - } -} - -fn input_channel() -> ( - InputSender, - mpsc::Receiver, - watch::Receiver, -) { - let (reports, rx) = mpsc::channel(INPUT_QUEUE_CAPACITY); - let (failed, failure_rx) = watch::channel(false); - (InputSender { reports, failed }, rx, failure_rx) -} - #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DeviceSummary { @@ -435,11 +404,11 @@ struct HidSession { candidates: HashMap, open_devices: HashMap, listeners: HashSet, - input_tx: InputSender, + input_tx: mpsc::UnboundedSender, } impl HidSession { - fn new(session_id: u64, input_tx: InputSender) -> Result { + fn new(session_id: u64, input_tx: mpsc::UnboundedSender) -> Result { Ok(Self { session_id, api: HidApi::new() @@ -869,7 +838,6 @@ impl HidSession { }) .is_err() { - thread_stop.store(true, Ordering::Release); break; } } @@ -915,11 +883,6 @@ impl HidSession { .open_devices .get(key) .ok_or_else(|| "the HID interface is not open".to_owned())?; - if open.paths.len() != 1 { - return Err("HID physical identity is ambiguous; refusing report fan-out".into()); - } - let _access = crate::drivers::manual_report_access(open.vendor_id) - .map_err(|error| error.to_string())?; let mut frame = Vec::with_capacity(data.len() + 1); frame.push(report_id); frame.extend_from_slice(&data); @@ -1085,32 +1048,35 @@ fn report_payload(data: &[u8], size: usize) -> Vec { .to_vec() } -fn grouped_device_key( - vendor_id: u16, - product_id: u16, - path: &[u8], - serial: Option<&str>, -) -> Vec { - // A serial is a verified physical identity, so every collection of that - // device is one candidate. Without one, collections stay apart by path. - let serial = serial.filter(|serial| !serial.is_empty()); - let identity = serial.map_or(path, str::as_bytes); - let mut key = Vec::with_capacity(13 + identity.len()); - key.extend_from_slice(&vendor_id.to_le_bytes()); - key.extend_from_slice(&product_id.to_le_bytes()); - key.push(u8::from(serial.is_some())); - key.extend_from_slice(&(identity.len() as u64).to_le_bytes()); - key.extend_from_slice(identity); - key +fn grouped_device_key(vendor_id: u16, product_id: u16, serial: Option<&str>) -> Vec { + format!( + "{vendor_id:04x}:{product_id:04x}:{}", + serial.unwrap_or("no-serial") + ) + .into_bytes() +} +/// Whether a product's report paths collapse into one connect-page card. +/// +/// Always true. hidapi's serial — the only true physical identity it exposes +/// — is used when present, and vendor:product is the most specific no-serial +/// fallback the platform offers, which is exactly the identity WebHID hands +/// the app (no serial available there at all). Two identical serial-less +/// mice of one model merge into a single logical device; that is the same, +/// unavoidable trade-off the WebHID path makes. This is kept as a named +/// decision point + the path fallback in `device_group_key` so a future +/// platform rule (e.g. "never merge two distinct same-model dongles") has a +/// single, documented place to live. +fn should_group_interfaces(_vendor_id: u16, _has_serial: bool) -> bool { + true } fn device_group_key(info: &hidapi::DeviceInfo) -> Vec { - grouped_device_key( - info.vendor_id(), - info.product_id(), - info.path().to_bytes(), - info.serial_number(), - ) + let serial = info.serial_number().filter(|serial| !serial.is_empty()); + if should_group_interfaces(info.vendor_id(), serial.is_some()) { + grouped_device_key(info.vendor_id(), info.product_id(), serial) + } else { + info.path().to_bytes().to_vec() + } } fn inspect_candidate( @@ -1129,10 +1095,6 @@ fn inspect_candidate( } } - if unique_paths.len() != 1 { - return Err("HID physical identity is ambiguous; refusing multi-path access".into()); - } - let inspect_descriptors = primary.vendor_id() != RAZER_VENDOR_ID; let mut collections = Vec::new(); let mut paths = Vec::with_capacity(unique_paths.len()); @@ -1400,7 +1362,6 @@ struct ActiveSessionGuard; impl ActiveSessionGuard { fn new() -> Self { - let _access = crate::drivers::device_access(); ACTIVE_SESSIONS.fetch_add(1, Ordering::AcqRel); Self } @@ -1414,14 +1375,12 @@ impl Drop for ActiveSessionGuard { pub async fn serve(mut socket: WebSocket) { let session_id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed); - let Ok(_active) = tokio::task::spawn_blocking(ActiveSessionGuard::new).await else { - return; - }; + let _active = ActiveSessionGuard::new(); tracing::info!(session_id, "HID websocket connected"); start_resume_watch(); let mut resume_seen = RESUME_GENERATION.load(Ordering::Acquire); let mut resume_tick = tokio::time::interval(Duration::from_secs(1)); - let (input_tx, mut input_rx, mut failure_rx) = input_channel(); + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); let session = match HidSession::new(session_id, input_tx) { Ok(session) => Arc::new(Mutex::new(session)), Err(error) => { @@ -1433,11 +1392,6 @@ pub async fn serve(mut socket: WebSocket) { loop { tokio::select! { - biased; - _ = failure_rx.changed() => { - tracing::warn!(session_id, "HID input overflow; disconnecting session"); - break; - } message = socket.recv() => { let Some(Ok(message)) = message else { break }; let Message::Text(text) = message else { @@ -1498,26 +1452,9 @@ pub async fn serve(mut socket: WebSocket) { tracing::info!(session_id, "HID websocket disconnected"); } -trait HidSocket { - fn send( - &mut self, - message: Message, - ) -> impl std::future::Future> + Send; -} - -impl HidSocket for WebSocket { - async fn send(&mut self, message: Message) -> Result<(), String> { - WebSocket::send(self, message) - .await - .map_err(|error| error.to_string()) - } -} - -async fn send_json(socket: &mut impl HidSocket, value: &impl Serialize) -> Result<(), String> { - let json = serde_json::to_string(value).map_err(|error| error.to_string())?; - tokio::time::timeout(SOCKET_SEND_TIMEOUT, socket.send(Message::Text(json.into()))) - .await - .map_err(|_| "HID socket send deadline exceeded; session disconnected".to_owned())? +async fn send_json(socket: &mut WebSocket, value: &impl Serialize) -> Result<(), axum::Error> { + let json = serde_json::to_string(value).expect("serializing a fixed HID response cannot fail"); + socket.send(Message::Text(json.into())).await } #[cfg(test)] @@ -1528,66 +1465,6 @@ mod tests { /// entries; used only to pin the grouping rule for them here. const LOGITECH_VENDOR_ID: u16 = 0x046D; - struct FakeSocket { - stalled: bool, - frames: Vec, - } - - impl HidSocket for FakeSocket { - async fn send(&mut self, message: Message) -> Result<(), String> { - if self.stalled { - std::future::pending::<()>().await; - } - self.frames.push(message); - Ok(()) - } - } - - #[test] - fn input_overflow_is_terminal_and_never_silently_accepts_reports() { - let (tx, rx, failure) = input_channel(); - let report = || InputReport { - kind: "inputreport", - device: "fake".into(), - report_id: 1, - data: vec![7], - }; - for _ in 0..INPUT_QUEUE_CAPACITY { - tx.send(report()).unwrap(); - } - assert!(tx.send(report()).is_err()); - assert!(*failure.borrow()); - assert_eq!(rx.len(), INPUT_QUEUE_CAPACITY); - assert!(tx.send(report()).is_err()); - } - - #[tokio::test] - async fn protocol_replies_are_delivered_or_fail_at_the_send_deadline() { - let mut fast = FakeSocket { - stalled: false, - frames: Vec::new(), - }; - send_json(&mut fast, &Reply::ok(77)).await.unwrap(); - let Message::Text(frame) = &fast.frames[0] else { - panic!("expected text reply"); - }; - let reply: serde_json::Value = serde_json::from_str(frame).unwrap(); - assert_eq!(reply["id"], 77); - assert_eq!(reply["ok"], true); - let mut slow = FakeSocket { - stalled: true, - frames: Vec::new(), - }; - let outcome = tokio::time::timeout( - SOCKET_SEND_TIMEOUT + Duration::from_secs(1), - send_json(&mut slow, &Reply::ok(78)), - ) - .await - .expect("send must return its explicit deadline error"); - assert!(outcome.unwrap_err().contains("deadline")); - assert!(slow.frames.is_empty()); - } - #[test] fn descriptor_becomes_webhid_collections_and_report_lengths() { let descriptor = [ @@ -1618,40 +1495,39 @@ mod tests { } #[test] - fn opaque_interfaces_do_not_merge_without_verified_physical_identity() { - assert_ne!( - grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), - grouped_device_key(0x046d, 0xc54d, b"opaque-B", None) - ); + fn multi_interface_devices_group_into_one_logical_device_everywhere() { + // A physical mouse that reports a serial number is one logical device + // everywhere, so its interfaces never become separate connect cards. + assert!(should_group_interfaces(0x1234, true)); + assert!(should_group_interfaces(RAZER_VENDOR_ID, true)); + assert!(should_group_interfaces(LOGITECH_VENDOR_ID, true)); + // Without a serial, one logical device per vendor:product on every + // platform too — the OS splits report paths into separate enumeration + // entries, so grouping them is what keeps a serial-less receiver from + // surfacing one card per path. Two identical serial-less mice of the + // same model merge; that is the same trade-off the WebHID path makes. + assert!(should_group_interfaces(RAZER_VENDOR_ID, false)); + assert!(should_group_interfaces(LOGITECH_VENDOR_ID, false)); + assert!(should_group_interfaces(0x1234, false)); assert_eq!( - grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), - grouped_device_key(0x046d, 0xc54d, b"opaque-A", None) + grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")), + grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")) ); - assert_ne!( - grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), - grouped_device_key(0x1532, 0xc54d, b"opaque-A", None) + assert_eq!( + grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), + grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None) ); assert_ne!( - grouped_device_key(0x046d, 0xc54d, b"opaque-A", Some("")), - grouped_device_key(0x046d, 0xc54d, b"opaque-B", Some("")), - "an empty serial is no identity" - ); - } - - #[test] - fn collections_of_one_serial_bearing_device_group_together() { - assert_eq!( - grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-123")), - grouped_device_key(0x1532, 0x00b0, b"col02", Some("receiver-123")) + grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")), + grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-456")) ); assert_ne!( - grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-123")), - grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-456")) + grouped_device_key(RAZER_VENDOR_ID, 0x00b0, None), + grouped_device_key(LOGITECH_VENDOR_ID, 0x00b0, None) ); - // A serial that happens to equal another device's path is still a different identity. assert_ne!( - grouped_device_key(0x1532, 0x00b0, b"same", Some("same")), - grouped_device_key(0x1532, 0x00b0, b"same", None) + grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), + grouped_device_key(LOGITECH_VENDOR_ID, 0xc548, None) ); } @@ -1710,7 +1586,7 @@ mod tests { fn inventory_removal_cleans_handles_and_preserves_other_sessions() { let session_id = u64::MAX - 41_138; let other_session_id = u64::MAX - 41_139; - let (input_tx, _, _) = input_channel(); + let (input_tx, _) = mpsc::unbounded_channel(); let mut session = HidSession::new(session_id, input_tx).unwrap(); for (key, vendor_id, name) in [ ("hid-1", RAZER_VENDOR_ID, "unplugged-test-mouse"), diff --git a/src/lib.rs b/src/lib.rs index d6e72e3..06984e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,10 @@ pub mod api; pub mod applications; -pub mod buttons; pub mod config; pub mod drivers; pub mod games; mod hid; -pub mod local_targets; pub mod platform; -pub mod remote_image; pub mod service; pub mod updater; diff --git a/src/local_targets.rs b/src/local_targets.rs deleted file mode 100644 index acb1d14..0000000 --- a/src/local_targets.rs +++ /dev/null @@ -1,764 +0,0 @@ -use std::collections::HashSet; - -use anyhow::Result; - -use crate::config::LocalApplication; - -#[cfg(any(target_os = "macos", test))] -#[path = "native_image.rs"] -mod native_image; - -#[derive(Debug)] -pub struct InvalidInput(pub String); - -impl std::fmt::Display for InvalidInput { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.0) - } -} - -impl std::error::Error for InvalidInput {} - -pub fn canonicalize(applications: &[LocalApplication]) -> Result> { - canonicalize_against(applications, &[]) -} - -pub fn canonicalize_against( - applications: &[LocalApplication], - previous: &[LocalApplication], -) -> Result> { - canonicalize_with_previous(applications, previous, resolve_bundle) -} - -#[cfg(test)] -fn canonicalize_with( - applications: &[LocalApplication], - resolve: impl Fn(&str) -> Result<(String, Option)>, -) -> Result> { - canonicalize_with_previous(applications, &[], resolve) -} - -fn canonicalize_with_previous( - applications: &[LocalApplication], - previous: &[LocalApplication], - resolve: impl Fn(&str) -> Result<(String, Option)>, -) -> Result> { - if applications.len() > 256 { - return invalid("at most 256 local applications are allowed"); - } - let mut canonical = applications.to_vec(); - for application in &mut canonical { - if application.path.ends_with(".app") { - if !valid_leaf(&application.executable) { - return invalid("invalid bundle executable filename"); - } - application.executable = application - .path - .rsplit('/') - .next() - .unwrap_or_default() - .into(); - } - } - validate(&canonical)?; - for application in &mut canonical { - if application.path.ends_with(".app") { - let (executable, identifier) = resolve(&application.path).map_err(|error| { - InvalidInput(format!("could not resolve application bundle: {error}")) - })?; - if !executable.starts_with(&format!("{}/", application.path)) { - return invalid("bundle executable must remain inside the selected bundle"); - } - application.bundle_path = Some(application.path.clone()); - application.bundle_identifier = identifier; - application.executable = executable.rsplit('/').next().unwrap_or_default().into(); - application.path = executable; - } else if let Some(bundle) = &application.bundle_path { - let unchanged = previous.iter().any(|old| { - old.id == application.id - && old.path == application.path - && old.executable == application.executable - && old.bundle_path == application.bundle_path - && old.bundle_identifier == application.bundle_identifier - }); - if !unchanged { - let (executable, identifier) = resolve(bundle).map_err(|error| { - InvalidInput(format!("could not verify application bundle: {error}")) - })?; - if executable != application.path - || application - .bundle_identifier - .as_ref() - .is_some_and(|claimed| Some(claimed) != identifier.as_ref()) - { - return invalid("bundle metadata does not identify the selected executable"); - } - application.bundle_identifier = identifier; - } - } - } - validate(&canonical)?; - Ok(canonical) -} - -#[cfg(not(target_os = "macos"))] -fn resolve_bundle(_path: &str) -> Result<(String, Option)> { - invalid(".app bundles can only be resolved on macOS; select the actual executable") -} - -#[cfg(any(target_os = "macos", test))] -fn same_metadata(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { - let same = left.is_file() - && right.is_file() - && left.len() == right.len() - && left.modified().ok() == right.modified().ok(); - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - same && left.dev() == right.dev() - && left.ino() == right.ino() - && left.mode() == right.mode() - && left.ctime() == right.ctime() - && left.ctime_nsec() == right.ctime_nsec() - } - #[cfg(not(unix))] - { - same - } -} - -#[cfg(any(target_os = "macos", test))] -fn check_unchanged( - path: &std::path::Path, - file: &std::fs::File, - original: &std::fs::Metadata, -) -> Result<()> { - anyhow::ensure!( - same_metadata(original, &file.metadata()?) - && same_metadata(original, &std::fs::symlink_metadata(path)?), - "application file changed during validation" - ); - Ok(()) -} - -#[cfg(any(target_os = "macos", test))] -fn checked_open(path: &std::path::Path, limit: u64) -> Result<(std::fs::File, std::fs::Metadata)> { - checked_open_with(path, limit, || Ok(())) -} - -#[cfg(any(target_os = "macos", test))] -fn checked_open_with( - path: &std::path::Path, - limit: u64, - after_metadata: impl FnOnce() -> Result<()>, -) -> Result<(std::fs::File, std::fs::Metadata)> { - let before = std::fs::symlink_metadata(path)?; - anyhow::ensure!( - before.is_file() && before.len() <= limit, - "application file is not regular or exceeds its size limit" - ); - after_metadata()?; - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(target_os = "macos")] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(0x4 | 0x100); - } - let file = options.open(path)?; - check_unchanged(path, &file, &before)?; - Ok((file, before)) -} - -#[cfg(any(target_os = "macos", test))] -fn read_bundle_metadata(path: &std::path::Path) -> Result> { - use std::io::Read; - let (mut file, metadata) = checked_open(path, 65536)?; - let mut bytes = Vec::new(); - (&mut file).take(65537).read_to_end(&mut bytes)?; - anyhow::ensure!( - bytes.len() <= 65536 && bytes.len() as u64 == metadata.len(), - "bundle metadata exceeded its size limit or changed" - ); - check_unchanged(path, &file, &metadata)?; - Ok(bytes) -} - -#[cfg(target_os = "macos")] -fn resolve_bundle(path: &str) -> Result<(String, Option)> { - use anyhow::{Context, ensure}; - use objc2::{class, msg_send, rc::Retained, runtime::AnyObject}; - use objc2_foundation::{NSData, NSString}; - use std::{fs, os::unix::fs::PermissionsExt, path::Path}; - objc2::rc::autoreleasepool(|_| { - let root = fs::canonicalize(path).context("bundle is missing or inaccessible")?; - ensure!( - root == Path::new(path), - "select the canonical bundle path, not a link" - ); - ensure!(root.is_dir(), "selected bundle is not a directory"); - let info = root.join("Contents/Info.plist"); - ensure!( - fs::canonicalize(&info)? == info, - "bundle metadata must not be linked" - ); - let bytes = read_bundle_metadata(&info)?; - let data = NSData::with_bytes(&bytes); - let dictionary: Option> = unsafe { - msg_send![class!(NSPropertyListSerialization), - propertyListWithData: &*data, - options: 0_usize, - format: std::ptr::null_mut::(), - error: std::ptr::null_mut::<*mut AnyObject>()] - }; - let dictionary = dictionary.context("invalid application property list")?; - let is_dictionary: bool = - unsafe { msg_send![&*dictionary, isKindOfClass: class!(NSDictionary)] }; - ensure!(is_dictionary, "application metadata must be a dictionary"); - let string = |key: &str| -> Result> { - let value: Option> = - unsafe { msg_send![&*dictionary, objectForKey: &*NSString::from_str(key)] }; - value - .map(|value| { - value - .downcast::() - .map(|value| value.to_string()) - .map_err(|_| anyhow::anyhow!("bundle metadata {key} must be a string")) - }) - .transpose() - }; - ensure!( - string("CFBundlePackageType")?.as_deref() == Some("APPL"), - "bundle is not a native application" - ); - let leaf = string("CFBundleExecutable")?.context("bundle has no executable")?; - ensure!(valid_leaf(&leaf), "invalid bundle executable filename"); - let executable_selection = root.join("Contents/MacOS").join(leaf); - let executable = fs::canonicalize(&executable_selection)?; - ensure!( - executable.starts_with(&root), - "bundle executable escapes the bundle" - ); - let (mut image, metadata) = checked_open(&executable, u64::MAX)?; - ensure!( - metadata.permissions().mode() & 0o500 == 0o500, - "bundle executable requires owner read and execute access" - ); - ensure!( - native_image::validate(&mut image, metadata.len()), - "bundle executable is not a supported native binary" - ); - check_unchanged(&executable, &image, &metadata)?; - ensure!( - read_bundle_metadata(&info)? == bytes, - "bundle metadata changed during resolution" - ); - ensure!( - fs::canonicalize(path)? == root - && fs::canonicalize(&info)? == info - && fs::canonicalize(&executable_selection)? == executable, - "application bundle changed during resolution" - ); - let identifier = string("CFBundleIdentifier")?; - Ok(( - executable - .to_str() - .context("executable path is not Unicode")? - .into(), - identifier, - )) - }) -} - -pub fn validate(applications: &[LocalApplication]) -> Result<()> { - if applications.len() > 256 { - return invalid("at most 256 local applications are allowed"); - } - let mut ids = HashSet::new(); - for application in applications { - let suffix = application.id.strip_prefix("local:").unwrap_or_default(); - if suffix.is_empty() - || suffix.len() > 128 - || !suffix - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return invalid( - "local application ID must have a local: prefix and a 1–128 character ASCII suffix", - ); - } - if !ids.insert(&application.id) { - return invalid("local application IDs must be unique"); - } - if application.name.trim().is_empty() - || application.name.chars().count() > 80 - || application.name.chars().any(char::is_control) - { - return invalid("local application name must contain 1–80 characters without controls"); - } - if application.bundle_path.as_ref().is_some_and(|value| { - !value.starts_with('/') - || !value.ends_with(".app") - || value.chars().count() > 1024 - || value.chars().any(forbidden_character) - || value.split('/').any(|part| matches!(part, "." | "..")) - }) || application.bundle_identifier.as_ref().is_some_and(|value| { - value.is_empty() || value.len() > 255 || value.chars().any(char::is_control) - }) { - return invalid("invalid bundle metadata"); - } - if application.bundle_identifier.is_some() && application.bundle_path.is_none() { - return invalid("bundle identifier requires a bundle path"); - } - if let Some(bundle) = &application.bundle_path - && application.path != *bundle - && !application.path.starts_with(&format!("{bundle}/")) - { - return invalid("bundle executable must remain inside the selected bundle"); - } - if !valid_leaf(&application.executable) { - return invalid( - "executable must be a leaf filename of at most 255 characters, not a command", - ); - } - if !application.path.is_empty() { - let path = &application.path; - let windows = crate::games::is_windows_path(path); - let parts: Vec<_> = path - .split(['/', '\\']) - .filter(|part| !part.is_empty()) - .collect(); - let leaf = parts.last().copied().unwrap_or_default(); - if path.chars().count() > 1024 - || path.chars().any(forbidden_character) - || if windows && path.as_bytes().get(1) == Some(&b':') { - path[2..].contains(':') - } else { - path.contains(':') - } - || (!windows && !path.starts_with('/')) - || path.ends_with(['/', '\\']) - || parts.iter().any(|part| matches!(*part, "." | "..")) - || ((path.starts_with("\\\\") || path.starts_with("//")) && parts.len() < 3) - || if windows { - !leaf.eq_ignore_ascii_case(&application.executable) - } else { - leaf != application.executable - } - { - return invalid( - "path must be a full executable path matching the executable filename, at most 1024 characters", - ); - } - } - } - Ok(()) -} - -pub fn canonicalize_profile( - profile: &mut crate::config::ApplicationProfile, - applications: &[LocalApplication], -) -> Result<()> { - if let Some(id) = &profile.application.local_id { - let target = applications - .iter() - .find(|target| &target.id == id) - .ok_or_else(|| InvalidInput(format!("unknown local application ID: {id}")))?; - profile.application.name = target.name.clone(); - profile.application.executable = target.executable.clone(); - profile.application.path = target.path.clone(); - profile.application.activation = target.activation; - profile.application.bundle_path = target.bundle_path.clone(); - profile.application.bundle_identifier = target.bundle_identifier.clone(); - } - Ok(()) -} - -fn invalid(message: &str) -> Result { - Err(InvalidInput(message.into()).into()) -} - -fn forbidden_character(character: char) -> bool { - character.is_control() || matches!(character, '*' | '?' | '"' | ';' | '|' | '<' | '>' | '`') -} - -fn valid_leaf(value: &str) -> bool { - !value.trim().is_empty() - && value == value.trim() - && value.chars().count() <= 255 - && !matches!(value, "." | "..") - && !value.chars().any(|character| { - forbidden_character(character) || matches!(character, '/' | '\\' | ':') - }) - && !value.contains(" -") - && !value.to_ascii_lowercase().contains(".exe ") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::LocalApplicationKind; - - fn target() -> LocalApplication { - LocalApplication { - id: "local:editor".into(), - name: "Editor".into(), - executable: "editor.exe".into(), - path: String::new(), - kind: LocalApplicationKind::App, - activation: None, - bundle_path: None, - bundle_identifier: None, - } - } - - #[test] - fn bundle_ingress_resolves_all_before_accepting_batch() { - let mut input = target(); - input.path = "/Applications/Editor.app".into(); - let original = input.clone(); - let prepared = canonicalize_with(std::slice::from_ref(&input), |_| { - Ok(( - "/Applications/Editor.app/Contents/MacOS/editor".into(), - Some("io.editor".into()), - )) - }) - .unwrap(); - assert_eq!(prepared[0].executable, "editor"); - assert_eq!( - prepared[0].bundle_path.as_deref(), - Some(input.path.as_str()) - ); - assert_eq!(prepared[0].bundle_identifier.as_deref(), Some("io.editor")); - assert_eq!(input, original); - let mut missing = input.clone(); - missing.id = "local:missing".into(); - missing.path = "/Applications/Missing.app".into(); - assert!( - canonicalize_with(&[input.clone(), missing], |path| { - if path.contains("Missing") { - return invalid("missing bundle"); - } - Ok(( - "/Applications/Editor.app/Contents/MacOS/editor".into(), - None, - )) - }) - .is_err() - ); - assert_eq!(input, original); - } - - #[cfg(target_os = "macos")] - #[test] - fn native_bundle_resolution_is_bounded_and_rejects_moved_executables() { - use std::{fs, os::unix::fs::PermissionsExt}; - let parent = fs::canonicalize(std::env::temp_dir()).unwrap(); - let root = parent.join(format!("bridge-native-bundle-{}", std::process::id())); - let bundle = root.join("Editor.app"); - let executable = bundle.join("Contents/MacOS/editor"); - fs::create_dir_all(executable.parent().unwrap()).unwrap(); - let info = bundle.join("Contents/Info.plist"); - fs::write(&info, r#"CFBundleExecutableeditorCFBundleIdentifierio.editorCFBundlePackageTypeAPPL"#).unwrap(); - fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); - let mut input = target(); - input.path = bundle.to_str().unwrap().into(); - input.executable = "Editor.app".into(); - let result = canonicalize(std::slice::from_ref(&input)).unwrap(); - assert_eq!(result[0].path, executable.to_str().unwrap()); - assert_eq!(result[0].bundle_identifier.as_deref(), Some("io.editor")); - let original_image = fs::read(&executable).unwrap(); - fs::write(&executable, [0xcf, 0xfa, 0xed, 0xfe]).unwrap(); - assert!(canonicalize(std::slice::from_ref(&input)).is_err()); - fs::write(&executable, original_image).unwrap(); - fs::rename(&executable, executable.with_extension("moved")).unwrap(); - assert!(canonicalize(std::slice::from_ref(&input)).is_err()); - fs::write(&info, vec![b'x'; 65537]).unwrap(); - assert!(canonicalize(std::slice::from_ref(&input)).is_err()); - fs::remove_dir_all(root).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn native_metadata_and_image_open_reject_fifo_and_symlinks_without_blocking() { - use std::{ - ffi::CString, - os::unix::{ffi::OsStrExt, fs::symlink}, - }; - unsafe extern "C" { - fn mkfifo(path: *const std::ffi::c_char, mode: u16) -> std::ffi::c_int; - } - let root = std::env::temp_dir().join(format!("bridge-fifo-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let fifo = root.join("Info.plist"); - let cpath = CString::new(fifo.as_os_str().as_bytes()).unwrap(); - assert_eq!(unsafe { mkfifo(cpath.as_ptr(), 0o600) }, 0); - let start = std::time::Instant::now(); - assert!(read_bundle_metadata(&fifo).is_err()); - assert!(checked_open(&fifo, u64::MAX).is_err()); - assert!(start.elapsed() < std::time::Duration::from_secs(1)); - let file = root.join("regular"); - std::fs::write(&file, b"data").unwrap(); - let link = root.join("link"); - symlink(&file, &link).unwrap(); - assert!(read_bundle_metadata(&link).is_err()); - let raced = root.join("raced"); - std::fs::write(&raced, b"regular").unwrap(); - let raced_cpath = CString::new(raced.as_os_str().as_bytes()).unwrap(); - let start = std::time::Instant::now(); - assert!( - checked_open_with(&raced, 65536, || { - std::fs::remove_file(&raced)?; - anyhow::ensure!( - unsafe { mkfifo(raced_cpath.as_ptr(), 0o600) } == 0, - "mkfifo failed" - ); - Ok(()) - }) - .is_err() - ); - assert!(start.elapsed() < std::time::Duration::from_secs(1)); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn canonical_bundle_metadata_requires_native_consistency() { - let mut input = target(); - input.executable = "editor".into(); - input.path = "/Applications/Editor.app/Contents/MacOS/editor".into(); - input.bundle_path = Some("/Applications/Editor.app".into()); - input.bundle_identifier = Some("io.editor".into()); - let resolve = |_: &str| Ok((input.path.clone(), input.bundle_identifier.clone())); - assert_eq!( - canonicalize_with(std::slice::from_ref(&input), resolve).unwrap(), - vec![input.clone()] - ); - for (path, identifier) in [ - ( - "/Applications/Editor.app/Contents/MacOS/other", - Some("io.editor"), - ), - (input.path.as_str(), Some("io.other")), - (input.path.as_str(), None), - ] { - assert!( - canonicalize_with(std::slice::from_ref(&input), |_| Ok(( - path.into(), - identifier.map(str::to_owned) - ))) - .is_err() - ); - } - let mut identifier_only = target(); - identifier_only.bundle_identifier = Some("io.editor".into()); - assert!(canonicalize_with(&[identifier_only], |_| unreachable!()).is_err()); - } - - #[test] - fn unchanged_missing_bundle_allows_unrelated_crud_but_retarget_requires_resolution() { - let mut old = target(); - old.executable = "editor".into(); - old.path = "/Applications/Editor.app/Contents/MacOS/editor".into(); - old.bundle_path = Some("/Applications/Editor.app".into()); - old.bundle_identifier = Some("io.editor".into()); - let mut renamed = old.clone(); - renamed.name = "Renamed".into(); - let mut other = target(); - other.id = "local:other".into(); - let result = canonicalize_with_previous( - &[renamed.clone(), other.clone()], - std::slice::from_ref(&old), - |_| invalid("missing bundle"), - ) - .unwrap(); - assert_eq!(result, vec![renamed, other.clone()]); - assert_eq!( - canonicalize_with_previous( - std::slice::from_ref(&other), - std::slice::from_ref(&old), - |_| unreachable!() - ) - .unwrap(), - vec![other] - ); - let mut retargeted = old.clone(); - retargeted.path.push('2'); - retargeted.executable.push('2'); - let mut identifier_changed = old.clone(); - identifier_changed.bundle_identifier = Some("io.other".into()); - let mut new_id = old.clone(); - new_id.id = "local:new".into(); - for changed in [retargeted, identifier_changed, new_id] { - assert!( - canonicalize_with_previous(&[changed], std::slice::from_ref(&old), |_| invalid( - "missing bundle" - )) - .is_err() - ); - } - } - - #[test] - fn bounded_bundle_metadata_rejects_oversize_nonregular_and_changed_files() { - let root = std::env::temp_dir().join(format!("bridge-metadata-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("Info.plist"); - std::fs::write(&path, b"plist").unwrap(); - assert_eq!(read_bundle_metadata(&path).unwrap(), b"plist"); - let (file, metadata) = checked_open(&path, 65536).unwrap(); - std::fs::write(&path, b"changed length").unwrap(); - assert!(check_unchanged(&path, &file, &metadata).is_err()); - std::fs::write(&path, vec![0; 65537]).unwrap(); - assert!(read_bundle_metadata(&path).is_err()); - assert!(read_bundle_metadata(&root).is_err()); - drop(file); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn local_validation_accepts_literal_filename_punctuation_without_mutation() { - for (executable, path) in [ - ( - "editor.exe", - "C:/Users/O'Connor/Apps & Tools/$Apps/editor.exe", - ), - ("Tom Clancy's Game.exe", "C:/Games/Tom Clancy's Game.exe"), - ("Tools & Games.exe", r"C:\Apps & Tools\Tools & Games.exe"), - ("$editor.exe", r"\\server\share$\$editor.exe"), - ("editor.exe", "/opt/O'Connor/Apps & Tools/$Apps/editor.exe"), - ("Tom Clancy's Game.exe", ""), - ("Tools & Games.exe", ""), - ("$editor.exe", ""), - ] { - let mut entry = target(); - entry.executable = executable.into(); - entry.path = path.into(); - let original = entry.clone(); - assert!(validate(std::slice::from_ref(&entry)).is_ok(), "{entry:?}"); - assert_eq!(entry, original); - } - } - - #[test] - fn local_validation_rejects_nonliteral_targets_without_mutation() { - for (executable, path) in [ - (".", ""), - ("..", ""), - ("editor?.exe", ""), - ("editor\u{7f}.exe", ""), - ("editor.exe --flag", "C:/Tools/editor.exe --flag"), - ("editor.exe;calc", "C:/Tools/editor.exe;calc"), - ("editor.exe", "C:/Tools/./editor.exe"), - ("editor.exe", "C:/Tools/../editor.exe"), - ("editor.exe", "C:/Tools/ed\nitor/editor.exe"), - ("editor.exe", "C:/Tools/*/editor.exe"), - ("editor.exe", "C:/Tools/?/editor.exe"), - ("editor.exe", "file:///Tools/editor.exe"), - ("editor.exe", "C:/Users/O'Connor/Apps & Tools/other.exe"), - ] { - let mut entry = target(); - entry.executable = executable.into(); - entry.path = path.into(); - let original = entry.clone(); - assert!(validate(std::slice::from_ref(&entry)).is_err(), "{entry:?}"); - assert_eq!(entry, original); - } - } - - #[test] - fn local_validation_accepts_boundaries_and_rejects_extra_path_colons() { - let mut entry = target(); - entry.id = format!("local:{}", "a".repeat(128)); - entry.name = "字".repeat(80); - entry.executable = "a".repeat(255); - entry.path = format!("C:/{}/{}", "d".repeat(765), entry.executable); - assert_eq!(entry.path.chars().count(), 1024); - assert!(validate(&[entry]).is_ok()); - for path in [ - r"C:\Tools:stream\editor.exe", - "/usr:bin/editor.exe", - r"\\server:port\share\editor.exe", - ] { - let mut entry = target(); - entry.path = path.into(); - assert!(validate(&[entry]).is_err(), "{path}"); - } - for path in [ - r"C:\Program Files\Editor\editor.exe", - r"\\server\share\editor.exe", - "/usr/bin/editor.exe", - ] { - let mut entry = target(); - entry.path = path.into(); - assert!(validate(&[entry]).is_ok(), "{path}"); - } - let targets = (0..256) - .map(|index| LocalApplication { - id: format!("local:{index}"), - ..target() - }) - .collect::>(); - assert!(validate(&targets).is_ok()); - } - - #[test] - fn local_input_rejects_duplicate_ids_invalid_commands_and_limits() { - let valid = target(); - assert!(validate(std::slice::from_ref(&valid)).is_ok()); - assert!(validate(&[valid.clone(), valid.clone()]).is_err()); - for id in ["", "editor", "local:", "local:bad/id", "local:日本語"] { - let mut entry = valid.clone(); - entry.id = id.into(); - assert!(validate(&[entry]).is_err(), "{id}"); - } - for executable in [ - "", - "../editor", - "C:\\editor.exe", - "editor.exe --flag", - "*.exe", - "editor.exe;calc", - "editor\n.exe", - "\"editor.exe\"", - ] { - let mut entry = valid.clone(); - entry.executable = executable.into(); - assert!(validate(&[entry]).is_err(), "{executable}"); - } - for path in [ - "tools/editor.exe", - "C:editor.exe", - "https://host/editor.exe", - "C:/Tools/editor.exe --flag", - "C:/Tools/*.exe", - "C:/Tools/other.exe", - ] { - let mut entry = valid.clone(); - entry.path = path.into(); - assert!(validate(&[entry]).is_err(), "{path}"); - } - let mut entry = valid.clone(); - entry.name = "字".repeat(80); - assert!(validate(&[entry.clone()]).is_ok()); - entry.name.push('字'); - assert!(validate(&[entry]).is_err()); - let mut entry = valid.clone(); - entry.executable = "a".repeat(256); - assert!(validate(&[entry]).is_err()); - let mut entry = valid.clone(); - entry.path = format!("C:/{}/editor.exe", "a".repeat(1024)); - assert!(validate(&[entry]).is_err()); - assert!( - validate( - &(0..257) - .map(|index| LocalApplication { - id: format!("local:{index}"), - ..valid.clone() - }) - .collect::>() - ) - .is_err() - ); - } -} diff --git a/src/main.rs b/src/main.rs index ceebbe1..4e26615 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,6 @@ #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] mod desktop; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -mod native_ui; -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] mod overlay; use std::fs; @@ -64,7 +62,6 @@ async fn run() -> Result<()> { // application icons over the API, so always keep them extracted. service.start_game_monitor(Arc::new(AtomicBool::new(true))); service.start_battery_monitor(); - service.start_mode_monitor(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), BRIDGE_PORT); let listener = TcpListener::bind(address) .await diff --git a/src/native_image.rs b/src/native_image.rs deleted file mode 100644 index 3a1255a..0000000 --- a/src/native_image.rs +++ /dev/null @@ -1,476 +0,0 @@ -use std::io::{Read, Seek, SeekFrom}; - -pub(super) fn validate(image: &mut (impl Read + Seek), length: u64) -> bool { - validate_image(image, length).is_some() -} - -fn read_at(image: &mut (impl Read + Seek), offset: u64, count: usize) -> Option> { - image.seek(SeekFrom::Start(offset)).ok()?; - let mut bytes = vec![0; count]; - image.read_exact(&mut bytes).ok()?; - Some(bytes) -} - -fn word(bytes: &[u8], offset: usize, little: bool) -> Option { - let bytes = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?; - Some(if little { - u32::from_le_bytes(bytes) - } else { - u32::from_be_bytes(bytes) - }) -} - -fn wide(bytes: &[u8], offset: usize, little: bool) -> Option { - let bytes = bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?; - Some(if little { - u64::from_le_bytes(bytes) - } else { - u64::from_be_bytes(bytes) - }) -} - -fn fits(offset: u64, size: u64, length: u64) -> Option<()> { - (offset.checked_add(size)? <= length).then_some(()) -} - -fn validate_image(image: &mut (impl Read + Seek), length: u64) -> Option<()> { - fits(0, 8, length)?; - let header = read_at(image, 0, 8)?; - let magic = word(&header, 0, false)?; - if !matches!(magic, 0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca) { - return thin(image, 0, length, None); - } - let little = matches!(magic, 0xbebafeca | 0xbfbafeca); - let fat64 = matches!(magic, 0xcafebabf | 0xbfbafeca); - let count = word(&header, 4, little)? as usize; - if !(1..=16).contains(&count) { - return None; - } - let stride = if fat64 { 32 } else { 20 }; - let table_end = 8 + count * stride; - fits(0, table_end as u64, length)?; - let table = read_at(image, 8, count * stride)?; - let mut ranges = Vec::new(); - let mut cpus = Vec::new(); - for arch in table.chunks_exact(stride) { - let cpu = word(arch, 0, little)?; - let subtype = word(arch, 4, little)?; - let (offset, size, align) = if fat64 { - if word(arch, 28, little)? != 0 { - return None; - } - ( - wide(arch, 8, little)?, - wide(arch, 16, little)?, - word(arch, 24, little)?, - ) - } else { - ( - word(arch, 8, little)? as u64, - word(arch, 12, little)? as u64, - word(arch, 16, little)?, - ) - }; - if align > 31 - || offset < table_end as u64 - || !offset.is_multiple_of(1_u64 << align) - || size == 0 - || cpus.contains(&(cpu, subtype)) - { - return None; - } - fits(offset, size, length)?; - let end = offset.checked_add(size)?; - if ranges - .iter() - .any(|&(start, stop)| offset < stop && end > start) - { - return None; - } - ranges.push((offset, end)); - cpus.push((cpu, subtype)); - thin(image, offset, size, Some((cpu, subtype)))?; - } - Some(()) -} - -fn thin( - image: &mut (impl Read + Seek), - base: u64, - length: u64, - arch: Option<(u32, u32)>, -) -> Option<()> { - fits(0, 32, length)?; - let header = read_at(image, base, 32)?; - let little = match word(&header, 0, false)? { - 0xfeedfacf => false, - 0xcffaedfe => true, - _ => return None, - }; - let cpu = word(&header, 4, little)?; - let subtype = word(&header, 8, little)?; - if !matches!(cpu, 0x01000007 | 0x0100000c) - || if cpu == 0x01000007 { - !matches!(subtype & 0x00ffffff, 3 | 8) - } else { - !matches!(subtype & 0x00ffffff, 0..=2) - } - || arch.is_some_and(|value| value != (cpu, subtype)) - || word(&header, 12, little)? != 2 - || word(&header, 28, little)? != 0 - { - return None; - } - let count = word(&header, 16, little)? as usize; - let size = word(&header, 20, little)? as usize; - if count == 0 || count > 16384 || size > 1024 * 1024 || count > size / 8 { - return None; - } - fits(32, size as u64, length)?; - let commands = read_at(image, base.checked_add(32)?, size)?; - let mut cursor: usize = 0; - let mut executable_ranges = Vec::new(); - let mut entry = None; - for _ in 0..count { - let cmd = word(&commands, cursor, little)?; - let size = word(&commands, cursor.checked_add(4)?, little)? as usize; - if size < 8 || !size.is_multiple_of(8) { - return None; - } - let data = commands.get(cursor..cursor.checked_add(size)?)?; - match cmd { - 0x19 => { - let offset = wide(data, 40, little)?; - let size = wide(data, 48, little)?; - let vmaddr = wide(data, 24, little)?; - let vmsize = wide(data, 32, little)?; - vmaddr.checked_add(vmsize)?; - fits(offset, size, length)?; - if size > vmsize { - return None; - } - let maxprot = word(data, 56, little)?; - let initprot = word(data, 60, little)?; - if initprot & !maxprot != 0 || maxprot & !7 != 0 { - return None; - } - if initprot & 4 != 0 && size > 0 { - executable_ranges.push((offset, offset.checked_add(size)?)); - } - let sections = word(data, 64, little)? as usize; - if data.len() != 72_usize.checked_add(sections.checked_mul(80)?)? { - return None; - } - for section in data[72..].as_chunks::<80>().0 { - let size = wide(section, 40, little)?; - fits( - wide(section, 32, little)?.checked_sub(vmaddr)?, - size, - vmsize, - )?; - if word(section, 52, little)? > 31 { - return None; - } - let start = word(section, 48, little)? as u64; - let flags = word(section, 64, little)? & 0xff; - if !matches!(flags, 1 | 0xc | 0x12) { - fits(start, size, length)?; - if size > 0 - && (start < offset - || start.checked_add(size)? - > offset.checked_add(wide(data, 48, little)?)?) - { - return None; - } - } - fits( - word(section, 56, little)? as u64, - (word(section, 60, little)? as u64).checked_mul(8)?, - length, - )?; - } - } - 0x2 => { - if data.len() != 24 { - return None; - } - fits( - word(data, 8, little)? as u64, - (word(data, 12, little)? as u64).checked_mul(16)?, - length, - )?; - fits( - word(data, 16, little)? as u64, - word(data, 20, little)? as u64, - length, - )?; - } - 0xb => { - if data.len() != 80 { - return None; - } - for (field, stride) in [(32, 8), (40, 56), (48, 4), (56, 4), (64, 8), (72, 8)] { - fits( - word(data, field, little)? as u64, - (word(data, field + 4, little)? as u64).checked_mul(stride)?, - length, - )?; - } - } - 0x22 | 0x80000022 => { - if data.len() != 48 { - return None; - } - for field in (8..48).step_by(8) { - fits( - word(data, field, little)? as u64, - word(data, field + 4, little)? as u64, - length, - )?; - } - } - 0x1d | 0x1e | 0x26 | 0x29 | 0x2b | 0x2e | 0x80000033 | 0x80000034 | 0x36 | 0x37 - | 0x38 => { - if data.len() != 16 { - return None; - } - fits( - word(data, 8, little)? as u64, - word(data, 12, little)? as u64, - length, - )?; - } - 0x16 => { - if data.len() != 16 { - return None; - } - fits( - word(data, 8, little)? as u64, - (word(data, 12, little)? as u64).checked_mul(4)?, - length, - )?; - } - 0x31 => { - if data.len() != 40 { - return None; - } - fits(wide(data, 24, little)?, wide(data, 32, little)?, length)?; - } - 0x2c => { - if data.len() != 24 || word(data, 20, little)? != 0 { - return None; - } - fits( - word(data, 8, little)? as u64, - word(data, 12, little)? as u64, - length, - )?; - } - 0xc | 0x80000018 | 0x8000001f | 0x20 | 0x80000023 => { - if data.len() < 24 { - return None; - } - let offset = word(data, 8, little)? as usize; - if offset < 24 || !data.get(offset..)?.contains(&0) { - return None; - } - } - 0xe | 0x8000001c | 0x27 => { - let offset = word(data, 8, little)? as usize; - if offset < 12 || !data.get(offset..)?.contains(&0) { - return None; - } - } - 0x32 => { - let platform = word(data, 8, little)?; - if !matches!(platform, 1 | 6) - || data.len() - != 24_usize - .checked_add((word(data, 20, little)? as usize).checked_mul(8)?)? - { - return None; - } - } - 0x1 | 0x3 | 0x21 | 0x25 | 0x2f | 0x30 | 0x80000035 => return None, - 0x80000028 => { - if data.len() != 24 || entry.is_some() { - return None; - } - entry = Some(wide(data, 8, little)?); - } - 0x1b => { - if data.len() != 24 { - return None; - } - } - 0x24 | 0x2a => { - if data.len() != 16 { - return None; - } - } - _ => return None, - } - cursor = cursor.checked_add(size)?; - } - let entry = entry?; - (cursor == commands.len() - && executable_ranges - .iter() - .any(|&(start, stop)| entry >= start && entry < stop)) - .then_some(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - fn structural_image() -> Vec { - let mut bytes = vec![0; 132]; - for (offset, value) in [ - (0, 0xfeedfacf_u32), - (4, 0x0100000c), - (12, 2), - (16, 2), - (20, 96), - (32, 0x19), - (36, 72), - (88, 7), - (92, 5), - (104, 0x80000028), - (108, 24), - ] { - bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - } - for (offset, value) in [(64, 4096_u64), (80, 132), (112, 128)] { - bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); - } - bytes[128..132].copy_from_slice(&[0xc0, 0x03, 0x5f, 0xd6]); - bytes - } - - fn accepted(bytes: &[u8]) -> bool { - validate(&mut Cursor::new(bytes), bytes.len() as u64) - } - - #[test] - fn accepts_structural_thin_and_fat_images_and_rejects_truncation() { - let image = structural_image(); - assert!(accepted(&image)); - for length in 0..image.len() { - assert!(!accepted(&image[..length]), "{length}"); - } - for fat64 in [false, true] { - for little in [false, true] { - let mut fat = vec![0; 64]; - let magic: u32 = if fat64 { 0xcafebabf } else { 0xcafebabe }; - let put = |fat: &mut Vec, offset: usize, value: u32| { - fat[offset..offset + 4].copy_from_slice(&if little { - value.to_le_bytes() - } else { - value.to_be_bytes() - }); - }; - put(&mut fat, 0, magic); - put(&mut fat, 4, 1); - put(&mut fat, 8, 0x0100000c); - if fat64 { - for (offset, value) in [(16, 64_u64), (24, image.len() as u64)] { - fat[offset..offset + 8].copy_from_slice(&if little { - value.to_le_bytes() - } else { - value.to_be_bytes() - }); - } - put(&mut fat, 32, 6); - } else { - put(&mut fat, 16, 64); - put(&mut fat, 20, image.len() as u32); - put(&mut fat, 24, 6); - } - fat.extend_from_slice(&image); - assert!(accepted(&fat)); - let end = fat.len() - 1; - assert!(!accepted(&fat[..end])); - put(&mut fat, 8, 0x01000007); - assert!(!accepted(&fat)); - } - } - } - - #[test] - fn rejects_invalid_architecture_commands_segments_and_entry_points() { - for (offset, value) in [ - (4, 0_u32), - (12, 6), - (16, 0), - (16, u32::MAX), - (20, u32::MAX), - (28, 1), - (36, 0), - (36, 8), - (80, u32::MAX), - (92, 8), - (96, 1), - (108, 16), - (112, 132), - ] { - let mut image = structural_image(); - image[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - assert!(!accepted(&image), "{offset}: {value}"); - } - } - - #[test] - fn rejects_out_of_bounds_referenced_data_in_load_commands() { - for (cmd, size, fields) in [ - (0x2_u32, 24, vec![(8, u32::MAX), (12, 1)]), - (0xb, 80, vec![(32, u32::MAX), (36, 1)]), - (0x80000022, 48, vec![(8, u32::MAX), (12, 1)]), - (0x1d, 16, vec![(8, u32::MAX), (12, 1)]), - (0x16, 16, vec![(8, u32::MAX), (12, 1)]), - (0x31, 40, vec![(24, u32::MAX), (32, 1)]), - (0x2c, 24, vec![(8, u32::MAX), (12, 1)]), - (0xc, 24, vec![(8, u32::MAX)]), - (0x1b, 8, vec![]), - ] { - let mut command = vec![0; size]; - command[0..4].copy_from_slice(&cmd.to_le_bytes()); - command[4..8].copy_from_slice(&(size as u32).to_le_bytes()); - for (offset, value) in fields { - command[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - } - let mut image = structural_image(); - image.splice(128..128, command); - for (offset, value) in [ - (16, 3_u32), - (20, 96 + size as u32), - (80, image.len() as u32), - (112, 128 + size as u32), - ] { - image[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - } - assert!(!accepted(&image), "{cmd:x}"); - } - } - - #[test] - fn rejects_magic_only_and_truncated_native_images() { - for magic in [ - 0xfeedface_u32, - 0xcefaedfe, - 0xfeedfacf, - 0xcffaedfe, - 0xcafebabe, - 0xbebafeca, - 0xcafebabf, - 0xbfbafeca, - ] { - let bytes = magic.to_be_bytes().to_vec(); - assert!( - !validate(&mut Cursor::new(&bytes), bytes.len() as u64), - "{magic:x}" - ); - } - } -} diff --git a/src/native_ui.rs b/src/native_ui.rs deleted file mode 100644 index e4209ae..0000000 --- a/src/native_ui.rs +++ /dev/null @@ -1,528 +0,0 @@ -use eframe::egui::{ - self, Align2, Color32, FontData, FontDefinitions, FontFamily, FontId, Pos2, Rect, Sense, Shape, - Stroke, TextStyle, Vec2, -}; - -pub const FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/DMSans-Variable.ttf"); -pub const CHROME: Color32 = Color32::from_rgb(8, 9, 10); -pub const BACKGROUND: Color32 = Color32::from_rgb(12, 13, 15); -pub const SURFACE: Color32 = Color32::from_rgb(20, 22, 24); -pub const SURFACE_HOVER: Color32 = Color32::from_rgb(27, 30, 33); -pub const DIVIDER: Color32 = Color32::from_rgb(38, 42, 45); -pub const TEXT: Color32 = Color32::from_rgb(238, 240, 242); -pub const MUTED: Color32 = Color32::from_rgb(170, 176, 183); -pub const QUIET: Color32 = Color32::from_rgb(144, 151, 160); -pub const ACCENT: Color32 = Color32::from_rgb(93, 222, 137); -pub const ON_ACCENT: Color32 = Color32::from_rgb(7, 22, 14); -pub const WARNING: Color32 = Color32::from_rgb(242, 184, 75); -pub const DANGER: Color32 = Color32::from_rgb(255, 107, 107); -pub const CONTROL_RADIUS: u8 = 6; -pub const PANEL_RADIUS: u8 = 10; -pub const ROW_HEIGHT: f32 = 28.0; - -#[derive(Clone, Copy)] -pub enum Icon { - Gear, - Back, - Minimize, - Close, - External, - Info, - Mouse, - Game, - Logs, -} - -pub fn icon(painter: &egui::Painter, rect: Rect, kind: Icon, color: Color32) { - let r = Rect::from_center_size(rect.center(), Vec2::splat(16.0)); - let point = |x: f32, y: f32| Pos2::new(r.left() + x, r.top() + y); - let stroke = Stroke::new(1.45, color); - let line = |a: (f32, f32), b: (f32, f32)| { - painter.line_segment([point(a.0, a.1), point(b.0, b.1)], stroke) - }; - match kind { - Icon::Gear => { - painter.circle_stroke(point(8.0, 8.0), 2.3, stroke); - painter.circle_stroke(point(8.0, 8.0), 5.3, stroke); - for i in 0..8 { - let a = i as f32 * std::f32::consts::FRAC_PI_4; - let d = Vec2::angled(a); - painter.line_segment( - [point(8.0, 8.0) + d * 5.4, point(8.0, 8.0) + d * 7.0], - stroke, - ); - } - } - Icon::Back => { - line((10.0, 3.0), (5.0, 8.0)); - line((5.0, 8.0), (10.0, 13.0)); - } - Icon::Minimize => { - line((3.0, 9.0), (13.0, 9.0)); - } - Icon::Close => { - line((4.0, 4.0), (12.0, 12.0)); - line((12.0, 4.0), (4.0, 12.0)); - } - Icon::External => { - line((6.0, 3.0), (13.0, 3.0)); - line((13.0, 3.0), (13.0, 10.0)); - line((13.0, 3.0), (6.0, 10.0)); - line((3.0, 6.0), (3.0, 13.0)); - line((3.0, 13.0), (10.0, 13.0)); - } - Icon::Info => { - painter.circle_stroke(point(8.0, 8.0), 6.0, stroke); - line((8.0, 7.0), (8.0, 11.0)); - painter.circle_filled(point(8.0, 4.5), 0.9, color); - } - Icon::Mouse => { - painter.rect_stroke( - Rect::from_min_max(point(3.5, 1.0), point(12.5, 15.0)), - 4, - stroke, - egui::StrokeKind::Inside, - ); - line((8.0, 4.0), (8.0, 7.0)); - } - Icon::Game => { - painter.rect_stroke( - Rect::from_min_max(point(1.0, 4.0), point(15.0, 12.0)), - 3, - stroke, - egui::StrokeKind::Inside, - ); - line((4.0, 6.0), (4.0, 10.0)); - line((2.0, 8.0), (6.0, 8.0)); - painter.circle_filled(point(11.0, 7.0), 0.9, color); - painter.circle_filled(point(13.0, 9.0), 0.9, color); - } - Icon::Logs => { - for y in [4.0, 8.0, 12.0] { - line((5.0, y), (13.0, y)); - painter.circle_filled(point(2.0, y), 0.8, color); - } - } - } -} - -fn gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> Shape { - let mut mesh = egui::Mesh::default(); - let color = |p: Pos2| { - let t = ((p.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0); - Color32::from(egui::lerp( - egui::Rgba::from(top)..=egui::Rgba::from(bottom), - t, - )) - }; - mesh.colored_vertex(rect.center(), color(rect.center())); - let radius = radius.min(rect.width() / 2.0).min(rect.height() / 2.0); - for (center, angle) in [ - ( - Pos2::new(rect.left() + radius, rect.top() + radius), - std::f32::consts::PI, - ), - ( - Pos2::new(rect.right() - radius, rect.top() + radius), - std::f32::consts::PI * 1.5, - ), - ( - Pos2::new(rect.right() - radius, rect.bottom() - radius), - 0.0, - ), - ( - Pos2::new(rect.left() + radius, rect.bottom() - radius), - std::f32::consts::FRAC_PI_2, - ), - ] { - for i in 0..=8 { - let a = angle + i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; - let p = center + Vec2::angled(a) * radius; - mesh.colored_vertex(p, color(p)); - } - } - let n = mesh.vertices.len() as u32 - 1; - for i in 1..=n { - mesh.add_triangle(0, i, if i == n { 1 } else { i + 1 }); - } - Shape::mesh(mesh) -} - -pub fn focus(ui: &egui::Ui, response: &egui::Response, radius: u8) { - if response.has_focus() { - ui.painter().rect_stroke( - response.rect.expand(2.0), - radius, - Stroke::new(1.5, ACCENT), - egui::StrokeKind::Outside, - ); - } -} - -pub fn panel( - ui: &mut egui::Ui, - lit: bool, - margin: i8, - content: impl FnOnce(&mut egui::Ui) -> R, -) -> egui::InnerResponse { - let layer = ui.painter().add(Shape::Noop); - let result = egui::Frame::NONE.inner_margin(margin).show(ui, content); - let rect = result.response.rect; - let top = if lit { - Color32::from_rgb(28, 37, 32) - } else { - Color32::from_rgb(28, 30, 32) - }; - let shapes = vec![ - gradient(rect, 10.0, top, SURFACE), - Shape::rect_stroke( - rect, - 10, - Stroke::new(1.0, DIVIDER), - egui::StrokeKind::Inside, - ), - Shape::line_segment( - [ - Pos2::new(rect.left() + 10.0, rect.top() + 0.5), - Pos2::new(rect.right() - 10.0, rect.top() + 0.5), - ], - Stroke::new(1.0, Color32::from_rgb(48, 53, 52)), - ), - ]; - ui.painter().set(layer, Shape::Vec(shapes)); - result -} - -fn control(ui: &egui::Ui, response: &egui::Response, primary: bool) { - let rect = response.rect; - let (top, bottom) = if primary { - (ACCENT, Color32::from_rgb(75, 204, 120)) - } else if response.hovered() { - (Color32::from_rgb(46, 50, 53), Color32::from_rgb(32, 35, 38)) - } else { - (Color32::from_rgb(39, 42, 45), Color32::from_rgb(28, 31, 34)) - }; - ui.painter().add(gradient(rect, 6.0, top, bottom)); - ui.painter().rect_stroke( - rect, - 6, - Stroke::new( - 1.0, - if primary { - Color32::from_rgb(105, 231, 151) - } else { - Color32::from_rgb(49, 53, 56) - }, - ), - egui::StrokeKind::Inside, - ); - focus(ui, response, 6); -} - -pub fn icon_button(ui: &mut egui::Ui, kind: Icon, label: &str) -> egui::Response { - let (rect, response) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click()); - response.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label) - }); - if response.hovered() || response.has_focus() { - ui.painter().rect_filled(rect, 6, SURFACE_HOVER); - } - icon( - ui.painter(), - rect, - kind, - if matches!(kind, Icon::Close) && response.hovered() { - DANGER - } else { - MUTED - }, - ); - focus(ui, &response, 6); - response.on_hover_text(label) -} - -pub fn button( - ui: &mut egui::Ui, - label: &str, - kind: Option, - primary: bool, - width: f32, -) -> egui::Response { - let (rect, response) = ui.allocate_exact_size( - Vec2::new(width, if primary { 34.0 } else { 26.0 }), - Sense::click(), - ); - response.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label) - }); - control(ui, &response, primary); - let ink = if primary { ON_ACCENT } else { TEXT }; - let text = ui - .painter() - .layout_no_wrap(label.into(), FontId::proportional(12.5), ink); - let total = text.size().x + if kind.is_some() { 23.0 } else { 0.0 }; - let left = rect.center().x - total / 2.0; - ui.painter().galley( - Pos2::new(left, rect.center().y - text.size().y / 2.0), - text, - ink, - ); - if let Some(kind) = kind { - icon( - ui.painter(), - Rect::from_center_size( - Pos2::new(left + total - 8.0, rect.center().y), - Vec2::splat(16.0), - ), - kind, - ink, - ); - } - response -} - -pub fn switch_row(ui: &mut egui::Ui, label: &str, value: &mut bool) -> egui::Response { - let (rect, mut response) = - ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::click()); - if response.clicked() { - *value = !*value; - response.mark_changed(); - } - response.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *value, label) - }); - let ink = if ui.is_enabled() { TEXT } else { QUIET }; - ui.painter().text( - Pos2::new(rect.left(), rect.center().y), - Align2::LEFT_CENTER, - label, - FontId::proportional(13.0), - ink, - ); - let track = Rect::from_center_size( - Pos2::new(rect.right() - 19.0, rect.center().y), - Vec2::new(38.0, 22.0), - ); - ui.painter() - .rect_filled(track, 11, if *value { ACCENT } else { CHROME }); - if !*value { - ui.painter().rect_stroke( - track, - 11, - Stroke::new(1.0, DIVIDER), - egui::StrokeKind::Inside, - ); - } - let center = Pos2::new( - track.left() + 11.0 + if *value { 16.0 } else { 0.0 }, - track.center().y, - ); - ui.painter().circle_filled( - center, - 9.0, - if *value { - ON_ACCENT - } else { - Color32::from_rgb(49, 53, 55) - }, - ); - if *value { - let p = ui.painter(); - p.line_segment( - [center + Vec2::new(-3.0, 0.0), center + Vec2::new(-0.6, 2.5)], - Stroke::new(1.3, ACCENT), - ); - p.line_segment( - [center + Vec2::new(-0.6, 2.5), center + Vec2::new(3.5, -2.5)], - Stroke::new(1.3, ACCENT), - ); - } - focus(ui, &response, 6); - response -} - -pub fn install(context: &egui::Context) { - context.set_theme(egui::Theme::Dark); - let mut fonts = FontDefinitions::default(); - fonts - .font_data - .insert("DM Sans".into(), FontData::from_static(FONT_BYTES).into()); - fonts - .families - .entry(FontFamily::Proportional) - .or_default() - .insert(0, "DM Sans".into()); - context.set_fonts(fonts); - let mut style = (*context.style_of(egui::Theme::Dark)).clone(); - for (kind, size) in [ - (TextStyle::Heading, 18.0), - (TextStyle::Body, 13.0), - (TextStyle::Button, 12.5), - (TextStyle::Small, 11.5), - ] { - style.text_styles.insert(kind, FontId::proportional(size)); - } - style.spacing.item_spacing = Vec2::new(6.0, 4.0); - style.spacing.button_padding = Vec2::new(9.0, 4.0); - style.spacing.interact_size.y = 26.0; - style.spacing.slider_width = 108.0; - let mut visuals = egui::Visuals::dark(); - visuals.panel_fill = BACKGROUND; - visuals.window_fill = SURFACE_HOVER; - visuals.override_text_color = Some(TEXT); - visuals.weak_text_color = Some(MUTED); - visuals.window_stroke = Stroke::new(1.0, DIVIDER); - visuals.window_corner_radius = PANEL_RADIUS.into(); - visuals.menu_corner_radius = CONTROL_RADIUS.into(); - visuals.selection.bg_fill = Color32::from_rgb(32, 59, 42); - visuals.selection.stroke = Stroke::new(1.5, TEXT); - for (widget, fill) in [ - (&mut visuals.widgets.noninteractive, BACKGROUND), - (&mut visuals.widgets.inactive, SURFACE_HOVER), - (&mut visuals.widgets.hovered, Color32::from_rgb(43, 47, 50)), - (&mut visuals.widgets.active, Color32::from_rgb(32, 59, 42)), - (&mut visuals.widgets.open, SURFACE_HOVER), - ] { - widget.bg_fill = fill; - widget.weak_bg_fill = fill; - widget.bg_stroke = Stroke::new(1.0, DIVIDER); - widget.fg_stroke = Stroke::new(1.0, TEXT); - widget.corner_radius = CONTROL_RADIUS.into(); - widget.expansion = 0.0; - } - visuals.widgets.active.bg_stroke = Stroke::new(1.5, ACCENT); - visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, QUIET); - style.visuals = visuals; - context.set_style_of(egui::Theme::Dark, style); -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn switch_responds_to_the_right_aligned_control() { - let context = egui::Context::default(); - let mut value = false; - let position = egui::pos2(300.0, 17.0); - for events in [ - vec![], - vec![ - egui::Event::PointerMoved(position), - egui::Event::PointerButton { - pos: position, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::default(), - }, - ], - vec![egui::Event::PointerButton { - pos: position, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::default(), - }], - ] { - context - .run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(320.0, 80.0), - )), - events, - ..Default::default() - }, - |ui| { - switch_row(ui, "Launch at login", &mut value); - }, - ) - .drop_without_applying_deltas(); - } - assert!(value); - } - - #[test] - fn focused_switch_is_keyboard_operable() { - let context = egui::Context::default(); - let mut value = false; - for events in [ - vec![], - vec![egui::Event::Key { - key: egui::Key::Space, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::default(), - }], - ] { - context - .run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(320.0, 80.0), - )), - events, - ..Default::default() - }, - |ui| { - switch_row(ui, "Sound", &mut value).request_focus(); - }, - ) - .drop_without_applying_deltas(); - } - assert!(value); - } - - #[test] - fn rounded_material_mesh_stays_inside_its_panel() { - let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(336.0, 70.0)); - let Shape::Mesh(mesh) = gradient(rect, 10.0, SURFACE_HOVER, SURFACE) else { - panic!("expected material mesh") - }; - assert!( - mesh.vertices - .iter() - .all(|vertex| rect.expand(0.01).contains(vertex.pos)) - ); - assert!( - mesh.indices - .iter() - .all(|index| (*index as usize) < mesh.vertices.len()) - ); - } - - #[test] - fn selected_options_keep_readable_text() { - let context = egui::Context::default(); - install(&context); - let style = context.style_of(egui::Theme::Dark); - let luminance = |color: Color32| { - let linear = |value: u8| { - let value = f64::from(value) / 255.0; - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) - }; - let foreground = luminance(TEXT); - let background = luminance(style.visuals.selection.bg_fill); - assert!((foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) >= 4.5); - } - - #[test] - fn emphasis_uses_ink_not_the_action_accent() { - let context = egui::Context::default(); - install(&context); - assert_eq!( - context - .style_of(egui::Theme::Dark) - .visuals - .strong_text_color(), - TEXT - ); - } -} diff --git a/src/overlay/linux.rs b/src/overlay/linux.rs index 4ba9317..20e4fea 100644 --- a/src/overlay/linux.rs +++ b/src/overlay/linux.rs @@ -438,12 +438,12 @@ impl Speaker { tracing::warn!("No audio player (paplay or aplay) for the notification chime"); return Ok(()); }; - if let Some(mut stdin) = child.stdin.take() - && stdin.write_all(&wav).is_err() - { - let _ = child.kill(); - let _ = child.wait(); - return Ok(()); + if let Some(mut stdin) = child.stdin.take() { + if stdin.write_all(&wav).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return Ok(()); + } } self.playback = Some(child); Ok(()) diff --git a/src/overlay/render.rs b/src/overlay/render.rs index f8d2e16..cef0849 100644 --- a/src/overlay/render.rs +++ b/src/overlay/render.rs @@ -10,13 +10,14 @@ use skrifa::{ use vello_cpu::{ Pixmap, RenderContext, Resources, color::{AlphaColor, Srgb}, - kurbo::{BezPath, RoundedRect, Shape as _}, + kurbo::{BezPath, Circle, RoundedRect, Shape as _}, }; const HEIGHT: f64 = 60.0; const RADIUS: f64 = 14.0; const PADDING: f64 = 18.0; -const TEXT_LEFT: f64 = PADDING; +const DOT_RADIUS: f64 = 4.0; +const TEXT_LEFT: f64 = PADDING + DOT_RADIUS * 2.0 + 12.0; const MIN_WIDTH: f64 = 240.0; const MAX_WIDTH: f64 = 460.0; const TITLE_SIZE: f32 = 15.0; @@ -24,10 +25,11 @@ const DETAIL_SIZE: f32 = 12.5; const TITLE_BASELINE: f64 = 27.0; const DETAIL_BASELINE: f64 = 45.0; -const BACKGROUND: AlphaColor = AlphaColor::from_rgba8(12, 13, 15, 245); +const BACKGROUND: AlphaColor = AlphaColor::from_rgba8(16, 17, 19, 245); const BORDER: AlphaColor = AlphaColor::from_rgba8(255, 255, 255, 20); -const TEXT: AlphaColor = AlphaColor::from_rgba8(238, 240, 242, 255); -const MUTED: AlphaColor = AlphaColor::from_rgba8(170, 176, 183, 255); +const TEXT: AlphaColor = AlphaColor::from_rgba8(236, 238, 240, 255); +const MUTED: AlphaColor = AlphaColor::from_rgba8(150, 156, 163, 255); +const ACCENT: AlphaColor = AlphaColor::from_rgba8(93, 222, 137, 255); /// A rendered banner: premultiplied RGBA8 rows, top to bottom. pub struct Banner { @@ -38,8 +40,8 @@ pub struct Banner { /// Renders `title` over `detail` at `scale` physical pixels per point. pub fn banner(title: &str, detail: &str, scale: f64) -> Banner { - let font = - FontRef::new(crate::native_ui::FONT_BYTES).expect("the bundled DM Sans font is valid"); + let font = FontRef::new(epaint_default_fonts::UBUNTU_LIGHT) + .expect("the bundled Ubuntu Light font is valid"); let text_room = MAX_WIDTH - TEXT_LEFT - PADDING; let title = fit(&font, title, TITLE_SIZE, text_room); let detail = fit(&font, detail, DETAIL_SIZE, text_room); @@ -64,6 +66,10 @@ pub fn banner(title: &str, detail: &str, scale: f64) -> Banner { context.set_paint(BACKGROUND); context.fill_path(&scaled(inner.to_path(0.1))); + context.set_paint(ACCENT); + let dot = Circle::new((PADDING + DOT_RADIUS, HEIGHT / 2.0), DOT_RADIUS); + context.fill_path(&scaled(dot.to_path(0.1))); + context.set_paint(TEXT); context.fill_path(&text_path(&font, &title, TITLE_SIZE, TITLE_BASELINE, scale)); context.set_paint(MUTED); @@ -185,20 +191,6 @@ impl OutlinePen for PathPen<'_> { mod tests { use super::*; - #[test] - fn banner_uses_openmouse_ground_without_a_decorative_status_dot() { - let banner = banner("Game profile", "Saved settings selected", 1.0); - let x = PADDING as usize + 4; - let y = HEIGHT as usize / 2; - let pixel = &banner.pixels[(y * banner.width as usize + x) * 4..][..4]; - for (actual, expected) in pixel.iter().zip([12_u8, 13, 15, 245]) { - assert!( - actual.abs_diff(expected) <= 2, - "unexpected panel pixel {pixel:?}" - ); - } - } - #[test] fn banner_is_opaque_inside_and_transparent_at_the_corners() { let banner = banner("Counter-Strike 2", "Profile on · 1600 DPI", 2.0); @@ -211,7 +203,7 @@ mod tests { #[test] fn long_titles_are_shortened_to_fit() { - let font = FontRef::new(crate::native_ui::FONT_BYTES).unwrap(); + let font = FontRef::new(epaint_default_fonts::UBUNTU_LIGHT).unwrap(); let fitted = fit(&font, &"Very Long Game Name ".repeat(10), TITLE_SIZE, 200.0); assert!(fitted.ends_with('…')); assert!(line_width(&font, &fitted, TITLE_SIZE) <= 200.0); diff --git a/src/platform/linux.rs b/src/platform/linux.rs index df2bc31..731da92 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -35,11 +35,10 @@ pub fn set_autostart(enabled: bool) -> Result<()> { return Ok(()); } let executable = env::current_exe().context("could not locate the Bridge executable")?; - let desktop = desktop_entry( - executable - .to_str() - .context("Bridge executable path is not Unicode")?, - )?; + let desktop = format!( + "[Desktop Entry]\nType=Application\nName=OpenMouse Bridge\nExec=\"{}\"\nHidden=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n", + executable.display(), + ); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("could not create {}", parent.display()))?; @@ -49,46 +48,20 @@ pub fn set_autostart(enabled: bool) -> Result<()> { fn autostart_path() -> Result { let home = BaseDirs::new().context("the operating system did not provide a home directory")?; - Ok(home.config_dir().join("autostart").join(DESKTOP_FILE_NAME)) -} - -fn desktop_entry(executable: &str) -> Result { - anyhow::ensure!( - !executable - .chars() - .any(|character| character.is_control() || character == '='), - "Bridge executable path cannot be represented in a desktop entry" - ); - let executable = executable - .chars() - .fold(String::new(), |mut value, character| { - match character { - '%' => value.push_str("%%"), - '\\' => value.push_str(r"\\\\"), - '"' | '$' | '`' => { - value.push_str(r"\\"); - value.push(character); - } - _ => value.push(character), - } - value - }); - Ok(format!( - "[Desktop Entry]\nType=Application\nName=OpenMouse Bridge\nExec=\"{executable}\"\nHidden=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n" - )) + Ok(home + .home_dir() + .join(".config/autostart") + .join(DESKTOP_FILE_NAME)) } #[cfg(test)] mod tests { - use super::*; - #[test] - fn desktop_entry_preserves_literal_exec_metacharacters() { - let entry = desktop_entry("/opt/50%/A \"$`B\\bridge").unwrap(); - assert!( - entry.contains(r#"Exec="/opt/50%%/A \\"\\$\\`B\\\\bridge""#), - "{entry}" + fn desktop_entry_quotes_executable_paths() { + let entry = format!( + "[Desktop Entry]\nType=Application\nName=OpenMouse Bridge\nExec=\"{}\"\n", + "/opt/openmouse/openmouse-bridge" ); - assert!(desktop_entry("/opt/bad\nentry").is_err()); + assert!(entry.contains("Exec=\"/opt/openmouse/openmouse-bridge\"")); } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 9452878..50dfa3a 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -8,17 +8,14 @@ mod windows; #[cfg(target_os = "macos")] mod macos; -#[cfg(any(target_os = "linux", test))] -pub mod linux; +#[cfg(target_os = "linux")] +mod linux; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] mod portable; #[cfg(target_os = "windows")] -pub use windows::{ - autostart_enabled, battery_icon_rgba, light_tray_theme, linux_distribution, platform_name, - set_autostart, tray_icon_size, -}; +pub use windows::{autostart_enabled, linux_distribution, platform_name, set_autostart}; #[cfg(target_os = "macos")] pub use macos::{autostart_enabled, linux_distribution, platform_name, set_autostart}; @@ -29,115 +26,6 @@ pub use linux::{autostart_enabled, linux_distribution, platform_name, set_autost #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub use portable::{autostart_enabled, linux_distribution, platform_name, set_autostart}; -/// The pixel size a tray icon should be drawn at, in physical pixels. -/// Windows reports the notification area's small-icon size for the current -/// system DPI (16 at 100%, 20/24/32 as scaling rises); other desktops get a -/// fixed size that reads well in menu bars and appindicator hosts. -#[cfg(not(target_os = "windows"))] -pub fn tray_icon_size() -> u32 { - 22 -} - -/// Whether the shell paints its surfaces light. Only Windows reports it; -/// macOS menu bars and Linux appindicators are dark in practice. -#[cfg(not(target_os = "windows"))] -pub fn light_tray_theme() -> bool { - false -} - -/// What the tray battery icon shows. The Windows 11 taskbar only renders -/// square tray icons correctly, so the glyph and the number are two -/// mutually exclusive styles chosen in the control panel, not one wide -/// display. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrayBatteryStyle { - /// The native Segoe battery glyph alone, filling the square icon. - Glyph, - /// The percentage number alone in bold text, filling the square icon. - Percentage, -} - -impl TrayBatteryStyle { - /// The config value this style is stored as. - pub fn as_config_str(self) -> &'static str { - match self { - Self::Glyph => "glyph", - Self::Percentage => "percentage", - } - } - - /// Reads a stored config value; anything unknown falls back to the - /// default glyph. - pub fn from_config_str(value: &str) -> Self { - match value.trim().to_ascii_lowercase().as_str() { - "percentage" => Self::Percentage, - _ => Self::Glyph, - } - } -} - -/// Renders the tray battery display as an RGBA buffer in `ink`. Windows -/// draws either the Segoe Fluent battery glyph or the percentage number in -/// bold UI text, per `style`; other desktops get a simple monochrome -/// fallback: a bold outline battery with the charge fill, no anti-aliasing -/// (the style is ignored there). -#[cfg(not(target_os = "windows"))] -pub fn battery_icon_rgba( - percent: u8, - charging: bool, - style: TrayBatteryStyle, - size: u32, - ink: (u8, u8, u8), -) -> Result<(Vec, u32, u32)> { - let _ = style; - let size = size.max(8); - let mut rgba = vec![0u8; (size * size * 4) as usize]; - let mut put = |x0: u32, y0: u32, x1: u32, y1: u32| { - for y in y0..y1.min(size) { - for x in x0..x1.min(size) { - let index = ((y * size + x) * 4) as usize; - rgba[index] = ink.0; - rgba[index + 1] = ink.1; - rgba[index + 2] = ink.2; - rgba[index + 3] = u8::MAX; - } - } - }; - let stroke = (size / 10).max(1); - let body_height = size * 2 / 3; - let body_top = (size - body_height) / 2; - let body_bottom = body_top + body_height; - let nub_width = (size / 16).max(1); - let body_width = size.saturating_sub(nub_width + 2); - // The shell, a nub on the right, and the charge fill inside. - put(0, body_top, body_width, body_top + stroke); - put(0, body_bottom - stroke, body_width, body_bottom); - put(0, body_top, stroke, body_bottom); - put(body_width - stroke, body_top, body_width, body_bottom); - put( - body_width + 1, - size / 3, - body_width + 1 + nub_width, - size * 2 / 3, - ); - let fill = if charging { - 100 - } else { - u32::from(percent.min(100)) - }; - let inner_x0 = stroke; - let inner_x1 = body_width - stroke; - let inner_width = inner_x1 - inner_x0; - let fill_columns = fill * inner_width / 100; - put( - inner_x0, - body_top + stroke, - inner_x0 + fill_columns, - body_bottom - stroke, - ); - Ok((rgba, size, size)) -} - pub fn notify(summary: &str, body: &str) -> Result<()> { // notify-rust resolves a bundle identifier on macOS. Run as a bare binary // (e.g. `cargo run`) there is none, and macOS pops a blocking "Choose diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 3b4270c..742aed0 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,23 +1,14 @@ use std::{env, ffi::OsStr, mem::size_of, os::windows::ffi::OsStrExt, ptr::null_mut}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result}; use windows_sys::Win32::{ Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, WIN32_ERROR}, - Graphics::Gdi::{ - ANTIALIASED_QUALITY, CLIP_DEFAULT_PRECIS, CreateCompatibleDC, CreateFontW, DEFAULT_CHARSET, - DEFAULT_PITCH, DeleteDC, DeleteObject, FF_DONTCARE, FIXED, FW_BOLD, FW_NORMAL, GDI_ERROR, - GGO_GRAY8_BITMAP, GGO_METRICS, GLYPHMETRICS, GetGlyphOutlineW, HDC, HFONT, MAT2, - OUT_TT_PRECIS, SelectObject, - }, System::Registry::{ - HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegDeleteKeyValueW, - RegGetValueW, RegSetKeyValueW, + HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_SZ, RegDeleteKeyValueW, RegGetValueW, RegSetKeyValueW, }, - UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}, }; const RUN_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; -const PERSONALIZE_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; const VALUE_NAME: &str = "OpenMouse Bridge"; pub const fn platform_name() -> &'static str { @@ -28,519 +19,6 @@ pub fn linux_distribution() -> Option { None } -/// The notification area's icon size in physical pixels at the current -/// system DPI: 16 at 100% scaling, 20/24/32 as the display scaling rises. -pub fn tray_icon_size() -> u32 { - // SAFETY: a trivial metrics query with no side effects. - let width = unsafe { GetSystemMetrics(SM_CXSMICON) }; - width.max(16) as u32 -} - -/// Whether Windows paints system surfaces (the taskbar) light. The taskbar -/// follows `SystemUsesLightTheme`, falling back to `AppsUseLightTheme` when -/// the system value has never been written; anything unreadable means dark, -/// the Windows default. -pub fn light_tray_theme() -> bool { - let subkey = wide(PERSONALIZE_SUBKEY); - for value in ["SystemUsesLightTheme", "AppsUseLightTheme"] { - let value_name = wide(value); - let mut data = 0u32; - let mut byte_count = size_of::() as u32; - let result = unsafe { - RegGetValueW( - HKEY_CURRENT_USER, - subkey.as_ptr(), - value_name.as_ptr(), - RRF_RT_REG_DWORD, - null_mut(), - &mut data as *mut u32 as *mut _, - &mut byte_count, - ) - }; - if result == ERROR_SUCCESS { - return data != 0; - } - } - false -} - -/// Segoe icon-font code points for the battery glyphs, verified against -/// Microsoft's Segoe Fluent Icons documentation: `Battery0`..`Battery9` -/// (E850..E859) cover 0–90% in 10% steps, `Battery10` (E83F) is full, and -/// every level below full has a charging variant carrying the bolt -/// (`BatteryCharging0`..8 at E85A..E862, `BatteryCharging9` at E83E). -pub fn battery_glyph(percent: u8, charging: bool) -> u32 { - let level = u32::from(percent.min(100)) / 10; - if !charging { - return if level == 10 { 0xE83F } else { 0xE850 + level }; - } - match level { - 0..=8 => 0xE85A + level, - 9 => 0xE83E, - // The font has no charging glyph for a full battery; the filled - // Battery10 is what Windows itself falls back to. - _ => 0xE83F, - } -} - -/// Renders the tray battery display as a square `size` × `size` RGBA -/// buffer, tinted with `ink` — the Windows 11 taskbar only renders square -/// tray icons faithfully. The `style` picks what fills the square: the -/// Segoe Fluent battery glyph for the level (the charging variant with the -/// bolt when `charging`), or the percentage number in bold UI text (the -/// `%` is left off — at tray sizes it would shrink the digits to a -/// squint; the tooltip spells the full `NN%` out). Returns the buffer with -/// its width and height. `Segoe Fluent Icons` ships with Windows 11; -/// `Segoe MDL2 Assets` carries the same glyphs on Windows 10. -pub fn battery_icon_rgba( - percent: u8, - charging: bool, - style: super::TrayBatteryStyle, - height: u32, - ink: (u8, u8, u8), -) -> Result<(Vec, u32, u32)> { - let codepoint = battery_glyph(percent, charging); - // The label carries only the digits for the percentage style; the - // glyph style never rasterizes text. - let label: Vec = match style { - super::TrayBatteryStyle::Percentage => { - percent.min(100).to_string().encode_utf16().collect() - } - super::TrayBatteryStyle::Glyph => Vec::new(), - }; - for family in ["Segoe Fluent Icons", "Segoe MDL2 Assets"] { - match render_battery_icon(codepoint, &label, family, height, ink) { - Ok(icon) => return Ok(icon), - // A missing family or glyph means the fallback font should be - // tried; anything else is a real error. - Err(error) if MISSING_GLYPH.with(|flag| flag.get()) => { - tracing::debug!(family, %error, "icon font unusable, trying the next"); - MISSING_GLYPH.with(|flag| flag.set(false)); - } - Err(error) => return Err(error), - } - } - Err(anyhow!( - "no Segoe icon font could render the battery display" - )) -} - -thread_local! { - /// Set by the renderer when the font family or glyph is missing, telling - /// `battery_icon_rgba` to fall through to the next font family. - static MISSING_GLYPH: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// One rasterized glyph: GDI's 8-bit coverage bitmap plus the metrics that -/// place it. -struct Raster { - metrics: GLYPHMETRICS, - coverage: Vec, -} - -impl Raster { - fn width(&self) -> u32 { - self.metrics.gmBlackBoxX - } - - fn height(&self) -> u32 { - self.metrics.gmBlackBoxY - } - - /// The bitmap's bytes per row (DWORD-aligned, as GDI reports). - fn stride(&self) -> usize { - ((self.metrics.gmBlackBoxX + 3) & !3) as usize - } - - /// The coverage compacted to tight rows (no DWORD padding). - fn tight(&self) -> Vec { - let stride = self.stride(); - let width = self.width() as usize; - let mut out = Vec::with_capacity(width * self.height() as usize); - for row in 0..self.height() as usize { - out.extend_from_slice(&self.coverage[row * stride..row * stride + width]); - } - out - } -} - -/// The label's text face: the system UI font in bold, so the percentage -/// reads clearly at tray sizes. -const TEXT_FAMILY: &str = "Segoe UI"; - -fn render_battery_icon( - codepoint: u32, - label: &[u16], - family: &str, - height: u32, - ink: (u8, u8, u8), -) -> Result<(Vec, u32, u32)> { - MISSING_GLYPH.with(|flag| flag.set(false)); - let family = wide(family); - let text_family = wide(TEXT_FAMILY); - // SAFETY: the GDI calls below use their own memory DC, every handle is - // deleted on the way out, and the coverage buffers are sized to what - // `GetGlyphOutlineW` reports. - unsafe { - let dc = CreateCompatibleDC(null_mut()); - if dc.is_null() { - return Err(anyhow!("could not create a memory device context")); - } - let result = - compose_battery_display(dc, codepoint, label, &family, &text_family, height, ink); - DeleteDC(dc); - result - } -} - -fn compose_battery_display( - dc: HDC, - codepoint: u32, - label: &[u16], - family: &[u16], - text_family: &[u16], - height: u32, - ink: (u8, u8, u8), -) -> Result<(Vec, u32, u32)> { - // An empty label is the glyph style: the battery fills the square. - // Otherwise the digits fill it. - if label.is_empty() { - // A landscape glyph in a square icon: fitting its width to all but - // 1px of the canvas — the footprint the old square renderer used — - // is the largest it can be, and the height lands near half. - let battery = fit_and_rasterize( - dc, - codepoint, - family, - FW_NORMAL as i32, - height.saturating_sub(1).max(8), - ) - .ok_or_else(|| anyhow!("could not rasterize the battery glyph U+{codepoint:04X}"))?; - let mut canvas = Canvas { - rgba: vec![0u8; (height * height * 4) as usize], - width: height, - height, - }; - canvas.blit( - (height - battery.width()) / 2, - (height - battery.height()) / 2, - &battery.tight(), - battery.width(), - ink, - ); - return Ok((canvas.rgba, height, height)); - } - - // The digits are drawn as tall as the square allows — a 2px margin — - // and squeezed proportionally when the string is wider than tall - // ("100" is, at every size that matters). - let mut target = height.saturating_sub(4).max(8); - let mut raster = rasterize_label(dc, label, text_family, target) - .ok_or_else(|| anyhow!("could not rasterize the battery label"))?; - if raster.width > height.saturating_sub(2) { - target = - (f64::from(target) * f64::from(height - 2) / f64::from(raster.width)).max(6.0) as u32; - raster = rasterize_label(dc, label, text_family, target) - .ok_or_else(|| anyhow!("could not re-fit the battery label"))?; - } - let mut canvas = Canvas { - rgba: vec![0u8; (height * height * 4) as usize], - width: height, - height, - }; - canvas.blit( - (height - raster.width) / 2, - (height - raster.height) / 2, - &raster.coverage, - raster.width, - ink, - ); - Ok((canvas.rgba, height, height)) -} - -/// A laid-out label: one tight coverage bitmap for the whole string. -struct LabelRaster { - coverage: Vec, - width: u32, - height: u32, -} - -/// Rasterizes the label into one tight coverage bitmap in a bold UI face, -/// sized so a digit stands `target` pixels tall. Characters are placed by -/// the font's own advances and side bearings, top-aligned — digits and -/// `%` share the cap height, so the row lines up. -fn rasterize_label(dc: HDC, label: &[u16], family: &[u16], target: u32) -> Option { - if label.is_empty() { - return None; - } - // SAFETY: `dc` is a live memory DC and the font created here is - // deselected and deleted on every exit path. - unsafe { - let mut font = create_glyph_font(family, target as i32, FW_BOLD as i32); - if font.is_null() { - return None; - } - let previous_font = SelectObject(dc, font); - let mut font_height = target as i32; - for _ in 0..3 { - let Some(metrics) = glyph_metrics(dc, u32::from(b'0')) else { - break; - }; - let size = metrics.gmBlackBoxY; - if size == 0 || size.abs_diff(target) <= 1 { - break; - } - let scaled = - (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; - if scaled == font_height { - break; - } - font_height = scaled; - let replacement = create_glyph_font(family, font_height, FW_BOLD as i32); - SelectObject(dc, replacement); - DeleteObject(font); - font = replacement; - if font.is_null() { - SelectObject(dc, previous_font); - return None; - } - } - - let mut rasters = Vec::with_capacity(label.len()); - for &character in label { - match glyph_metrics(dc, u32::from(character)) { - // `glyph_bitmap` writes the bitmap's real bounds back into - // the metrics, and the raster needs those. - Some(mut metrics) => match glyph_bitmap(dc, u32::from(character), &mut metrics) { - Some(coverage) => rasters.push(Raster { metrics, coverage }), - None => break, - }, - None => break, - } - } - SelectObject(dc, previous_font); - DeleteObject(font); - if rasters.len() != label.len() { - return None; - } - - let height = rasters.iter().map(Raster::height).max().unwrap_or(0); - let width: u32 = rasters - .iter() - .map(|r| r.metrics.gmCellIncX.max(0) as u32) - .sum(); - let mut coverage = vec![0u8; (width * height) as usize]; - let mut pen_x = 0u32; - for raster in &rasters { - let tight = raster.tight(); - let x = pen_x + u32::try_from(raster.metrics.gmptGlyphOrigin.x.max(0)).unwrap_or(0); - for row in 0..raster.height() as usize { - for column in 0..raster.width() as usize { - let level = tight[row * raster.width() as usize + column]; - if level == 0 { - continue; - } - let index = row * width as usize + x as usize + column; - if index < coverage.len() { - coverage[index] = coverage[index].max(level); - } - } - } - pen_x += raster.metrics.gmCellIncX.max(0) as u32; - } - Some(LabelRaster { - coverage, - width, - height, - }) - } -} - -/// The RGBA canvas the display is composed onto. -struct Canvas { - rgba: Vec, - width: u32, - height: u32, -} - -impl Canvas { - /// Stamps a tight coverage bitmap at (x0, y0), tinted with `ink`. - /// Coverage levels are normalized so the raster's densest pixel is - /// fully opaque (`GGO_GRAY8_BITMAP` does not promise a 255 peak). - fn blit(&mut self, x0: u32, y0: u32, coverage: &[u8], width: u32, ink: (u8, u8, u8)) { - let peak = coverage.iter().copied().max().unwrap_or(0); - if peak == 0 { - return; - } - let height = coverage.len() as u32 / width.max(1); - for row in 0..height as usize { - let y = y0 as usize + row; - if y as u32 >= self.height { - break; - } - for column in 0..width as usize { - let x = x0 as usize + column; - if x as u32 >= self.width { - break; - } - let level = coverage[row * width as usize + column]; - if level == 0 { - continue; - } - let alpha = ((u16::from(level) * 255) / u16::from(peak)).min(255) as u8; - let index = (y as u32 * self.width + x as u32) as usize * 4; - self.rgba[index] = ink.0; - self.rgba[index + 1] = ink.1; - self.rgba[index + 2] = ink.2; - self.rgba[index + 3] = alpha; - } - } - } -} - -/// Fits the font so the battery glyph's visible bounds span `target` -/// pixels wide — the right fit for a landscape glyph in a square icon (the -/// fit loop accepts within a pixel) — then rasterizes the glyph as an -/// 8-bit coverage bitmap. `None` when the font or glyph is missing. -fn fit_and_rasterize( - dc: HDC, - codepoint: u32, - family: &[u16], - weight: i32, - target: u32, -) -> Option { - // SAFETY: `dc` is a live memory DC and the font created here is - // deselected and deleted on every exit path. - unsafe { - let mut font = create_glyph_font(family, target as i32, weight); - if font.is_null() { - MISSING_GLYPH.with(|flag| flag.set(true)); - return None; - } - let previous_font = SelectObject(dc, font); - let mut font_height = target as i32; - let mut raster = None; - for _ in 0..4 { - let Some(mut metrics) = glyph_metrics(dc, codepoint) else { - MISSING_GLYPH.with(|flag| flag.set(true)); - break; - }; - let size = metrics.gmBlackBoxX; - if size == 0 { - break; - } - if size.abs_diff(target) <= 1 { - raster = glyph_bitmap(dc, codepoint, &mut metrics) - .map(|coverage| Raster { metrics, coverage }); - break; - } - let scaled = - (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; - if scaled == font_height { - raster = glyph_bitmap(dc, codepoint, &mut metrics) - .map(|coverage| Raster { metrics, coverage }); - break; - } - // Recreate the font at the scaled height and keep fitting. - font_height = scaled; - let replacement = create_glyph_font(family, font_height, weight); - SelectObject(dc, replacement); - DeleteObject(font); - font = replacement; - if font.is_null() { - MISSING_GLYPH.with(|flag| flag.set(true)); - break; - } - } - SelectObject(dc, previous_font); - DeleteObject(font); - raster - } -} - -/// Rasterizes the glyph as an 8-bit grayscale coverage bitmap -/// (`GGO_GRAY8_BITMAP`): GDI's own anti-aliased glyph raster, no drawing -/// surface involved. GDI writes the bitmap's *actual* bounds back into -/// `metrics` — which can come back a pixel wider than `GGO_METRICS` -/// reported (the anti-aliased edge column), so the caller must lay the -/// buffer out by the written-back values or every row skews. -fn glyph_bitmap(dc: HDC, codepoint: u32, metrics: &mut GLYPHMETRICS) -> Option> { - // SAFETY: `dc` is a live DC with a font selected; the metrics and - // matrix structs are valid for the call and the buffer is sized to hold - // the reported black box (rows are DWORD-aligned) plus slack — GDI - // rejects an exact-size buffer for some glyphs, asking for a few bytes - // more than stride × rows. - unsafe { - let stride = (metrics.gmBlackBoxX + 3) & !3; - let byte_count = stride as usize * metrics.gmBlackBoxY as usize; - for slack in [64usize, 1024, 8192] { - let mut buffer = vec![0u8; byte_count + slack]; - let mut matrix: MAT2 = std::mem::zeroed(); - matrix.eM11 = FIXED { fract: 0, value: 1 }; - matrix.eM22 = FIXED { fract: 0, value: 1 }; - let filled = GetGlyphOutlineW( - dc, - codepoint, - GGO_GRAY8_BITMAP, - metrics, - buffer.len() as u32, - buffer.as_mut_ptr().cast(), - &matrix, - ); - if filled != GDI_ERROR as u32 && filled as usize <= buffer.len() { - return Some(buffer); - } - } - None - } -} - -fn create_glyph_font(family: &[u16], height: i32, weight: i32) -> HFONT { - // SAFETY: a plain font creation; the face name is null-terminated and - // outlives the call. - unsafe { - CreateFontW( - -height, - 0, - 0, - 0, - weight, - 0, - 0, - 0, - u32::from(DEFAULT_CHARSET), - u32::from(OUT_TT_PRECIS), - u32::from(CLIP_DEFAULT_PRECIS), - u32::from(ANTIALIASED_QUALITY), - u32::from(DEFAULT_PITCH | FF_DONTCARE), - family.as_ptr(), - ) - } -} - -/// The glyph's measured visible bounds (`GGO_METRICS`): `None` when the -/// selected font cannot draw the character at all. -fn glyph_metrics(dc: HDC, codepoint: u32) -> Option { - // SAFETY: `dc` is a live DC with a font selected, and the metrics - // struct and transform matrix are valid for the call. - unsafe { - let mut matrix: MAT2 = std::mem::zeroed(); - matrix.eM11 = FIXED { fract: 0, value: 1 }; - matrix.eM22 = FIXED { fract: 0, value: 1 }; - let mut metrics: GLYPHMETRICS = std::mem::zeroed(); - let ok = GetGlyphOutlineW( - dc, - codepoint, - GGO_METRICS, - &mut metrics, - 0, - null_mut(), - &matrix, - ); - (ok != GDI_ERROR as u32).then_some(metrics) - } -} - pub fn autostart_enabled() -> bool { let subkey = wide(RUN_SUBKEY); let value_name = wide(VALUE_NAME); @@ -592,123 +70,3 @@ fn wide(value: impl AsRef) -> Vec { fn registry_error(code: WIN32_ERROR) -> std::io::Error { std::io::Error::from_raw_os_error(code as i32) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_glyph_matches_the_level_in_ten_percent_steps() { - assert_eq!(battery_glyph(0, false), 0xE850); - assert_eq!(battery_glyph(9, false), 0xE850); - assert_eq!(battery_glyph(10, false), 0xE851); - assert_eq!(battery_glyph(45, false), 0xE854); - assert_eq!(battery_glyph(95, false), 0xE859); - assert_eq!(battery_glyph(100, false), 0xE83F); - assert_eq!(battery_glyph(140, false), 0xE83F); - assert_eq!(battery_glyph(0, true), 0xE85A); - assert_eq!(battery_glyph(45, true), 0xE85E); - assert_eq!(battery_glyph(90, true), 0xE83E); - assert_eq!(battery_glyph(100, true), 0xE83F); - } - - #[test] - fn the_glyph_style_fills_the_square() { - for size in [16u32, 20, 24, 32] { - let (rgba, width, height) = battery_icon_rgba( - 87, - false, - super::super::TrayBatteryStyle::Glyph, - size, - (255, 255, 255), - ) - .expect("Segoe battery glyph should render"); - assert_eq!((width, height), (size, size), "square at {size}px"); - assert_eq!(rgba.len(), (size * size * 4) as usize); - let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; - // The glyph's visible bounds fill the canvas: across the - // middle row, the covered span (first to last covered pixel — - // the battery's body-to-nub gap doesn't count against it) is - // at most two blank columns short of the full width. - let row = size / 2; - let covered: Vec = (0..size).filter(|x| coverage(*x, row) > 0).collect(); - let span = covered - .last() - .and_then(|last| covered.first().map(|first| last - first + 1)); - assert!( - span.is_some_and(|span| span + 2 >= size), - "glyph spans {span:?} of {size}px at {size}px" - ); - // The battery is a horizontal glyph, so it clears the top and - // bottom rows. - assert_eq!(coverage(size / 2, 0), 0); - assert_eq!(coverage(size / 2, size - 1), 0); - } - } - - #[test] - fn the_percentage_style_fills_the_square_with_the_number() { - for size in [16u32, 20, 24, 32] { - for percent in [87u8, 100] { - let (rgba, width, height) = battery_icon_rgba( - percent, - false, - super::super::TrayBatteryStyle::Percentage, - size, - (255, 255, 255), - ) - .expect("Segoe battery number should render"); - assert_eq!((width, height), (size, size), "square at {size}px"); - let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; - let row = size / 2; - // The digits fill the square vertically and reach past - // half of the middle row ("100" is squeezed to fit its - // width inside). - let first = (0..width).find(|x| coverage(*x, row) > 0); - let last = (0..width).rev().find(|x| coverage(*x, row) > 0); - assert!( - first - .zip(last) - .is_some_and(|(first, last)| last + 1 - first >= size / 2), - "digits span {first:?}..{last:?} of {size}px at {size}px" - ); - // No content in the margin rows. - assert_eq!((0..size).filter(|x| coverage(*x, 0) > 0).count(), 0); - } - } - } - - #[test] - fn charging_renders_a_bolt_glyph_at_every_level() { - for percent in [0u8, 45, 90, 100] { - let (rgba, _, _) = battery_icon_rgba( - percent, - true, - super::super::TrayBatteryStyle::Glyph, - 16, - (255, 255, 255), - ) - .expect("charging display"); - assert!( - rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] > 0), - "charging at {percent}% must paint" - ); - } - } - - #[test] - fn the_ink_color_carries_through() { - for style in [ - super::super::TrayBatteryStyle::Glyph, - super::super::TrayBatteryStyle::Percentage, - ] { - let (rgba, _, _) = - battery_icon_rgba(12, false, style, 16, (250, 100, 50)).expect("display"); - for pixel in rgba.as_chunks::<4>().0 { - if pixel[3] > 0 { - assert_eq!((pixel[0], pixel[1], pixel[2]), (250, 100, 50)); - } - } - } - } -} diff --git a/src/remote_image.rs b/src/remote_image.rs deleted file mode 100644 index 580eae9..0000000 --- a/src/remote_image.rs +++ /dev/null @@ -1,260 +0,0 @@ -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - time::Duration, -}; - -use anyhow::{Context, Result, anyhow, bail, ensure}; -use reqwest::{Url, redirect::Policy}; -use tokio::sync::Semaphore; - -const FETCH_TIMEOUT: Duration = Duration::from_secs(10); -/// A wallpaper can be a few MB; the page scales it down before it is saved. -const MAX_DOWNLOAD_BYTES: usize = 12 * 1024 * 1024; -const MAX_REDIRECTS: usize = 4; -const UNREACHABLE: &str = "that link cannot be reached"; -/// Each download can hold 12 MB, so only a couple run at once. -static DOWNLOADS: Semaphore = Semaphore::const_new(2); - -/// Whether `ip` is an address on the public internet. Loopback, private, link-local, -/// shared (carrier-grade NAT), multicast and unspecified addresses are not. -pub fn is_public_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => is_public_v4(ip), - IpAddr::V6(ip) => is_public_v6(ip), - } -} - -fn is_public_v4(ip: Ipv4Addr) -> bool { - let [a, b, c, _] = ip.octets(); - !(ip.is_loopback() - || ip.is_private() - || ip.is_link_local() - || ip.is_unspecified() - || ip.is_broadcast() - || ip.is_multicast() - || a == 0 - || a >= 240 // reserved - || (a == 100 && (64..128).contains(&b)) // 100.64.0.0/10 - || (a == 192 && b == 0 && c == 0) // 192.0.0.0/24 - || (a == 198 && (18..20).contains(&b)) // 198.18.0.0/15, benchmarking - || matches!((a, b, c), (192, 0, 2) | (198, 51, 100) | (203, 0, 113))) // documentation -} - -fn v4_from(high: u16, low: u16) -> Ipv4Addr { - Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8) -} - -/// IPv6 forms that carry an IPv4 address are judged by that address, so a private one cannot hide inside. -fn is_public_v6(ip: Ipv6Addr) -> bool { - let segments = ip.segments(); - let first = segments[0]; - let embedded = v4_from(segments[6], segments[7]); - match segments[..6] { - [0, 0, 0, 0, 0, 0] | [0, 0, 0, 0, 0, 0xffff] | [0x64, 0xff9b, 0, 0, 0, 0] => { - return is_public_v4(embedded); - } - _ => {} - } - if first == 0x2002 { - return is_public_v4(v4_from(segments[1], segments[2])); // 6to4 - } - !(ip.is_loopback() - || ip.is_multicast() - || first & 0xfe00 == 0xfc00 // unique local - || first & 0xffc0 == 0xfe80 // link-local - || (first == 0x2001 && segments[1] == 0) // Teredo - || (first == 0x2001 && segments[1] == 0x0db8)) // documentation -} - -pub const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; - -pub fn image_type(bytes: &[u8]) -> Option<&'static str> { - if bytes.starts_with(&PNG_SIGNATURE) { - Some("image/png") - } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { - Some("image/jpeg") - } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { - Some("image/gif") - } else if bytes.len() > 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { - Some("image/webp") - } else { - None - } -} - -/// The public addresses `url`'s host resolves to. The download connects only to these, -/// so the host cannot answer differently the second time. -async fn check(url: &Url) -> Result> { - ensure!( - matches!(url.scheme(), "http" | "https"), - "only http and https links work" - ); - let host = url.host_str().context("the link has no host")?; - let port = url.port_or_known_default().unwrap_or(443); - // One message for "not found" and "not public", so a page cannot tell which names exist on the local network. - let addresses: Vec = tokio::net::lookup_host((host, port)) - .await - .map_err(|_| anyhow!(UNREACHABLE))? - .collect(); - ensure!( - !addresses.is_empty() && addresses.iter().all(|address| is_public_ip(address.ip())), - UNREACHABLE - ); - Ok(addresses) -} - -/// The whole download, however many redirects and name lookups it takes. A name -/// lookup has no time limit of its own, and the download's slot is held until it ends. -const TOTAL_TIMEOUT: Duration = Duration::from_secs(30); - -pub async fn fetch(link: &str) -> Result<(Vec, &'static str)> { - let _download = DOWNLOADS - .try_acquire() - .map_err(|_| anyhow!("too many downloads at once, try again in a moment"))?; - tokio::time::timeout(TOTAL_TIMEOUT, download(link)) - .await - .map_err(|_| anyhow!("the download took too long"))? -} - -async fn download(link: &str) -> Result<(Vec, &'static str)> { - let mut url = Url::parse(link).context("that is not a link")?; - for _ in 0..=MAX_REDIRECTS { - let addresses = check(&url).await?; - let client = reqwest::Client::builder() - // A proxy would resolve the name itself, past the addresses checked above. - .no_proxy() - .timeout(FETCH_TIMEOUT) - .user_agent(concat!("OpenMouse-Bridge/", env!("CARGO_PKG_VERSION"))) - .redirect(Policy::none()) - .resolve_to_addrs(url.host_str().unwrap_or_default(), &addresses) - .build() - .context("could not create the download client")?; - let mut response = client - .get(url.clone()) - .send() - .await - .with_context(|| format!("could not download {url}"))?; - if response.status().is_redirection() { - let next = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|value| value.to_str().ok()) - .context("the link redirected nowhere")?; - url = url - .join(next) - .context("the link redirected to something that is not a link")?; - continue; - } - ensure!( - response.status().is_success(), - "the site answered {}", - response.status() - ); - let mut bytes = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .context("the download was interrupted")? - { - bytes.extend_from_slice(&chunk); - ensure!(bytes.len() <= MAX_DOWNLOAD_BYTES, "that image is too big"); - } - let kind = image_type(&bytes).context("that link is not a PNG, JPEG, GIF or WebP image")?; - return Ok((bytes, kind)); - } - bail!("the link redirects too many times") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn public(ip: &str) -> bool { - is_public_ip(ip.parse().expect("an address")) - } - - #[test] - fn addresses_on_this_pc_or_the_local_network_are_refused() { - for ip in [ - "127.0.0.1", - "10.1.2.3", - "172.16.0.1", - "172.31.255.254", - "192.168.1.1", - "169.254.169.254", - "0.0.0.0", - "100.64.0.1", - "224.0.0.1", - "255.255.255.255", - "::1", - "::", - "fc00::1", - "fd12::1", - "fe80::1", - "ff02::1", - "::ffff:192.168.0.5", - "::ffff:127.0.0.1", - "64:ff9b::c0a8:1", - "64:ff9b::7f00:1", - "2002:c0a8:101::1", - "2002:7f00:1::", - "::192.168.0.5", - "2001::1", - "2001:db8::1", - "198.18.0.1", - "198.19.255.1", - "192.0.0.8", - "192.0.2.1", - "198.51.100.1", - "203.0.113.1", - "240.0.0.1", - "250.1.1.1", - ] { - assert!(!public(ip), "{ip} should not be fetched"); - } - } - - #[test] - fn addresses_on_the_public_internet_are_allowed() { - for ip in [ - "1.1.1.1", - "8.8.8.8", - "93.184.216.34", - "172.32.0.1", - "100.63.0.1", - "2606:4700:4700::1111", - "::ffff:8.8.8.8", - "64:ff9b::808:808", - "2002:808:808::1", - "198.17.255.1", - "198.20.0.1", - ] { - assert!(public(ip), "{ip} should be fetched"); - } - } - - #[test] - fn only_real_images_are_recognised() { - assert_eq!( - image_type(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), - Some("image/png") - ); - assert_eq!(image_type(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); - assert_eq!(image_type(b"GIF89a......"), Some("image/gif")); - assert_eq!(image_type(b"RIFF\0\0\0\0WEBPVP8 ...."), Some("image/webp")); - assert_eq!(image_type(b"not an image"), None); - } - - #[tokio::test] - async fn a_link_to_this_pc_is_refused_before_anything_is_fetched() { - let error = fetch("http://127.0.0.1:17846/v1/status") - .await - .expect_err("refused"); - assert_eq!(error.to_string(), UNREACHABLE); - let error = fetch("file:///C:/Windows/win.ini") - .await - .expect_err("refused"); - assert!(error.to_string().contains("only http and https"), "{error}"); - assert!(fetch("not a link").await.is_err()); - } -} diff --git a/src/service.rs b/src/service.rs index 6d356c5..bf08075 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,6 +1,6 @@ use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::{Path, PathBuf}, + collections::HashMap, + path::PathBuf, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -8,14 +8,13 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::{Context, Result}; +use anyhow::Result; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::{ applications::{self, ApplicationInfo}, - config::{self, ApplicationProfile, BridgeConfig, GameConfig, LocalApplication}, - drivers, + config::{self, ApplicationProfile, BridgeConfig, GameConfig}, games::GameDetector, platform, }; @@ -44,7 +43,6 @@ fn is_registered_game(application: &ApplicationInfo, games: &[GameConfig]) -> bo /// The running applications that are registered games. Profile matching only /// looks at these; the unfiltered list feeds the game-request picker. -#[cfg(test)] fn registered_applications( running: &[ApplicationInfo], games: &[GameConfig], @@ -56,28 +54,6 @@ fn registered_applications( .collect() } -fn profile_applications( - running: &[ApplicationInfo], - games: &[GameConfig], - local_applications: &[LocalApplication], -) -> Vec { - running - .iter() - .filter(|application| { - is_registered_game(application, games) - || local_applications.iter().any(|target| { - crate::games::matches_target( - &target.path, - &target.executable, - &application.path, - &application.executable, - ) - }) - }) - .cloned() - .collect() -} - /// Drops cached icons for applications that are no longer running, so the /// cache stays the size of the running list instead of every app ever seen. fn prune_application_icons( @@ -99,32 +75,13 @@ const PROFILE_DEBOUNCE: Duration = Duration::from_millis(2500); /// without the control panel open. Each read spawns the native-hid helper. const BATTERY_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); -/// A reading this old is shown as stale: more than two missed polls. +/// A reading older than this is shown as stale: more than two missed polls. const BATTERY_STALE_AFTER: Duration = Duration::from_secs(12 * 60); /// A reading this old belongs to a mouse that is gone, not just asleep: it is /// dropped from the list instead of lingering as a ghost device. const BATTERY_EVICT_AFTER: Duration = Duration::from_secs(24 * 60 * 60); -/// How many samples of each device's percent history are kept for the tray -/// tooltip's time-remaining estimate. -const BATTERY_HISTORY_SAMPLES: usize = 48; - -/// Percent samples older than this no longer count toward a trend, so a -/// mouse's days-old readings never poison the estimate. -const BATTERY_TREND_WINDOW: Duration = Duration::from_secs(6 * 60 * 60); - -/// A trend needs at least this much history before an estimate is worth -/// showing: two samples 5 minutes apart are mostly quantization noise. -const BATTERY_MIN_TREND_SPAN: Duration = Duration::from_secs(30 * 60); - -/// The smallest drift (percent per minute) that counts as a trend, about 1% -/// per 3 hours — the pace a mouse battery actually moves at. -const BATTERY_TREND_EPSILON: f64 = 0.005; - -/// Estimates beyond this are noise for a mouse battery, not a plan. -const BATTERY_MAX_ESTIMATE_MINUTES: u32 = 48 * 60; - /// Drops battery entries that can no longer describe a live mouse: anything /// past `BATTERY_EVICT_AFTER`, and anything outside the saved profiles that /// has also gone stale (a renamed or removed device, or an id a client pushed @@ -152,132 +109,41 @@ fn profile_devices(config: &BridgeConfig) -> Vec<(String, String)> { /// one for a game that is still running, so alt-tabbing out of a game keeps /// its profile until the game closes, else the configured default profile. /// Shared by the snapshot and the monitor. -#[cfg(test)] fn active_profile_for( config: &BridgeConfig, applications: &[ApplicationInfo], active_games: &[String], ) -> Option { - resolve_profile(config, applications, active_games, &[]) -} - -#[cfg(test)] -fn resolve_profile( - config: &BridgeConfig, - applications: &[ApplicationInfo], - active_games: &[String], - processes: &[crate::games::RunningProcess], -) -> Option { - resolve_profile_retaining(config, applications, active_games, processes, None) -} - -fn resolve_profile_retaining( - config: &BridgeConfig, - applications: &[ApplicationInfo], - active_games: &[String], - processes: &[crate::games::RunningProcess], - current: Option<&ApplicationProfile>, -) -> Option { - // The catalog plus the user's custom games, so a custom game's profile activates too. - let tracked_games = config.tracked_games(); - let eligible = |profile: &&ApplicationProfile| { - if !profile.enabled { - return false; - } - if let Some(id) = &profile.application.local_id { - return config - .local_applications - .iter() - .find(|target| &target.id == id) - .is_some_and(|target| { - if target.path.is_empty() { - return false; - } - match target.effective_activation() { - config::LocalApplicationActivation::Running => { - processes.iter().any(|process| { - crate::games::matches_target( - &target.path, - &target.executable, - &process.path, - &process.executable, - ) - }) - } - config::LocalApplicationActivation::Focused => { - applications.iter().any(|application| { - application.foreground - && crate::games::matches_target( - &target.path, - &target.executable, - &application.path, - &application.executable, - ) - }) - } - } - }); - } - let application = &profile.application; - let focused = applications.iter().any(|running| { - running.foreground - && if !application.path.is_empty() { - crate::games::matches_target( - &application.path, - &application.executable, - &running.path, - &running.executable, - ) - } else if !application.executable.is_empty() { - application - .executable - .eq_ignore_ascii_case(&running.executable) - } else { - !application.name.is_empty() - && application.name.eq_ignore_ascii_case(&running.name) - } - }); - focused - || tracked_games.iter().any(|game| { - active_games.contains(&game.name) - && if application.path.is_empty() { - profile_matches_game(profile, game) - } else { - processes.iter().any(|process| { - crate::games::matches_target( - &application.path, - &application.executable, - &process.path, - &process.executable, - ) && game.executables.iter().any(|executable| { - executable.eq_ignore_ascii_case(&process.executable) - }) - }) - } + let enabled = || config.profiles.iter().filter(|profile| profile.enabled); + let foreground = applications + .iter() + .find(|application| application.foreground) + .and_then(|application| { + enabled().find(|profile| { + // Game profiles leave some of these empty; an empty field + // must not match an application that also reports none. + let matches = |saved: &str, running: &str| { + !saved.is_empty() && saved.eq_ignore_ascii_case(running) + }; + matches(&profile.application.path, &application.path) + || matches(&profile.application.name, &application.name) + || matches(&profile.application.executable, &application.executable) }) - }; - let retained = current.and_then(|current| { - config.profiles.iter().filter(eligible).find(|profile| { - profile.application_key() == current.application_key() - && profile.device.id == current.device.id + }); + let running_game = || { + active_games.iter().find_map(|name| { + let game = config.games.iter().find(|game| &game.name == name)?; + enabled().find(|profile| profile_matches_game(profile, game)) }) - }); - retained - .or_else(|| config.profiles.iter().find(eligible)) + }; + foreground + .or_else(running_game) .cloned() - .or_else(|| { - config - .default_profile - .clone() - .filter(|profile| profile.enabled) - }) + .or_else(|| config.default_profile.clone()) } fn profile_matches_game(profile: &ApplicationProfile, game: &GameConfig) -> bool { let application = &profile.application; - if !application.path.is_empty() || application.local_id.is_some() { - return false; - } (!application.name.is_empty() && normalized_name(&application.name) == normalized_name(&game.name)) || (!application.executable.is_empty() @@ -289,76 +155,11 @@ fn profile_matches_game(profile: &ApplicationProfile, game: &GameConfig) -> bool /// A stable identity for a profile, used to detect switches. fn profile_key(profile: &ApplicationProfile) -> String { - let application = if let Some(id) = &profile.application.local_id { - id.clone() - } else if profile.application.path.is_empty() { - format!("preset:{}", profile.application.name.to_ascii_lowercase()) + if profile.application.path.is_empty() { + profile.application.name.to_ascii_lowercase() } else { - format!("path:{}", crate::games::path_key(&profile.application.path)) - }; - format!("{}:{}{}", application.len(), application, profile.device.id) -} - -const MAX_IMAGE_BYTES: usize = 512 * 1024; - -fn image_type(bytes: &[u8]) -> Option<&'static str> { - if bytes.len() > MAX_IMAGE_BYTES { - return None; + profile.application.path.to_ascii_lowercase() } - crate::remote_image::image_type(bytes).filter(|kind| *kind != "image/gif") -} - -fn game_image_dir(config_path: &Path) -> PathBuf { - config_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("custom-game-images") -} - -fn game_image_file(dir: &Path, name: &str) -> PathBuf { - // FNV-1a: stable across runs, unlike the standard hasher. - let hash = name - .to_ascii_lowercase() - .bytes() - .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { - (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) - }); - dir.join(format!("{hash:016x}.img")) -} - -fn prune_game_images(dir: &Path, keep: &[&str]) { - let wanted: HashSet = keep.iter().map(|name| game_image_file(dir, name)).collect(); - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "img") && !wanted.contains(&path) { - let _ = std::fs::remove_file(path); - } - } -} - -/// The profile `key` names as it is in the config now, and whether it is the -/// default profile. A profile keeps its key while its settings are edited, so -/// the copy Bridge applied earlier can be out of date. -fn current_profile<'a>( - config: &'a BridgeConfig, - key: &str, -) -> Option<(&'a ApplicationProfile, bool)> { - // Only an enabled profile is ever the active one, so a disabled copy of the same game is not it. - config - .profiles - .iter() - .find(|profile| profile.enabled && profile_key(profile) == key) - .map(|profile| (profile, false)) - .or_else(|| { - config - .default_profile - .as_ref() - .filter(|profile| profile_key(profile) == key) - .map(|profile| (profile, true)) - }) } /// Something worth telling the user about, already worded: a title such as the @@ -428,19 +229,13 @@ fn switch_message( detail: "Profile off".into(), }, }; - if !panel_connected { + if current.is_some() && !panel_connected { match outcome { Some(ApplyOutcome::Failed) => { - switch.detail = if current.is_none() { - "Couldn't restore original mouse settings — check Bridge status".into() - } else { - "Couldn't reach the mouse — open OpenMouse to apply".into() - }; + switch.detail = "Couldn't reach the mouse — open OpenMouse to apply".into(); } Some(ApplyOutcome::NoDriver) => { - switch.detail = - "Native automation unsupported — acknowledged device readback is required" - .into(); + switch.detail = "Open OpenMouse to apply this profile".into(); } _ => {} } @@ -448,31 +243,17 @@ fn switch_message( Some(switch) } -type PanelListener = Arc; - #[derive(Clone)] pub struct BridgeService { inner: Arc>, config_path: Arc, notice_listener: Arc>>, - show_panel: Arc>>, - buttons: Arc, - native_automation: Arc>, -} - -/// A control panel is connected when it holds a HID WebSocket session or -/// handshook within the last few seconds. -fn panel_connected(state: &BridgeState) -> bool { - crate::hid::client_session_active() - || state - .last_client_heartbeat - .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)) + show_panel: Arc>>>, } struct BridgeState { config: BridgeConfig, active_games: Vec, - processes: Vec, applications: Vec, // Every visible application, registered game or not — what a control // panel offers when someone requests a new game or app be added. @@ -482,34 +263,10 @@ struct BridgeState { // Debounced active-profile tracking for switch notifications. active_profile_key: Option, applied_profile: Option, - native_automation_error: Option, - pending_native_restores: usize, pending_profile: Option<(Option, Instant)>, profile_seeded: bool, started_at: Instant, last_client_heartbeat: Option, - /// Who currently drives the mouse: Bridge in onboard memory mode or the - /// web panel in live mode. Tracked by the mode monitor. - device_mode: DeviceMode, -} - -/// Who currently drives the mouse. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DeviceMode { - /// Bridge drives the mouse itself: the active profile's stored settings - /// live in the onboard memory and the battery is read natively. - Onboard, - /// The OpenMouse web panel holds the HID session and drives the mouse. - Live, -} - -impl DeviceMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Onboard => "onboard", - Self::Live => "live", - } - } } #[derive(Clone, Debug, Deserialize)] @@ -520,15 +277,6 @@ pub struct BatteryReading { pub percent: u8, #[serde(default)] pub charging: bool, - /// The settings currently in the mouse's onboard memory, when the - /// reader could see them — native reads report them; a control panel's - /// `PUT /v1/battery` may not. - #[serde(default)] - pub dpi: Option, - #[serde(default)] - pub polling_rate_hz: Option, - #[serde(default)] - pub switch_mode: Option, } struct BatteryState { @@ -536,46 +284,7 @@ struct BatteryState { device_name: String, percent: u8, charging: bool, - dpi: Option, - polling_rate_hz: Option, - switch_mode: Option, updated_at: Instant, - // Recent (reading time, percent) samples, oldest first, for the tray - // tooltip's time-remaining estimate. - history: VecDeque<(Instant, u8)>, -} - -/// Estimates the minutes left until the battery is empty (discharging) or -/// full (charging) from the percent history: the level's drift across the -/// samples inside the trend window. `None` while there is no confident -/// trend yet — too little history, or the level is holding still. -fn estimate_minutes_remaining( - history: &VecDeque<(Instant, u8)>, - percent: u8, - charging: bool, -) -> Option { - let now = Instant::now(); - let recent = |at: &Instant| now.duration_since(*at) <= BATTERY_TREND_WINDOW; - let (first_at, first_percent) = history.iter().find(|(at, _)| recent(at))?; - let (last_at, last_percent) = history.iter().rev().find(|(at, _)| recent(at))?; - if last_at <= first_at { - return None; - } - let span = last_at.duration_since(*first_at); - if span < BATTERY_MIN_TREND_SPAN { - return None; - } - let span_minutes = span.as_secs_f64() / 60.0; - let rate = f64::from(*last_percent as i16 - *first_percent as i16) / span_minutes; - let raw_minutes = if charging { - (rate > BATTERY_TREND_EPSILON).then(|| f64::from(100 - percent) / rate)? - } else { - (rate < -BATTERY_TREND_EPSILON).then(|| f64::from(percent) / -rate)? - }; - // Round to 5-minute granularity so the estimate holds still instead of - // jittering every snapshot. - let minutes = (raw_minutes / 5.0).round() as u32 * 5; - (minutes > 0 && minutes <= BATTERY_MAX_ESTIMATE_MINUTES).then_some(minutes) } #[derive(Clone, Debug, Serialize)] @@ -586,18 +295,6 @@ pub struct DeviceBattery { pub percent: u8, pub charging: bool, pub stale: bool, - /// Minutes until the battery is empty (discharging) or full (charging), - /// from the recent trend; `None` while there is no confident estimate. - #[serde(skip_serializing_if = "Option::is_none")] - pub minutes_remaining: Option, - /// The settings currently in the mouse's onboard memory, from the most - /// recent native status read; `None` when unreadable. - #[serde(skip_serializing_if = "Option::is_none")] - pub dpi: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub polling_rate_hz: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub switch_mode: Option, } /// A mouse the control panel currently holds open over native HID, most @@ -632,12 +329,6 @@ pub struct BridgeSnapshot { pub games: Vec, pub tracked_game_count: usize, pub battery_threshold_percent: u8, - pub tray_battery_style: String, - pub hybrid_mode: bool, - /// Who drives the mouse right now: `"onboard"` (Bridge applies the - /// stored settings and reads the battery) or `"live"` (the web panel - /// holds the HID session). - pub device_mode: String, pub autostart_enabled: bool, pub automatic_updates: bool, pub notification_sound: bool, @@ -645,8 +336,6 @@ pub struct BridgeSnapshot { pub foreground_application: Option, pub active_profile: Option, pub active_profile_is_default: bool, - pub native_automation_error: Option, - pub pending_native_restores: usize, pub visible_application_count: usize, pub profile_count: usize, pub client_connected: bool, @@ -660,26 +349,20 @@ impl BridgeService { inner: Arc::new(RwLock::new(BridgeState { config, active_games: Vec::new(), - processes: Vec::new(), applications: Vec::new(), running_applications: Vec::new(), application_icons: HashMap::new(), battery: HashMap::new(), active_profile_key: None, applied_profile: None, - native_automation_error: None, - pending_native_restores: 0, pending_profile: None, profile_seeded: false, started_at: Instant::now(), last_client_heartbeat: None, - device_mode: DeviceMode::Onboard, })), config_path: Arc::new(config_path), notice_listener: Arc::default(), show_panel: Arc::default(), - buttons: Arc::default(), - native_automation: Arc::default(), } } @@ -704,14 +387,15 @@ impl BridgeService { } pub async fn acknowledge_tray_hint(&self) -> Result<()> { - if self.inner.read().await.config.tray_hint_shown { - return Ok(()); - } - self.update_config(|config| { - config.tray_hint_shown = true; - Ok(()) - }) - .await + let config = { + let mut state = self.inner.write().await; + if state.config.tray_hint_shown { + return Ok(()); + } + state.config.tray_hint_shown = true; + state.config.clone() + }; + config::save(&self.config_path, &config) } /// Sends profile switches and low-battery alerts to `listener` instead of @@ -747,44 +431,29 @@ impl BridgeService { pub async fn snapshot(&self) -> BridgeSnapshot { let state = self.inner.read().await; - let client_connected = panel_connected(&state); - let device_mode = state.device_mode; + let client_connected = crate::hid::client_session_active() + || state + .last_client_heartbeat + .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)); let foreground_application = state .applications .iter() .find(|application| application.foreground) .cloned(); - let tracked_games = state.config.tracked_games(); - let active_profile = resolve_profile_retaining( - &state.config, - &state.applications, - &state.active_games, - &state.processes, - state.applied_profile.as_ref(), - ); + let active_profile = + active_profile_for(&state.config, &state.applications, &state.active_games); let active_profile_is_default = active_profile.is_some() && active_profile == state.config.default_profile; let mut batteries = state .battery .iter() .filter(|(_, entry)| entry.updated_at.elapsed() < BATTERY_EVICT_AFTER) - .map(|(device_id, entry)| { - let stale = entry.updated_at.elapsed() >= BATTERY_STALE_AFTER; - DeviceBattery { - device_id: device_id.clone(), - device_name: entry.device_name.clone(), - percent: entry.percent, - charging: entry.charging, - minutes_remaining: if stale { - None - } else { - estimate_minutes_remaining(&entry.history, entry.percent, entry.charging) - }, - dpi: entry.dpi, - polling_rate_hz: entry.polling_rate_hz, - switch_mode: entry.switch_mode.clone(), - stale, - } + .map(|(device_id, entry)| DeviceBattery { + device_id: device_id.clone(), + device_name: entry.device_name.clone(), + percent: entry.percent, + charging: entry.charging, + stale: entry.updated_at.elapsed() >= BATTERY_STALE_AFTER, }) .collect::>(); batteries.sort_by(|a, b| a.device_id.cmp(&b.device_id)); @@ -802,53 +471,18 @@ impl BridgeService { platform: platform::platform_name(), linux_distribution: platform::linux_distribution(), uptime_seconds: state.started_at.elapsed().as_secs(), - active_games: state - .active_games - .iter() - .filter_map(|active| { - state - .config - .local_applications - .iter() - .find(|target| &target.id == active) - .map(|target| target.name.clone()) - .or_else(|| { - tracked_games - .iter() - .find(|game| &game.name == active) - .map(|game| game.name.clone()) - }) - }) - .collect(), - games: tracked_games + active_games: state.active_games.clone(), + games: state + .config + .games .iter() .map(|game| GameActivity { name: game.name.clone(), active: state.active_games.iter().any(|active| active == &game.name), }) - .chain( - state - .config - .local_applications - .iter() - .filter(|target| target.kind == config::LocalApplicationKind::Game) - .map(|target| GameActivity { - name: target.name.clone(), - active: state.active_games.contains(&target.id), - }), - ) .collect(), - tracked_game_count: tracked_games.len() - + state - .config - .local_applications - .iter() - .filter(|target| target.kind == config::LocalApplicationKind::Game) - .count(), + tracked_game_count: state.config.games.len(), battery_threshold_percent: state.config.battery_threshold_percent, - tray_battery_style: state.config.tray_battery_style.clone(), - hybrid_mode: state.config.hybrid_mode, - device_mode: device_mode.as_str().to_owned(), autostart_enabled: platform::autostart_enabled(), automatic_updates: state.config.automatic_updates, notification_sound: state.config.notification_sound, @@ -856,8 +490,6 @@ impl BridgeService { foreground_application, active_profile, active_profile_is_default, - native_automation_error: state.native_automation_error.clone(), - pending_native_restores: state.pending_native_restores, visible_application_count: state.applications.len(), profile_count: state.config.profiles.len(), client_connected, @@ -896,148 +528,28 @@ impl BridgeService { self.inner.read().await.config.profiles.clone() } - pub async fn games(&self) -> Vec { - let state = self.inner.read().await; - let tracked = state.config.tracked_games(); - tracked.iter().map(|game| { - let custom = !state.config.games.iter().any(|preset| preset.name == game.name); - serde_json::json!({"name":game.name,"executables":game.executables,"source":if custom { "custom" } else { "preset" }}) - }) - .chain(state.config.local_applications.iter().map(|target| { - let mut entry = serde_json::json!({"id":target.id,"name":target.name,"executables":[target.executable],"source":"local","path":target.path,"kind":target.kind}); - if let Some(activation) = target.activation { entry["activation"] = serde_json::json!(activation); } - if let Some(path) = &target.bundle_path { entry["bundlePath"] = path.clone().into(); } - if let Some(identifier) = &target.bundle_identifier { entry["bundleIdentifier"] = identifier.clone().into(); } - entry - })).collect() - } - - pub async fn custom_games(&self) -> Vec { - self.inner.read().await.config.custom_games.clone() - } - - pub async fn set_custom_games(&self, games: Vec) -> Result> { - self.update_config(|config| { - config.custom_games = games; - Ok(()) - }) - .await?; - let saved = self.custom_games().await; - let names: Vec<&str> = saved.iter().map(|game| game.name.as_str()).collect(); - prune_game_images(&game_image_dir(&self.config_path), &names); - Ok(saved) - } - - pub async fn custom_game_image(&self, name: &str) -> Option<(Vec, &'static str)> { - let bytes = tokio::fs::read(game_image_file(&game_image_dir(&self.config_path), name)) - .await - .ok()?; - let kind = image_type(&bytes)?; - Some((bytes, kind)) - } - - pub async fn set_custom_game_image(&self, name: &str, image: &[u8]) -> Result<()> { - let known = self - .inner - .read() - .await - .config - .custom_games - .iter() - .any(|game| game.name.eq_ignore_ascii_case(name)); - anyhow::ensure!(known, "{name} is not a custom game"); - anyhow::ensure!( - image_type(image).is_some(), - "the image must be a PNG, JPEG or WebP of at most {MAX_IMAGE_BYTES} bytes" - ); - let dir = game_image_dir(&self.config_path); - tokio::fs::create_dir_all(&dir) - .await - .context("could not create the image folder")?; - tokio::fs::write(game_image_file(&dir, name), image) - .await - .context("could not save the image") - } - - pub async fn local_applications(&self) -> Vec { - self.inner.read().await.config.local_applications.clone() - } - - pub async fn default_profile(&self) -> Option { - self.inner.read().await.config.default_profile.clone() - } - - async fn update_config( - &self, - update: impl FnOnce(&mut BridgeConfig) -> Result<()>, - ) -> Result<()> { - let mut state = self.inner.write().await; - let mut next = state.config.clone(); - update(&mut next)?; - next = next.normalized(); - for profile in next - .profiles - .iter_mut() - .chain(next.default_profile.iter_mut()) - { - crate::local_targets::canonicalize_profile(profile, &next.local_applications)?; - } - config::save(&self.config_path, &next)?; - state.config = next; - Ok(()) - } - - pub async fn replace_local_applications( - &self, - applications: Vec, - ) -> Result> { - let mut canonical = Vec::new(); - self.update_config(|config| { - let applications = crate::local_targets::canonicalize_against( - &applications, - &config.local_applications, - )?; - canonical = applications.clone(); - config.profiles.retain(|profile| { - profile - .application - .local_id - .as_ref() - .is_none_or(|id| applications.iter().any(|target| &target.id == id)) - }); - if config - .default_profile - .as_ref() - .and_then(|profile| profile.application.local_id.as_ref()) - .is_some_and(|id| !applications.iter().any(|target| &target.id == id)) - { - config.default_profile = None; - } - config.local_applications = applications; - Ok(()) - }) - .await?; - Ok(canonical) + pub async fn games(&self) -> Vec { + self.inner.read().await.config.games.clone() } - pub async fn replace_profiles(&self, mut profiles: Vec) -> Result<()> { - self.update_config(move |config| { - for profile in &mut profiles { - crate::local_targets::canonicalize_profile(profile, &config.local_applications)?; - } - config.profiles = profiles; - Ok(()) - }) - .await + pub async fn replace_profiles(&self, profiles: Vec) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.profiles = profiles; + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config) } - pub async fn set_default_profile(&self, mut profile: ApplicationProfile) -> Result<()> { - self.update_config(move |config| { - crate::local_targets::canonicalize_profile(&mut profile, &config.local_applications)?; - config.default_profile = Some(profile); - Ok(()) - }) - .await + pub async fn set_default_profile(&self, profile: ApplicationProfile) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.default_profile = Some(profile); + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config) } /// Turns launch-at-login on the first time a release build runs; after @@ -1047,62 +559,50 @@ impl BridgeService { return Ok(()); } platform::set_autostart(true)?; - self.update_config(|config| { - config.autostart_configured = true; - Ok(()) - }) - .await + let config = { + let mut state = self.inner.write().await; + state.config.autostart_configured = true; + state.config.clone() + }; + config::save(&self.config_path, &config) } pub async fn set_battery_threshold(&self, percent: u8) -> Result<()> { - self.update_config(|config| { - config.battery_threshold_percent = percent; - Ok(()) - }) - .await - } - - pub async fn set_tray_battery_style(&self, style: &str) -> Result<()> { let config = { let mut state = self.inner.write().await; - state.config.tray_battery_style = style.to_owned(); + state.config.battery_threshold_percent = percent; state.config = state.config.clone().normalized(); state.config.clone() }; config::save(&self.config_path, &config) } - pub async fn set_hybrid_mode(&self, enabled: bool) -> Result<()> { + pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { let config = { let mut state = self.inner.write().await; - state.config.hybrid_mode = enabled; + state.config.automatic_updates = enabled; state.config.clone() }; config::save(&self.config_path, &config) } - pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { - self.update_config(|config| { - config.automatic_updates = enabled; - Ok(()) - }) - .await - } - pub async fn set_notification_sound(&self, enabled: bool) -> Result<()> { - self.update_config(|config| { - config.notification_sound = enabled; - Ok(()) - }) - .await + let config = { + let mut state = self.inner.write().await; + state.config.notification_sound = enabled; + state.config.clone() + }; + config::save(&self.config_path, &config) } pub async fn set_notification_volume(&self, percent: u8) -> Result<()> { - self.update_config(|config| { - config.notification_volume = percent; - Ok(()) - }) - .await + let config = { + let mut state = self.inner.write().await; + state.config.notification_volume = percent; + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config) } /// Forgets a lingering battery entry so a dead device leaves the list. @@ -1119,24 +619,13 @@ impl BridgeService { let mut state = self.inner.write().await; let threshold = state.config.battery_threshold_percent; let cooldown = Duration::from_secs(state.config.alert_cooldown_minutes * 60); - let previous = state.battery.get(&reading.device_id); - let previous_alert = previous.and_then(|entry| entry.last_alert); + let previous_alert = state + .battery + .get(&reading.device_id) + .and_then(|entry| entry.last_alert); let should_alert = !reading.charging && reading.percent <= threshold && previous_alert.is_none_or(|last| last.elapsed() >= cooldown); - let mut history = previous - .map(|entry| entry.history.clone()) - .unwrap_or_default(); - history.push_back((Instant::now(), reading.percent)); - while history.len() > BATTERY_HISTORY_SAMPLES { - history.pop_front(); - } - // A control panel's `PUT /v1/battery` reports the charge level - // only; keep the settings a previous native status read saw - // instead of blanking them until the next native poll. - let (previous_dpi, previous_rate, previous_mode) = previous - .map(|entry| (entry.dpi, entry.polling_rate_hz, entry.switch_mode.clone())) - .unwrap_or((None, None, None)); state.battery.insert( reading.device_id.clone(), BatteryState { @@ -1148,11 +637,7 @@ impl BridgeService { device_name: reading.device_name.clone(), percent: reading.percent, charging: reading.charging, - dpi: reading.dpi.or(previous_dpi), - polling_rate_hz: reading.polling_rate_hz.or(previous_rate), - switch_mode: reading.switch_mode.or(previous_mode), updated_at: Instant::now(), - history, }, ); should_alert @@ -1180,135 +665,56 @@ impl BridgeService { interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - poll_batteries_once(service.clone()).await; - } - }); - } - - /// Watches the connection state and flips between the two halves of - /// hybrid mode: while the OpenMouse web panel is disconnected, Bridge - /// drives the mouse in onboard memory mode (stored settings applied, - /// battery read natively); the moment the panel connects it takes the - /// mouse over live, and when it leaves, Bridge restores the stored - /// settings and reads the battery right away. - pub fn start_mode_monitor(&self) { - let service = self.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(1)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - interval.tick().await; - let mode = { - let state = service.inner.read().await; - if panel_connected(&state) { - DeviceMode::Live - } else { - DeviceMode::Onboard - } - }; - let previous = { - let mut state = service.inner.write().await; - let previous = state.device_mode; - state.device_mode = mode; - previous - }; - if previous == mode { + let devices = profile_devices(&service.inner.read().await.config); + // Pruning is in-memory only, so it runs even while the + // control panel holds a HID session and reads are paused. + prune_batteries(&mut service.inner.write().await.battery, &devices); + if crate::hid::client_session_active() { continue; } - match mode { - DeviceMode::Live => { - tracing::info!("the web panel connected — it drives the mouse live now"); - } - DeviceMode::Onboard => { - tracing::info!("the web panel disconnected — resuming onboard memory mode"); - let hybrid = service.inner.read().await.config.hybrid_mode; - if !hybrid { - continue; + for (device_id, device_name) in devices { + let id = device_id.clone(); + let outcome = + tokio::task::spawn_blocking(move || crate::drivers::read_battery(&id)) + .await; + match outcome { + Ok(Ok(Some(battery))) => { + let reading = BatteryReading { + device_id, + device_name, + percent: battery.percent, + charging: battery.charging, + }; + if let Err(error) = service.record_battery(reading).await { + tracing::warn!(%error, "Could not record the mouse battery"); + } + } + Ok(Ok(None)) => { + tracing::debug!(%device_id, "No native battery reader for this device"); + } + Ok(Err(error)) => { + tracing::debug!(%error, %device_id, "Could not read the mouse battery"); + } + Err(error) => { + tracing::warn!(%error, "The battery reader stopped unexpectedly"); } - resume_onboard(&service).await; } } } }); } - async fn reconcile_native_with( - &self, - operation: impl FnOnce(Option) -> (Option, usize) + Send + 'static, - ) -> Option { - let mut state = self.inner.clone().write_owned().await; - let selected = resolve_profile_retaining( - &state.config, - &state.applications, - &state.active_games, - &state.processes, - state.applied_profile.as_ref(), - ); - if selected.is_some() && selected != state.applied_profile { - return None; - } - let desired = selected; - let result = tokio::task::spawn_blocking(move || { - let has_profile = desired.is_some(); - let (error, pending) = operation(desired); - let outcome = match &error { - Some(message) if message.contains("unsupported") => Some(ApplyOutcome::NoDriver), - Some(_) => Some(ApplyOutcome::Failed), - None if has_profile => Some(ApplyOutcome::Applied), - None => None, - }; - state.native_automation_error = error; - state.pending_native_restores = pending; - outcome - }) - .await; - match result { - Ok(outcome) => outcome, - Err(error) => { - tracing::warn!(%error, "Native automation worker failed without publishing a stale completion"); - Some(ApplyOutcome::Failed) - } - } - } - - async fn reconcile_native(&self) -> Option { - let automation = self.native_automation.clone(); - self.reconcile_native_with(move |desired| { - let mut automation = automation - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let result = crate::drivers::reconcile_automatic(&mut automation, desired.as_ref()); - ( - result.err().map(|error| format!("{error:#}")), - automation.pending_restores_for(desired.as_ref()), - ) - }) - .await - } - pub fn start_game_monitor(&self, extract_icons: Arc) { let service = self.clone(); tokio::spawn(async move { let mut detector = GameDetector::default(); let mut interval = tokio::time::interval(Duration::from_secs(3)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - let (games, local_applications) = { - let state = service.inner.read().await; - ( - state.config.tracked_games(), - state.config.local_applications.clone(), - ) - }; - let mut active = detector.detect(&games); - let processes = detector.running_processes(); - active.extend(crate::games::matches_local_games( - &local_applications, - &processes, - )); + let games = service.inner.read().await.config.games.clone(); + let active = detector.detect(&games); let running = applications::visible_applications(); - let applications = profile_applications(&running, &games, &local_applications); + let applications = registered_applications(&running, &games); // Icon extraction can be disabled by callers that never serve // the application's icon endpoint. let icons = if extract_icons.load(Ordering::Acquire) { @@ -1333,7 +739,6 @@ impl BridgeService { }; let mut state = service.inner.write().await; state.active_games = active; - state.processes = processes; state.applications = applications; state.application_icons.extend(icons); prune_application_icons(&mut state.application_icons, &running); @@ -1343,13 +748,8 @@ impl BridgeService { // profile's DPI/polling rate to the mouse over native HID // when Bridge has a driver for it, and announce the switch // once the new profile has stayed active long enough. - let current = resolve_profile_retaining( - &state.config, - &state.applications, - &state.active_games, - &state.processes, - state.applied_profile.as_ref(), - ); + let current = + active_profile_for(&state.config, &state.applications, &state.active_games); let current_key = current.as_ref().map(profile_key); // (new profile, the one it replaces, whether to announce it) let mut switch: Option<( @@ -1366,9 +766,7 @@ impl BridgeService { state.pending_profile = None; state.profile_seeded = true; switch = Some((current, None, false)); - } else if current_key == state.active_profile_key - && current == state.applied_profile - { + } else if current_key == state.active_profile_key { state.pending_profile = None; } else { let ready = matches!( @@ -1388,156 +786,37 @@ impl BridgeService { } } let default = state.config.default_profile.clone(); - // Read from the config each time: editing the active profile's - // keys does not change its key, so the applied copy goes stale. - let desired_buttons = state - .active_profile_key - .as_deref() - .and_then(|key| current_profile(&state.config, key)) - .and_then(|(profile, is_default)| { - crate::buttons::Assignments::from_profile(profile, is_default) - }); drop(state); - let panel_connected = crate::hid::client_session_active(); - // A control panel holding the mouse is not interrupted by a new helper. - service.buttons.update(desired_buttons, !panel_connected); - let outcome = service.reconcile_native().await; - if let Some((current, previous, true)) = switch - && let Some(message) = switch_message( - previous.as_ref(), - current.as_ref(), - default.as_ref(), - outcome, - panel_connected, - ) - { - service.announce(message); - } - } - }); - } -} - -/// One battery-polling pass: covers every device named by the saved profiles -/// plus every mouse Bridge discovered on the HID bus whose brand no profile -/// already names, reads each mouse's battery natively (skipped while the web -/// panel holds the session — it reports the battery itself over -/// `PUT /v1/battery`) and records it. Used by the five-minute monitor and by -/// onboard-mode handovers. -async fn poll_batteries_once(service: BridgeService) { - let profiled = profile_devices(&service.inner.read().await.config); - let discovered = tokio::task::spawn_blocking(drivers::detect_mice) - .await - .unwrap_or_default(); - let devices = merge_detected_mice(profiled, &discovered); - // Pruning is in-memory only, so it runs even while the - // control panel holds a HID session and reads are paused. - prune_batteries(&mut service.inner.write().await.battery, &devices); - if crate::hid::client_session_active() { - return; - } - for (device_id, device_name) in devices { - let id = device_id.clone(); - let outcome = tokio::task::spawn_blocking(move || crate::drivers::read_status(&id)).await; - match outcome { - Ok(Ok(Some(status))) => { - // The web panel reports batteries as `:` (the - // driver's own model name), while discovery ids use the HID - // descriptor's tail (a LIGHTSPEED receiver reads - // `Logitech:046d:c547`). Recording under the panel's name - // for the same physical mouse keeps one entry instead of - // duplicate rows; the discovery-shaped entry is dropped. - let record_id = status - .battery - .reported_name - .as_ref() - .map(|name| canonical_battery_identity(&device_id, name)) - .unwrap_or_else(|| device_id.clone()); - if record_id != device_id { - service.inner.write().await.battery.remove(&device_id); - } - let record_name = status.battery.reported_name.clone().unwrap_or(device_name); - let reading = BatteryReading { - device_id: record_id, - device_name: record_name, - percent: status.battery.percent, - charging: status.battery.charging, - dpi: status.dpi, - polling_rate_hz: status.polling_rate_hz, - switch_mode: status.switch_mode, - }; - if let Err(error) = service.record_battery(reading).await { - tracing::warn!(%error, "Could not record the mouse battery"); + if let Some((current, previous, announce)) = switch { + let service = service.clone(); + std::thread::spawn(move || { + // A connected control panel applies the profile over its + // own HID session; writing natively as well would + // interleave two writers on the same mouse. + let panel_connected = crate::hid::client_session_active(); + let outcome = if panel_connected { + None + } else { + current.as_ref().map(apply_natively) + }; + if !announce { + return; + } + let Some(message) = switch_message( + previous.as_ref(), + current.as_ref(), + default.as_ref(), + outcome, + panel_connected, + ) else { + return; + }; + service.announce(message); + }); } } - Ok(Ok(None)) => { - tracing::debug!(%device_id, "No native battery reader for this device"); - } - Ok(Err(error)) => { - tracing::debug!(%error, %device_id, "Could not read the mouse battery"); - } - Err(error) => { - tracing::warn!(%error, "The battery reader stopped unexpectedly"); - } - } - } -} - -/// The device id the web panel would use for the same physical mouse: -/// `:`, from the driver's own `MouseStatus.name`. -fn canonical_battery_identity(device_id: &str, reported_name: &str) -> String { - let brand = device_id.split(':').next().unwrap_or_default(); - format!("{brand}:{reported_name}") -} - -/// The devices battery polling should cover: the saved profiles' devices plus -/// discovered mice whose brand no profile already names — discovery fills the -/// gap before the panel has ever saved a profile, without double-reporting a -/// mouse a profile already covers (profile and discovery ids for the same -/// physical mouse differ, so brand-level dedup keeps one battery entry). -fn merge_detected_mice( - mut profiled: Vec<(String, String)>, - detected: &[drivers::DetectedMouse], -) -> Vec<(String, String)> { - for mouse in detected { - let brand = mouse.device_id.split(':').next().unwrap_or_default(); - let already_covered = profiled.iter().any(|(id, _)| { - id.split(':') - .next() - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(brand)) }); - if !already_covered { - profiled.push((mouse.device_id.clone(), mouse.name.clone())); - } } - profiled -} - -/// Enters onboard memory mode: pushes the stored settings to the mouse's -/// onboard memory (when a default profile exists — without one, the device -/// simply keeps the memory it already has) and reads the battery right away. -/// Shared by the startup seed and the panel-disconnect handover. -async fn resume_onboard(service: &BridgeService) { - let profile = { - let state = service.inner.read().await; - resolve_profile_retaining( - &state.config, - &state.applications, - &state.active_games, - &state.processes, - None, - ) - }; - if let Some(profile) = profile { - tracing::info!( - profile = %profile.application.name, - "applying the stored settings to the onboard memory" - ); - std::thread::spawn(move || { - apply_natively(&profile); - }); - } - tokio::spawn(poll_batteries_once(service.clone())); } fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { @@ -1573,7 +852,6 @@ fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { #[cfg(test)] mod tests { use super::*; - use crate::remote_image::PNG_SIGNATURE; fn service(config: BridgeConfig) -> BridgeService { BridgeService::new(config, PathBuf::from("unused-test-config.json")) @@ -1590,10 +868,6 @@ mod tests { name: name.into(), executable: format!("{name}.exe"), path: path.into(), - local_id: None, - activation: None, - bundle_path: None, - bundle_identifier: None, }, device: config::ProfileDevice { id: "device".into(), @@ -1618,512 +892,6 @@ mod tests { } } - #[tokio::test] - async fn failed_native_join_does_not_publish_unowned_stale_completion() { - let bridge = service(BridgeConfig::default()); - bridge.inner.write().await.native_automation_error = Some("owned status".into()); - assert_eq!( - bridge - .reconcile_native_with(|_| panic!("inert worker failure")) - .await, - Some(ApplyOutcome::Failed) - ); - assert_eq!( - bridge.inner.read().await.native_automation_error.as_deref(), - Some("owned status") - ); - } - - #[tokio::test] - async fn native_worker_retains_serialization_after_waiter_cancellation() { - let bridge = service(BridgeConfig::default()); - let (started_tx, started_rx) = tokio::sync::oneshot::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let running = bridge.clone(); - let waiter = tokio::spawn(async move { - running - .reconcile_native_with(move |_| { - started_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - (Some("retained completion".into()), 1) - }) - .await - }); - started_rx.await.unwrap(); - waiter.abort(); - let _ = waiter.await; - assert!(bridge.inner.try_write().is_err()); - release_tx.send(()).unwrap(); - let state = tokio::time::timeout(Duration::from_secs(5), bridge.inner.write()) - .await - .unwrap(); - assert_eq!( - state.native_automation_error.as_deref(), - Some("retained completion") - ); - assert_eq!(state.pending_native_restores, 1); - } - - #[tokio::test] - async fn native_pending_handoff_keeps_original_baseline_without_old_writes() { - let old = test_profile("Old", "/old", Some(1600), None); - let next = test_profile("Next", "/next", Some(3200), None); - let bridge = service(BridgeConfig { - profiles: vec![old.clone(), next], - ..BridgeConfig::default() - }); - { - let mut state = bridge.inner.write().await; - state.applied_profile = Some(old); - state.applications = vec![test_app("Next", "/next", true)]; - } - assert_eq!(bridge.reconcile_native_with(|_| panic!("pending handoff must retain journal without applying/restoring until arbitration settles")).await, None); - } - - #[tokio::test] - async fn native_selection_rechecks_disabled_profile_before_hardware() { - let game = test_profile("Game", "/game", Some(1600), None); - let bridge = service(BridgeConfig { - profiles: vec![game.clone()], - ..BridgeConfig::default() - }); - { - let mut state = bridge.inner.write().await; - state.applied_profile = Some(game); - state.applications = vec![test_app("Game", "/game", true)]; - state.config.profiles[0].enabled = false; - } - bridge - .reconcile_native_with(|desired| { - assert!( - desired.is_none(), - "disabled profile must never reach the native writer" - ); - (None, 0) - }) - .await; - } - - #[tokio::test] - async fn native_reconcile_serializes_configuration_and_reports_truthful_failure() { - let game = test_profile("Game", "/game", Some(1600), None); - let bridge = service(BridgeConfig { - profiles: vec![game.clone()], - ..BridgeConfig::default() - }); - { - let mut state = bridge.inner.write().await; - state.applied_profile = Some(game.clone()); - state.applications = vec![test_app("Game", "/game", true)]; - } - let checked = bridge.clone(); - bridge - .reconcile_native_with(move |desired| { - assert_eq!(desired, Some(game)); - assert!( - checked.inner.try_write().is_err(), - "configuration must not change during the bounded HID operation" - ); - (Some("unsupported acknowledged readback".into()), 1) - }) - .await; - let snapshot = bridge.snapshot().await; - assert_eq!( - snapshot.native_automation_error.as_deref(), - Some("unsupported acknowledged readback") - ); - assert_eq!(snapshot.pending_native_restores, 1); - assert!(snapshot.active_profile.is_some()); - } - - #[tokio::test] - async fn running_arbitration_retains_current_then_uses_persisted_order() { - let mut first = test_profile("Zed", "/opt/zed", Some(800), None); - first.application.local_id = Some("local:zed".into()); - let mut second = test_profile("Alpha", "/opt/alpha", Some(1600), None); - second.application.local_id = Some("local:alpha".into()); - let config = BridgeConfig { - local_applications: serde_json::from_value(serde_json::json!([ - {"id":"local:zed","name":"Zed","executable":"zed","path":"/opt/zed","kind":"app","activation":"running"}, - {"id":"local:alpha","name":"Alpha","executable":"alpha","path":"/opt/alpha","kind":"app","activation":"running"} - ])).unwrap(), - profiles: vec![first.clone(), second.clone()], - ..BridgeConfig::default() - }.normalized(); - let first = config.profiles[0].clone(); - let second = config.profiles[1].clone(); - let bridge = service(config); - { - let mut state = bridge.inner.write().await; - state.active_games = vec!["local:alpha".into(), "local:zed".into()]; - state.processes = vec![ - crate::games::RunningProcess { - executable: "zed".into(), - path: "/opt/zed".into(), - }, - crate::games::RunningProcess { - executable: "alpha".into(), - path: "/opt/alpha".into(), - }, - ]; - } - assert_eq!(bridge.snapshot().await.active_profile, Some(first.clone())); - bridge.inner.write().await.applied_profile = Some(second.clone()); - assert_eq!(bridge.snapshot().await.active_profile, Some(second)); - bridge.inner.write().await.config.profiles[1].enabled = false; - assert_eq!(bridge.snapshot().await.active_profile, Some(first)); - bridge.inner.write().await.active_games.clear(); - bridge.inner.write().await.processes.clear(); - assert_eq!(bridge.snapshot().await.active_profile, None); - } - - #[test] - fn local_running_ids_cannot_override_changed_executable_identity() { - let target: config::LocalApplication = serde_json::from_value(serde_json::json!({ - "id":"local:editor","name":"Editor","executable":"editor","path":"/new/editor","kind":"app","activation":"running" - })).unwrap(); - let mut profile = test_profile("Editor", "/new/editor", Some(800), None); - profile.application.local_id = Some(target.id.clone()); - let config = BridgeConfig { - local_applications: vec![target], - profiles: vec![profile], - ..BridgeConfig::default() - }; - assert_eq!( - resolve_profile( - &config, - &[], - &["local:editor".into()], - &[crate::games::RunningProcess { - executable: "editor".into(), - path: "/old/editor".into() - }] - ), - None - ); - } - - #[test] - fn full_path_never_falls_back_to_name_or_executable() { - let profile = test_profile("Editor", "C:/Trusted/Editor.exe", Some(800), None); - let config = BridgeConfig { - profiles: vec![profile.clone()], - ..BridgeConfig::default() - }; - assert_eq!( - active_profile_for( - &config, - &[test_app("Editor", "C:/Other/Editor.exe", true)], - &[] - ), - None - ); - assert_eq!( - active_profile_for( - &config, - &[test_app("Editor", r"c:\trusted\EDITOR.exe", true)], - &[] - ), - Some(profile) - ); - let profile = test_profile("Editor", "/Apps/Editor", Some(800), None); - let config = BridgeConfig { - profiles: vec![profile], - ..BridgeConfig::default() - }; - assert_eq!( - active_profile_for(&config, &[test_app("Editor", "/apps/editor", true)], &[]), - None - ); - } - - #[test] - fn local_game_background_activity_is_id_linked_and_not_catalog_gated() { - let mut profile = test_profile("Editor", "/opt/Editor.exe", Some(1200), None); - profile.application.local_id = Some("local:editor".into()); - let config = BridgeConfig { - local_applications: vec![serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"/opt/Editor.exe","kind":"game"})).unwrap()], - profiles: vec![profile.clone()], - default_profile: Some(test_profile("Default", "", Some(800), None)), - ..BridgeConfig::default() - }; - assert_eq!( - resolve_profile( - &config, - &[], - &["local:editor".into()], - &[crate::games::RunningProcess { - executable: "Editor.exe".into(), - path: "/opt/Editor.exe".into() - }] - ), - Some(profile) - ); - } - - #[test] - fn local_foreground_apps_bypass_catalog_but_do_not_stay_active_in_background() { - let mut profile = test_profile("Editor", "C:/Tools/Editor.exe", Some(1200), None); - profile.application.local_id = Some("local:editor".into()); - let target = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"C:/Tools/Editor.exe","kind":"app"})).unwrap(); - let config = BridgeConfig { - local_applications: vec![target], - profiles: vec![profile.clone()], - ..BridgeConfig::default() - }; - let application = test_app("Editor", "C:/Tools/Editor.exe", true); - let selected = profile_applications( - std::slice::from_ref(&application), - &config.games, - &config.local_applications, - ); - assert_eq!(selected, [application]); - assert_eq!(active_profile_for(&config, &selected, &[]), Some(profile)); - let background = test_app("Editor", "C:/Tools/Editor.exe", false); - assert_eq!( - active_profile_for(&config, &[background], &["local:editor".into()]), - None - ); - } - - #[tokio::test] - async fn local_activity_status_uses_display_names_not_internal_ids() { - let target = serde_json::from_value(serde_json::json!({"id":"local:game","name":"Custom Game","executable":"custom.exe","path":"","kind":"game"})).unwrap(); - let bridge = service(BridgeConfig { - local_applications: vec![target], - ..BridgeConfig::default() - }); - bridge.inner.write().await.active_games = vec!["local:game".into()]; - let status = bridge.snapshot().await; - assert_eq!(status.active_games, ["Custom Game"]); - assert_eq!(status.tracked_game_count, 1); - assert!( - status - .games - .iter() - .any(|game| game.name == "Custom Game" && game.active) - ); - } - - #[test] - fn local_display_name_does_not_activate_an_unrelated_preset() { - let local: config::LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"custom.exe","path":"","kind":"app"})).unwrap(); - let preset = test_profile("Editor", "", Some(800), None); - let config = BridgeConfig { - games: vec![GameConfig { - name: "Editor".into(), - executables: vec!["Editor.exe".into()], - }], - local_applications: vec![local], - profiles: vec![preset], - ..BridgeConfig::default() - }; - let mut application = test_app("Editor", "C:/Tools/custom.exe", true); - application.executable = "custom.exe".into(); - let candidates = - profile_applications(&[application], &config.games, &config.local_applications); - assert_eq!(active_profile_for(&config, &candidates, &[]), None); - } - - #[test] - fn explicit_local_paths_win_and_disabled_locals_return_to_default() { - let broad: config::LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:broad","name":"Same","executable":"Editor.exe","path":"","kind":"game"})).unwrap(); - let exact = config::LocalApplication { - id: "local:exact".into(), - path: "C:/Tools/Editor.exe".into(), - ..broad.clone() - }; - let mut broad_profile = test_profile("Same", "", Some(800), None); - broad_profile.application.local_id = Some(broad.id.clone()); - let mut exact_profile = test_profile("Same", &exact.path, Some(1600), None); - exact_profile.application.local_id = Some(exact.id.clone()); - let default = test_profile("Default", "", Some(400), None); - let mut config = BridgeConfig { - local_applications: vec![broad, exact], - profiles: vec![exact_profile.clone(), broad_profile], - default_profile: Some(default.clone()), - ..BridgeConfig::default() - }; - let foreground = test_app("Editor", "C:/Tools/Editor.exe", true); - assert_eq!( - resolve_profile( - &config, - std::slice::from_ref(&foreground), - &[], - &[crate::games::RunningProcess { - executable: foreground.executable.clone(), - path: foreground.path.clone() - }] - ), - Some(exact_profile.clone()) - ); - let processes = vec![crate::games::RunningProcess { - path: foreground.path.clone(), - executable: foreground.executable.clone(), - }]; - let active = crate::games::matches_local_games(&config.local_applications, &processes); - assert_eq!( - resolve_profile(&config, &[], &active, &processes), - Some(exact_profile) - ); - for profile in &mut config.profiles { - profile.enabled = false; - } - assert_eq!( - resolve_profile(&config, &[foreground], &active, &processes), - Some(default) - ); - } - - #[test] - fn background_preset_matching_never_ignores_a_saved_full_path() { - let profile = test_profile("Editor", "C:/Trusted/Editor.exe", Some(800), None); - let config = BridgeConfig { - games: vec![GameConfig { - name: "Editor".into(), - executables: vec!["Editor.exe".into()], - }], - profiles: vec![profile.clone()], - ..BridgeConfig::default() - }; - let wrong = crate::games::RunningProcess { - executable: "Editor.exe".into(), - path: "C:/Other/Editor.exe".into(), - }; - assert_eq!( - resolve_profile(&config, &[], &["Editor".into()], &[wrong]), - None - ); - let matching = crate::games::RunningProcess { - executable: "Editor.exe".into(), - path: r"c:\trusted\EDITOR.exe".into(), - }; - assert_eq!( - resolve_profile(&config, &[], &["Editor".into()], &[matching]), - Some(profile) - ); - } - - #[test] - fn local_and_preset_same_name_have_distinct_switch_keys() { - let preset = test_profile("Editor", "", Some(800), None); - let mut local = preset.clone(); - local.application.local_id = Some("local:editor".into()); - assert_ne!(profile_key(&preset), profile_key(&local)); - let mut other_device = local.clone(); - other_device.device.id = "other-device".into(); - assert_ne!(profile_key(&local), profile_key(&other_device)); - let mut other = local.clone(); - other.application.local_id = Some("local:other".into()); - assert_ne!(profile_key(&local), profile_key(&other)); - } - - #[test] - fn a_game_image_is_kept_under_a_stable_name_whatever_the_case() { - let dir = Path::new("icons"); - assert_eq!( - game_image_file(dir, "My Game"), - game_image_file(dir, "my game") - ); - assert_ne!( - game_image_file(dir, "My Game"), - game_image_file(dir, "Other") - ); - let file = game_image_file(dir, "../../evil\\name"); - assert_eq!( - file.parent(), - Some(dir), - "a hostile name cannot leave the image folder" - ); - } - - #[test] - fn only_a_png_jpeg_or_webp_of_a_sensible_size_is_kept_as_an_image() { - let mut png = PNG_SIGNATURE.to_vec(); - png.extend_from_slice(&[0; 32]); - assert_eq!(image_type(&png), Some("image/png")); - assert_eq!( - image_type(&[0xff, 0xd8, 0xff, 0xe0, 0, 0]), - Some("image/jpeg") - ); - let mut webp = b"RIFF\0\0\0\0WEBPVP8 ".to_vec(); - webp.extend_from_slice(&[0; 8]); - assert_eq!(image_type(&webp), Some("image/webp")); - assert_eq!(image_type(b"not an image"), None); - assert_eq!(image_type(b"GIF89a......"), None, "a GIF is not accepted"); - png.resize(MAX_IMAGE_BYTES + 1, 0); - assert_eq!(image_type(&png), None, "an oversized file is refused"); - } - - #[test] - fn removing_a_game_removes_its_image() { - let dir = std::env::temp_dir().join(format!("bridge-icons-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); - for name in ["Kept", "Gone"] { - std::fs::write(game_image_file(&dir, name), PNG_SIGNATURE).expect("image"); - } - prune_game_images(&dir, &["Kept"]); - assert!(game_image_file(&dir, "Kept").exists()); - assert!(!game_image_file(&dir, "Gone").exists()); - std::fs::remove_dir_all(&dir).expect("cleanup"); - } - - #[test] - fn the_active_profile_is_read_from_the_config_so_edits_are_seen() { - let mut config = BridgeConfig { - default_profile: Some(test_profile("Default", "", Some(800), None)), - profiles: vec![test_profile("Valorant", "/games/valorant", Some(800), None)], - ..BridgeConfig::default() - }; - let game_key = profile_key(&config.profiles[0]); - let default_key = profile_key(config.default_profile.as_ref().unwrap()); - - let (game, is_default) = current_profile(&config, &game_key).expect("the game profile"); - assert_eq!( - (game.application.name.as_str(), is_default), - ("Valorant", false) - ); - let (default, is_default) = - current_profile(&config, &default_key).expect("the default profile"); - assert_eq!( - (default.application.name.as_str(), is_default), - ("Default", true) - ); - - // Editing a profile keeps its key, so the lookup finds the new settings. - config.default_profile = Some(test_profile("Default", "", Some(1600), None)); - let (edited, _) = - current_profile(&config, &default_key).expect("the edited default profile"); - assert_eq!(edited.settings.dpi, Some(1600)); - let other_key = profile_key(&test_profile("Other", "/games/other", None, None)); - assert!(current_profile(&config, &other_key).is_none()); - - // A disabled profile of the same game is not the one that is applied. - let mut disabled = test_profile("Valorant", "/games/valorant", Some(400), None); - disabled.enabled = false; - config.profiles = vec![disabled]; - assert!(current_profile(&config, &game_key).is_none()); - } - - #[test] - fn a_custom_games_profile_activates_while_the_game_runs() { - let mut profile = test_profile("My Game", "", Some(1600), None); - profile.application.executable = "mygame.exe".into(); - let config = BridgeConfig { - custom_games: vec![GameConfig { - name: "My Game".into(), - executables: vec!["mygame.exe".into()], - }], - profiles: vec![profile], - ..BridgeConfig::default() - } - .normalized(); - let active = resolve_profile_retaining(&config, &[], &["My Game".into()], &[], None) - .expect("the custom game's profile"); - assert_eq!(active.settings.dpi, Some(1600)); - assert!(resolve_profile_retaining(&config, &[], &[], &[], None).is_none()); - } - #[test] fn profile_devices_are_distinct_and_include_the_default() { let mut config = BridgeConfig::default(); @@ -2231,23 +999,6 @@ mod tests { ); } - #[test] - fn failed_native_restore_is_not_announced_as_profile_off() { - let game = test_profile("Game", "/game", Some(1600), None); - let notice = - switch_message(Some(&game), None, None, Some(ApplyOutcome::Failed), false).unwrap(); - assert_eq!( - notice.detail, - "Couldn't restore original mouse settings — check Bridge status" - ); - let unsupported = - switch_message(None, Some(&game), None, Some(ApplyOutcome::NoDriver), false).unwrap(); - assert_eq!( - unsupported.detail, - "Native automation unsupported — acknowledged device readback is required" - ); - } - #[test] fn switch_messages_name_the_game_and_the_return_to_default() { let default = test_profile("PRO X SUPERLIGHT 2c", "", Some(800), Some(1000)); @@ -2315,9 +1066,6 @@ mod tests { device_name: "Mouse".into(), percent: 90, charging: false, - dpi: None, - polling_rate_hz: None, - switch_mode: None, }) .await .unwrap() @@ -2329,115 +1077,22 @@ mod tests { device_name: "Mouse".into(), percent: 10, charging: true, - dpi: None, - polling_rate_hz: None, - switch_mode: None, }) .await .unwrap() ); } - #[tokio::test] - async fn settings_less_panel_reports_keep_the_stored_settings() { - let bridge = service(BridgeConfig::default()); - // Native status read: battery plus onboard settings. - bridge - .record_battery(BatteryReading { - device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), - device_name: "PRO X SUPERLIGHT 2 SE".into(), - percent: 81, - charging: false, - dpi: Some(800), - polling_rate_hz: Some(1000), - switch_mode: Some("Hybrid".into()), - }) - .await - .unwrap(); - // The panel's PUT carries the charge level only. - bridge - .record_battery(BatteryReading { - device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), - device_name: "PRO X SUPERLIGHT 2 SE".into(), - percent: 80, - charging: false, - dpi: None, - polling_rate_hz: None, - switch_mode: None, - }) - .await - .unwrap(); - let battery = bridge - .snapshot() - .await - .batteries - .into_iter() - .next() - .expect("battery entry survives"); - assert_eq!(battery.percent, 80); - assert_eq!(battery.dpi, Some(800)); - assert_eq!(battery.polling_rate_hz, Some(1000)); - assert_eq!(battery.switch_mode.as_deref(), Some("Hybrid")); - } - fn battery_entry(age: Duration) -> BatteryState { BatteryState { last_alert: None, device_name: "Mouse".into(), percent: 50, charging: false, - dpi: None, - polling_rate_hz: None, - switch_mode: None, updated_at: Instant::now() - age, - history: VecDeque::new(), } } - fn history(minutes_ago_percent: &[(u64, u8)]) -> VecDeque<(Instant, u8)> { - let now = Instant::now(); - minutes_ago_percent - .iter() - .map(|(minutes_ago, percent)| (now - Duration::from_secs(minutes_ago * 60), *percent)) - .collect() - } - - #[test] - fn a_steady_drain_estimates_time_to_empty() { - let samples = history(&[(60, 50), (30, 48), (0, 45)]); - assert_eq!(estimate_minutes_remaining(&samples, 45, false), Some(540)); - } - - #[test] - fn a_steady_charge_estimates_time_to_full() { - let samples = history(&[(60, 20), (0, 80)]); - assert_eq!(estimate_minutes_remaining(&samples, 80, true), Some(20)); - } - - #[test] - fn a_flat_level_has_no_estimate() { - let samples = history(&[(60, 50), (0, 50)]); - assert_eq!(estimate_minutes_remaining(&samples, 50, false), None); - } - - #[test] - fn too_little_history_has_no_estimate() { - let samples = history(&[(10, 50), (0, 45)]); - assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); - } - - #[test] - fn samples_outside_the_trend_window_are_ignored() { - let samples = history(&[(10 * 60, 50), (0, 45)]); - assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); - } - - #[test] - fn an_estimate_past_two_days_is_noise() { - let samples = history(&[(60, 50), (0, 49)]); - assert_eq!(estimate_minutes_remaining(&samples, 49, false), None); - } - #[test] fn prune_evicts_old_entries_and_unknown_stale_ones() { let mut battery = HashMap::new(); @@ -2476,9 +1131,6 @@ mod tests { device_name: id.into(), percent: 80, charging: false, - dpi: None, - polling_rate_hz: None, - switch_mode: None, }) .await .unwrap(); @@ -2577,78 +1229,4 @@ mod tests { assert!(bridge.snapshot().await.client_connected); } - - #[tokio::test] - async fn the_mode_flips_to_live_when_the_panel_handshakes() { - let bridge = service(BridgeConfig::default()); - assert_eq!(bridge.snapshot().await.device_mode, "onboard"); - - let monitored = bridge.clone(); - monitored.start_mode_monitor(); - bridge.record_client_heartbeat().await; - // The mode monitor ticks every second; give it a beat to spot the - // handshake and flip the mode. - tokio::time::sleep(Duration::from_millis(1400)).await; - - assert_eq!(bridge.snapshot().await.device_mode, "live"); - assert!(bridge.snapshot().await.hybrid_mode); - } - - #[test] - fn the_discovered_battery_id_becomes_the_panel_style_id() { - // The panel reports `:`; discovery's descriptor tail - // (a LIGHTSPEED receiver) must canonicalize onto that id so the - // same mouse never appears as two rows. - assert_eq!( - canonical_battery_identity("Logitech:046d:c547", "PRO X SUPERLIGHT 2 SE"), - "Logitech:PRO X SUPERLIGHT 2 SE" - ); - // Brands with no model name keep their discovered id. - assert_eq!( - canonical_battery_identity("Logitech:046d:c547", ""), - "Logitech:" - ); - } - - #[test] - fn discovered_mice_fill_the_gap_without_shadowing_profiled_devices() { - let detected = |device_id: &str, name: &str| drivers::DetectedMouse { - device_id: device_id.into(), - name: name.into(), - }; - // No profiles: every discovered mouse is polled. - assert_eq!( - merge_detected_mice( - Vec::new(), - &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] - ), - vec![( - "Pulsar:0d1f0200".to_owned(), - "Pulsar X2 CrazyLight".to_owned() - )] - ); - // A profile already names the brand: discovery adds nothing, so the - // same physical mouse is never reported under two ids. - assert_eq!( - merge_detected_mice( - vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())], - &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] - ), - vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())] - ); - // Brand matching is case-insensitive; other brands still merge. - assert_eq!( - merge_detected_mice( - vec![("pulsar:X2".to_owned(), "X2".to_owned())], - &[ - detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight"), - detected("Delux:M800", "Delux M800") - ] - ), - vec![ - ("pulsar:X2".to_owned(), "X2".to_owned()), - ("Delux:M800".to_owned(), "Delux M800".to_owned()) - ] - ); - } } diff --git a/src/updater.rs b/src/updater.rs index 282d4cb..e6d8349 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -3,13 +3,14 @@ use std::os::windows::process::CommandExt; #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] use std::process::Command; use std::{ - env, fs, + env, + fs::{self, File}, io::{self, Cursor}, path::Path, time::Duration, }; -use anyhow::{Context, Result, anyhow, ensure}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use semver::Version; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -48,16 +49,16 @@ pub async fn check_for_update() -> Result> { .timeout(CHECK_TIMEOUT) .build() .context("could not create the update client")?; - let response = client + let release = client .get(&url) .header(reqwest::header::USER_AGENT, USER_AGENT) .send() .await .with_context(|| format!("could not check {url}"))? .error_for_status() - .with_context(|| format!("update check failed for {url}"))?; - let bytes = read_bounded_response(response, MAX_RELEASE_BYTES).await?; - let release = serde_json::from_slice::(&bytes) + .with_context(|| format!("update check failed for {url}"))? + .json::() + .await .context("could not parse the latest Bridge release")?; select_update(release, crate::BRIDGE_VERSION) } @@ -68,8 +69,8 @@ pub async fn download_and_stage(update: &UpdateInfo) -> Result<()> { .build() .context("could not create the update download client")?; let (archive, checksum) = tokio::try_join!( - download(&client, &update.archive_url, MAX_ARCHIVE_BYTES), - download(&client, &update.checksum_url, MAX_CHECKSUM_BYTES), + download(&client, &update.archive_url), + download(&client, &update.checksum_url), )?; verify_checksum(&archive, &checksum)?; let version = update.version.clone(); @@ -79,49 +80,19 @@ pub async fn download_and_stage(update: &UpdateInfo) -> Result<()> { Ok(()) } -async fn download(client: &reqwest::Client, url: &str, limit: usize) -> Result> { - let response = client +async fn download(client: &reqwest::Client, url: &str) -> Result> { + Ok(client .get(url) .header(reqwest::header::USER_AGENT, USER_AGENT) .send() .await .with_context(|| format!("could not download {url}"))? .error_for_status() - .with_context(|| format!("update download failed for {url}"))?; - read_bounded_response(response, limit).await -} - -pub(crate) async fn read_bounded_response( - mut response: reqwest::Response, - limit: usize, -) -> Result> { - if let Some(length) = response.headers().get(reqwest::header::CONTENT_LENGTH) { - let length = length - .to_str()? - .parse::() - .context("invalid download content length")?; - ensure!(length <= limit as u64, "download exceeds byte limit"); - } - let mut bytes = Vec::new(); - while let Some(chunk) = response - .chunk() + .with_context(|| format!("update download failed for {url}"))? + .bytes() .await - .context("could not read bounded download")? - { - ensure!( - chunk.len() <= limit.saturating_sub(bytes.len()), - "download exceeds byte limit" - ); - if bytes.capacity() - bytes.len() < chunk.len() { - let capacity = - limit.min((bytes.len() + chunk.len()).max(bytes.capacity().saturating_mul(2))); - bytes - .try_reserve_exact(capacity - bytes.len()) - .context("could not allocate bounded download buffer")?; - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) + .with_context(|| format!("could not read update download from {url}"))? + .to_vec()) } fn select_update(release: Release, current: &str) -> Result> { @@ -160,7 +131,7 @@ fn platform_archive_name() -> Result<&'static str> { #[cfg(target_os = "linux")] return Ok("openmouse-bridge-linux-x64.zip"); #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - anyhow::bail!("automatic Bridge updates are available only on macOS, Windows, and Linux"); + bail!("automatic Bridge updates are available only on macOS, Windows, and Linux"); } fn verify_checksum(archive: &[u8], checksum_file: &[u8]) -> Result<()> { @@ -181,457 +152,71 @@ fn verify_checksum(archive: &[u8], checksum_file: &[u8]) -> Result<()> { Ok(()) } -struct StagingDirectory { - path: std::path::PathBuf, - handed_off: bool, -} - -impl StagingDirectory { - fn new_in(parent: &Path) -> Result { - use std::hash::BuildHasher; - verify_directory_chain(parent)?; - for _ in 0..32 { - let nonce = std::collections::hash_map::RandomState::new() - .hash_one((std::process::id(), std::time::SystemTime::now())); - let path = parent.join(format!("openmouse-update-{nonce:016x}")); - match private_directory_builder().create(&path) { - Ok(()) => { - return Ok(Self { - path, - handed_off: false, - }); - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error).context("could not create private update staging"), - } - } - anyhow::bail!("could not allocate unique update staging") - } -} - -impl Drop for StagingDirectory { - fn drop(&mut self) { - if !self.handed_off { - let _ = fs::remove_dir_all(&self.path); - } +fn stage_archive(archive: &[u8], version: &str) -> Result<()> { + let staging = env::temp_dir().join(format!( + "openmouse-bridge-update-{version}-{}", + std::process::id() + )); + if staging.exists() { + fs::remove_dir_all(&staging) + .with_context(|| format!("could not clear {}", staging.display()))?; } -} + fs::create_dir_all(&staging) + .with_context(|| format!("could not create {}", staging.display()))?; + extract_archive(archive, &staging)?; -fn stage_archive(archive: &[u8], version: &str) -> Result<()> { - Version::parse(version).context("invalid update version")?; - #[cfg(windows)] - let parent = directories::BaseDirs::new() - .context("could not locate private user cache")? - .cache_dir() - .to_path_buf(); - #[cfg(not(windows))] - let parent = env::temp_dir(); - let parent = fs::canonicalize(parent).context("could not resolve update staging parent")?; - let mut staging = StagingDirectory::new_in(&parent)?; - extract_archive(archive, &staging.path)?; - verify_package(&staging.path)?; let current_executable = env::current_exe().context("could not locate the Bridge executable")?; let install_directory = current_executable .parent() .context("Bridge executable has no parent directory")?; - ensure!( - current_executable.file_name() == Some(std::ffi::OsStr::new(platform_binary_name())), - "Bridge executable has an unexpected name" - ); verify_install_directory(install_directory)?; - spawn_install_helper(&staging.path, install_directory, ¤t_executable)?; - staging.handed_off = true; - Ok(()) + spawn_install_helper(&staging, install_directory, ¤t_executable) } -fn verify_package(staging: &Path) -> Result<()> { - verify_tree(staging)?; - let license = staging.join("DM-Sans-OFL.txt"); - let expected = include_bytes!("../assets/fonts/DM-Sans-OFL.txt"); - ensure!( - reject_link(&license)?.is_file() && fs::metadata(&license)?.len() == expected.len() as u64, - "update package requires the complete DM Sans license" - ); - ensure!( - fs::read(&license)? == expected, - "update package contains an incomplete DM Sans license" - ); - let node = if cfg!(windows) { - "native-hid/node/node.exe" - } else { - "native-hid/node/bin/node" - }; - for name in [ - platform_binary_name(), - "native-hid/src/apply.mjs", - "native-hid/src/brands.mjs", - "native-hid/src/hid-device-adapter.mjs", - "native-hid/src/attack-shark-x11.mjs", - "native-hid/package.json", - node, - "native-hid/node_modules/node-hid/package.json", - "native-hid/node_modules/@openmouse/protocol/package.json", - "native-hid/node_modules/@openmouse/keyboard-protocol/package.json", - ] { - let path = staging.join(name); - verify_directory_chain(path.parent().context("missing package parent")?)?; - let metadata = - reject_link(&path).with_context(|| format!("update package is missing {name}"))?; - ensure!( - metadata.is_file() && metadata.len() > 0, - "update package file is empty or invalid: {name}" - ); - #[cfg(unix)] - if name == platform_binary_name() || name == node { - use std::os::unix::fs::PermissionsExt; - ensure!( - metadata.permissions().mode() & 0o500 == 0o500, - "update executable requires owner read and execute access: {name}" - ); - } - } - Ok(()) -} - -const MAX_ARCHIVE_BYTES: usize = 256 * 1024 * 1024; -const MAX_CHECKSUM_BYTES: usize = 4096; -const MAX_RELEASE_BYTES: usize = 1024 * 1024; -const MAX_ARCHIVE_ENTRIES: usize = 8192; -const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; -const MAX_OUTPUT_BYTES: u64 = 1024 * 1024 * 1024; - fn extract_archive(archive: &[u8], staging: &Path) -> Result<()> { - extract_archive_with_limits( - archive, - staging, - MAX_ARCHIVE_ENTRIES, - MAX_FILE_BYTES, - MAX_OUTPUT_BYTES, - ) -} - -fn safe_entry_path(name: &str) -> Result<&Path> { - ensure!( - !name.is_empty() && name.len() <= 1024 && !name.starts_with('/'), - "unsafe archive path" - ); - ensure!(!name.contains(['\\', ':', '\0']), "unsafe archive path"); - let name = name.strip_suffix('/').unwrap_or(name); - let mut count = 0; - for part in name.split('/') { - count += 1; - ensure!( - !part.is_empty() && part != "." && part != ".." && !part.ends_with(['.', ' ']), - "unsafe archive path component" - ); - ensure!( - !part - .chars() - .any(|c| c.is_control() || matches!(c, '<' | '>' | '"' | '|' | '?' | '*')), - "unsafe archive path component" - ); - let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); - ensure!( - !matches!( - stem.as_str(), - "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" - ) && !(stem.len() == 4 - && (stem.starts_with("COM") || stem.starts_with("LPT")) - && stem.as_bytes()[3].is_ascii_digit()), - "reserved archive path component" - ); - } - ensure!(count <= 64, "archive path is too deep"); - Ok(Path::new(name)) -} - -fn reject_link(path: &Path) -> Result { - let metadata = fs::symlink_metadata(path)?; - ensure!( - !metadata.file_type().is_symlink(), - "update path contains a symbolic link" - ); - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - ensure!( - metadata.file_attributes() & 0x400 == 0, - "update path contains a reparse point" - ); - } - Ok(metadata) -} - -fn verify_directory_chain(directory: &Path) -> Result<()> { - ensure!(directory.is_absolute(), "update directory must be absolute"); - for ancestor in directory.ancestors() { - ensure!( - reject_link(ancestor)?.is_dir(), - "update ancestor is not a directory" - ); - } - Ok(()) -} - -fn private_directory_builder() -> fs::DirBuilder { - let builder = fs::DirBuilder::new(); - #[cfg(unix)] - let builder = { - use std::os::unix::fs::DirBuilderExt; - let mut builder = builder; - builder.mode(0o700); - builder - }; - builder -} - -fn create_entry_directories(root: &Path, relative: &Path) -> Result<()> { - let mut path = root.to_path_buf(); - for component in relative.components() { - path.push(component); - match fs::symlink_metadata(&path) { - Ok(_) => ensure!( - reject_link(&path)?.is_dir(), - "archive parent is not a directory" - ), - Err(error) if error.kind() == io::ErrorKind::NotFound => { - let builder = private_directory_builder(); - builder.create(&path)?; - } - Err(error) => return Err(error.into()), - } - } - ensure!( - fs::canonicalize(&path)?.starts_with(fs::canonicalize(root)?), - "archive path escapes staging" - ); - Ok(()) -} - -const MAX_ZIP_METADATA_READ_BYTES: usize = 16 * 1024 * 1024; - -struct ArchiveReader<'a> { - inner: Cursor<&'a [u8]>, - metadata_budget: &'a std::cell::Cell>, -} - -impl io::Read for ArchiveReader<'_> { - fn read(&mut self, buffer: &mut [u8]) -> io::Result { - let remaining = self.metadata_budget.get(); - let limit = remaining.unwrap_or(buffer.len()).min(buffer.len()); - if !buffer.is_empty() && remaining == Some(0) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "ZIP metadata read budget exceeded", - )); - } - let read = self.inner.read(&mut buffer[..limit])?; - if let Some(remaining) = remaining { - self.metadata_budget.set(Some(remaining - read)); - } - Ok(read) - } -} - -impl io::Seek for ArchiveReader<'_> { - fn seek(&mut self, position: io::SeekFrom) -> io::Result { - self.inner.seek(position) - } -} - -fn extract_archive_with_limits( - archive: &[u8], - staging: &Path, - entries: usize, - per_file: u64, - total: u64, -) -> Result<()> { - use std::io::Read; - ensure!( - archive.len() <= MAX_ARCHIVE_BYTES, - "update archive exceeds download limit" - ); - verify_directory_chain(staging)?; - let metadata_budget = std::cell::Cell::new(Some(MAX_ZIP_METADATA_READ_BYTES)); - let reader = ArchiveReader { - inner: Cursor::new(archive), - metadata_budget: &metadata_budget, - }; - let mut zip = zip::ZipArchive::new(reader) - .context("update archive is invalid or exceeds metadata budget")?; - metadata_budget.set(None); - ensure!(zip.len() <= entries, "update archive has too many entries"); - let mut declared_total = 0u64; - let mut names = std::collections::BTreeSet::new(); - let mut output_paths = std::collections::BTreeSet::new(); + let mut zip = + zip::ZipArchive::new(Cursor::new(archive)).context("update archive is invalid")?; for index in 0..zip.len() { - let entry = zip + let mut entry = zip .by_index(index) .context("could not read update archive entry")?; - let name = entry.name()?; - let relative = safe_entry_path(&name)?; - let enclosed = entry - .enclosed_name() - .context("update archive contains an unsafe path")?; - ensure!( - relative == enclosed, - "archive path normalization is not allowed" - ); - ensure!( - names.insert(relative.to_string_lossy().to_lowercase()), - "duplicate archive path" - ); - for path in relative - .ancestors() - .filter(|path| !path.as_os_str().is_empty()) - { - output_paths.insert(path.to_string_lossy().to_lowercase()); - ensure!( - output_paths.len() <= entries, - "archive creates too many filesystem entries" - ); - } - if let Some(mode) = entry.unix_mode() { - let kind = mode & 0o170000; - ensure!( - kind == 0 || kind == if entry.is_dir() { 0o040000 } else { 0o100000 }, - "unsupported archive entry type" - ); - } - ensure!(entry.size() <= per_file, "archive entry exceeds size limit"); - ensure!( - !entry.is_dir() || entry.size() == 0, - "archive directory has file data" - ); - declared_total = declared_total - .checked_add(entry.size()) - .context("archive size overflow")?; - ensure!(declared_total <= total, "archive exceeds output limit"); + let Some(relative) = entry.enclosed_name() else { + bail!("update archive contains an unsafe path"); + }; let destination = staging.join(relative); - ensure!( - destination.starts_with(staging), - "archive path escapes staging" - ); - let mut cursor = destination.as_path(); - while cursor != staging { - match fs::symlink_metadata(cursor) { - Ok(_) => { - let metadata = reject_link(cursor)?; - ensure!( - metadata.is_dir() && (cursor != destination || entry.is_dir()), - "archive would overwrite an existing file" - ); - } - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - cursor = cursor.parent().context("archive path escapes staging")?; - } - } - let mut written_total = 0; - for index in 0..zip.len() { - let mut entry = zip.by_index(index)?; - let relative = safe_entry_path(&entry.name()?)?.to_path_buf(); - let destination = staging.join(&relative); if entry.is_dir() { - create_entry_directories(staging, &relative)?; + fs::create_dir_all(&destination)?; continue; } - if let Some(parent) = relative.parent() { - create_entry_directories(staging, parent)?; - } - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; } - let mut output = options - .open(&destination) + let mut output = File::create(&destination) .with_context(|| format!("could not create {}", destination.display()))?; - let limit = per_file.min(total - written_total); - let copied = io::copy(&mut (&mut entry).take(limit), &mut output)?; - let mut extra = [0u8; 1]; - ensure!( - entry.read(&mut extra)? == 0 && copied == entry.size(), - "archive entry exceeds output limit or declared size" - ); - written_total += copied; + io::copy(&mut entry, &mut output) + .with_context(|| format!("could not extract {}", destination.display()))?; #[cfg(unix)] if let Some(mode) = entry.unix_mode() { use std::os::unix::fs::PermissionsExt; - output.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; + fs::set_permissions(&destination, fs::Permissions::from_mode(mode))?; } } ensure!( - reject_link(&staging.join(platform_binary_name()))?.is_file(), + staging.join(platform_binary_name()).is_file(), "update archive does not contain the Bridge executable" ); Ok(()) } fn verify_install_directory(directory: &Path) -> Result<()> { - verify_directory_chain(directory)?; - match fs::symlink_metadata(directory.join(".openmouse-update.lock")) { - Ok(_) => anyhow::bail!("Bridge update lock already exists"), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error).context("could not inspect Bridge update lock"), - } - for name in [ - platform_binary_name(), - "native-hid", - "openmouse-app-icon.png", - "DM-Sans-OFL.txt", - ] { - let path = directory.join(name); - match fs::symlink_metadata(&path) { - Ok(_) => verify_tree(&path)?, - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - } - let _probe = - StagingDirectory::new_in(directory).context("Bridge install directory is not writable")?; - Ok(()) -} - -fn verify_tree(root: &Path) -> Result<()> { - let mut pending = vec![root.to_path_buf()]; - let mut count = 0; - while let Some(path) = pending.pop() { - count += 1; - ensure!( - count <= MAX_ARCHIVE_ENTRIES, - "update tree has too many entries" - ); - let metadata = reject_link(&path)?; - ensure!( - metadata.is_file() || metadata.is_dir(), - "unsupported update file type" - ); - if metadata.is_dir() { - for entry in fs::read_dir(&path)? { - pending.push(entry?.path()); - ensure!( - count + pending.len() <= MAX_ARCHIVE_ENTRIES, - "update tree has too many entries" - ); - } - } - } - Ok(()) -} - -fn write_helper(path: &Path, script: &str) -> Result<()> { - use std::io::Write; - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path)?; - file.write_all(script.as_bytes()) - .context("could not write the update helper") + let probe = directory.join(format!( + ".openmouse-update-write-test-{}", + std::process::id() + )); + File::create(&probe) + .with_context(|| format!("Bridge cannot update files in {}", directory.display()))?; + fs::remove_file(probe).context("could not remove the update write test") } fn platform_binary_name() -> &'static str { @@ -641,91 +226,31 @@ fn platform_binary_name() -> &'static str { return "openmouse-bridge"; } -#[cfg(any(unix, test))] -const UNIX_TRANSACTION: &str = r#"install_package() ( -set -eu -stage="$1" -destination="$2" -binary="$3" -[ -f "$stage/$binary" ] && [ -d "$stage/native-hid" ] || exit 1 -for tree_root in "$stage" "$destination"; do - parent="$tree_root" - while [ "$parent" != / ]; do - [ -d "$parent" ] && [ ! -L "$parent" ] || exit 1 - parent=$(dirname "$parent") || exit 1 - done -done -special=$(find "$stage" ! -type f ! -type d -print) || exit 1 -[ -z "$special" ] || exit 1 -names="native-hid $binary DM-Sans-OFL.txt" -if [ -f "$stage/openmouse-app-icon.png" ]; then names="openmouse-app-icon.png $names"; fi -lock="$destination/.openmouse-update.lock" -(umask 077; mkdir "$lock") || exit 2 -transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock" || :; exit 2; } -committed=no -rollback() { - status=$? - trap - 0 1 2 15 - failed=no - if [ "$committed" != yes ]; then - for name in $names; do - if [ -e "$transaction/old/$name" ]; then - rm -rf "$destination/$name" && mv "$transaction/old/$name" "$destination/$name" || failed=yes - elif [ -f "$transaction/installed/$name" ]; then - rm -rf "$destination/$name" || failed=yes - fi - done - fi - if [ "$failed" = yes ]; then - printf '%s\n' "Update rollback failed; backups retained at $transaction" >&2 - exit 1 - fi - rm -rf "$transaction" - rmdir "$lock" - if [ "$committed" != yes ]; then exit 2; fi - exit "$status" -} -trap rollback 0 -trap 'exit 1' 1 2 15 -mkdir "$transaction/new" "$transaction/old" "$transaction/installed" || exit 1 -for name in $names; do - if [ -e "$destination/$name" ] || [ -L "$destination/$name" ]; then - special=$(find "$destination/$name" ! -type f ! -type d -print) || exit 1 - [ -z "$special" ] || exit 1 - fi - cp -R "$stage/$name" "$transaction/new/$name" || exit 1 - done -chmod u+x "$transaction/new/$binary" || exit 1 -for name in $names; do - if [ -e "$destination/$name" ]; then mv "$destination/$name" "$transaction/old/$name" || exit 1; fi - done -for name in $names; do - : > "$transaction/installed/$name" || exit 1 - mv "$transaction/new/$name" "$destination/$name" || exit 1 - done -committed=yes -) -"#; - -#[cfg(any(unix, test))] -const UNIX_COMPLETION: &str = r#"complete_update() { - if install_package "$1" "$2" "$3"; then - : - else - status=$? - [ "$status" -eq 2 ] || return 1 - fi - "$4" "$2" "$3" -} -"#; - -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) -> Result<()> { + const SCRIPT: &str = r#"#!/bin/sh +pid="$1" +stage="$2" +destination="$3" +binary="$4" +while kill -0 "$pid" 2>/dev/null; do sleep 1; done +cp -f "$stage/$binary" "$destination/$binary.update" || exit 1 +chmod +x "$destination/$binary.update" || exit 1 +mv -f "$destination/$binary.update" "$destination/$binary" || exit 1 +if [ -d "$stage/native-hid" ]; then + rm -rf "$destination/native-hid.update" + cp -R "$stage/native-hid" "$destination/native-hid.update" || exit 1 + rm -rf "$destination/native-hid.old" + if [ -d "$destination/native-hid" ]; then mv "$destination/native-hid" "$destination/native-hid.old"; fi + mv "$destination/native-hid.update" "$destination/native-hid" || exit 1 + rm -rf "$destination/native-hid.old" +fi +cd "$destination" || exit 1 +nohup "$destination/$binary" >/dev/null 2>&1 & +rm -rf "$stage" +"#; let script = staging.join("install-update.sh"); - let contents = format!( - "#!/bin/sh\n{UNIX_TRANSACTION}\n{UNIX_COMPLETION}\npid=\"$1\"\nstage=\"$2\"\ndestination=\"$3\"\nbinary=\"$4\"\ntrap 'rm -rf \"$stage\"' 0\nwhile kill -0 \"$pid\" 2>/dev/null; do sleep 1; done\nlaunch_bridge() {{ cd \"$1\" || return 1; nohup \"$1/$2\" >/dev/null 2>&1 & }}\ncomplete_update \"$stage\" \"$destination\" \"$binary\" launch_bridge\n" - ); - write_helper(&script, &contents)?; + fs::write(&script, SCRIPT).context("could not write the update helper")?; Command::new("/bin/sh") .arg(&script) .arg(std::process::id().to_string()) @@ -741,113 +266,31 @@ fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) - Ok(()) } -#[cfg(windows)] -const WINDOWS_TRANSACTION: &str = r#"function Assert-CleanTree([string]$Path) { - $Pending = [System.Collections.Generic.Stack[string]]::new() - $Pending.Push($Path) - $Count = 0 - while ($Pending.Count -gt 0) { - $Item = Get-Item -LiteralPath $Pending.Pop() -Force -ErrorAction Stop - if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Update path contains a reparse point' } - $Count++ - if ($Count -gt 8192) { throw 'Update tree has too many entries' } - if ($Item.PSIsContainer) { - foreach ($Child in Get-ChildItem -LiteralPath $Item.FullName -Force -ErrorAction Stop) { - $Pending.Push($Child.FullName) - if ($Count + $Pending.Count -gt 8192) { throw 'Update tree has too many entries' } - } - } - } -} -function Install-BridgePackage([string]$Stage, [string]$Destination, [string]$Binary) { - foreach ($Root in @($Stage, $Destination)) { - $Parent = Get-Item -LiteralPath $Root -Force - while ($null -ne $Parent) { - if (($Parent.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Update parent contains a reparse point' } - $Parent = $Parent.Parent - } - } - Assert-CleanTree $Stage - $Names = @('native-hid', $Binary, 'DM-Sans-OFL.txt') - if (Test-Path -LiteralPath (Join-Path $Stage 'openmouse-app-icon.png')) { $Names = @('openmouse-app-icon.png') + $Names } - foreach ($Name in $Names) { - $Source = Get-Item -LiteralPath (Join-Path $Stage $Name) -Force - if (($Name -eq 'native-hid') -ne $Source.PSIsContainer) { throw 'Invalid update package type' } - if (Test-Path -LiteralPath (Join-Path $Destination $Name)) { Assert-CleanTree (Join-Path $Destination $Name) } - } - $Lock = Join-Path $Destination '.openmouse-update.lock' - New-Item -ItemType Directory -Path $Lock -ErrorAction Stop | Out-Null - $Transaction = Join-Path $Destination ('.openmouse-update.' + [guid]::NewGuid().ToString('N')) - $Backed = @() - $Installed = @() - $RollbackFailed = $false - try { - New-Item -ItemType Directory -Path $Transaction -ErrorAction Stop | Out-Null - $New = Join-Path $Transaction 'new' - $Old = Join-Path $Transaction 'old' - New-Item -ItemType Directory -Path $New, $Old -ErrorAction Stop | Out-Null - foreach ($Name in $Names) { - Copy-Item -LiteralPath (Join-Path $Stage $Name) -Destination (Join-Path $New $Name) -Recurse -ErrorAction Stop - } - foreach ($Name in $Names) { - if (Test-Path -LiteralPath (Join-Path $Destination $Name)) { - Move-Item -LiteralPath (Join-Path $Destination $Name) -Destination (Join-Path $Old $Name) -ErrorAction Stop - $Backed += $Name - } - } - foreach ($Name in $Names) { - $Installed += $Name - Move-Item -LiteralPath (Join-Path $New $Name) -Destination (Join-Path $Destination $Name) -ErrorAction Stop - } - } catch { - $Failure = $_ - foreach ($Name in $Installed) { - try { - $Target = Join-Path $Destination $Name - if (Test-Path -LiteralPath $Target) { Remove-Item -LiteralPath $Target -Recurse -Force -ErrorAction Stop } - } catch { $RollbackFailed = $true } - } - foreach ($Name in $Backed) { - try { - $Target = Join-Path $Destination $Name - if (Test-Path -LiteralPath $Target) { throw 'Cannot safely restore update backup over an existing target' } - Move-Item -LiteralPath (Join-Path $Old $Name) -Destination $Target -ErrorAction Stop - } - catch { $RollbackFailed = $true } - } - if ($RollbackFailed) { throw "Update rollback failed; backups retained at $Transaction" } - Write-Warning $Failure - return $false - } finally { - if (-not $RollbackFailed) { - Remove-Item -LiteralPath $Transaction -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $Lock -Force -ErrorAction SilentlyContinue - } - } - return $true -} -"#; - -#[cfg(windows)] -const WINDOWS_COMPLETION: &str = r#"function Complete-BridgeUpdate([string]$Stage, [string]$Destination, [string]$Binary, [scriptblock]$Launch) { - $Updated = Install-BridgePackage $Stage $Destination $Binary - & $Launch $Destination $Binary - return $Updated -} -"#; - #[cfg(target_os = "windows")] fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) -> Result<()> { + const SCRIPT: &str = r#"param([int]$BridgePid, [string]$Stage, [string]$Destination, [string]$Binary) +$ErrorActionPreference = "Stop" +while (Get-Process -Id $BridgePid -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 1 } +Copy-Item -LiteralPath (Join-Path $Stage $Binary) -Destination (Join-Path $Destination "${Binary}.update") -Force +Move-Item -LiteralPath (Join-Path $Destination "${Binary}.update") -Destination (Join-Path $Destination $Binary) -Force +if (Test-Path -LiteralPath (Join-Path $Stage "native-hid")) { + $Update = Join-Path $Destination "native-hid.update" + $Old = Join-Path $Destination "native-hid.old" + Remove-Item -LiteralPath $Update -Recurse -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath (Join-Path $Stage "native-hid") -Destination $Update -Recurse -Force + Remove-Item -LiteralPath $Old -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath (Join-Path $Destination "native-hid")) { Move-Item -LiteralPath (Join-Path $Destination "native-hid") -Destination $Old -Force } + Move-Item -LiteralPath $Update -Destination (Join-Path $Destination "native-hid") -Force + Remove-Item -LiteralPath $Old -Recurse -Force -ErrorAction SilentlyContinue +} +Start-Process -FilePath (Join-Path $Destination $Binary) -WorkingDirectory $Destination +"#; let script = staging.join("install-update.ps1"); - let contents = format!( - "param([int]$BridgePid, [string]$Stage, [string]$Destination, [string]$Binary)\n$ErrorActionPreference = 'Stop'\n{WINDOWS_TRANSACTION}\n{WINDOWS_COMPLETION}\ntry {{\nwhile (Get-Process -Id $BridgePid -ErrorAction SilentlyContinue) {{ Start-Sleep -Seconds 1 }}\nComplete-BridgeUpdate $Stage $Destination $Binary {{ param($Directory, $Name) Start-Process -FilePath (Join-Path $Directory $Name) -WorkingDirectory $Directory }}\n}} finally {{ Remove-Item -LiteralPath $Stage -Recurse -Force -ErrorAction SilentlyContinue }}\n" - ); - write_helper(&script, &contents)?; + fs::write(&script, SCRIPT).context("could not write the update helper")?; let mut command = Command::new("powershell.exe"); command .args([ "-NoProfile", - "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", @@ -874,10 +317,105 @@ fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) - Ok(()) } +#[cfg(target_os = "linux")] +fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) -> Result<()> { + const SCRIPT: &str = r#"#!/bin/sh +pid="$1" +stage="$2" +destination="$3" +binary="$4" +while kill -0 "$pid" 2>/dev/null; do sleep 1; done +cp -f "$stage/$binary" "$destination/$binary.update" || exit 1 +chmod +x "$destination/$binary.update" || exit 1 +mv -f "$destination/$binary.update" "$destination/$binary" || exit 1 +if [ -f "$stage/openmouse-app-icon.png" ]; then + cp -f "$stage/openmouse-app-icon.png" "$destination/openmouse-app-icon.png" || exit 1 +fi +if [ -d "$stage/native-hid" ]; then + rm -rf "$destination/native-hid.update" + cp -R "$stage/native-hid" "$destination/native-hid.update" || exit 1 + rm -rf "$destination/native-hid.old" + if [ -d "$destination/native-hid" ]; then mv "$destination/native-hid" "$destination/native-hid.old"; fi + mv "$destination/native-hid.update" "$destination/native-hid" || exit 1 + rm -rf "$destination/native-hid.old" +fi +cd "$destination" || exit 1 +nohup "$destination/$binary" >/dev/null 2>&1 & +rm -rf "$stage" +"#; + let script = staging.join("install-update.sh"); + fs::write(&script, SCRIPT).context("could not write the update helper")?; + Command::new("/bin/sh") + .arg(&script) + .arg(std::process::id().to_string()) + .arg(staging) + .arg(destination) + .arg( + executable + .file_name() + .context("Bridge executable has no file name")?, + ) + .spawn() + .context("could not launch the update helper")?; + Ok(()) +} + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn spawn_install_helper(_staging: &Path, _destination: &Path, _executable: &Path) -> Result<()> { - anyhow::bail!("automatic Bridge updates are available only on macOS, Windows, and Linux") + bail!("automatic Bridge updates are available only on macOS, Windows, and Linux") } #[cfg(test)] -mod tests; +mod tests { + use super::*; + + fn release(version: &str) -> Release { + Release { + tag_name: format!("v{version}"), + assets: vec![ + ReleaseAsset { + name: platform_archive_name().unwrap_or("unsupported.zip").into(), + browser_download_url: "https://example.test/bridge.zip".into(), + }, + ReleaseAsset { + name: format!( + "{}.sha256", + platform_archive_name().unwrap_or("unsupported.zip") + ), + browser_download_url: "https://example.test/bridge.zip.sha256".into(), + }, + ], + } + } + + #[test] + fn newer_stable_release_is_selected() { + if platform_archive_name().is_err() { + return; + } + let update = select_update(release("2.0.0"), "1.9.0") + .expect("release should parse") + .expect("new release should be selected"); + assert_eq!(update.version, "2.0.0"); + } + + #[test] + fn current_or_older_release_is_ignored() { + assert!(select_update(release("1.3.2"), "1.3.2").unwrap().is_none()); + assert!(select_update(release("1.2.9"), "1.3.2").unwrap().is_none()); + } + + #[test] + fn checksum_must_match_archive() { + let archive = b"bridge"; + let checksum = format!("{:x} bridge.zip\n", Sha256::digest(archive)); + verify_checksum(archive, checksum.as_bytes()).expect("checksum should match"); + assert!( + verify_checksum( + archive, + b"0000000000000000000000000000000000000000000000000000000000000000" + ) + .is_err() + ); + } +} diff --git a/src/updater/tests.rs b/src/updater/tests.rs deleted file mode 100644 index 09c1849..0000000 --- a/src/updater/tests.rs +++ /dev/null @@ -1,722 +0,0 @@ -use super::*; - -fn fixture() -> std::path::PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT: AtomicU64 = AtomicU64::new(0); - let parent = env::temp_dir(); - #[cfg(unix)] - let parent = fs::canonicalize(parent).unwrap(); - let root = parent.join(format!( - "bridge-archive-test-{}-{}-literal $ O'Connor [x]", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&root).unwrap(); - root -} - -fn directory_link(link: &Path, target: &Path) { - #[cfg(unix)] - std::os::unix::fs::symlink(target, link).unwrap(); - #[cfg(windows)] - { - let output = Command::new("cmd.exe") - .args(["/c", "mklink", "/J"]) - .arg(link) - .arg(target) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } -} - -fn remove_directory_link(link: &Path) { - #[cfg(unix)] - fs::remove_file(link).unwrap(); - #[cfg(windows)] - fs::remove_dir(link).unwrap(); -} - -fn archive(entries: &[(&str, &[u8])]) -> Vec { - use std::io::Write; - let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); - for (name, data) in entries { - writer - .start_file(*name, zip::write::SimpleFileOptions::default()) - .unwrap(); - writer.write_all(data).unwrap(); - } - writer.finish().unwrap().into_inner() -} - -#[test] -fn install_preflight_rejects_existing_lock_without_mutation() { - for kind in ["file", "directory", "link"] { - let root = fixture(); - let lock = root.join(".openmouse-update.lock"); - fs::write(root.join("sentinel"), b"untouched").unwrap(); - match kind { - "file" => fs::write(&lock, b"other-transaction").unwrap(), - "directory" => fs::create_dir(&lock).unwrap(), - _ => directory_link(&lock, &root.join("missing")), - } - let result = verify_install_directory(&root); - assert_eq!(fs::read(root.join("sentinel")).unwrap(), b"untouched"); - assert_eq!(fs::read_dir(&root).unwrap().count(), 2); - if kind == "link" { - remove_directory_link(&lock); - } - fs::remove_dir_all(root).unwrap(); - assert!(result.is_err(), "accepted existing {kind} lock"); - } - let root = fixture(); - verify_install_directory(&root).unwrap(); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - fs::remove_dir_all(root).unwrap(); -} - -#[cfg(unix)] -#[test] -fn install_preflight_rejects_fifo_lock() { - let root = fixture(); - let lock = root.join(".openmouse-update.lock"); - assert!( - Command::new("mkfifo") - .arg(&lock) - .status() - .unwrap() - .success() - ); - let result = verify_install_directory(&root); - assert_eq!(fs::read_dir(&root).unwrap().count(), 1); - fs::remove_dir_all(root).unwrap(); - assert!(result.is_err()); -} - -#[test] -fn extraction_never_overwrites_existing_files() { - let root = fixture(); - let executable = root.join(platform_binary_name()); - fs::write(&executable, b"original fixture").unwrap(); - let bytes = archive(&[(platform_binary_name(), b"replacement")]); - assert!(extract_archive(&bytes, &root).is_err()); - assert_eq!(fs::read(executable).unwrap(), b"original fixture"); - fs::remove_dir_all(root).unwrap(); -} - -#[test] -fn unsafe_archive_types_and_portable_paths_are_rejected() { - for name in [ - "../escape", - "/absolute", - "C:/escape", - "dir\\escape", - "file:stream", - "CON", - "trailing.", - ] { - let root = fixture(); - let bytes = archive(&[(name, b"bad"), (platform_binary_name(), b"bridge")]); - assert!(extract_archive(&bytes, &root).is_err(), "accepted {name}"); - fs::remove_dir_all(root).unwrap(); - } - for kind in [0o120777u32, 0o010644, 0o020644, 0o060644, 0o140644] { - let root = fixture(); - let mut bytes = archive(&[(platform_binary_name(), b"bridge")]); - let central = bytes.windows(4).position(|p| p == b"PK\x01\x02").unwrap(); - bytes[central + 5] = 3; - bytes[central + 38..central + 42].copy_from_slice(&(kind << 16).to_le_bytes()); - assert!( - extract_archive(&bytes, &root).is_err(), - "accepted type {kind:o}" - ); - fs::remove_dir_all(root).unwrap(); - } -} - -#[test] -fn extraction_does_not_follow_existing_directory_links() { - let root = fixture(); - let outside = fixture(); - fs::write(outside.join("sentinel"), b"untouched").unwrap(); - let link = root.join("linked"); - directory_link(&link, &outside); - let bytes = archive(&[ - ("linked/escape", b"bad"), - (platform_binary_name(), b"bridge"), - ]); - assert!(extract_archive(&bytes, &root).is_err()); - assert!(!outside.join("escape").exists()); - assert!(extract_archive(&bytes, &link).is_err()); - assert_eq!(fs::read(outside.join("sentinel")).unwrap(), b"untouched"); - remove_directory_link(&link); - let executable = root.join(platform_binary_name()); - #[cfg(unix)] - std::os::unix::fs::symlink(outside.join("sentinel"), &executable).unwrap(); - #[cfg(windows)] - fs::hard_link(outside.join("sentinel"), &executable).unwrap(); - let bytes = archive(&[(platform_binary_name(), b"replacement")]); - assert!(extract_archive(&bytes, &root).is_err()); - assert_eq!(fs::read(outside.join("sentinel")).unwrap(), b"untouched"); - fs::remove_dir_all(root).unwrap(); - fs::remove_dir_all(outside).unwrap(); -} - -#[cfg(unix)] -#[test] -fn extraction_masks_all_special_permission_bits() { - use std::os::unix::fs::PermissionsExt; - let root = fixture(); - let mut bytes = archive(&[(platform_binary_name(), b"bridge")]); - let central = bytes.windows(4).position(|p| p == b"PK\x01\x02").unwrap(); - bytes[central + 5] = 3; - bytes[central + 38..central + 42].copy_from_slice(&(0o107755u32 << 16).to_le_bytes()); - extract_archive(&bytes, &root).unwrap(); - assert_eq!( - fs::metadata(root.join(platform_binary_name())) - .unwrap() - .permissions() - .mode() - & 0o7777, - 0o755 - ); - fs::remove_dir_all(root).unwrap(); -} - -#[tokio::test] -async fn downloads_are_bounded_without_network_or_content_length() { - let response = reqwest::Response::from(axum::http::Response::new(vec![b'x'; 17])); - assert!(read_bounded_response(response, 16).await.is_err()); - let response = reqwest::Response::from( - axum::http::Response::builder() - .header("content-length", "1") - .body(vec![b'x'; 17]) - .unwrap(), - ); - assert!(read_bounded_response(response, 16).await.is_err()); - let response = reqwest::Response::from(axum::http::Response::new(vec![b'x'; 16])); - assert_eq!( - read_bounded_response(response, 16).await.unwrap(), - vec![b'x'; 16] - ); - let response = reqwest::Response::from( - axum::http::Response::builder() - .header("content-length", "9999999999") - .body(Vec::::new()) - .unwrap(), - ); - assert!( - read_bounded_response(response, MAX_CHECKSUM_BYTES) - .await - .is_err() - ); -} - -#[test] -fn archive_entry_and_decompression_limits_preserve_existing_fixtures() { - let bytes = archive(&[(platform_binary_name(), b"12345"), ("other", b"12345")]); - for (entries, file, total) in [(1, 5, 10), (2, 4, 10), (2, 5, 9)] { - let root = fixture(); - fs::write(root.join("sentinel"), b"untouched").unwrap(); - assert!(extract_archive_with_limits(&bytes, &root, entries, file, total).is_err()); - assert_eq!(fs::read(root.join("sentinel")).unwrap(), b"untouched"); - assert_eq!(fs::read_dir(&root).unwrap().count(), 1); - fs::remove_dir_all(root).unwrap(); - } - let root = fixture(); - extract_archive_with_limits(&bytes, &root, 2, 5, 10).unwrap(); - assert_eq!(fs::read(root.join("other")).unwrap(), b"12345"); - fs::remove_dir_all(root).unwrap(); -} - -#[test] -fn private_staging_is_unique_and_cleans_failed_preparation() { - let parent = fixture(); - let path; - { - let first = StagingDirectory::new_in(&parent).unwrap(); - let second = StagingDirectory::new_in(&parent).unwrap(); - assert_ne!(first.path, second.path); - path = first.path.clone(); - let bytes = archive(&[(platform_binary_name(), b"bridge")]); - extract_archive(&bytes, &first.path).unwrap(); - assert!(verify_package(&first.path).is_err()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&first.path).unwrap().permissions().mode() & 0o777, - 0o700 - ); - } - } - assert!(!path.exists()); - assert_eq!(fs::read_dir(&parent).unwrap().count(), 0); - fs::remove_dir_all(parent).unwrap(); -} - -#[test] -fn zip_metadata_reader_stops_before_unbounded_directory_work() { - let bytes = archive(&[(platform_binary_name(), b"bridge")]); - let budget = std::cell::Cell::new(Some(8)); - let reader = ArchiveReader { - inner: Cursor::new(bytes.as_slice()), - metadata_budget: &budget, - }; - assert!(zip::ZipArchive::new(reader).is_err()); - assert_eq!(budget.get(), Some(0)); - let budget = std::cell::Cell::new(Some(MAX_ZIP_METADATA_READ_BYTES)); - let reader = ArchiveReader { - inner: Cursor::new(bytes.as_slice()), - metadata_budget: &budget, - }; - assert_eq!(zip::ZipArchive::new(reader).unwrap().len(), 1); -} - -#[test] -fn decompression_limit_does_not_trust_declared_entry_size() { - let root = fixture(); - fs::write(root.join("sentinel"), b"untouched").unwrap(); - let mut bytes = archive(&[(platform_binary_name(), &[b'A'; 1024])]); - let central = bytes.windows(4).position(|p| p == b"PK\x01\x02").unwrap(); - bytes[central + 24..central + 28].copy_from_slice(&1u32.to_le_bytes()); - bytes[22..26].copy_from_slice(&1u32.to_le_bytes()); - assert!(extract_archive_with_limits(&bytes, &root, 1, 3, 3).is_err()); - assert!( - fs::metadata(root.join(platform_binary_name())).is_ok_and(|metadata| metadata.len() <= 3) - ); - assert_eq!(fs::read(root.join("sentinel")).unwrap(), b"untouched"); - fs::remove_dir_all(root).unwrap(); -} - -#[test] -fn generated_unix_transaction_commits_or_restores_fixture_package() { - for case in [ - "success", - "lock-creation-failure", - "transaction-creation-failure", - "existing-directory-lock", - "existing-file-lock", - "existing-link-lock", - "copy-failure", - "commit-failure", - "rollback-failure", - ] { - let root = fixture(); - let stage = root.join("stage"); - let destination = root.join("install"); - for directory in [&stage, &destination] { - fs::create_dir(directory).unwrap(); - fs::create_dir(directory.join("native-hid")).unwrap(); - } - fs::write(root.join("sandbox-marker"), b"bridge-update-fixture-only").unwrap(); - fs::write(stage.join("openmouse-bridge"), b"new-binary").unwrap(); - fs::write(stage.join("native-hid/helper"), b"new-helper").unwrap(); - fs::write(destination.join("openmouse-bridge"), b"old-binary").unwrap(); - fs::write(destination.join("native-hid/helper"), b"old-helper").unwrap(); - fs::write(destination.join("sentinel"), b"untouched").unwrap(); - fs::write( - stage.join("DM-Sans-OFL.txt"), - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), - ) - .unwrap(); - fs::write(destination.join("DM-Sans-OFL.txt"), b"old-license").unwrap(); - let lock = destination.join(".openmouse-update.lock"); - match case { - "existing-directory-lock" => fs::create_dir(&lock).unwrap(), - "existing-file-lock" => fs::write(&lock, b"other-transaction").unwrap(), - "existing-link-lock" => directory_link(&lock, &root.join("missing")), - _ => {} - } - let script = root.join("sandbox.sh"); - let contents = format!( - "{UNIX_TRANSACTION}\n{UNIX_COMPLETION}\n{}", - r#" -root="$1" -fixture_case="$2" -[ "$(cat "$root/sandbox-marker")" = bridge-update-fixture-only ] || exit 90 -mkdir() { - if [ "$fixture_case" = lock-creation-failure ] && [ "$1" = "$root/install/.openmouse-update.lock" ]; then return 1; fi - command mkdir "$@" -} -mktemp() { - if [ "$fixture_case" = transaction-creation-failure ]; then return 1; fi - command mktemp "$@" -} -cp() { - if [ "$fixture_case" = copy-failure ] && [ "$2" = "$root/stage/native-hid" ]; then return 1; fi - command cp "$@" -} -mv() { - case "$1" in */new/openmouse-bridge) if [ "$fixture_case" = commit-failure ] || [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac - case "$1" in */old/openmouse-bridge) if [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac - command mv "$@" -} -record_launch() { cat "$1/$2" > "$root/launch-record"; } -case "$fixture_case" in - lock-creation-failure|transaction-creation-failure|existing-*-lock) - if install_package "$root/stage" "$root/install" openmouse-bridge; then exit 91; else status=$?; fi - [ "$status" -eq 2 ] || { printf 'Expected safe failure status 2, got %s\n' "$status" >&2; exit 92; } - ;; -esac -complete_update "$root/stage" "$root/install" openmouse-bridge record_launch -"# - ); - assert!(!contents.contains("nohup") && !contents.contains("kill -0")); - write_helper(&script, &contents).unwrap(); - let root_argument = root.to_string_lossy().replace('\\', "/"); - #[cfg(windows)] - let root_argument = format!( - "/{}{}", - root_argument[..1].to_ascii_lowercase(), - &root_argument[2..] - ); - let output = Command::new(if cfg!(windows) { "sh" } else { "/bin/sh" }) - .arg(&script) - .arg(&root_argument) - .arg(case) - .output() - .unwrap(); - assert_eq!( - output.status.success(), - case != "rollback-failure", - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - root.join("launch-record").exists(), - case != "rollback-failure", - "{case}: relaunch eligibility" - ); - if case == "rollback-failure" { - assert!(destination.join(".openmouse-update.lock").exists()); - fs::remove_dir_all(root).unwrap(); - continue; - } - let new = case == "success"; - assert_eq!( - fs::read(root.join("launch-record")).unwrap(), - if new { b"new-binary" } else { b"old-binary" } - ); - assert_eq!( - fs::read(destination.join("openmouse-bridge")).unwrap(), - if new { b"new-binary" } else { b"old-binary" } - ); - assert_eq!( - fs::read(destination.join("DM-Sans-OFL.txt")) - .unwrap() - .as_slice(), - if new { - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt").as_slice() - } else { - b"old-license" - } - ); - assert_eq!( - fs::read(destination.join("native-hid/helper")).unwrap(), - if new { b"new-helper" } else { b"old-helper" } - ); - assert_eq!( - fs::read(destination.join("sentinel")).unwrap(), - b"untouched" - ); - assert_eq!( - fs::read_dir(&destination).unwrap().count(), - if case.starts_with("existing-") { 5 } else { 4 } - ); - if case == "existing-file-lock" { - assert_eq!(fs::read(&lock).unwrap(), b"other-transaction"); - } - if case == "existing-link-lock" { - remove_directory_link(&lock); - } - fs::remove_dir_all(root).unwrap(); - } -} - -#[cfg(windows)] -#[test] -fn generated_windows_transaction_commits_or_restores_fixture_package() { - use std::os::windows::fs::OpenOptionsExt; - for case in [ - "success", - "copy-failure", - "locked-binary", - "commit-failure", - "existing-lock", - "rollback-failure", - ] { - let root = fixture(); - let stage = root.join("stage"); - let destination = root.join("install"); - for directory in [&stage, &destination] { - fs::create_dir(directory).unwrap(); - fs::create_dir(directory.join("native-hid")).unwrap(); - } - fs::write(root.join("sandbox-marker"), b"bridge-update-fixture-only").unwrap(); - fs::write(stage.join(platform_binary_name()), b"new-binary").unwrap(); - fs::write(stage.join("native-hid/helper"), b"new-helper").unwrap(); - fs::write(stage.join("openmouse-app-icon.png"), b"new-icon").unwrap(); - fs::write(destination.join(platform_binary_name()), b"old-binary").unwrap(); - fs::write(destination.join("native-hid/helper"), b"old-helper").unwrap(); - fs::write(destination.join("openmouse-app-icon.png"), b"old-icon").unwrap(); - fs::write(destination.join("sentinel"), b"untouched").unwrap(); - fs::write( - stage.join("DM-Sans-OFL.txt"), - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), - ) - .unwrap(); - fs::write(destination.join("DM-Sans-OFL.txt"), b"old-license").unwrap(); - if case == "existing-lock" { - fs::create_dir(destination.join(".openmouse-update.lock")).unwrap(); - fs::write( - destination.join(".openmouse-update.lock/owner"), - b"other-transaction", - ) - .unwrap(); - } - let lock = if case == "locked-binary" { - Some( - fs::OpenOptions::new() - .read(true) - .share_mode(1) - .open(destination.join(platform_binary_name())) - .unwrap(), - ) - } else { - None - }; - let script = root.join("sandbox.ps1"); - let contents = format!("param([string]$Root, [string]$Case)\n$ErrorActionPreference = 'Stop'\n{WINDOWS_TRANSACTION}\n{WINDOWS_COMPLETION}\n{} -", r#" -if (-not [IO.Path]::IsPathRooted($Root) -or [IO.File]::ReadAllText((Join-Path $Root 'sandbox-marker')) -ne 'bridge-update-fixture-only') { throw 'Not a fixture' } -$Stage = Join-Path $Root 'stage' -$Destination = Join-Path $Root 'install' -$Binary = 'openmouse-bridge.exe' -function Copy-Item([string]$LiteralPath, [string]$Destination, [switch]$Recurse, [string]$ErrorAction) { - if ($Case -eq 'copy-failure' -and $LiteralPath.EndsWith('native-hid')) { throw 'Injected fixture copy failure' } - Microsoft.PowerShell.Management\Copy-Item @PSBoundParameters -} -function Move-Item([string]$LiteralPath, [string]$Destination, [string]$ErrorAction) { - if (($Case -eq 'commit-failure' -or $Case -eq 'rollback-failure') -and $LiteralPath.EndsWith(('new\' + $Binary))) { throw 'Injected fixture commit failure' } - if ($Case -eq 'rollback-failure' -and $LiteralPath.EndsWith(('old\' + $Binary))) { throw 'Injected fixture rollback failure' } - Microsoft.PowerShell.Management\Move-Item @PSBoundParameters -} -$Failed = $false -try { $Updated = Complete-BridgeUpdate $Stage $Destination $Binary { param($Directory, $Name) [IO.File]::WriteAllText((Join-Path $Root 'launch-record'), [IO.File]::ReadAllText((Join-Path $Directory $Name))) }; $Failed = -not $Updated } catch { $Failed = $true } -if (($Case -eq 'success') -eq $Failed) { throw 'Unexpected fixture transaction result' } -"#); - assert!(!contents.contains("Start-Process") && !contents.contains("Get-Process")); - write_helper(&script, &contents).unwrap(); - let output = Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(&script) - .arg(&root) - .arg(case) - .creation_flags(CREATE_NO_WINDOW) - .output() - .unwrap(); - assert!( - output.status.success(), - "{case}: {}", - String::from_utf8_lossy(&output.stderr) - ); - drop(lock); - let new = case == "success"; - assert_eq!( - root.join("launch-record").exists(), - !matches!(case, "existing-lock" | "rollback-failure"), - "{case}: relaunch eligibility" - ); - if case == "rollback-failure" { - assert!(fs::read_dir(&destination).unwrap().any(|entry| { - entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with(".openmouse-update.") - })); - fs::remove_dir_all(root).unwrap(); - continue; - } - if case != "existing-lock" { - assert_eq!( - fs::read(root.join("launch-record")).unwrap(), - if new { b"new-binary" } else { b"old-binary" } - ); - } - assert_eq!( - fs::read(destination.join(platform_binary_name())).unwrap(), - if new { b"new-binary" } else { b"old-binary" } - ); - assert_eq!( - fs::read(destination.join("DM-Sans-OFL.txt")) - .unwrap() - .as_slice(), - if new { - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt").as_slice() - } else { - b"old-license" - } - ); - assert_eq!( - fs::read(destination.join("native-hid/helper")).unwrap(), - if new { b"new-helper" } else { b"old-helper" } - ); - assert_eq!( - fs::read(destination.join("openmouse-app-icon.png")).unwrap(), - if new { b"new-icon" } else { b"old-icon" } - ); - assert_eq!( - fs::read(destination.join("sentinel")).unwrap(), - b"untouched" - ); - assert_eq!( - fs::read_dir(&destination).unwrap().count(), - if case == "existing-lock" { 6 } else { 5 } - ); - if case == "existing-lock" { - assert_eq!( - fs::read(destination.join(".openmouse-update.lock/owner")).unwrap(), - b"other-transaction" - ); - } - assert_eq!( - fs::read(stage.join(platform_binary_name())).unwrap(), - b"new-binary" - ); - fs::remove_dir_all(root).unwrap(); - } -} - -#[test] -fn helper_write_is_exclusive_and_package_preflight_is_complete() { - let root = fixture(); - let script = root.join("install-update.ps1"); - fs::write(&script, b"fixture").unwrap(); - assert!(write_helper(&script, "replacement").is_err()); - assert_eq!(fs::read(&script).unwrap(), b"fixture"); - let node = if cfg!(windows) { - "native-hid/node/node.exe" - } else { - "native-hid/node/bin/node" - }; - let names = [ - platform_binary_name(), - "native-hid/src/apply.mjs", - "native-hid/src/brands.mjs", - "native-hid/src/hid-device-adapter.mjs", - "native-hid/src/attack-shark-x11.mjs", - "native-hid/package.json", - node, - "native-hid/node_modules/node-hid/package.json", - "native-hid/node_modules/@openmouse/protocol/package.json", - "native-hid/node_modules/@openmouse/keyboard-protocol/package.json", - ]; - for name in names { - let path = root.join(name); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, b"inert-fixture").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); - } - } - assert!(verify_package(&root).is_err(), "font license is mandatory"); - fs::write( - root.join("DM-Sans-OFL.txt"), - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), - ) - .unwrap(); - verify_package(&root).unwrap(); - fs::write(root.join("DM-Sans-OFL.txt"), b"truncated notice").unwrap(); - assert!( - verify_package(&root).is_err(), - "font license must be complete" - ); - fs::write( - root.join("DM-Sans-OFL.txt"), - include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for name in [platform_binary_name(), node] { - let path = root.join(name); - for mode in [0o641, 0o111, 0o400, 0o600, 0o050, 0o005] { - fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); - let result = verify_package(&root); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); - assert!(result.is_err(), "accepted {name} mode {mode:o}"); - } - } - } - for name in names { - fs::write(root.join(name), []).unwrap(); - assert!(verify_package(&root).is_err(), "accepted empty {name}"); - fs::write(root.join(name), b"inert-fixture").unwrap(); - } - fs::remove_dir_all(root).unwrap(); -} - -fn release(version: &str) -> Release { - Release { - tag_name: format!("v{version}"), - assets: vec![ - ReleaseAsset { - name: platform_archive_name().unwrap_or("unsupported.zip").into(), - browser_download_url: "https://example.test/bridge.zip".into(), - }, - ReleaseAsset { - name: format!( - "{}.sha256", - platform_archive_name().unwrap_or("unsupported.zip") - ), - browser_download_url: "https://example.test/bridge.zip.sha256".into(), - }, - ], - } -} - -#[test] -fn newer_stable_release_is_selected() { - if platform_archive_name().is_err() { - return; - } - let update = select_update(release("2.0.0"), "1.9.0") - .expect("release should parse") - .expect("new release should be selected"); - assert_eq!(update.version, "2.0.0"); -} - -#[test] -fn current_or_older_release_is_ignored() { - assert!(select_update(release("1.3.2"), "1.3.2").unwrap().is_none()); - assert!(select_update(release("1.2.9"), "1.3.2").unwrap().is_none()); -} - -#[test] -fn checksum_must_match_archive() { - let archive = b"bridge"; - let checksum = format!("{:x} bridge.zip\n", Sha256::digest(archive)); - verify_checksum(archive, checksum.as_bytes()).expect("checksum should match"); - assert!( - verify_checksum( - archive, - b"0000000000000000000000000000000000000000000000000000000000000000" - ) - .is_err() - ); -} diff --git a/tests/backend_local_profiles.rs b/tests/backend_local_profiles.rs deleted file mode 100644 index 396956b..0000000 --- a/tests/backend_local_profiles.rs +++ /dev/null @@ -1,558 +0,0 @@ -use openmouse_bridge::{ - config::{BridgeConfig, LocalApplication}, - local_targets, -}; - -use openmouse_bridge::{api, service::BridgeService}; -use std::{ - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, -}; - -static NEXT_TEST: AtomicU64 = AtomicU64::new(0); - -struct TestServer { - url: String, - path: PathBuf, - service: BridgeService, - task: tokio::task::JoinHandle<()>, -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.task.abort(); - let _ = std::fs::remove_dir_all(self.path.parent().unwrap()); - } -} - -async fn server(storage_failure: bool) -> TestServer { - server_with_config(storage_failure, BridgeConfig::default()).await -} - -async fn server_with_config(storage_failure: bool, config: BridgeConfig) -> TestServer { - let directory = std::env::temp_dir().join(format!( - "local-profile-test-{}-{}", - std::process::id(), - NEXT_TEST.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&directory).unwrap(); - let path = directory.join("config.json"); - if storage_failure { - std::fs::create_dir(&path).unwrap(); - } - let service = BridgeService::new(config, path.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let router = api::router(service.clone(), &[]); - let task = tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - TestServer { - url, - path, - service, - task, - } -} - -fn target() -> serde_json::Value { - serde_json::json!({"id":"local:editor","name":"Editor","executable":"editor.exe","path":"C:/Tools/editor.exe","kind":"app"}) -} - -#[tokio::test] -async fn activation_metadata_roundtrips_into_canonical_profiles() { - let server = server(false).await; - let mut value = target(); - value["activation"] = "running".into(); - let application: LocalApplication = serde_json::from_value(value.clone()).unwrap(); - let response = reqwest::Client::new() - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[application]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - assert_eq!( - response.json::().await.unwrap(), - serde_json::json!({"ok":true,"applications":[value.clone()]}) - ); - let stored = server.service.local_applications().await; - assert_eq!(serde_json::to_value(&stored[0]).unwrap(), value); - let catalog = server.service.games().await; - let listed = catalog - .iter() - .find(|entry| entry["id"] == value["id"]) - .unwrap(); - for key in ["activation", "bundlePath", "bundleIdentifier"] { - assert_eq!(listed[key], value[key]); - } - let mut profile = serde_json::from_value(serde_json::json!({ - "application":{"localId":"local:editor","name":"stale","executable":"stale","path":""}, - "device":{"id":"d","name":"Mouse"},"settings":{"dpi":800} - })) - .unwrap(); - local_targets::canonicalize_profile(&mut profile, &stored).unwrap(); - let metadata = serde_json::to_value(profile).unwrap(); - for key in ["activation", "bundlePath", "bundleIdentifier"] { - assert_eq!(metadata["application"][key], value[key]); - } -} - -#[tokio::test] -async fn mismatched_bundle_metadata_rejects_atomically_with_profiles_intact() { - let server = server(false).await; - server - .service - .replace_local_applications(vec![serde_json::from_value(target()).unwrap()]) - .await - .unwrap(); - let profile = serde_json::from_value(serde_json::json!({ - "application":{"localId":"local:editor","name":"Editor","executable":"editor.exe","path":""}, - "device":{"id":"d","name":"Mouse"},"settings":{"dpi":1600} - })).unwrap(); - server - .service - .replace_profiles(vec![profile]) - .await - .unwrap(); - let before = serde_json::to_value(server.service.config().await).unwrap(); - let disk = std::fs::read(&server.path).unwrap(); - for path in [ - "C:/Tools/editor.exe", - "/opt/editor.exe", - "/Applications/Editor.app/Contents/MacOS/editor.exe", - ] { - let mut value = target(); - value["path"] = path.into(); - value["bundlePath"] = "/Applications/Editor.app".into(); - value["bundleIdentifier"] = "io.editor".into(); - let response = reqwest::Client::new() - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[value]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 400, "{path}"); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); - assert_eq!(std::fs::read(&server.path).unwrap(), disk); - } -} - -#[tokio::test] -async fn saved_missing_canonical_bundle_survives_batch_crud_and_profile_readback() { - let bundle = serde_json::json!({"id":"local:missing", "name":"Missing", "executable":"editor", "path":"/Applications/AbsentEditor.app/Contents/MacOS/editor", "kind":"app", "bundlePath":"/Applications/AbsentEditor.app", "bundleIdentifier":"io.editor", "activation":"running"}); - let config = serde_json::from_value(serde_json::json!({"localApplications":[bundle.clone()], "profiles":[{ - "application":{"localId":"local:missing", "name":"Missing", "executable":"editor", "path":"/Applications/AbsentEditor.app/Contents/MacOS/editor"}, - "device":{"id":"d","name":"Mouse"}, "settings":{"dpi":1600}, "enabled":false - }]})).unwrap(); - let server = server_with_config(false, config).await; - let client = reqwest::Client::new(); - let url = format!("{}/v1/local-applications", server.url); - for applications in [ - serde_json::json!([bundle.clone(), target()]), - serde_json::json!([bundle.clone()]), - ] { - let response = client - .put(&url) - .json(&serde_json::json!({"applications":applications})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let readback: serde_json::Value = response.json().await.unwrap(); - assert_eq!(readback["applications"][0], bundle); - let profiles = server.service.profiles().await; - assert_eq!(profiles.len(), 1); - assert_eq!( - profiles[0].application.bundle_path.as_deref(), - Some("/Applications/AbsentEditor.app") - ); - assert_eq!(profiles[0].settings.dpi, Some(1600)); - assert!(!profiles[0].enabled); - } - let before = std::fs::read(&server.path).unwrap(); - let mut retargeted = bundle; - retargeted["path"] = "/Applications/AbsentEditor.app/Contents/MacOS/other".into(); - retargeted["executable"] = "other".into(); - let response = client - .put(&url) - .json(&serde_json::json!({"applications":[retargeted]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 400); - assert_eq!(std::fs::read(&server.path).unwrap(), before); - assert_eq!(server.service.profiles().await.len(), 1); -} - -#[tokio::test] -async fn local_http_roundtrip_reconciles_id_linked_profiles_and_restarts() { - let server = server(false).await; - let client = reqwest::Client::new(); - let response = client - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[target()]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - assert_eq!( - response.json::().await.unwrap(), - serde_json::json!({"ok":true,"applications":[target()]}) - ); - let local = serde_json::json!({"application":{"localId":"local:editor","name":"Wrong","executable":"wrong.exe","path":""},"device":{"id":"d","name":"Mouse"},"settings":{"dpi":1600},"enabled":false}); - let preset = serde_json::json!({"application":{"name":"Editor","executable":"editor.exe","path":""},"device":{"id":"d","name":"Mouse"},"settings":{"dpi":800}}); - let response = client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":[local,preset]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let profiles = server.service.profiles().await; - let profile = profiles - .iter() - .find(|profile| profile.application.local_id.is_some()) - .unwrap(); - assert_eq!(profile.application.name, "Editor"); - assert_eq!(profile.application.path, "C:/Tools/editor.exe"); - let mut renamed = target(); - renamed["name"] = "Renamed".into(); - let response = client - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[renamed.clone()]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let profiles = server.service.profiles().await; - let linked = profiles - .iter() - .find(|profile| profile.application.local_id.is_some()) - .unwrap(); - assert_eq!(linked.application.name, "Renamed"); - assert!(!linked.enabled); - assert_eq!(linked.settings.dpi, Some(1600)); - let saved: BridgeConfig = - serde_json::from_slice(&std::fs::read(&server.path).unwrap()).unwrap(); - let restarted = BridgeService::new(saved, server.path.clone()); - assert_eq!(restarted.profiles().await, profiles); - let response = client - .get(format!("{}/v1/games", server.url)) - .send() - .await - .unwrap(); - let catalog: serde_json::Value = response.json().await.unwrap(); - assert_eq!( - catalog[0], - serde_json::json!({"id":"local:editor","source":"local","name":"Renamed","executables":["editor.exe"],"path":"C:/Tools/editor.exe","kind":"app"}) - ); - let response = client - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 200); - let profiles = server.service.profiles().await; - assert_eq!(profiles.len(), 1); - assert!(profiles[0].application.local_id.is_none()); -} - -#[tokio::test] -async fn local_http_validation_is_400_and_never_changes_state() { - let server = server(false).await; - let client = reqwest::Client::new(); - let url = format!("{}/v1/local-applications", server.url); - assert_eq!( - client - .put(&url) - .json(&serde_json::json!({"applications":[target()]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let original = std::fs::read(&server.path).unwrap(); - let mut batches = vec![ - serde_json::json!({}), - serde_json::json!({"applications":[target(),target()]}), - ]; - for (field, value) in [ - ("kind", "shell"), - ("executable", "../bad.exe"), - ("path", "relative/path.exe"), - ("name", ""), - ("id", "local:"), - ("path", "C:/Other/wrong.exe"), - ] { - let mut invalid = target(); - invalid[field] = value.into(); - batches.push(serde_json::json!({"applications":[target(),invalid]})); - } - let mut missing = target(); - missing.as_object_mut().unwrap().remove("executable"); - batches.push(serde_json::json!({"applications":[missing]})); - for payload in batches { - let response = client.put(&url).json(&payload).send().await.unwrap(); - assert_eq!(response.status(), 400, "{payload}"); - assert_eq!(std::fs::read(&server.path).unwrap(), original); - let targets: serde_json::Value = - client.get(&url).send().await.unwrap().json().await.unwrap(); - assert_eq!(targets, serde_json::json!([target()])); - } - assert_eq!( - client - .put(&url) - .header("content-type", "application/json") - .body("{") - .send() - .await - .unwrap() - .status(), - 400 - ); - let unknown = serde_json::json!({"application":{"localId":"local:missing","name":"Editor","executable":"editor.exe","path":""},"device":{"id":"d","name":"Mouse"},"settings":{}}); - assert_eq!( - client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":[unknown]})) - .send() - .await - .unwrap() - .status(), - 400 - ); - assert!(server.service.profiles().await.is_empty()); - assert_eq!(std::fs::read(&server.path).unwrap(), original); -} - -#[tokio::test] -async fn failed_storage_returns_500_without_changing_targets_or_profiles() { - let server = server(true).await; - let client = reqwest::Client::new(); - let before = serde_json::to_value(server.service.config().await).unwrap(); - let response = client - .put(format!("{}/v1/local-applications", server.url)) - .json(&serde_json::json!({"applications":[target()]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 500); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); - assert!(server.path.is_dir()); - let profile = serde_json::json!({"application":{"name":"Editor","executable":"editor.exe","path":""},"device":{"id":"d","name":"Mouse"},"settings":{}}); - let response = client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":[profile]})) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 500); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); - assert_eq!( - std::fs::read_dir(server.path.parent().unwrap()) - .unwrap() - .count(), - 1 - ); -} - -#[tokio::test] -async fn local_rename_delete_reconcile_only_linked_profiles_across_devices() { - let server = server(false).await; - let client = reqwest::Client::new(); - let mut other = target(); - other["id"] = "local:other".into(); - other["path"] = "C:/Other/editor.exe".into(); - let url = format!("{}/v1/local-applications", server.url); - assert_eq!( - client - .put(&url) - .json(&serde_json::json!({"applications":[target(),other.clone()]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let make_profile = |id: Option<&str>, device: &str| { - let mut application = - serde_json::json!({"name":"Editor","executable":"editor.exe","path":""}); - if let Some(id) = id { - application["localId"] = id.into(); - } - serde_json::json!({"application":application,"device":{"id":device,"name":"Mouse"},"settings":{"dpi":800},"enabled":false}) - }; - let profiles = serde_json::json!([ - make_profile(Some("local:editor"), "one"), - make_profile(Some("local:editor"), "two"), - make_profile(Some("local:other"), "one"), - make_profile(None, "one") - ]); - assert_eq!( - client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":profiles})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let mut renamed = target(); - renamed["name"] = "Renamed".into(); - renamed["executable"] = "renamed.exe".into(); - renamed["path"] = "C:/Tools/renamed.exe".into(); - assert_eq!( - client - .put(&url) - .json(&serde_json::json!({"applications":[renamed,other.clone()]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let profiles = server.service.profiles().await; - assert_eq!(profiles.len(), 4); - for profile in &profiles { - assert!(!profile.enabled); - assert_eq!(profile.settings.dpi, Some(800)); - if profile.application.local_id.as_deref() == Some("local:editor") { - assert_eq!(profile.application.name, "Renamed"); - assert_eq!(profile.application.executable, "renamed.exe"); - assert_eq!(profile.application.path, "C:/Tools/renamed.exe"); - } else { - assert_eq!(profile.application.name, "Editor"); - } - } - assert_eq!( - client - .put(&url) - .json(&serde_json::json!({"applications":[other.clone()]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let profiles = server.service.profiles().await; - assert_eq!(profiles.len(), 2); - assert!( - profiles - .iter() - .all(|profile| profile.application.local_id.as_deref() != Some("local:editor")) - ); - assert_eq!( - client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":[]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - assert_eq!(server.service.local_applications().await.len(), 1); -} - -#[tokio::test] -async fn local_reconciliation_rolls_back_after_storage_failure() { - let server = server(false).await; - let client = reqwest::Client::new(); - let url = format!("{}/v1/local-applications", server.url); - assert_eq!( - client - .put(&url) - .json(&serde_json::json!({"applications":[target()]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let profile = serde_json::json!({"application":{"localId":"local:editor","name":"Editor","executable":"editor.exe","path":""},"device":{"id":"d","name":"Mouse"},"settings":{"dpi":800}}); - assert_eq!( - client - .put(format!("{}/v1/profiles", server.url)) - .json(&serde_json::json!({"profiles":[profile]})) - .send() - .await - .unwrap() - .status(), - 200 - ); - let before = serde_json::to_value(server.service.config().await).unwrap(); - let original = std::fs::read(&server.path).unwrap(); - let backup = server.path.with_extension("backup"); - std::fs::rename(&server.path, &backup).unwrap(); - std::fs::create_dir(&server.path).unwrap(); - let mut renamed = target(); - renamed["name"] = "Renamed".into(); - for payload in [ - serde_json::json!({"applications":[renamed]}), - serde_json::json!({"applications":[]}), - ] { - assert_eq!( - client - .put(&url) - .json(&payload) - .send() - .await - .unwrap() - .status(), - 500 - ); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); - assert_eq!(std::fs::read(&backup).unwrap(), original); - } -} - -#[tokio::test] -async fn failed_config_writes_preserve_all_in_memory_preferences() { - let server = server(true).await; - let before = serde_json::to_value(server.service.config().await).unwrap(); - assert!(server.service.set_battery_threshold(35).await.is_err()); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); - assert!(server.service.set_automatic_updates(true).await.is_err()); - assert!(server.service.set_notification_sound(false).await.is_err()); - assert!(server.service.set_notification_volume(15).await.is_err()); - assert!(server.service.acknowledge_tray_hint().await.is_err()); - assert_eq!( - serde_json::to_value(server.service.config().await).unwrap(), - before - ); -} - -#[test] -fn local_batch_validation_rejects_duplicate_ids() { - let target: LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"editor.exe","path":"","kind":"app"})).unwrap(); - assert!(local_targets::validate(&[target.clone(), target]).is_err()); -} - -#[test] -fn local_id_profile_identity_is_persisted_without_colliding_with_preset() { - let json = serde_json::json!({"profiles":[ - {"application":{"name":"Editor","executable":"editor.exe","path":"","localId":"local:editor"}, "device":{"id":"d","name":"Mouse"},"settings":{}}, - {"application":{"name":"Editor","executable":"editor.exe","path":""}, "device":{"id":"d","name":"Mouse"},"settings":{}} - ]}); - let config: BridgeConfig = serde_json::from_value(json).unwrap(); - assert_eq!(config.normalized().profiles.len(), 2); -} diff --git a/tests/linux_startup.sh b/tests/linux_startup.sh deleted file mode 100644 index de0698d..0000000 --- a/tests/linux_startup.sh +++ /dev/null @@ -1,83 +0,0 @@ -set -eu -export OPENMOUSE_INSTALL_SOURCED=1 -fixture_parent="$1" -set -- -source ./install-linux.sh -root=$(mktemp -d "$fixture_parent/bridge-startup.XXXXXXXX") -trap 'rm -rf "$root"' EXIT -executable='/opt/name-value/50%/A "$`B\bridge' -expected='Exec="/opt/name-value/50%%/A \\"\\$\\`B\\\\bridge"' -write_autostart "$root/autostart.desktop" "$executable" -write_launcher "$root/launcher.desktop" "$executable" '/opt/icon.png' -for entry in "$root/autostart.desktop" "$root/launcher.desktop"; do - actual=$(while IFS= read -r line; do case "$line" in Exec=*) printf '%s' "$line";; esac; done < "$entry") - [ "$actual" = "$expected" ] || { printf 'Incorrect Exec entry: %s\n' "$actual" >&2; exit 1; } -done -printf '%s' sentinel > "$root/equals.desktop" -if write_autostart "$root/equals.desktop" '/opt/name=value/bridge'; then - printf '%s\n' 'Autostart accepted an equals executable path' >&2 - exit 1 -fi -[ "$(cat "$root/equals.desktop")" = sentinel ] -if write_launcher "$root/equals.desktop" '/opt/name=value/bridge' '/opt/icon.png'; then - printf '%s\n' 'Launcher accepted an equals executable path' >&2 - exit 1 -fi -[ "$(cat "$root/equals.desktop")" = sentinel ] -if write_autostart "$root/invalid.desktop" $'/opt/invalid\nentry'; then exit 1; fi -if write_launcher "$root/invalid.desktop" '/opt/bridge' $'/opt/invalid\nicon'; then exit 1; fi -[ "$(HOME=/home/fixture XDG_CONFIG_HOME=/xdg/config startup_config_home)" = /xdg/config ] -[ "$(HOME=/home/fixture XDG_CONFIG_HOME=relative startup_config_home)" = /home/fixture/.config ] -for code in {1..31} 127; do - printf -v octal '%03o' "$code" - printf -v control '%b' "\\$octal" - printf '%s' sentinel > "$root/invalid.desktop" - if write_autostart "$root/invalid.desktop" "/opt/a${control}b"; then exit 1; fi - if write_launcher "$root/invalid.desktop" "/opt/a${control}b" '/opt/icon.png'; then exit 1; fi - if write_launcher "$root/invalid.desktop" '/opt/bridge' "/opt/a${control}b"; then exit 1; fi - [ "$(cat "$root/invalid.desktop")" = sentinel ] -done -for fixture_case in valid autostart launcher equals-autostart equals-launcher; do - call_root="$root/$fixture_case" - mkdir -p "$call_root" - set +e - ( - set -e - export HOME="$call_root/home" XDG_CONFIG_HOME="$call_root/config" - case "$fixture_case" in - valid) INSTALL_DIR="$call_root/name-value" ;; - equals-*) INSTALL_DIR="$call_root/name=value" ;; - *) INSTALL_DIR="$call_root/"$'invalid\tpath' ;; - esac - BIN_LINK_DIR="$call_root/bin" - WITH_DEPS=false - WITH_UDEV=false - case "$fixture_case" in *launcher) WITH_AUTOSTART=false ;; esac - uname() { printf '%s\n' x86_64; } - sudo_cmd() { printf '%s\n' none; } - detect_pm() { printf '%s\n' unknown; } - download() { :; } - sha256sum() { :; } - shasum() { :; } - unzip() { :; } - chmod() { :; } - ln() { :; } - missing_libs() { :; } - update_desktop_database() { :; } - check_optionals() { :; } - mktemp() { command mktemp -d "$call_root/download.XXXXXXXX"; } - main - ) > "$call_root/output" 2>&1 - status=$? - set -e - if [ "$fixture_case" = valid ]; then - [ "$status" -eq 0 ] - [ -f "$call_root/config/autostart/io.openmouse.bridge.desktop" ] - [ -f "$call_root/home/.local/share/applications/io.openmouse.bridge.desktop" ] - else - [ "$status" -ne 0 ] - grep -q "error: could not write ${fixture_case#equals-} entry" "$call_root/output" || { cat "$call_root/output"; exit 1; } - if grep -q 'Done.' "$call_root/output"; then exit 1; fi - fi -done -printf '%s\n' 'PASS: valid escaping, equals rejection with output preservation, all representable ASCII controls, and both inert production callsites'