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 0000000..c672f98 Binary files /dev/null and b/assets/fonts/DMSans-Variable.ttf differ diff --git a/install-linux.sh b/install-linux.sh index 2313a57..ccc9060 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -201,8 +201,30 @@ 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 + for ((index=0; index<${#value}; index++)); do + character="${value:index:1}" + case "$character" in + '%') printf '%s' '%%' ;; + '\') printf '%s' '\\\\' ;; + '"'|'$'|'`') printf '%s%s' '\\' "$character" ;; + *) printf '%s' "$character" ;; + esac + done +} + +startup_config_home() { + case "${XDG_CONFIG_HOME:-}" in + /*) printf '%s' "$XDG_CONFIG_HOME" ;; + *) printf '%s' "$HOME/.config" ;; + esac +} + write_autostart() { - local path="$1" exec="$2" + local path="$1" exec + exec=$(desktop_exec "$2") || return 1 mkdir -p "$(dirname "$path")" cat >"$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..2e8f4d1 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) { @@ -598,10 +662,21 @@ impl TrayApp { } } +fn should_hide_on_escape(visible: bool, context: &egui::Context) -> bool { + visible + && context.input(|input| input.key_pressed(egui::Key::Escape)) + && !egui::Popup::is_any_open(context) +} + impl eframe::App for TrayApp { fn logic(&mut self, context: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "linux")] + drain_tray_events(gtk::events_pending, || { + gtk::main_iteration_do(false); + }); self.restore_tray_after_resume(); self.refresh_snapshot(); + self.resize_panel(context); if self.refresh_updates() { // The updater has already spawned its own post-exit relaunch helper. self.keep_running.store(false, Ordering::Relaxed); @@ -611,7 +686,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 +696,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 +716,9 @@ impl eframe::App for TrayApp { } } } + if should_hide_on_escape(self.visible, context) { + self.set_visible(context, false); + } if self.visible { match context.input(|input| input.viewport().focused) { Some(true) => self.panel_had_focus = true, @@ -659,12 +738,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 +770,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 +1395,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 +1406,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 +1758,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 +1830,347 @@ fn wide(value: &str) -> Vec { mod tests { use super::*; + #[test] + fn escape_keeps_panel_open_until_popup_closes() { + let context = egui::Context::default(); + context + .run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: Some(egui::Key::Escape), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let popup = egui::Id::new("escape-review-popup"); + egui::Popup::open_id(ui.ctx(), popup); + assert!(egui::Popup::is_any_open(ui.ctx())); + assert!(!should_hide_on_escape(true, ui.ctx())); + assert!( + egui::Popup::new(popup, ui.ctx().clone(), egui::Pos2::ZERO, ui.layer_id(),) + .open_memory(None) + .show(|ui| ui.label("Popup")) + .is_some() + ); + assert!(!egui::Popup::is_any_open(ui.ctx())); + }, + ) + .drop_without_applying_deltas(); + } + + #[test] + fn escape_requires_visible_panel_and_keypress() { + for (visible, pressed, expected) in [ + (true, true, true), + (false, true, false), + (true, false, false), + (false, false, false), + ] { + egui::Context::default() + .run_ui( + egui::RawInput { + events: if pressed { + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: Some(egui::Key::Escape), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }] + } else { + vec![] + }, + ..Default::default() + }, + |ui| assert_eq!(should_hide_on_escape(visible, ui.ctx()), expected), + ) + .drop_without_applying_deltas(); + } + } + + fn simulated_header_drag(start: Pos2, end: Pos2) -> bool { + let context = egui::Context::default(); + let rect = egui::Rect::from_min_max(Pos2::new(20.0, 20.0), Pos2::new(260.0, 60.0)); + let frames = [ + vec![], + vec![ + egui::Event::PointerMoved(start), + egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ], + vec![egui::Event::PointerMoved(end)], + ]; + let mut started = false; + for events in frames { + let output = context.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + Pos2::ZERO, + Vec2::new(372.0, 484.0), + )), + events, + ..Default::default() + }, + |ui| drag_header(ui, rect, "test-drag"), + ); + started |= output.viewport_output.values().any(|viewport| { + viewport + .commands + .iter() + .any(|command| matches!(command, ViewportCommand::StartDrag)) + }); + output.drop_without_applying_deltas(); + } + started + } + + #[test] + fn tray_menu_can_recover_the_window_without_click_coordinates() { + assert!(matches!( + tray_menu_action(true, false, false), + Some(TrayAction::ShowWindow) + )); + assert!(tray_menu_action(false, false, false).is_none()); + } + + #[test] + fn tray_event_pump_is_bounded_and_drains_pending_events() { + let remaining = std::cell::Cell::new(40); + drain_tray_events( + || remaining.get() > 0, + || remaining.set(remaining.get() - 1), + ); + assert_eq!(remaining.get(), 8); + drain_tray_events( + || remaining.get() > 0, + || remaining.set(remaining.get() - 1), + ); + assert_eq!(remaining.get(), 0); + } + + #[test] + fn header_drag_requests_a_native_window_move() { + assert!(simulated_header_drag( + Pos2::new(80.0, 35.0), + Pos2::new(140.0, 75.0) + )); + } + + #[test] + fn dragging_outside_header_leaves_window_in_place() { + assert!(!simulated_header_drag( + Pos2::new(300.0, 35.0), + Pos2::new(340.0, 75.0) + )); + } + + #[test] + fn content_growth_stays_bounded_and_settings_has_its_own_budget() { + assert_eq!(panel_height(View::Home, false, false, false), 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..02ce0b6 --- /dev/null +++ b/src/drivers/automation.rs @@ -0,0 +1,342 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::config::{ApplicationProfile, ProfileSettings}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DeviceIdentity { + vendor_id: u16, + product_id: u16, + path: String, + serial_number: String, + interface: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Baseline { + pub identity: DeviceIdentity, + pub dpi: u32, + pub polling_rate_hz: u32, +} + +pub(crate) trait AutomationIo { + fn read(&mut self, device_id: &str) -> Result; + fn write( + &mut self, + device_id: &str, + baseline: &Baseline, + settings: &ProfileSettings, + ) -> Result<()>; +} + +struct Entry { + baseline: Baseline, + dpi: bool, + polling: bool, + failed: bool, + restore_after: Option, + restore_delay: Duration, +} + +#[derive(Clone, Copy, Default)] +pub(crate) struct ManualIntent { + dpi: u64, + polling: u64, +} + +impl ManualIntent { + pub const fn new() -> Self { + Self { dpi: 0, polling: 0 } + } + + pub fn invalidate(&mut self, brand: &str, dpi: bool, polling: bool) -> Result<()> { + if brand + .trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}') + .eq_ignore_ascii_case("Zaunkoenig") + { + if dpi { + self.dpi = self + .dpi + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("manual DPI ownership generation exhausted"))?; + } + if polling { + self.polling = self.polling.checked_add(1).ok_or_else(|| { + anyhow::anyhow!("manual polling ownership generation exhausted") + })?; + } + } + Ok(()) + } +} + +#[derive(Default)] +pub(crate) struct NativeAutomation { + entries: HashMap, + attempted: Option, + apply_deferred: bool, + apply_error: Option, + observed_manual: ManualIntent, +} + +impl NativeAutomation { + pub fn observe_manual_intent(&mut self, intent: &ManualIntent) { + let dpi = intent.dpi != self.observed_manual.dpi; + let polling = intent.polling != self.observed_manual.polling; + if dpi || polling { + let mut superseded = false; + self.entries.retain(|id, entry| { + if id + .split_once(':') + .is_some_and(|(brand, _)| brand.eq_ignore_ascii_case("Zaunkoenig")) + { + superseded |= (dpi && entry.dpi) || (polling && entry.polling); + entry.dpi &= !dpi; + entry.polling &= !polling; + } + entry.dpi || entry.polling + }); + if superseded { + self.apply_error = Some("native automatic settings superseded by manual intent; untouched fields remain owned".into()); + } + } + self.observed_manual = *intent; + } + + pub fn pending_restores_for(&self, desired: Option<&ApplicationProfile>) -> usize { + self.entries + .iter() + .filter(|(id, entry)| { + entry.failed || desired.is_none_or(|profile| &profile.device.id != *id) + }) + .count() + } + + pub fn reconcile( + &mut self, + desired: Option<&ApplicationProfile>, + io: &mut impl AutomationIo, + ) -> Result<()> { + self.reconcile_at(desired, io, Instant::now()) + } + + fn reconcile_at( + &mut self, + desired: Option<&ApplicationProfile>, + io: &mut impl AutomationIo, + now: Instant, + ) -> Result<()> { + let mut errors = Vec::new(); + let mut removed: Vec<_> = self + .entries + .keys() + .filter(|id| { + desired.is_none_or(|profile| &profile.device.id != *id) || self.entries[*id].failed + }) + .cloned() + .collect(); + removed.sort(); + for id in removed { + let entry = self.entries.get_mut(&id).unwrap(); + entry.failed = true; + if entry.restore_after.is_some_and(|deadline| now < deadline) { + errors.push(format!( + "{id}: native restoration pending; retry delayed, original baseline retained" + )); + continue; + } + let settings = ProfileSettings { + dpi: entry.dpi.then_some(entry.baseline.dpi), + polling_rate_hz: entry.polling.then_some(entry.baseline.polling_rate_hz), + snapshot: None, + }; + match io.write(&id, &entry.baseline, &settings) { + Ok(()) => { + self.entries.remove(&id); + } + Err(error) => { + entry.restore_after = Some(now + entry.restore_delay); + entry.restore_delay = entry + .restore_delay + .saturating_mul(2) + .min(Duration::from_secs(60)); + errors.push(format!("{id}: native restoration pending: {error:#}")); + } + } + } + let needs_apply = desired != self.attempted.as_ref() || self.apply_deferred; + let blocked = needs_apply + && desired.is_some_and(|profile| match self.entries.get(&profile.device.id) { + Some(entry) => entry.failed, + None => self.entries.len() >= 64, + }); + if blocked { + self.apply_deferred = true; + self.apply_error = Some("native apply deferred until restoration completes".into()); + } else if needs_apply { + self.apply_deferred = false; + self.attempted = desired.cloned(); + self.apply_error = None; + if let Some(profile) = desired { + let result = self.apply(profile, io); + if let Err(error) = result { + self.apply_error = Some(format!("{}: {error:#}", profile.device.id)); + } + } + } + if let Some(error) = &self.apply_error { + errors.push(error.clone()); + } + if !errors.is_empty() { + anyhow::bail!("{}", errors.join("; ")); + } + Ok(()) + } + + fn apply(&mut self, profile: &ApplicationProfile, io: &mut impl AutomationIo) -> Result<()> { + let id = &profile.device.id; + if self.entries.get(id).is_some_and(|entry| entry.failed) { + anyhow::bail!("previous native restoration must finish before another write"); + } + if profile.settings.dpi.is_none() + && profile.settings.polling_rate_hz.is_none() + && !self.entries.contains_key(id) + { + return Ok(()); + } + if let Some(entry) = self.entries.get_mut(id) { + let acquire_dpi = profile.settings.dpi.is_some() && !entry.dpi; + let acquire_polling = profile.settings.polling_rate_hz.is_some() && !entry.polling; + if acquire_dpi || acquire_polling { + let fresh = io.read(id)?; + if fresh.identity != entry.baseline.identity + || fresh.dpi == 0 + || fresh.polling_rate_hz == 0 + { + anyhow::bail!("fresh native baseline identity/readback is missing or changed"); + } + if acquire_dpi { + entry.baseline.dpi = fresh.dpi; + } + if acquire_polling { + entry.baseline.polling_rate_hz = fresh.polling_rate_hz; + } + } + } + if !self.entries.contains_key(id) { + if self.entries.len() >= 64 { + anyhow::bail!( + "native restoration journal is full; refusing another hardware change" + ); + } + let baseline = io.read(id)?; + if baseline.dpi == 0 + || baseline.polling_rate_hz == 0 + || baseline.identity.path.is_empty() + || baseline.identity.path.len() > 2048 + || baseline.identity.serial_number.is_empty() + || baseline.identity.serial_number.len() > 256 + { + anyhow::bail!( + "unsupported or invalid acknowledged native baseline identity/readback" + ); + } + if self + .entries + .values() + .any(|entry| entry.baseline.identity == baseline.identity) + { + anyhow::bail!( + "resolved native device is already owned by another profile device identity" + ); + } + self.entries.insert( + id.clone(), + Entry { + baseline, + dpi: false, + polling: false, + failed: false, + restore_after: None, + restore_delay: Duration::from_secs(1), + }, + ); + } + let entry = self.entries.get_mut(id).unwrap(); + entry.dpi |= profile.settings.dpi.is_some(); + entry.polling |= profile.settings.polling_rate_hz.is_some(); + let settings = ProfileSettings { + dpi: profile + .settings + .dpi + .or_else(|| entry.dpi.then_some(entry.baseline.dpi)), + polling_rate_hz: profile + .settings + .polling_rate_hz + .or_else(|| entry.polling.then_some(entry.baseline.polling_rate_hz)), + snapshot: None, + }; + entry.failed = true; + io.write(id, &entry.baseline, &settings)?; + entry.failed = false; + Ok(()) + } +} + +pub(crate) struct HelperIo(pub F); + +impl Result>>> AutomationIo for HelperIo { + fn read(&mut self, device_id: &str) -> Result { + let brand = supported_brand(device_id)?; + let request = + serde_json::json!({"brand":brand,"deviceId":device_id,"action":"automatic-read"}); + let bytes = + (self.0)(&request)?.ok_or_else(|| anyhow::anyhow!("unsupported native readback"))?; + Ok(serde_json::from_slice(&bytes)?) + } + fn write( + &mut self, + device_id: &str, + baseline: &Baseline, + settings: &ProfileSettings, + ) -> Result<()> { + let brand = supported_brand(device_id)?; + let mut request = serde_json::json!({"brand":brand,"deviceId":device_id,"action":"automatic-apply","identity":baseline.identity}); + if let Some(dpi) = settings.dpi { + request["dpi"] = dpi.into(); + } + if let Some(hz) = settings.polling_rate_hz { + request["pollingRateHz"] = hz.into(); + } + let bytes = + (self.0)(&request)?.ok_or_else(|| anyhow::anyhow!("unsupported native restoration"))?; + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Reply { + identity: DeviceIdentity, + } + let reply: Reply = serde_json::from_slice(&bytes)?; + if reply.identity != baseline.identity { + anyhow::bail!("native acknowledgement identity changed"); + } + Ok(()) + } +} + +fn supported_brand(device_id: &str) -> Result<&str> { + let (brand, name) = device_id + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("unsupported native profile device identity"))?; + if !brand.eq_ignore_ascii_case("Zaunkoenig") || name.is_empty() || device_id.len() > 512 { + anyhow::bail!("unsupported acknowledged native automation readback for {device_id}"); + } + Ok(brand) +} + +#[cfg(test)] +mod tests; diff --git a/src/drivers/automation/tests.rs b/src/drivers/automation/tests.rs new file mode 100644 index 0000000..7313fdd --- /dev/null +++ b/src/drivers/automation/tests.rs @@ -0,0 +1,498 @@ +use super::*; + +#[derive(Default)] +struct FakeIo { + calls: Vec, + fail_reads: bool, + fail_writes: usize, + invalid_baseline: bool, +} + +impl AutomationIo for FakeIo { + fn read(&mut self, id: &str) -> Result { + self.calls.push(format!("read:{id}")); + if self.fail_reads { + anyhow::bail!("unsupported readback"); + } + Ok(Baseline { + identity: DeviceIdentity { + vendor_id: 1, + product_id: 2, + path: id.into(), + serial_number: "serial".into(), + interface: Some(0), + }, + dpi: if self.invalid_baseline { 0 } else { 800 }, + polling_rate_hz: 1000, + }) + } + fn write(&mut self, id: &str, baseline: &Baseline, settings: &ProfileSettings) -> Result<()> { + assert_eq!(baseline.identity.path, id); + self.calls.push(format!( + "write:{id}:{:?}:{:?}", + settings.dpi, settings.polling_rate_hz + )); + if self.fail_writes > 0 { + self.fail_writes -= 1; + anyhow::bail!("write failed after possible partial hardware change"); + } + Ok(()) + } +} + +fn profile(id: &str, dpi: Option, hz: Option) -> ApplicationProfile { + serde_json::from_value(serde_json::json!({"application":{"name":"Game","path":"/game","executable":"game"},"device":{"id":id,"name":"Mouse"},"settings":{"dpi":dpi,"pollingRateHz":hz},"enabled":true})).unwrap() +} + +#[test] +fn unchanged_request_does_not_rewrite_and_empty_profile_does_not_read() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let game = profile("a", Some(1600), None); + automation + .reconcile(Some(&profile("a", None, None)), &mut io) + .unwrap(); + assert!(io.calls.is_empty()); + automation.reconcile(Some(&game), &mut io).unwrap(); + automation.reconcile(Some(&game), &mut io).unwrap(); + assert_eq!(io.calls, ["read:a", "write:a:Some(1600):None"]); +} + +#[test] +fn invalid_or_unreadable_baseline_never_writes() { + for invalid in [false, true] { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo { + fail_reads: !invalid, + invalid_baseline: invalid, + ..FakeIo::default() + }; + assert!( + automation + .reconcile(Some(&profile("a", Some(1600), None)), &mut io) + .is_err() + ); + assert_eq!(io.calls, ["read:a"]); + assert!(automation.entries.is_empty()); + } +} + +#[test] +fn failed_partial_apply_is_restored_even_if_same_request_remains() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo { + fail_writes: 1, + ..FakeIo::default() + }; + let game = profile("a", Some(1600), Some(2000)); + assert!(automation.reconcile(Some(&game), &mut io).is_err()); + let original_error = automation.apply_error.clone(); + assert_eq!(automation.attempted.as_ref(), Some(&game)); + let _ = automation.reconcile(Some(&game), &mut io); + let _ = automation.reconcile(Some(&game), &mut io); + assert_eq!(automation.apply_error, original_error); + assert_eq!( + io.calls, + [ + "read:a", + "write:a:Some(1600):Some(2000)", + "write:a:Some(800):Some(1000)" + ] + ); + assert!(automation.entries.is_empty()); +} + +#[test] +fn owned_baseline_is_pending_when_final_target_ends_before_hid_access() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let game = profile("a", Some(1600), None); + automation.reconcile(Some(&game), &mut io).unwrap(); + assert_eq!(automation.pending_restores_for(Some(&game)), 0); + assert_eq!(automation.pending_restores_for(None), 1); + assert_eq!(io.calls.len(), 2); +} + +#[test] +fn failed_restoration_retains_baseline_and_waits_before_retry() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + automation + .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) + .unwrap(); + io.fail_writes = 20; + assert!(automation.reconcile_at(None, &mut io, now).is_err()); + for millis in [0, 1, 500, 999] { + assert!( + automation + .reconcile_at(None, &mut io, now + Duration::from_millis(millis)) + .is_err() + ); + } + assert_eq!(io.calls.len(), 3); + assert_eq!(automation.entries["a"].baseline.dpi, 800); + assert_eq!(automation.pending_restores_for(None), 1); + io.fail_writes = 0; + automation + .reconcile_at(None, &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.len(), 4); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); + assert!(automation.entries.is_empty()); +} + +#[test] +fn restoration_failure_delays_increase_to_cap_without_abandoning_baseline() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let mut now = Instant::now(); + automation + .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) + .unwrap(); + io.fail_writes = 10; + for (index, seconds) in [1, 2, 4, 8, 16, 32, 60, 60, 60, 60].into_iter().enumerate() { + assert!(automation.reconcile_at(None, &mut io, now).is_err()); + let calls = io.calls.len(); + assert_eq!(calls, index + 3); + assert_eq!(automation.entries["a"].baseline.dpi, 800); + assert_eq!( + automation.entries["a"].baseline.identity.serial_number, + "serial" + ); + assert_eq!(automation.pending_restores_for(None), 1); + now += Duration::from_secs(seconds); + assert!( + automation + .reconcile_at(None, &mut io, now - Duration::from_nanos(1)) + .is_err() + ); + assert_eq!(io.calls.len(), calls, "retry before {seconds}s deadline"); + } + automation.reconcile_at(None, &mut io, now).unwrap(); + assert_eq!(io.calls.len(), 13); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); + assert!(automation.entries.is_empty()); +} + +#[test] +fn restoration_deadlines_are_independent_per_device() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + automation + .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) + .unwrap(); + io.fail_writes = 1; + assert!( + automation + .reconcile_at(Some(&profile("b", None, Some(2000))), &mut io, now) + .is_err() + ); + assert_eq!(io.calls.len(), 5); + assert!( + automation + .reconcile_at(None, &mut io, now + Duration::from_millis(100)) + .is_err() + ); + assert_eq!(io.calls.len(), 6); + assert_eq!(io.calls.last().unwrap(), "write:b:None:Some(1000)"); + assert_eq!(automation.entries.len(), 1); + assert_eq!(automation.entries["a"].baseline.dpi, 800); + automation + .reconcile_at(None, &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):None"); + assert!(automation.entries.is_empty()); +} + +#[test] +fn delayed_restoration_preserves_manual_override_and_untouched_ownership() { + for (dpi, polling, expected) in [ + (true, false, Some("write:Zaunkoenig:M3K:None:Some(1000)")), + (false, true, Some("write:Zaunkoenig:M3K:Some(800):None")), + (true, true, None), + ] { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + automation + .reconcile_at( + Some(&profile("Zaunkoenig:M3K", Some(1600), Some(2000))), + &mut io, + now, + ) + .unwrap(); + io.fail_writes = 1; + assert!(automation.reconcile_at(None, &mut io, now).is_err()); + let mut intent = ManualIntent::new(); + intent.invalidate("Zaunkoenig", dpi, polling).unwrap(); + automation.observe_manual_intent(&intent); + let _ = automation.reconcile_at(None, &mut io, now + Duration::from_millis(999)); + assert_eq!(io.calls.len(), 3); + let _ = automation.reconcile_at(None, &mut io, now + Duration::from_secs(1)); + if let Some(expected) = expected { + assert_eq!(io.calls.len(), 4); + assert_eq!(io.calls.last().unwrap(), expected); + } else { + assert_eq!(io.calls.len(), 3); + } + assert!(automation.entries.is_empty()); + } +} + +#[test] +fn delayed_restoration_retains_all_64_baselines_and_refuses_new_capture() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + let desired = profile("Zaunkoenig:new", Some(1600), None); + for index in 0..64 { + automation + .apply( + &profile(&format!("Zaunkoenig:{index}"), Some(1600), None), + &mut io, + ) + .unwrap(); + } + io.fail_writes = 64; + assert!(automation.reconcile_at(None, &mut io, now).is_err()); + let calls = io.calls.len(); + assert_eq!(automation.pending_restores_for(None), 64); + assert!( + automation + .reconcile_at(Some(&desired), &mut io, now + Duration::from_millis(999),) + .is_err() + ); + assert_eq!(io.calls.len(), calls); + assert!(automation.attempted.is_none()); + assert_eq!(automation.entries.len(), 64); + assert!( + automation + .entries + .values() + .all(|entry| entry.baseline.dpi == 800) + ); + automation + .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.len(), calls + 66); + assert_eq!( + io.calls.last().unwrap(), + "write:Zaunkoenig:new:Some(1600):None" + ); + assert_eq!(automation.attempted.as_ref(), Some(&desired)); + assert!(automation.apply_error.is_none()); + automation + .reconcile_at(None, &mut io, now + Duration::from_secs(2)) + .unwrap(); + assert!(automation.entries.is_empty()); +} + +#[test] +fn delayed_same_device_restoration_retries_unchanged_desired_profile() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + let desired = profile("a", Some(3200), None); + automation + .reconcile_at(Some(&profile("a", Some(1600), None)), &mut io, now) + .unwrap(); + io.fail_writes = 1; + assert!(automation.reconcile_at(None, &mut io, now).is_err()); + assert!( + automation + .reconcile_at(Some(&desired), &mut io, now + Duration::from_millis(999)) + .is_err() + ); + assert!(automation.attempted.is_none()); + assert_eq!(io.calls.len(), 3); + automation + .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!( + &io.calls[3..], + [ + "write:a:Some(800):None", + "read:a", + "write:a:Some(3200):None" + ] + ); + assert_eq!(automation.attempted.as_ref(), Some(&desired)); + assert!(automation.apply_error.is_none()); + automation + .reconcile_at(Some(&desired), &mut io, now + Duration::from_secs(2)) + .unwrap(); + assert_eq!(io.calls.len(), 6); +} + +#[test] +fn returning_to_previous_profile_while_deferred_retries_after_restoration() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo { + fail_writes: 1, + ..Default::default() + }; + let now = Instant::now(); + let original = profile("a", Some(1600), None); + assert!( + automation + .reconcile_at(Some(&original), &mut io, now) + .is_err() + ); + io.fail_writes = 1; + assert!( + automation + .reconcile_at(Some(&profile("a", Some(3200), None)), &mut io, now) + .is_err() + ); + assert_eq!(automation.attempted.as_ref(), Some(&original)); + assert!( + automation + .reconcile_at(Some(&original), &mut io, now + Duration::from_millis(999)) + .is_err() + ); + automation + .reconcile_at(Some(&original), &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(1600):None"); + assert!(automation.apply_error.is_none()); +} + +#[test] +fn restored_previous_device_profile_is_reapplied_after_blocked_switch() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + let now = Instant::now(); + let a = profile("a", Some(1600), None); + let b = profile("b", Some(3200), None); + automation.reconcile_at(Some(&b), &mut io, now).unwrap(); + io.fail_writes = 1; + assert!(automation.reconcile_at(Some(&a), &mut io, now).is_err()); + let waiting = now + Duration::from_millis(999); + assert!(automation.reconcile_at(Some(&b), &mut io, waiting).is_err()); + assert!(automation.reconcile_at(Some(&a), &mut io, waiting).is_err()); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(1600):None"); + assert!(automation.apply_error.is_none()); + let calls = io.calls.len(); + automation + .reconcile_at(Some(&a), &mut io, now + Duration::from_secs(1)) + .unwrap(); + assert_eq!(io.calls.len(), calls + 1); + assert_eq!(io.calls.last().unwrap(), "write:b:Some(800):None"); + assert!(automation.entries.contains_key("a")); + assert_eq!(automation.attempted.as_ref(), Some(&a)); + assert!(automation.apply_error.is_none()); +} + +#[test] +fn explicit_default_preserves_values_and_resets_only_omitted_owned_fields() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + automation + .reconcile(Some(&profile("a", Some(1600), Some(2000))), &mut io) + .unwrap(); + let mut default = profile("a", Some(400), None); + default.application.name = "Manual default".into(); + automation.reconcile(Some(&default), &mut io).unwrap(); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(400):Some(1000)"); + automation.reconcile(None, &mut io).unwrap(); + assert_eq!(io.calls.last().unwrap(), "write:a:Some(800):Some(1000)"); +} + +#[test] +fn device_switch_restores_old_identity_before_capturing_new_device() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + automation + .reconcile(Some(&profile("a", Some(1600), None)), &mut io) + .unwrap(); + automation + .reconcile(Some(&profile("b", Some(3200), None)), &mut io) + .unwrap(); + automation.reconcile(None, &mut io).unwrap(); + assert_eq!( + io.calls, + [ + "read:a", + "write:a:Some(1600):None", + "write:a:Some(800):None", + "read:b", + "write:b:Some(3200):None", + "write:b:Some(800):None" + ] + ); +} + +#[test] +fn helper_adapter_binds_read_and_write_to_full_profile_and_captured_identity() { + let mut requests = Vec::new(); + let captured = serde_json::json!({"identity":{"vendorId":1155,"productId":1,"path":"exact","serialNumber":"serial","interface":2},"dpi":800,"pollingRateHz":1000}); + let mut io = HelperIo(|request: &serde_json::Value| { + requests.push(request.clone()); + Ok(Some(serde_json::to_vec(&captured).unwrap())) + }); + let baseline = io.read("Zaunkoenig:M3K").unwrap(); + assert_eq!(baseline.dpi, 800); + assert_eq!(requests[0]["deviceId"], "Zaunkoenig:M3K"); + assert_eq!(requests[0]["action"], "automatic-read"); + let mut io = HelperIo(|request: &serde_json::Value| { + assert_eq!(request["identity"]["path"], "exact"); + assert_eq!(request["identity"]["serialNumber"], "serial"); + assert_eq!(request["deviceId"], "Zaunkoenig:M3K"); + assert_eq!(request["action"], "automatic-apply"); + Ok(Some( + serde_json::to_vec(&serde_json::json!({"identity":captured["identity"]})).unwrap(), + )) + }); + io.write( + "Zaunkoenig:M3K", + &baseline, + &profile("a", Some(1600), None).settings, + ) + .unwrap(); + let mut io = HelperIo(|_: &serde_json::Value| -> Result>> { + panic!("unsupported driver must not launch a helper"); + }); + assert!(io.read("Pulsar:Mouse").is_err()); +} + +#[test] +fn helper_adapter_rejects_changed_acknowledgement_identity() { + let mut source = FakeIo::default(); + let baseline = source.read("a").unwrap(); + let mut io = HelperIo(|_: &serde_json::Value| { + Ok(Some(br#"{"identity":{"vendorId":1,"productId":2,"path":"wrong","serialNumber":"serial","interface":0}}"#.to_vec())) + }); + assert!( + io.write( + "Zaunkoenig:M3K", + &baseline, + &profile("a", Some(1600), None).settings + ) + .is_err() + ); +} + +#[test] +fn captures_before_write_and_restores_original_not_handoff() { + let mut automation = NativeAutomation::default(); + let mut io = FakeIo::default(); + automation + .reconcile(Some(&profile("a", Some(1600), Some(2000))), &mut io) + .unwrap(); + automation + .reconcile(Some(&profile("a", Some(3200), Some(4000))), &mut io) + .unwrap(); + automation.reconcile(None, &mut io).unwrap(); + assert_eq!( + io.calls, + [ + "read:a", + "write:a:Some(1600):Some(2000)", + "write:a:Some(3200):Some(4000)", + "write:a:Some(800):Some(1000)" + ] + ); + assert!(automation.entries.is_empty()); +} diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index 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..2bd84cd 100644 --- a/src/games.rs +++ b/src/games.rs @@ -4,18 +4,108 @@ 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(entry) = seq.next_element::()? { + let serde_json::Value::String(name) = entry else { + continue; + }; + if name.len() > MAX_CATALOG_STRING + || name.trim().is_empty() + || name.chars().any(char::is_control) + { + continue; + } + if executables.len() >= MAX_EXECUTABLES { + return Err(serde::de::Error::custom( + "catalog executable count exceeds limit", + )); + } + executables.push(name); + } + Ok(executables) + } + } + deserializer.deserialize_seq(ExecutablesVisitor) +} + +fn deserialize_games<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + struct GamesVisitor; + impl<'de> serde::de::Visitor<'de> for GamesVisitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bounded array of game entries") + } + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut games = Vec::new(); + let mut game_count = 0; + while let Some(game) = seq.next_element::()? { + if game_count >= MAX_CATALOG_GAMES + || game.name.len() > MAX_CATALOG_STRING + || game.name.trim().is_empty() + || game.name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom( + "game catalog exceeds item limits or contains invalid strings", + )); + } + game_count += 1; + if !game.executables.is_empty() { + games.push(GameConfig { + name: game.name, + executables: game.executables, + }); + } + } + Ok(games) + } + } + deserializer.deserialize_seq(GamesVisitor) +} + pub fn catalog_url() -> Cow<'static, str> { env::var(GAMES_URL_ENV) .map(Cow::Owned) @@ -28,20 +118,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 +144,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 +212,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 +279,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 +430,84 @@ mod tests { ); } + #[test] + fn catalog_skips_invalid_executable_entries() { + let catalog = serde_json::json!({"games":[{ + "name":"Mixed", + "executables":["ok.exe", "", " ", "bad\u{0001}.exe", "x".repeat(MAX_CATALOG_STRING + 1), null, false, 42, {}, ["nested"], "other.exe"] + }]}); + let games = parse_catalog(&serde_json::to_vec(&catalog).unwrap()).unwrap(); + assert_eq!(games[0].executables, ["ok.exe", "other.exe"]); + } + + #[test] + fn catalog_drops_games_without_valid_executables() { + let catalog = serde_json::json!({"games":[ + {"name":"Invalid", "executables":["", "\n", null]}, + {"name":"Empty", "executables":[]}, + {"name":"Valid", "executables":["valid.exe"]} + ]}); + let games = parse_catalog(&serde_json::to_vec(&catalog).unwrap()).unwrap(); + assert_eq!( + games, + [GameConfig { + name: "Valid".into(), + executables: vec!["valid.exe".into()] + }] + ); + } + + #[test] + fn catalog_game_limit_counts_discarded_entries() { + let mut entries = + vec![serde_json::json!({"name":"Invalid", "executables":[]}); MAX_CATALOG_GAMES]; + entries.push(serde_json::json!({"name":"Valid", "executables":["valid.exe"]})); + let error = + parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":entries})).unwrap()) + .unwrap_err(); + assert!(format!("{error:#}").contains("game catalog exceeds item limits")); + } + + #[test] + fn catalog_limits_reject_bytes_counts_and_strings() { + let oversized = serde_json::json!({"games":[{"name":"x","executables":["x"]}],"padding":"x".repeat(MAX_CATALOG_BYTES)}); + assert!(parse_catalog(&serde_json::to_vec(&oversized).unwrap()).is_err()); + for game in [ + serde_json::json!({"name":"x".repeat(257),"executables":["x"]}), + serde_json::json!({"name":"x","executables":["x".repeat(257)]}), + serde_json::json!({"name":"x","executables":vec!["x";33]}), + serde_json::json!({"name":" ","executables":["x"]}), + serde_json::json!({"name":"x","executables":[""]}), + ] { + assert!( + parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":[game]})).unwrap()) + .is_err() + ); + } + let boundary = + serde_json::json!({"name":"x".repeat(256),"executables":vec!["x".repeat(256);32]}); + assert!( + parse_catalog(&serde_json::to_vec(&serde_json::json!({"games":[boundary]})).unwrap()) + .is_ok() + ); + let game = serde_json::json!({"name":"x","executables":["x"]}); + assert_eq!( + parse_catalog( + &serde_json::to_vec(&serde_json::json!({"games":vec![game.clone();10000]})) + .unwrap() + ) + .unwrap() + .len(), + 10000 + ); + assert!( + parse_catalog( + &serde_json::to_vec(&serde_json::json!({"games":vec![game;10001]})).unwrap() + ) + .is_err() + ); + } + #[test] fn empty_desktop_catalog_is_rejected() { assert!(parse_catalog(br#"{"games":[]}"#).is_err()); diff --git a/src/hid.rs b/src/hid.rs index 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/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(()) 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..282d4cb 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,457 @@ 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()))?; +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); + } } - fs::create_dir_all(&staging) - .with_context(|| format!("could not create {}", staging.display()))?; - extract_archive(archive, &staging)?; +} +fn stage_archive(archive: &[u8], version: &str) -> Result<()> { + Version::parse(version).context("invalid update version")?; + #[cfg(windows)] + let parent = directories::BaseDirs::new() + .context("could not locate private user cache")? + .cache_dir() + .to_path_buf(); + #[cfg(not(windows))] + let parent = env::temp_dir(); + let parent = fs::canonicalize(parent).context("could not resolve update staging parent")?; + let mut staging = StagingDirectory::new_in(&parent)?; + extract_archive(archive, &staging.path)?; + verify_package(&staging.path)?; let current_executable = env::current_exe().context("could not locate the Bridge executable")?; let install_directory = current_executable .parent() .context("Bridge executable has no parent directory")?; + ensure!( + current_executable.file_name() == Some(std::ffi::OsStr::new(platform_binary_name())), + "Bridge executable has an unexpected name" + ); verify_install_directory(install_directory)?; - spawn_install_helper(&staging, 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 options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); } - let mut output = File::create(&destination) + 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)?; + match fs::symlink_metadata(directory.join(".openmouse-update.lock")) { + Ok(_) => anyhow::bail!("Bridge update lock already exists"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("could not inspect Bridge update lock"), + } + for name in [ + platform_binary_name(), + "native-hid", + "openmouse-app-icon.png", + "DM-Sans-OFL.txt", + ] { + let path = directory.join(name); + match fs::symlink_metadata(&path) { + Ok(_) => verify_tree(&path)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + let _probe = + StagingDirectory::new_in(directory).context("Bridge install directory is not writable")?; + Ok(()) +} + +fn verify_tree(root: &Path) -> Result<()> { + let mut pending = vec![root.to_path_buf()]; + let mut count = 0; + while let Some(path) = pending.pop() { + count += 1; + ensure!( + count <= MAX_ARCHIVE_ENTRIES, + "update tree has too many entries" + ); + let metadata = reject_link(&path)?; + ensure!( + metadata.is_file() || metadata.is_dir(), + "unsupported update file type" + ); + if metadata.is_dir() { + for entry in fs::read_dir(&path)? { + pending.push(entry?.path()); + ensure!( + count + pending.len() <= MAX_ARCHIVE_ENTRIES, + "update tree has too many entries" + ); + } + } + } + Ok(()) +} + +fn write_helper(path: &Path, script: &str) -> Result<()> { + use std::io::Write; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + file.write_all(script.as_bytes()) + .context("could not write the update helper") } fn platform_binary_name() -> &'static str { @@ -226,31 +641,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 2 +transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock" || :; exit 2; } +committed=no +rollback() { + status=$? + trap - 0 1 2 15 + failed=no + if [ "$committed" != yes ]; then + for name in $names; do + if [ -e "$transaction/old/$name" ]; then + rm -rf "$destination/$name" && mv "$transaction/old/$name" "$destination/$name" || failed=yes + elif [ -f "$transaction/installed/$name" ]; then + rm -rf "$destination/$name" || failed=yes + fi + done + fi + if [ "$failed" = yes ]; then + printf '%s\n' "Update rollback failed; backups retained at $transaction" >&2 + exit 1 + fi + rm -rf "$transaction" + rmdir "$lock" + if [ "$committed" != yes ]; then exit 2; fi + exit "$status" +} +trap rollback 0 +trap 'exit 1' 1 2 15 +mkdir "$transaction/new" "$transaction/old" "$transaction/installed" || exit 1 +for name in $names; do + if [ -e "$destination/$name" ] || [ -L "$destination/$name" ]; then + special=$(find "$destination/$name" ! -type f ! -type d -print) || exit 1 + [ -z "$special" ] || exit 1 + fi + cp -R "$stage/$name" "$transaction/new/$name" || exit 1 + done +chmod u+x "$transaction/new/$binary" || exit 1 +for name in $names; do + if [ -e "$destination/$name" ]; then mv "$destination/$name" "$transaction/old/$name" || exit 1; fi + done +for name in $names; do + : > "$transaction/installed/$name" || exit 1 + mv "$transaction/new/$name" "$destination/$name" || exit 1 + done +committed=yes +) "#; + +#[cfg(any(unix, test))] +const UNIX_COMPLETION: &str = r#"complete_update() { + if install_package "$1" "$2" "$3"; then + : + else + status=$? + [ "$status" -eq 2 ] || return 1 + fi + "$4" "$2" "$3" +} +"#; + +#[cfg(any(target_os = "macos", target_os = "linux"))] +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 +741,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,105 +874,10 @@ 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 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/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..de0698d --- /dev/null +++ b/tests/linux_startup.sh @@ -0,0 +1,83 @@ +set -eu +export OPENMOUSE_INSTALL_SOURCED=1 +fixture_parent="$1" +set -- +source ./install-linux.sh +root=$(mktemp -d "$fixture_parent/bridge-startup.XXXXXXXX") +trap 'rm -rf "$root"' EXIT +executable='/opt/name-value/50%/A "$`B\bridge' +expected='Exec="/opt/name-value/50%%/A \\"\\$\\`B\\\\bridge"' +write_autostart "$root/autostart.desktop" "$executable" +write_launcher "$root/launcher.desktop" "$executable" '/opt/icon.png' +for entry in "$root/autostart.desktop" "$root/launcher.desktop"; do + actual=$(while IFS= read -r line; do case "$line" in Exec=*) printf '%s' "$line";; esac; done < "$entry") + [ "$actual" = "$expected" ] || { printf 'Incorrect Exec entry: %s\n' "$actual" >&2; exit 1; } +done +printf '%s' sentinel > "$root/equals.desktop" +if write_autostart "$root/equals.desktop" '/opt/name=value/bridge'; then + printf '%s\n' 'Autostart accepted an equals executable path' >&2 + exit 1 +fi +[ "$(cat "$root/equals.desktop")" = sentinel ] +if write_launcher "$root/equals.desktop" '/opt/name=value/bridge' '/opt/icon.png'; then + printf '%s\n' 'Launcher accepted an equals executable path' >&2 + exit 1 +fi +[ "$(cat "$root/equals.desktop")" = sentinel ] +if write_autostart "$root/invalid.desktop" $'/opt/invalid\nentry'; then exit 1; fi +if write_launcher "$root/invalid.desktop" '/opt/bridge' $'/opt/invalid\nicon'; then exit 1; fi +[ "$(HOME=/home/fixture XDG_CONFIG_HOME=/xdg/config startup_config_home)" = /xdg/config ] +[ "$(HOME=/home/fixture XDG_CONFIG_HOME=relative startup_config_home)" = /home/fixture/.config ] +for code in {1..31} 127; do + printf -v octal '%03o' "$code" + printf -v control '%b' "\\$octal" + printf '%s' sentinel > "$root/invalid.desktop" + if write_autostart "$root/invalid.desktop" "/opt/a${control}b"; then exit 1; fi + if write_launcher "$root/invalid.desktop" "/opt/a${control}b" '/opt/icon.png'; then exit 1; fi + if write_launcher "$root/invalid.desktop" '/opt/bridge' "/opt/a${control}b"; then exit 1; fi + [ "$(cat "$root/invalid.desktop")" = sentinel ] +done +for fixture_case in valid autostart launcher equals-autostart equals-launcher; do + call_root="$root/$fixture_case" + mkdir -p "$call_root" + set +e + ( + set -e + export HOME="$call_root/home" XDG_CONFIG_HOME="$call_root/config" + case "$fixture_case" in + valid) INSTALL_DIR="$call_root/name-value" ;; + equals-*) INSTALL_DIR="$call_root/name=value" ;; + *) INSTALL_DIR="$call_root/"$'invalid\tpath' ;; + esac + BIN_LINK_DIR="$call_root/bin" + WITH_DEPS=false + WITH_UDEV=false + case "$fixture_case" in *launcher) WITH_AUTOSTART=false ;; esac + uname() { printf '%s\n' x86_64; } + sudo_cmd() { printf '%s\n' none; } + detect_pm() { printf '%s\n' unknown; } + download() { :; } + sha256sum() { :; } + shasum() { :; } + unzip() { :; } + chmod() { :; } + ln() { :; } + missing_libs() { :; } + update_desktop_database() { :; } + check_optionals() { :; } + mktemp() { command mktemp -d "$call_root/download.XXXXXXXX"; } + main + ) > "$call_root/output" 2>&1 + status=$? + set -e + if [ "$fixture_case" = valid ]; then + [ "$status" -eq 0 ] + [ -f "$call_root/config/autostart/io.openmouse.bridge.desktop" ] + [ -f "$call_root/home/.local/share/applications/io.openmouse.bridge.desktop" ] + else + [ "$status" -ne 0 ] + grep -q "error: could not write ${fixture_case#equals-} entry" "$call_root/output" || { cat "$call_root/output"; exit 1; } + if grep -q 'Done.' "$call_root/output"; then exit 1; fi + fi +done +printf '%s\n' 'PASS: valid escaping, equals rejection with output preservation, all representable ASCII controls, and both inert production callsites'