From 98294bc8a8547a8c9d17b2552b8184b26f28aee0 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:05:41 +0300 Subject: [PATCH 1/4] feat: add local app profiles and harden Bridge --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 20 +- Cargo.lock | 55 +- Cargo.toml | 2 +- README.md | 81 +- assets/fonts/DM-Sans-OFL.txt | 93 ++ assets/fonts/DM-Sans-source.json | 17 + assets/fonts/DMSans-Variable.ttf | Bin 0 -> 240164 bytes install-linux.sh | 31 +- native-hid/src/apply.mjs | 12 +- native-hid/src/automation.mjs | 37 + native-hid/src/automation.test.mjs | 211 ++++ native-hid/src/hid-device-adapter.mjs | 38 +- src/api.rs | 173 ++- src/applications.rs | 14 +- src/config.rs | 183 +++- src/desktop.rs | 1448 +++++++++++++++++-------- src/drivers/automation.rs | 555 ++++++++++ src/drivers/mod.rs | 306 +++++- src/drivers/native_hid.rs | 180 ++- src/games.rs | 332 +++++- src/hid.rs | 227 ++-- src/lib.rs | 1 + src/local_targets.rs | 764 +++++++++++++ src/main.rs | 2 + src/native_image.rs | 476 ++++++++ src/native_ui.rs | 528 +++++++++ src/overlay/render.rs | 36 +- src/platform/linux.rs | 53 +- src/platform/mod.rs | 4 +- src/service.rs | 998 ++++++++++++++--- src/updater.rs | 1360 ++++++++++++++++++++--- tests/backend_local_profiles.rs | 558 ++++++++++ tests/linux_startup.sh | 19 + 34 files changed, 7839 insertions(+), 984 deletions(-) create mode 100644 assets/fonts/DM-Sans-OFL.txt create mode 100644 assets/fonts/DM-Sans-source.json create mode 100644 assets/fonts/DMSans-Variable.ttf create mode 100644 native-hid/src/automation.mjs create mode 100644 native-hid/src/automation.test.mjs create mode 100644 src/drivers/automation.rs create mode 100644 src/local_targets.rs create mode 100644 src/native_image.rs create mode 100644 src/native_ui.rs create mode 100644 tests/backend_local_profiles.rs create mode 100644 tests/linux_startup.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1cd6c1..f12a6e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,15 +33,21 @@ 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 @@ -71,7 +77,8 @@ jobs: - name: Package shell: pwsh run: | - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,native-hid -DestinationPath openmouse-bridge-windows-x64.zip + 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 (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 1fb9423..a5c4b3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,17 @@ 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 != '' @@ -55,7 +59,8 @@ jobs: - name: Package shell: pwsh run: | - Compress-Archive -Path target/release/openmouse-bridge.exe,README.md,native-hid -DestinationPath openmouse-bridge-windows-x64.zip + 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 (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: @@ -71,6 +76,7 @@ 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 @@ -78,7 +84,9 @@ 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) @@ -104,7 +112,8 @@ jobs: target/x86_64-apple-darwin/release/openmouse-bridge \ -output openmouse-bridge chmod +x openmouse-bridge - zip -r openmouse-bridge-macos-universal.zip openmouse-bridge README.md native-hid + 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 shasum -a 256 openmouse-bridge-macos-universal.zip > openmouse-bridge-macos-universal.zip.sha256 - uses: actions/upload-artifact@v4 with: @@ -120,13 +129,17 @@ 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: | @@ -145,7 +158,8 @@ jobs: cp target/release/openmouse-bridge ./openmouse-bridge chmod +x openmouse-bridge cp assets/openmouse-app-icon.png ./openmouse-app-icon.png - zip -r openmouse-bridge-linux-x64.zip openmouse-bridge openmouse-app-icon.png README.md native-hid + 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 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 5bc9681..5de7b92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,15 +174,6 @@ 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" @@ -884,17 +875,6 @@ 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" @@ -2549,6 +2529,18 @@ 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" @@ -3627,9 +3619,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -4115,7 +4107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -4232,9 +4224,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -4549,6 +4541,12 @@ 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" @@ -5819,15 +5817,16 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap", "memchr", + "nix", + "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index e67c750..ba00254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ 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 = "4.6", default-features = false, features = ["deflate"] } +zip = { version = "9.0", 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"] } diff --git a/README.md b/README.md index 8e11ba9..7bee348 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,31 @@ 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 @@ -112,11 +137,19 @@ 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 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/games` returns presets (`source: "preset"`) and local targets + (`source: "local"`, with `id`, `path` and `kind`). +- `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 or local targets and identifies the foreground application. 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. @@ -124,6 +157,8 @@ 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 @@ -135,10 +170,40 @@ 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. 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. +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. ## Verify diff --git a/assets/fonts/DM-Sans-OFL.txt b/assets/fonts/DM-Sans-OFL.txt new file mode 100644 index 0000000..441171d --- /dev/null +++ b/assets/fonts/DM-Sans-OFL.txt @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..7717e47 --- /dev/null +++ b/assets/fonts/DM-Sans-source.json @@ -0,0 +1,17 @@ +{ + "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 new file mode 100644 index 0000000000000000000000000000000000000000..c672f98060af9b2c85fca704ebd5ad9a8717c1df GIT binary patch literal 240164 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 z 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%0DOsst9s5938VCl(I9M4!axwi7) z?&7`0#ZUkHYZEp!){(?69Re57k-f(He>gjFZv5Q2F{Ye4|K`QZsEbQK``33(BY^%# z$0_*fPhdiSb8^yujVPP3OZWcota|F!1C)4s)uhCc{Vy?K06A0eOKs~U7HZ9CUT#TP z1o)U>OTt5(XN8N58OSlHgJDfC1VPJ>S2yofU;&;+c+Ra2R!i+H0-1r4 z?3q|h1X}DdmAA38Ix;?PNQ!SD_BNyuu4Cjj;97{!{>#oH2fjJsUBh(RKET%z){ zkVsxSS6q#?KIf%d5Oj&gU>K}nqX4dbSSMCxO*xWPce7uUAk>@n4XAkg+Q`Uoq%8Ff z&VrWA4d#tT1^z-KKK-auqrKF7wgzayM}nWkcu5$Z>0s7 zyl^u?`0oVQ8YZ~bFac@2@wSF)1S&M5qXv1X(Z#Q9{BeepVTO}|;(_wE<_^#f z&ik$KKE&$|_FEz&ELO}A+6xTxKt>P#IBd>ap7&J31;@;X? zS?tjKT`)TAjUrG)v{$2*{P(Wg+FE~tB6;a8tsg0`H~4q0pS1pjh!`PaOY2XFL9#*r zezkrY2m81d+Q*F{A+%79>s=>l_MBp`K7Opy+w7v(x72z|>wA?2VK@D=sR#pqsaD9J z$<~Br&&JM%$AhG$3d`w{;gR${DVY+JFj^j8SkHCOwKafO>A;+C1pJn4UDpBn*p{v< zyL+~Gw*k{PbzM2LegAmHf^ur*b)3uV6xS-JUTBrC z&^=w4#^85KN5j!g`|Jmd58G`VSi@|Qg*}dnYIf5<`-yDCI?&!mN5{s-&W?>~MYE-V z+B4SQplO06OQ=euUGDXgb~(6ptX=+HOZoqzrO>V$ZwJM%UU~HM0(x3>uB}@J;%%*& zLqeH0*VdXj+b2zBZdjVa8bpmnt)D9kFif?^5G*8NwsM#!xb#pXm4{RxPzb>T7D^r& zsM5=!{PpS6zkcyW(U6PV2ov{3&yYv1${sq?FQBtLLdxNy>^-Bq;^WlGk`NC;m!lZe zh;WY|l?HuM=&&@kKTyfn0|)CsRqd5=GO0e_vwjvAtd*5NuAsNutEp}~UbMUJ=Vqz! zT)RXSt`vDMG7n)o5uCtZ5n(e1_7CgxEyNYf*qy4FkS@$?g)|ClWg8Z%;3W4bM=&Wr zQaspu1s2;vU}4MNsgog&CyE0n6bSey!DCGX6jXf(G)OLMhE8jjY-@bTz75OTk z>?2RAqNMiF9EiWTyAULMh|$zEWpj^FUC*e<3A~!OneXK zxGC@?RS3QT2X~pkQP7FCv_gDB04dut~4vRG_{>m1NW$ky*-XZ~z|C115t$ zDIvF%*``K`VZzc8fdL`zp%noHr7jsVVznV&o5|j9U6B|smpZTWjdOCm4mtkzQtzcw zhciPx}O;JZ?G5$x5s;5pLG~ZoS6sarE_pz19gE_$E>-I@?ff1%Z!S>M)k_ zoeHtmL~BvEp^=eDzYqGQuRj7_Pj7z&I|q?|uN!z>3$PUvY7?;?(OOC|NYa@bm?};7 zRFWu1DS-uzKx9>L`m{To+v;$z&7QYfu!kFS&vPw!hAWL3l*VbUGzPl#H*R+ilN%j{|iv(anP^B&11hAH|4vffWratWB{0bYLIuM1qq#o;Oh1i4vKYExVRV9`XmX;iQz4$#l>wxBufsK4< z4F>;NRJ2Gz>tI`zh3_cPW-H1fSHqL>_G)%A-kv2US*K3aF`{xa<_D-w^S4Id3HT-~ z>$)MFeJ3EC@GI>ty1ZKGsnB_`f z7A0_s(+XW&8+UOfV8f5JcKgtRNPAy~NMR!JEg)&{%OELRGQI^S?R^<0g}TulDE$Cq X5WH|"$path" <"$path" < 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); diff --git a/native-hid/src/automation.mjs b/native-hid/src/automation.mjs new file mode 100644 index 0000000..331a04c --- /dev/null +++ b/native-hid/src/automation.mjs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..0a9b0ef --- /dev/null +++ b/native-hid/src/automation.test.mjs @@ -0,0 +1,211 @@ +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/hid-device-adapter.mjs b/native-hid/src/hid-device-adapter.mjs index 51fef83..58af888 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) { + constructor(infos, identity = null, Handle = HID) { const [primary] = infos; this.vendorId = primary.vendorId; this.productId = primary.productId; @@ -45,6 +45,8 @@ 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); @@ -53,11 +55,32 @@ 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 HID(info.path); + const device = new this._Handle(info.path); device.on("data", this._onData); device.on("error", this._onError); openedDevices.push(device); @@ -245,3 +268,14 @@ 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 e92039f..86167af 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,17 +1,23 @@ -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::{ + collections::HashSet, + sync::{Arc, OnceLock}, + time::Duration, +}; use axum::{ Json, Router, body::Body, - extract::{Path, State, ws::WebSocketUpgrade}, + extract::{Path, Request, State, rejection::JsonRejection, 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}, + config::{ApplicationProfile, LocalApplication}, drivers, platform, service::{BatteryReading, BridgeService}, }; @@ -33,6 +39,11 @@ struct ProfilesPayload { profiles: Vec, } +#[derive(Debug, Deserialize)] +struct LocalApplicationsPayload { + applications: Vec, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct NativeSettingsPayload { @@ -46,7 +57,8 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .iter() .filter_map(|origin| origin.parse().ok()) .collect(); - let hid_origins = Arc::new(origins.iter().cloned().collect::>()); + let request_origins = Arc::new(origins.iter().cloned().collect::>()); + let hid_origins = Arc::clone(&request_origins); let hid_route = Router::new().route( "/v1/hid", get(move |websocket, headers| hid_upgrade(websocket, headers, Arc::clone(&hid_origins))), @@ -60,6 +72,10 @@ 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/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)) @@ -76,10 +92,70 @@ 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, @@ -96,7 +172,10 @@ async fn hid_upgrade( tracing::warn!(%origin, "Rejected native HID WebSocket from an unapproved origin"); return Err(StatusCode::FORBIDDEN); } - Ok(websocket.on_upgrade(crate::hid::serve)) + Ok(websocket + .max_message_size(64 * 1024) + .max_frame_size(64 * 1024) + .on_upgrade(crate::hid::serve)) } async fn status(State(service): State) -> Json { @@ -144,29 +223,49 @@ async fn profiles(State(service): State) -> Json) -> Json> { +async fn games(State(service): State) -> Json> { Json(service.games().await) } +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, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, (StatusCode, String)> { + let Json(payload) = payload.map_err(invalid_json)?; service .replace_profiles(payload.profiles) .await - .map_err(internal_error)?; + .map_err(profile_error)?; Ok(Json(ApiResult { ok: true })) } async fn set_default_profile( State(service): State, - Json(profile): Json, + payload: Result, JsonRejection>, ) -> Result, (StatusCode, String)> { + let Json(profile) = payload.map_err(invalid_json)?; service .set_default_profile(profile) .await - .map_err(internal_error)?; + .map_err(profile_error)?; Ok(Json(ApiResult { ok: true })) } @@ -196,6 +295,8 @@ 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)> { @@ -206,17 +307,44 @@ 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 || drivers::apply_settings(&brand, dpi, polling_rate_hz)) - .await - .map_err(|error| internal_error(anyhow::anyhow!(error)))? - .map_err(internal_error)?; + 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) + } + })?; if !applied { return Err(( @@ -227,6 +355,21 @@ 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()) diff --git a/src/applications.rs b/src/applications.rs index dd2826a..848697b 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(); - path.to_ascii_lowercase().hash(&mut hasher); + crate::games::path_key(path).hash(&mut hasher); format!("{:016x}", hasher.finish()) } @@ -273,7 +273,7 @@ mod imp { if name.is_empty() || executable.is_empty() { continue; } - let key = path.to_lowercase(); + let key = path.clone(); 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.to_lowercase(); + let key = path.clone(); applications .entry(key) .and_modify(|application: &mut ApplicationInfo| { @@ -428,6 +428,14 @@ 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/config.rs b/src/config.rs index 781e2b5..c096411 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,8 @@ pub struct BridgeConfig { #[serde(default)] pub games: Vec, #[serde(default)] + pub local_applications: Vec, + #[serde(default)] pub profiles: Vec, #[serde(default)] pub default_profile: Option, @@ -53,6 +55,45 @@ 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 { @@ -77,6 +118,12 @@ 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, @@ -95,6 +142,14 @@ 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)] @@ -126,6 +181,7 @@ impl Default for BridgeConfig { notification_sound: true, notification_volume: DEFAULT_NOTIFICATION_VOLUME, games: Vec::new(), + local_applications: Vec::new(), profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), @@ -163,15 +219,21 @@ 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() }); - 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 + let mut identities = std::collections::HashSet::new(); + self.profiles.retain(|profile| { + identities.insert((profile.application_key(), profile.device.id.clone())) }); if let Some(profile) = &mut self.default_profile { profile.application.name = profile.application.name.trim().to_owned(); @@ -256,12 +318,42 @@ pub async fn load_with_catalog() -> Result<(BridgeConfig, PathBuf)> { } pub fn save(path: &PathBuf, config: &BridgeConfig) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("could not create {}", parent.display()))?; - } + 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)?; - fs::write(path, json).with_context(|| format!("could not write {}", path.display())) + 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); + } + result.with_context(|| format!("could not write {}", path.display())) } const fn default_battery_threshold() -> u8 { @@ -291,6 +383,74 @@ fn default_origins() -> Vec { mod tests { use super::*; + #[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 { @@ -306,6 +466,7 @@ mod tests { profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), + local_applications: Vec::new(), autostart_configured: false, tray_hint_shown: false, } @@ -351,6 +512,10 @@ 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 7cd238f..06be841 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,8 +15,7 @@ use std::{ use crate::overlay::Overlay; use anyhow::{Context, Result, anyhow}; use eframe::egui::{ - self, Align, Button, Color32, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, - ViewportCommand, + self, Align, Button, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, ViewportCommand, }; use openmouse_bridge::{ BRIDGE_PORT, api, config, platform, @@ -123,20 +122,20 @@ fn run_headless( } } -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; +#[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 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,59 +145,73 @@ const GUI_STABLE_AFTER: Duration = Duration::from_secs(30); const MAX_QUICK_RELAUNCHES: u32 = 5; const RELAUNCH_DELAY: Duration = Duration::from_secs(2); -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 +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}, }; - ui.painter() - .circle_filled(Pos2::new(knob_x, rect.center().y), 5.0, knob); - response.clicked() + 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() + }; + 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, + ), + )) } -#[derive(Clone, Copy)] -enum Glyph { - Gear, - Back, +#[cfg(not(target_os = "windows"))] +fn tray_work_area(_rect: TrayRect, _scale: f64) -> Option { + None } -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), - ); - } +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); } - response } fn divider(ui: &mut egui::Ui) { @@ -207,37 +220,73 @@ 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(12.0).color(TEXT)); + ui.label(RichText::new(label).size(13.0).color(TEXT)); ui.with_layout(Layout::right_to_left(Align::Center), trailing); }, ); } -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 service_label(snapshot: Option<&BridgeSnapshot>) -> &'static str { + if snapshot.is_some() { + "Bridge service ready" + } else { + "Starting Bridge…" + } } -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() +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), + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -246,6 +295,13 @@ 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), @@ -281,18 +337,47 @@ enum UpdateState { Failed(String), } -enum UpdateUiAction { - Check, - Install(UpdateInfo), +fn update_is_notice(state: &UpdateState) -> bool { + matches!( + state, + UpdateState::Available(_) + | UpdateState::Downloading(_) + | UpdateState::Restarting(_) + | UpdateState::Failed(_) + ) +} + +fn panel_height(view: View, details: bool, saved_values: bool, notice: bool) -> f32 { + if view == View::Settings { + return 420.0; + } + if details { + return 340.0; + } + (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) + .min(340.0) } #[derive(Clone, Copy)] enum TrayAction { + ShowWindow, ToggleWindow { rect: TrayRect }, Restart, Exit, } +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, @@ -312,8 +397,12 @@ 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("Exit", true, None); + let exit_item = MenuItem::new("Quit Bridge", true, None); menu.append(&restart_item) .context("could not add Restart to the tray menu")?; menu.append(&exit_item) @@ -343,11 +432,11 @@ impl TrayState { let menu_repaint = context.clone(); MenuEvent::set_event_handler(Some(move |event: MenuEvent| { tracing::debug!(?event, "received tray menu event"); - let action = if event.id == restart_id { - TrayAction::Restart - } else if event.id == exit_id { - TrayAction::Exit - } else { + let Some(action) = tray_menu_action( + event.id == open_id, + event.id == restart_id, + event.id == exit_id, + ) else { return; }; let _ = event_tx.send(action); @@ -379,6 +468,8 @@ struct TrayApp { overlay: Option, snapshot: Option, view: View, + details_open: bool, + requested_height: f32, visible: bool, visibility_initialized: bool, panel_had_focus: bool, @@ -392,7 +483,6 @@ struct TrayApp { // 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, @@ -424,32 +514,7 @@ impl TrayApp { let overlay = Overlay::new() .inspect_err(|error| tracing::warn!(%error, "Could not create the overlay")) .ok(); - 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); + crate::native_ui::install(context); let (logo_pixels, logo_width, logo_height) = openmouse_icon_rgba()?; let logo = context.load_texture( "openmouse-logo", @@ -469,6 +534,8 @@ impl TrayApp { overlay, snapshot: None, view: View::Home, + details_open: false, + requested_height: WINDOW_HEIGHT, visible, visibility_initialized: false, panel_had_focus: false, @@ -480,7 +547,6 @@ impl TrayApp { notification_sound: true, notification_volume: 60, adjusting_volume: false, - tray_rect: None, last_tick: SystemTime::now(), panel_requests, update_state: UpdateState::Idle, @@ -494,7 +560,9 @@ impl TrayApp { context.send_viewport_cmd(ViewportCommand::Visible(visible)); if visible { self.show_view(context, View::Home); - context.send_viewport_cmd(ViewportCommand::Focus); + if test_position().is_none() { + context.send_viewport_cmd(ViewportCommand::Focus); + } } } @@ -504,32 +572,28 @@ impl TrayApp { } fn show_near_tray(&mut self, context: &egui::Context, rect: TrayRect) { - self.tray_rect = Some(rect); self.set_visible(context, true); - } - - /// 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 { + let height = self.requested_height; + if let Some(position) = test_position() { + context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); 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; - context.send_viewport_cmd(ViewportCommand::OuterPosition(Pos2::new( - x as f32, - y.max(8.0) as f32, - ))); + 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); } fn refresh_snapshot(&mut self) { @@ -600,8 +664,13 @@ impl TrayApp { 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); @@ -611,7 +680,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 { + if self.visible && test_position().is_none() { context.send_viewport_cmd(ViewportCommand::Focus); } } @@ -621,6 +690,7 @@ 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 { @@ -640,6 +710,9 @@ impl eframe::App for TrayApp { } } } + if self.visible && context.input(|input| input.key_pressed(egui::Key::Escape)) { + self.set_visible(context, false); + } if self.visible { match context.input(|input| input.viewport().focused) { Some(true) => self.panel_had_focus = true, @@ -659,12 +732,19 @@ 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) - .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), + .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), + } + }); }); } } @@ -684,367 +764,498 @@ impl TrayApp { } } - 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 { - format!("{}%", battery.percent) - }; - let color = if battery.stale { - MUTED - } else if battery.percent <= self.battery_threshold && !battery.charging { - DANGER + 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()); } 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(), + 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), ); }); - } else { - value_row(ui, &battery.device_name, &value, color); } - divider(ui); - } - } - _ => { - value_row(ui, "Battery", "Unknown", MUTED); - divider(ui); + 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")); } + 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); + } - 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| { + 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| { - 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()); - } - }); - } + 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()); + 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)); } + } + 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", - ); - } + } - 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") + 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("Notifications") .size(12.0) .strong() - .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 { + .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(); + } + }); ui.add_space(6.0); - ui.label(RichText::new(error).size(11.0).color(DANGER)); + 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.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 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); + 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")) } - 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()), + 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(); } - } - - 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 selected_threshold != self.battery_threshold - && self.send_command( - BridgeCommand::SetBatteryThreshold(selected_threshold), - "Bridge settings service is unavailable", - ) - { - self.battery_threshold = selected_threshold; + 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); } - 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; - } + 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 volume_released = false; - row(ui, "Volume", |ui| { - ui.add_enabled_ui(self.notification_sound, |ui| { + } + + 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 { ui.label( - RichText::new(format!("{}%", self.notification_volume)) - .size(12.0) + RichText::new(device_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), + ui.label( + RichText::new(profile_label(Some(snapshot))) + .size(11.5) + .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 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), + ); } - 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, - }); + 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())); } + ui.label( + RichText::new(format!( + "{}: {}", + battery.device_name, + battery_text(battery) + )) + .size(11.5) + .color(color), + ); }); } - }, - ); - 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"); - } + if !snapshot.batteries.is_empty() { + ui.label( + RichText::new("Battery reports are not proof of an open device session.") + .size(11.0) + .color(MUTED), + ); + } + } + }); + } - 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()); - } + 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 height = panel_height( + self.view, + self.details_open, + has_values, + update_is_notice(&self.update_state), + ); + 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(error) = &self.last_error { - ui.add_space(6.0); - ui.label(RichText::new(error).size(11.0).color(DANGER)); + context.send_viewport_cmd(ViewportCommand::OuterPosition(position)); } - }); + self.requested_height = height; + } } fn send_command(&mut self, command: BridgeCommand, unavailable: &str) -> bool { @@ -1178,7 +1389,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 options = eframe::NativeOptions { + let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT]) .with_decorations(false) @@ -1189,6 +1400,9 @@ 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( @@ -1538,6 +1752,21 @@ 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) } @@ -1595,6 +1824,285 @@ fn wide(value: &str) -> Vec { mod tests { use super::*; + 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), 280.0); + assert_eq!(panel_height(View::Home, false, true, false), 298.0); + assert_eq!(panel_height(View::Home, false, true, true), 338.0); + assert_eq!(panel_height(View::Home, true, false, false), 340.0); + assert_eq!(panel_height(View::Settings, true, true, true), 420.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) <= 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, + 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, + }); + 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, + }; + 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, + }; + 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 new file mode 100644 index 0000000..4f11ff9 --- /dev/null +++ b/src/drivers/automation.rs @@ -0,0 +1,555 @@ +use std::collections::HashMap; + +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_attempts: u8, +} + +#[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_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<()> { + 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_attempts >= 3 { + errors.push(format!("{id}: native restoration pending; three attempts exhausted, original baseline retained")); + continue; + } + entry.restore_attempts += 1; + 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) => errors.push(format!("{id}: native restoration pending: {error:#}")), + } + } + if desired != self.attempted.as_ref() { + 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_attempts: 0, + }, + ); + } + 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 { + 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 _ = automation.reconcile(Some(&game), &mut io); + let _ = automation.reconcile(Some(&game), &mut io); + 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_caps_attempts() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + automation + .reconcile(Some(&profile("a", Some(1600), None)), &mut io) + .unwrap(); + io.fail_writes = 20; + for _ in 0..10 { + assert!(automation.reconcile(None, &mut io).is_err()); + } + assert_eq!(io.calls.len(), 5); + assert_eq!(automation.entries["a"].baseline.dpi, 800); + } + + #[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 2b02005..a48d0f0 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -18,6 +18,7 @@ use anyhow::Result; use crate::config::ApplicationProfile; +pub(crate) mod automation; pub mod delux; mod native_hid; pub mod pulsar; @@ -28,16 +29,35 @@ pub use native_hid::NativeBattery; /// 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(()); +static DEVICE_ACCESS: Mutex = Mutex::new(automation::ManualIntent::new()); -fn device_access() -> MutexGuard<'static, ()> { - // The guarded value is (), so a panic while holding it leaves nothing - // inconsistent behind. +pub(crate) fn device_access() -> MutexGuard<'static, automation::ManualIntent> { 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. /// @@ -60,7 +80,13 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result { .next() .unwrap_or_default() .to_ascii_lowercase(); - let _access = device_access(); + let mut access = device_access(); + ensure_native_ownership()?; + access.invalidate( + &brand, + profile.settings.dpi.is_some(), + profile.settings.polling_rate_hz.is_some(), + )?; match brand.as_str() { pulsar::BRAND => { pulsar::apply(profile)?; @@ -77,6 +103,19 @@ 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 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 @@ -89,12 +128,16 @@ pub fn read_battery(device_id: &str) -> Result> { return Ok(None); } let _access = device_access(); + ensure_native_ownership()?; 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) } @@ -109,6 +152,10 @@ 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(), @@ -123,6 +170,255 @@ 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 diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index af54ec1..f33ac47 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -24,6 +24,8 @@ 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; @@ -114,12 +116,47 @@ fn parse_battery(reply: StatusReply) -> Option { }) } +#[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. -fn run_helper(request: &impl Serialize) -> Result>> { +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; + } + 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 @@ -129,7 +166,7 @@ fn run_helper(request: &impl Serialize) -> Result>> { .stderr(Stdio::piped()); #[cfg(target_os = "windows")] command.creation_flags(CREATE_NO_WINDOW); - let mut child = command.spawn().with_context(|| match &node { + let child = command.spawn().with_context(|| match &node { Some(bundled) => format!( "could not start the bundled Node.js runtime at {}", bundled.display() @@ -140,16 +177,7 @@ fn run_helper(request: &impl Serialize) -> Result>> { ), })?; - // 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)?; + let output = wait_with_timeout(child, payload, HELPER_TIMEOUT)?; match output.status.code() { Some(EXIT_OK) => Ok(Some(output.stdout)), Some(EXIT_NO_DRIVER) => Ok(None), @@ -203,48 +231,114 @@ fn installed_native_hid_dir() -> Option { Some(std::env::current_exe().ok()?.parent()?.join("native-hid")) } -/// 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 { +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 { let deadline = Instant::now() + timeout; - 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); + 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(mut pipe) = child.stderr.take() { - let _ = pipe.read_to_end(&mut stderr); + 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() + ); + } + 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))); + } } - return Ok(Output { - status, - stdout, - stderr, - }); - } - 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)); + })(); + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); } + 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, diff --git a/src/games.rs b/src/games.rs index 784ff6c..0404b68 100644 --- a/src/games.rs +++ b/src/games.rs @@ -4,18 +4,104 @@ use anyhow::{Context, Result, ensure}; use serde::Deserialize; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; -use crate::config::GameConfig; +#[cfg(test)] +use crate::config::LocalApplicationKind; +use crate::config::{GameConfig, LocalApplication}; 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; +const MAX_CATALOG_GAMES: usize = 10_000; +const MAX_EXECUTABLES: usize = 32; +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(name) = seq.next_element::()? { + if executables.len() >= MAX_EXECUTABLES + || name.trim().is_empty() + || name.len() > MAX_CATALOG_STRING + || name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom( + "invalid catalog executable or executable count", + )); + } + executables.push(name); + } + if executables.is_empty() { + return Err(serde::de::Error::custom( + "catalog game contains no executables", + )); + } + 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(); + while let Some(game) = seq.next_element::()? { + if games.len() >= MAX_CATALOG_GAMES + || game.name.trim().is_empty() + || game.name.len() > MAX_CATALOG_STRING + || game.name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom( + "game catalog exceeds item limits or contains invalid strings", + )); + } + 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) @@ -28,20 +114,22 @@ pub async fn fetch_catalog() -> Result> { .timeout(FETCH_TIMEOUT) .build() .context("could not create the game catalog client")?; - let bytes = client + let response = 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}"))? - .bytes() - .await - .with_context(|| format!("could not read game catalog from {url}"))?; + .with_context(|| format!("game catalog request failed for {url}"))?; + let bytes = crate::updater::read_bounded_response(response, MAX_CATALOG_BYTES).await?; 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"); @@ -52,6 +140,65 @@ 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 { @@ -61,11 +208,26 @@ 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::OnlyIfNotSet), + ProcessRefreshKind::nothing().with_exe(UpdateKind::Always), ); let running: BTreeSet = self .system @@ -113,6 +275,122 @@ 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 { @@ -148,6 +426,46 @@ mod tests { ); } + #[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 8884807..25741e7 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -12,9 +12,11 @@ use std::{ use axum::extract::ws::{Message, WebSocket}; use hidapi::{HidApi, HidDevice, MAX_REPORT_DESCRIPTOR_SIZE}; use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; 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 @@ -226,6 +228,35 @@ 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 { @@ -404,11 +435,11 @@ struct HidSession { candidates: HashMap, open_devices: HashMap, listeners: HashSet, - input_tx: mpsc::UnboundedSender, + input_tx: InputSender, } impl HidSession { - fn new(session_id: u64, input_tx: mpsc::UnboundedSender) -> Result { + fn new(session_id: u64, input_tx: InputSender) -> Result { Ok(Self { session_id, api: HidApi::new() @@ -838,6 +869,7 @@ impl HidSession { }) .is_err() { + thread_stop.store(true, Ordering::Release); break; } } @@ -883,6 +915,11 @@ 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); @@ -1048,35 +1085,29 @@ fn report_payload(data: &[u8], size: usize) -> Vec { .to_vec() } -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 grouped_device_key( + vendor_id: u16, + product_id: u16, + path: &[u8], + serial: Option<&str>, +) -> Vec { + let serial = serial.unwrap_or_default().as_bytes(); + let mut key = Vec::with_capacity(12 + path.len() + serial.len()); + key.extend_from_slice(&vendor_id.to_le_bytes()); + key.extend_from_slice(&product_id.to_le_bytes()); + key.extend_from_slice(&(path.len() as u64).to_le_bytes()); + key.extend_from_slice(path); + key.extend_from_slice(serial); + key } fn device_group_key(info: &hidapi::DeviceInfo) -> Vec { - 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() - } + grouped_device_key( + info.vendor_id(), + info.product_id(), + info.path().to_bytes(), + info.serial_number(), + ) } fn inspect_candidate( @@ -1095,6 +1126,10 @@ 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()); @@ -1362,6 +1397,7 @@ struct ActiveSessionGuard; impl ActiveSessionGuard { fn new() -> Self { + let _access = crate::drivers::device_access(); ACTIVE_SESSIONS.fetch_add(1, Ordering::AcqRel); Self } @@ -1375,12 +1411,14 @@ impl Drop for ActiveSessionGuard { pub async fn serve(mut socket: WebSocket) { let session_id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed); - let _active = ActiveSessionGuard::new(); + let Ok(_active) = tokio::task::spawn_blocking(ActiveSessionGuard::new).await else { + return; + }; 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) = mpsc::unbounded_channel(); + let (input_tx, mut input_rx, mut failure_rx) = input_channel(); let session = match HidSession::new(session_id, input_tx) { Ok(session) => Arc::new(Mutex::new(session)), Err(error) => { @@ -1392,6 +1430,11 @@ 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 { @@ -1452,9 +1495,26 @@ pub async fn serve(mut socket: WebSocket) { tracing::info!(session_id, "HID websocket disconnected"); } -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 +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())? } #[cfg(test)] @@ -1465,6 +1525,66 @@ 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 = [ @@ -1495,39 +1615,18 @@ mod tests { } #[test] - 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(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")), - grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")) - ); - assert_eq!( - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None) - ); + fn opaque_interfaces_do_not_merge_without_verified_physical_identity() { assert_ne!( - grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-123")), - grouped_device_key(RAZER_VENDOR_ID, 0x00b0, Some("receiver-456")) + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), + grouped_device_key(0x046d, 0xc54d, b"opaque-B", None) ); - assert_ne!( - grouped_device_key(RAZER_VENDOR_ID, 0x00b0, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0x00b0, None) + assert_eq!( + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None) ); assert_ne!( - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0xc548, None) + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), + grouped_device_key(0x1532, 0xc54d, b"opaque-A", None) ); } @@ -1586,7 +1685,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, _) = mpsc::unbounded_channel(); + let (input_tx, _, _) = input_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 06984e2..eb123ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod drivers; pub mod games; mod hid; +pub mod local_targets; pub mod platform; pub mod service; pub mod updater; diff --git a/src/local_targets.rs b/src/local_targets.rs new file mode 100644 index 0000000..acb1d14 --- /dev/null +++ b/src/local_targets.rs @@ -0,0 +1,764 @@ +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 4e26615..085c4e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ #[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; diff --git a/src/native_image.rs b/src/native_image.rs new file mode 100644 index 0000000..3a1255a --- /dev/null +++ b/src/native_image.rs @@ -0,0 +1,476 @@ +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 new file mode 100644 index 0000000..e4209ae --- /dev/null +++ b/src/native_ui.rs @@ -0,0 +1,528 @@ +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/render.rs b/src/overlay/render.rs index cef0849..f8d2e16 100644 --- a/src/overlay/render.rs +++ b/src/overlay/render.rs @@ -10,14 +10,13 @@ use skrifa::{ use vello_cpu::{ Pixmap, RenderContext, Resources, color::{AlphaColor, Srgb}, - kurbo::{BezPath, Circle, RoundedRect, Shape as _}, + kurbo::{BezPath, RoundedRect, Shape as _}, }; const HEIGHT: f64 = 60.0; const RADIUS: f64 = 14.0; const PADDING: f64 = 18.0; -const DOT_RADIUS: f64 = 4.0; -const TEXT_LEFT: f64 = PADDING + DOT_RADIUS * 2.0 + 12.0; +const TEXT_LEFT: f64 = PADDING; const MIN_WIDTH: f64 = 240.0; const MAX_WIDTH: f64 = 460.0; const TITLE_SIZE: f32 = 15.0; @@ -25,11 +24,10 @@ const DETAIL_SIZE: f32 = 12.5; const TITLE_BASELINE: f64 = 27.0; const DETAIL_BASELINE: f64 = 45.0; -const BACKGROUND: AlphaColor = AlphaColor::from_rgba8(16, 17, 19, 245); +const BACKGROUND: AlphaColor = AlphaColor::from_rgba8(12, 13, 15, 245); const BORDER: AlphaColor = AlphaColor::from_rgba8(255, 255, 255, 20); -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); +const TEXT: AlphaColor = AlphaColor::from_rgba8(238, 240, 242, 255); +const MUTED: AlphaColor = AlphaColor::from_rgba8(170, 176, 183, 255); /// A rendered banner: premultiplied RGBA8 rows, top to bottom. pub struct Banner { @@ -40,8 +38,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(epaint_default_fonts::UBUNTU_LIGHT) - .expect("the bundled Ubuntu Light font is valid"); + let font = + FontRef::new(crate::native_ui::FONT_BYTES).expect("the bundled DM Sans 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); @@ -66,10 +64,6 @@ 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); @@ -191,6 +185,20 @@ 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); @@ -203,7 +211,7 @@ mod tests { #[test] fn long_titles_are_shortened_to_fit() { - let font = FontRef::new(epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let font = FontRef::new(crate::native_ui::FONT_BYTES).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 731da92..df2bc31 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -35,10 +35,11 @@ pub fn set_autostart(enabled: bool) -> Result<()> { return Ok(()); } let executable = env::current_exe().context("could not locate the Bridge executable")?; - let desktop = format!( - "[Desktop Entry]\nType=Application\nName=OpenMouse Bridge\nExec=\"{}\"\nHidden=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n", - executable.display(), - ); + let desktop = desktop_entry( + executable + .to_str() + .context("Bridge executable path is not Unicode")?, + )?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("could not create {}", parent.display()))?; @@ -48,20 +49,46 @@ 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 - .home_dir() - .join(".config/autostart") - .join(DESKTOP_FILE_NAME)) + 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" + )) } #[cfg(test)] mod tests { + use super::*; + #[test] - fn desktop_entry_quotes_executable_paths() { - let entry = format!( - "[Desktop Entry]\nType=Application\nName=OpenMouse Bridge\nExec=\"{}\"\n", - "/opt/openmouse/openmouse-bridge" + 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}" ); - assert!(entry.contains("Exec=\"/opt/openmouse/openmouse-bridge\"")); + assert!(desktop_entry("/opt/bad\nentry").is_err()); } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 50dfa3a..e985308 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -8,8 +8,8 @@ mod windows; #[cfg(target_os = "macos")] mod macos; -#[cfg(target_os = "linux")] -mod linux; +#[cfg(any(target_os = "linux", test))] +pub mod linux; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] mod portable; diff --git a/src/service.rs b/src/service.rs index bf08075..2ca81e6 100644 --- a/src/service.rs +++ b/src/service.rs @@ -14,7 +14,7 @@ use tokio::sync::RwLock; use crate::{ applications::{self, ApplicationInfo}, - config::{self, ApplicationProfile, BridgeConfig, GameConfig}, + config::{self, ApplicationProfile, BridgeConfig, GameConfig, LocalApplication}, games::GameDetector, platform, }; @@ -43,6 +43,7 @@ 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], @@ -54,6 +55,28 @@ 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( @@ -109,41 +132,130 @@ 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 { - 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) - }) + 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 { + 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) + } }); - 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)) - }) + focused + || config.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) + }) + }) + } + }) }; - foreground - .or_else(running_game) + 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 + }) + }); + retained + .or_else(|| config.profiles.iter().find(eligible)) .cloned() - .or_else(|| config.default_profile.clone()) + .or_else(|| { + config + .default_profile + .clone() + .filter(|profile| profile.enabled) + }) } 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() @@ -155,11 +267,14 @@ 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 { - if profile.application.path.is_empty() { - profile.application.name.to_ascii_lowercase() + 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()) } else { - profile.application.path.to_ascii_lowercase() - } + format!("path:{}", crate::games::path_key(&profile.application.path)) + }; + format!("{}:{}{}", application.len(), application, profile.device.id) } /// Something worth telling the user about, already worded: a title such as the @@ -229,13 +344,19 @@ fn switch_message( detail: "Profile off".into(), }, }; - if current.is_some() && !panel_connected { + if !panel_connected { match outcome { Some(ApplyOutcome::Failed) => { - switch.detail = "Couldn't reach the mouse — open OpenMouse to apply".into(); + 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() + }; } Some(ApplyOutcome::NoDriver) => { - switch.detail = "Open OpenMouse to apply this profile".into(); + switch.detail = + "Native automation unsupported — acknowledged device readback is required" + .into(); } _ => {} } @@ -243,17 +364,21 @@ fn switch_message( Some(switch) } +type PanelListener = Arc; + #[derive(Clone)] pub struct BridgeService { inner: Arc>, config_path: Arc, notice_listener: Arc>>, - show_panel: Arc>>>, + show_panel: Arc>>, + native_automation: 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. @@ -263,6 +388,8 @@ 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, @@ -336,6 +463,8 @@ 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, @@ -349,12 +478,15 @@ 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(), @@ -363,6 +495,7 @@ impl BridgeService { config_path: Arc::new(config_path), notice_listener: Arc::default(), show_panel: Arc::default(), + native_automation: Arc::default(), } } @@ -387,15 +520,14 @@ impl BridgeService { } pub async fn acknowledge_tray_hint(&self) -> Result<()> { - 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) + if self.inner.read().await.config.tray_hint_shown { + return Ok(()); + } + self.update_config(|config| { + config.tray_hint_shown = true; + Ok(()) + }) + .await } /// Sends profile switches and low-battery alerts to `listener` instead of @@ -440,8 +572,13 @@ impl BridgeService { .iter() .find(|application| application.foreground) .cloned(); - let active_profile = - active_profile_for(&state.config, &state.applications, &state.active_games); + let active_profile = resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + state.applied_profile.as_ref(), + ); let active_profile_is_default = active_profile.is_some() && active_profile == state.config.default_profile; let mut batteries = state @@ -471,7 +608,26 @@ impl BridgeService { platform: platform::platform_name(), linux_distribution: platform::linux_distribution(), uptime_seconds: state.started_at.elapsed().as_secs(), - active_games: state.active_games.clone(), + 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(|| { + state + .config + .games + .iter() + .find(|game| &game.name == active) + .map(|game| game.name.clone()) + }) + }) + .collect(), games: state .config .games @@ -480,8 +636,25 @@ impl BridgeService { 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: state.config.games.len(), + tracked_game_count: state.config.games.len() + + state + .config + .local_applications + .iter() + .filter(|target| target.kind == config::LocalApplicationKind::Game) + .count(), battery_threshold_percent: state.config.battery_threshold_percent, autostart_enabled: platform::autostart_enabled(), automatic_updates: state.config.automatic_updates, @@ -490,6 +663,8 @@ 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, @@ -528,28 +703,93 @@ impl BridgeService { self.inner.read().await.config.profiles.clone() } - pub async fn games(&self) -> Vec { - self.inner.read().await.config.games.clone() + pub async fn games(&self) -> Vec { + let state = self.inner.read().await; + state.config.games.iter().map(|game| serde_json::json!({"name":game.name,"executables":game.executables,"source":"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 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 local_applications(&self) -> Vec { + self.inner.read().await.config.local_applications.clone() } - 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) + 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 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 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 } /// Turns launch-at-login on the first time a release build runs; after @@ -559,50 +799,43 @@ impl BridgeService { return Ok(()); } platform::set_autostart(true)?; - let config = { - let mut state = self.inner.write().await; - state.config.autostart_configured = true; - state.config.clone() - }; - config::save(&self.config_path, &config) + self.update_config(|config| { + config.autostart_configured = true; + Ok(()) + }) + .await } pub async fn set_battery_threshold(&self, percent: u8) -> Result<()> { - let config = { - let mut state = self.inner.write().await; - state.config.battery_threshold_percent = percent; - state.config = state.config.clone().normalized(); - state.config.clone() - }; - config::save(&self.config_path, &config) + self.update_config(|config| { + config.battery_threshold_percent = percent; + Ok(()) + }) + .await } pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { - let config = { - let mut state = self.inner.write().await; - state.config.automatic_updates = enabled; - state.config.clone() - }; - config::save(&self.config_path, &config) + self.update_config(|config| { + config.automatic_updates = enabled; + Ok(()) + }) + .await } pub async fn set_notification_sound(&self, enabled: bool) -> Result<()> { - let config = { - let mut state = self.inner.write().await; - state.config.notification_sound = enabled; - state.config.clone() - }; - config::save(&self.config_path, &config) + self.update_config(|config| { + config.notification_sound = enabled; + Ok(()) + }) + .await } pub async fn set_notification_volume(&self, percent: u8) -> Result<()> { - 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) + self.update_config(|config| { + config.notification_volume = percent; + Ok(()) + }) + .await } /// Forgets a lingering battery entry so a dead device leaves the list. @@ -704,17 +937,83 @@ impl BridgeService { }); } + 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 = service.inner.read().await.config.games.clone(); - let active = detector.detect(&games); + let (games, local_applications) = { + let state = service.inner.read().await; + ( + state.config.games.clone(), + 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 running = applications::visible_applications(); - let applications = registered_applications(&running, &games); + let applications = profile_applications(&running, &games, &local_applications); // Icon extraction can be disabled by callers that never serve // the application's icon endpoint. let icons = if extract_icons.load(Ordering::Acquire) { @@ -739,6 +1038,7 @@ 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); @@ -748,8 +1048,13 @@ 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 = - active_profile_for(&state.config, &state.applications, &state.active_games); + let current = resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + state.applied_profile.as_ref(), + ); let current_key = current.as_ref().map(profile_key); // (new profile, the one it replaces, whether to announce it) let mut switch: Option<( @@ -766,7 +1071,9 @@ impl BridgeService { state.pending_profile = None; state.profile_seeded = true; switch = Some((current, None, false)); - } else if current_key == state.active_profile_key { + } else if current_key == state.active_profile_key + && current == state.applied_profile + { state.pending_profile = None; } else { let ready = matches!( @@ -787,68 +1094,24 @@ impl BridgeService { } let default = state.config.default_profile.clone(); drop(state); - 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); - }); + let panel_connected = crate::hid::client_session_active(); + 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); } } }); } } -fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { - match crate::drivers::apply_profile(profile) { - Ok(true) => { - tracing::info!( - profile = %profile.application.name, - device = %profile.device.name, - "Applied the mouse profile natively" - ); - ApplyOutcome::Applied - } - Ok(false) => { - tracing::debug!( - profile = %profile.application.name, - device = %profile.device.name, - "No native driver for this device; not applying natively" - ); - ApplyOutcome::NoDriver - } - Err(error) => { - tracing::warn!( - %error, - profile = %profile.application.name, - device = %profile.device.name, - "Could not apply the mouse profile natively" - ); - ApplyOutcome::Failed - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -868,6 +1131,10 @@ 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(), @@ -892,6 +1159,406 @@ 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 profile_devices_are_distinct_and_include_the_default() { let mut config = BridgeConfig::default(); @@ -999,6 +1666,23 @@ 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)); diff --git a/src/updater.rs b/src/updater.rs index e6d8349..f824107 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -3,14 +3,13 @@ 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::{self, File}, + env, fs, io::{self, Cursor}, path::Path, time::Duration, }; -use anyhow::{Context, Result, anyhow, bail, ensure}; +use anyhow::{Context, Result, anyhow, ensure}; use semver::Version; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -49,16 +48,16 @@ pub async fn check_for_update() -> Result> { .timeout(CHECK_TIMEOUT) .build() .context("could not create the update client")?; - let release = client + let response = 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}"))? - .json::() - .await + .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) .context("could not parse the latest Bridge release")?; select_update(release, crate::BRIDGE_VERSION) } @@ -69,8 +68,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), - download(&client, &update.checksum_url), + download(&client, &update.archive_url, MAX_ARCHIVE_BYTES), + download(&client, &update.checksum_url, MAX_CHECKSUM_BYTES), )?; verify_checksum(&archive, &checksum)?; let version = update.version.clone(); @@ -80,19 +79,49 @@ pub async fn download_and_stage(update: &UpdateInfo) -> Result<()> { Ok(()) } -async fn download(client: &reqwest::Client, url: &str) -> Result> { - Ok(client +async fn download(client: &reqwest::Client, url: &str, limit: usize) -> Result> { + let response = 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}"))? - .bytes() + .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() .await - .with_context(|| format!("could not read update download from {url}"))? - .to_vec()) + .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) } fn select_update(release: Release, current: &str) -> Result> { @@ -131,7 +160,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")))] - bail!("automatic Bridge updates are available only on macOS, Windows, and Linux"); + anyhow::bail!("automatic Bridge updates are available only on macOS, Windows, and Linux"); } fn verify_checksum(archive: &[u8], checksum_file: &[u8]) -> Result<()> { @@ -152,71 +181,452 @@ fn verify_checksum(archive: &[u8], checksum_file: &[u8]) -> Result<()> { Ok(()) } -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)?; +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<()> { + 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, install_directory, ¤t_executable) + spawn_install_helper(&staging.path, install_directory, ¤t_executable)?; + staging.handed_off = true; + Ok(()) +} + +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<()> { - let mut zip = - zip::ZipArchive::new(Cursor::new(archive)).context("update archive is invalid")?; + 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(); for index in 0..zip.len() { - let mut entry = zip + let entry = zip .by_index(index) .context("could not read update archive entry")?; - let Some(relative) = entry.enclosed_name() else { - bail!("update archive contains an unsafe path"); - }; + 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 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() { - fs::create_dir_all(&destination)?; + create_entry_directories(staging, &relative)?; continue; } - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; + if let Some(parent) = relative.parent() { + create_entry_directories(staging, parent)?; } - let mut output = File::create(&destination) + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut output = options + .open(&destination) .with_context(|| format!("could not create {}", destination.display()))?; - io::copy(&mut entry, &mut output) - .with_context(|| format!("could not extract {}", 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; #[cfg(unix)] if let Some(mode) = entry.unix_mode() { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&destination, fs::Permissions::from_mode(mode))?; + output.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; } } ensure!( - staging.join(platform_binary_name()).is_file(), + reject_link(&staging.join(platform_binary_name()))?.is_file(), "update archive does not contain the Bridge executable" ); Ok(()) } fn verify_install_directory(directory: &Path) -> Result<()> { - 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") + verify_directory_chain(directory)?; + 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") } fn platform_binary_name() -> &'static str { @@ -226,31 +636,91 @@ fn platform_binary_name() -> &'static str { return "openmouse-bridge"; } -#[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" +#[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 1 +transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock"; exit 1; } +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"))] +fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) -> Result<()> { let script = staging.join("install-update.sh"); - fs::write(&script, SCRIPT).context("could not write the update helper")?; + 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)?; Command::new("/bin/sh") .arg(&script) .arg(std::process::id().to_string()) @@ -266,31 +736,113 @@ rm -rf "$stage" 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"); - fs::write(&script, SCRIPT).context("could not write the update helper")?; + 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)?; let mut command = Command::new("powershell.exe"); command .args([ "-NoProfile", + "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", @@ -317,58 +869,596 @@ Start-Process -FilePath (Join-Path $Destination $Binary) -WorkingDirectory $Dest 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<()> { - bail!("automatic Bridge updates are available only on macOS, Windows, and Linux") + anyhow::bail!("automatic Bridge updates are available only on macOS, Windows, and Linux") } #[cfg(test)] mod tests { 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 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 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"); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, &link).unwrap(); + #[cfg(windows)] + { + let status = Command::new("cmd.exe") + .args(["/c", "mklink", "/J"]) + .arg(&link) + .arg(&outside) + .status() + .unwrap(); + assert!(status.success()); + } + 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"); + #[cfg(unix)] + fs::remove_file(&link).unwrap(); + #[cfg(windows)] + fs::remove_dir(&link).unwrap(); + 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", + "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 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 +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"; } +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(), 4); + 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}"), diff --git a/tests/backend_local_profiles.rs b/tests/backend_local_profiles.rs new file mode 100644 index 0000000..396956b --- /dev/null +++ b/tests/backend_local_profiles.rs @@ -0,0 +1,558 @@ +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 new file mode 100644 index 0000000..2e4bf64 --- /dev/null +++ b/tests/linux_startup.sh @@ -0,0 +1,19 @@ +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/50%/A "$`B\bridge' +expected='Exec="/opt/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 +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 ] From 89601cc9d56cf87a4b1130fe5da3f1fff4f94d86 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:23:52 +0300 Subject: [PATCH 2/4] fix and tests --- install-linux.sh | 6 +- src/desktop.rs | 70 +++- src/drivers/automation.rs | 278 ++---------- src/drivers/automation/tests.rs | 392 +++++++++++++++++ src/games.rs | 72 +++- src/updater.rs | 644 +--------------------------- src/updater/tests.rs | 722 ++++++++++++++++++++++++++++++++ tests/linux_startup.sh | 54 ++- 8 files changed, 1330 insertions(+), 908 deletions(-) create mode 100644 src/drivers/automation/tests.rs create mode 100644 src/updater/tests.rs diff --git a/install-linux.sh b/install-linux.sh index 111d009..4fab309 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -203,7 +203,7 @@ write_udev_rule() { # Writes the XDG autostart entry Bridge itself uses (see src/platform/linux.rs). desktop_exec() { local value="$1" character index - [[ "$value" != *[$'\001'-$'\037'$'\177']* && "$value" != *=* ]] || return 1 + [[ "$value" != *[$'\001'-$'\037'$'\177']* ]] || return 1 for ((index=0; index<${#value}; index++)); do character="${value:index:1}" case "$character" in @@ -454,14 +454,14 @@ main() { # 6. Autostart. if [ "$WITH_AUTOSTART" = true ]; then - write_autostart "$desktop" "$binary" + write_autostart "$desktop" "$binary" || die "could not write autostart entry: invalid path or write failure" 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" + write_launcher "$launcher" "$binary" "$icon" || die "could not write launcher entry: invalid path or write failure" update_desktop_database log "Launcher entry installed." else diff --git a/src/desktop.rs b/src/desktop.rs index 06be841..2e8f4d1 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -662,6 +662,12 @@ 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")] @@ -710,7 +716,7 @@ impl eframe::App for TrayApp { } } } - if self.visible && context.input(|input| input.key_pressed(egui::Key::Escape)) { + if should_hide_on_escape(self.visible, context) { self.set_visible(context, false); } if self.visible { @@ -1824,6 +1830,68 @@ 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)); diff --git a/src/drivers/automation.rs b/src/drivers/automation.rs index 4f11ff9..de4c891 100644 --- a/src/drivers/automation.rs +++ b/src/drivers/automation.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::time::{Duration, Instant}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -38,7 +39,8 @@ struct Entry { dpi: bool, polling: bool, failed: bool, - restore_attempts: u8, + restore_after: Option, + restore_delay: Duration, } #[derive(Clone, Copy, Default)] @@ -118,6 +120,15 @@ impl NativeAutomation { &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 @@ -132,11 +143,12 @@ impl NativeAutomation { for id in removed { let entry = self.entries.get_mut(&id).unwrap(); entry.failed = true; - if entry.restore_attempts >= 3 { - errors.push(format!("{id}: native restoration pending; three attempts exhausted, original baseline retained")); + if entry.restore_after.is_some_and(|deadline| now < deadline) { + errors.push(format!( + "{id}: native restoration pending; retry delayed, original baseline retained" + )); continue; } - entry.restore_attempts += 1; let settings = ProfileSettings { dpi: entry.dpi.then_some(entry.baseline.dpi), polling_rate_hz: entry.polling.then_some(entry.baseline.polling_rate_hz), @@ -146,7 +158,14 @@ impl NativeAutomation { Ok(()) => { self.entries.remove(&id); } - Err(error) => errors.push(format!("{id}: native restoration pending: {error:#}")), + 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:#}")); + } } } if desired != self.attempted.as_ref() { @@ -232,7 +251,8 @@ impl NativeAutomation { dpi: false, polling: false, failed: false, - restore_attempts: 0, + restore_after: None, + restore_delay: Duration::from_secs(1), }, ); } @@ -308,248 +328,4 @@ fn supported_brand(device_id: &str) -> Result<&str> { } #[cfg(test)] -mod tests { - 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 _ = automation.reconcile(Some(&game), &mut io); - let _ = automation.reconcile(Some(&game), &mut io); - 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_caps_attempts() { - let mut automation = NativeAutomation::default(); - let mut io = FakeIo::default(); - automation - .reconcile(Some(&profile("a", Some(1600), None)), &mut io) - .unwrap(); - io.fail_writes = 20; - for _ in 0..10 { - assert!(automation.reconcile(None, &mut io).is_err()); - } - assert_eq!(io.calls.len(), 5); - assert_eq!(automation.entries["a"].baseline.dpi, 800); - } - - #[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()); - } -} +mod tests; diff --git a/src/drivers/automation/tests.rs b/src/drivers/automation/tests.rs new file mode 100644 index 0000000..882ddc2 --- /dev/null +++ b/src/drivers/automation/tests.rs @@ -0,0 +1,392 @@ +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 _ = automation.reconcile(Some(&game), &mut io); + let _ = automation.reconcile(Some(&game), &mut io); + 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(); + 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(&profile("Zaunkoenig:new", Some(1600), None)), + &mut io, + now + Duration::from_millis(999), + ) + .is_err() + ); + assert_eq!(io.calls.len(), calls); + assert_eq!(automation.entries.len(), 64); + assert!( + automation + .entries + .values() + .all(|entry| entry.baseline.dpi == 800) + ); + automation + .reconcile_at(None, &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.len(), calls + 64); + assert!(automation.entries.is_empty()); +} + +#[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/games.rs b/src/games.rs index 0404b68..2bd84cd 100644 --- a/src/games.rs +++ b/src/games.rs @@ -44,23 +44,23 @@ fn deserialize_executables<'de, D: serde::Deserializer<'de>>( mut seq: A, ) -> std::result::Result { let mut executables = Vec::new(); - while let Some(name) = seq.next_element::()? { - if executables.len() >= MAX_EXECUTABLES + 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.len() > MAX_CATALOG_STRING || name.chars().any(char::is_control) { + continue; + } + if executables.len() >= MAX_EXECUTABLES { return Err(serde::de::Error::custom( - "invalid catalog executable or executable count", + "catalog executable count exceeds limit", )); } executables.push(name); } - if executables.is_empty() { - return Err(serde::de::Error::custom( - "catalog game contains no executables", - )); - } Ok(executables) } } @@ -81,20 +81,24 @@ fn deserialize_games<'de, D: serde::Deserializer<'de>>( mut seq: A, ) -> std::result::Result { let mut games = Vec::new(); + let mut game_count = 0; while let Some(game) = seq.next_element::()? { - if games.len() >= MAX_CATALOG_GAMES - || game.name.trim().is_empty() + 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", )); } - games.push(GameConfig { - name: game.name, - executables: game.executables, - }); + game_count += 1; + if !game.executables.is_empty() { + games.push(GameConfig { + name: game.name, + executables: game.executables, + }); + } } Ok(games) } @@ -426,6 +430,44 @@ 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)}); diff --git a/src/updater.rs b/src/updater.rs index f824107..282d4cb 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -574,6 +574,11 @@ fn extract_archive_with_limits( 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", @@ -655,8 +660,8 @@ special=$(find "$stage" ! -type f ! -type d -print) || 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 1 -transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock"; exit 1; } +(umask 077; mkdir "$lock") || exit 2 +transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock" || :; exit 2; } committed=no rollback() { status=$? @@ -875,637 +880,4 @@ fn spawn_install_helper(_staging: &Path, _destination: &Path, _executable: &Path } #[cfg(test)] -mod tests { - 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 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 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"); - #[cfg(unix)] - std::os::unix::fs::symlink(&outside, &link).unwrap(); - #[cfg(windows)] - { - let status = Command::new("cmd.exe") - .args(["/c", "mklink", "/J"]) - .arg(&link) - .arg(&outside) - .status() - .unwrap(); - assert!(status.success()); - } - 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"); - #[cfg(unix)] - fs::remove_file(&link).unwrap(); - #[cfg(windows)] - fs::remove_dir(&link).unwrap(); - 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", - "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 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 -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"; } -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(), 4); - 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() - ); - } -} +mod tests; diff --git a/src/updater/tests.rs b/src/updater/tests.rs new file mode 100644 index 0000000..09c1849 --- /dev/null +++ b/src/updater/tests.rs @@ -0,0 +1,722 @@ +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/linux_startup.sh b/tests/linux_startup.sh index 2e4bf64..04d8a02 100644 --- a/tests/linux_startup.sh +++ b/tests/linux_startup.sh @@ -5,8 +5,8 @@ set -- source ./install-linux.sh root=$(mktemp -d "$fixture_parent/bridge-startup.XXXXXXXX") trap 'rm -rf "$root"' EXIT -executable='/opt/50%/A "$`B\bridge' -expected='Exec="/opt/50%%/A \\"\\$\\`B\\\\bridge"' +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 @@ -17,3 +17,53 @@ if write_autostart "$root/invalid.desktop" $'/opt/invalid\nentry'; then exit 1; 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 equals autostart 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" + INSTALL_DIR="$call_root/name=value" + [ "$fixture_case" = equals ] || INSTALL_DIR="$call_root/"$'invalid\tpath' + BIN_LINK_DIR="$call_root/bin" + WITH_DEPS=false + WITH_UDEV=false + [ "$fixture_case" != launcher ] || WITH_AUTOSTART=false + 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" = equals ]; 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 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: equals paths, all representable ASCII controls, and both inert production callsites' From 8b2976078c72c27ac6e022532c9f7f2c992126f3 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:30:59 +0300 Subject: [PATCH 3/4] fix ci --- src/overlay/linux.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/overlay/linux.rs b/src/overlay/linux.rs index 20e4fea..4ba9317 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() { - if stdin.write_all(&wav).is_err() { - let _ = child.kill(); - let _ = child.wait(); - return Ok(()); - } + if let Some(mut stdin) = child.stdin.take() + && stdin.write_all(&wav).is_err() + { + let _ = child.kill(); + let _ = child.wait(); + return Ok(()); } self.playback = Some(child); Ok(()) From 61168f67ccaaeaf6de59e08b54335ec993d8efd9 Mon Sep 17 00:00:00 2001 From: 7eedle <232406712+7eedle@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:17:22 +0300 Subject: [PATCH 4/4] fix ci --- install-linux.sh | 2 +- src/drivers/automation.rs | 13 +++- src/drivers/automation/tests.rs | 120 ++++++++++++++++++++++++++++++-- tests/linux_startup.sh | 32 ++++++--- 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/install-linux.sh b/install-linux.sh index 4fab309..ccc9060 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -203,7 +203,7 @@ write_udev_rule() { # 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 + [[ "$value" != *[=$'\001'-$'\037'$'\177']* ]] || return 1 for ((index=0; index<${#value}; index++)); do character="${value:index:1}" case "$character" in diff --git a/src/drivers/automation.rs b/src/drivers/automation.rs index de4c891..02ce0b6 100644 --- a/src/drivers/automation.rs +++ b/src/drivers/automation.rs @@ -79,6 +79,7 @@ impl ManualIntent { pub(crate) struct NativeAutomation { entries: HashMap, attempted: Option, + apply_deferred: bool, apply_error: Option, observed_manual: ManualIntent, } @@ -168,7 +169,17 @@ impl NativeAutomation { } } } - if desired != self.attempted.as_ref() { + 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 { diff --git a/src/drivers/automation/tests.rs b/src/drivers/automation/tests.rs index 882ddc2..7313fdd 100644 --- a/src/drivers/automation/tests.rs +++ b/src/drivers/automation/tests.rs @@ -86,8 +86,11 @@ fn failed_partial_apply_is_restored_even_if_same_request_remains() { }; 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, [ @@ -243,6 +246,7 @@ 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( @@ -257,14 +261,11 @@ fn delayed_restoration_retains_all_64_baselines_and_refuses_new_capture() { assert_eq!(automation.pending_restores_for(None), 64); assert!( automation - .reconcile_at( - Some(&profile("Zaunkoenig:new", Some(1600), None)), - &mut io, - now + Duration::from_millis(999), - ) + .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 @@ -273,12 +274,117 @@ fn delayed_restoration_retains_all_64_baselines_and_refuses_new_capture() { .all(|entry| entry.baseline.dpi == 800) ); automation - .reconcile_at(None, &mut io, now + Duration::from_secs(1)) + .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_eq!(io.calls.len(), calls + 64); 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(); diff --git a/tests/linux_startup.sh b/tests/linux_startup.sh index 04d8a02..de0698d 100644 --- a/tests/linux_startup.sh +++ b/tests/linux_startup.sh @@ -5,14 +5,25 @@ 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"' +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 ] @@ -26,19 +37,22 @@ for code in {1..31} 127; do 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 equals autostart launcher; do +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" - INSTALL_DIR="$call_root/name=value" - [ "$fixture_case" = equals ] || INSTALL_DIR="$call_root/"$'invalid\tpath' + 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 - [ "$fixture_case" != launcher ] || WITH_AUTOSTART=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; } @@ -56,14 +70,14 @@ for fixture_case in equals autostart launcher; do ) > "$call_root/output" 2>&1 status=$? set -e - if [ "$fixture_case" = equals ]; then + 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 entry" "$call_root/output" || { cat "$call_root/output"; exit 1; } + 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: equals paths, all representable ASCII controls, and both inert production callsites' +printf '%s\n' 'PASS: valid escaping, equals rejection with output preservation, all representable ASCII controls, and both inert production callsites'