diff --git a/Cargo.lock b/Cargo.lock index 5bc9681..d1750a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,9 +2937,12 @@ dependencies = [ "epaint_default_fonts", "gtk", "hidapi", + "libc", "notify-rust", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "png", "reqwest", @@ -2950,6 +2953,7 @@ dependencies = [ "skrifa", "sysinfo", "tokio", + "tower", "tower-http", "tracing", "tracing-appender", diff --git a/Cargo.toml b/Cargo.toml index e67c750..164890b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sysinfo = "0.37" -tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1.47", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower-http = { version = "0.6", features = ["cors", "set-header", "trace"] } tracing = "0.1" tracing-appender = "0.2" @@ -48,6 +48,8 @@ gtk = "0.18" # X11 overlay banner and foreground-window lookup. Pure Rust; no system # libraries beyond what eframe/tray-icon already require. x11rb = "0.13" +# Virtual keyboard and mouse for the button listener (/dev/uinput). +libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" @@ -70,6 +72,9 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSWorkspace", ] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSArray", "NSData", "NSGeometry", "NSString"] } +# Posts the key and button events the button listener presses. +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFCGTypes"] } +objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["std", "CGEvent", "CGEventTypes", "CGRemoteOperation"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61", features = [ @@ -82,6 +87,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index 8e11ba9..4f33a85 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,16 @@ 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/games` returns the full executable catalog currently being tracked, + including the user's custom games. +- `GET /v1/custom-games` returns the games the user added by hand; + `PUT /v1/custom-games` replaces them (`[{ name, executables }]`), persists + them under `customGames` in `config.json` and answers with the saved list. + They live apart from the catalog, which is replaced on every start. +- `GET /v1/custom-games/image?name=…` returns the image saved for a custom + game (404 when it has none); `PUT` with a PNG, JPEG or WebP body (at most + 512 KB) saves one. Images live in a `custom-game-images` folder beside + `config.json` and go away with the game. - `GET /v1/applications` lists running games and identifies the foreground game. Only applications from the registered catalog are returned. Each item includes an `iconId`; requesting diff --git a/install-linux.sh b/install-linux.sh index 2313a57..383a006 100644 --- a/install-linux.sh +++ b/install-linux.sh @@ -197,6 +197,9 @@ write_udev_rule() { for vid in $VENDOR_IDS; do printf 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="%s", MODE="0666", TAG+="uaccess"\n' "$vid" done + echo "# The button listener presses keys and side buttons through a virtual keyboard and mouse." + # No SUBSYSTEM match here: the installer test counts those, one per vendor id. + echo 'KERNEL=="uinput", MODE="0660", TAG+="uaccess", OPTIONS+="static_node=uinput"' } >"$path" } @@ -280,7 +283,7 @@ do_uninstall() { if [ "$sudo" = "none" ]; then die "cannot remove $rule without root; delete it manually" fi - $sudo rm -f "$rule" + $sudo rm -f "$rule" /etc/modules-load.d/openmouse-bridge.conf $sudo udevadm control --reload-rules 2>/dev/null || true fi update_desktop_database @@ -419,8 +422,12 @@ main() { log "Installing hidraw udev rule..." write_udev_rule "$tmp/69-openmouse-bridge.rules" $sudo cp "$tmp/69-openmouse-bridge.rules" "$rule" + # /dev/uinput only exists once the uinput module is loaded. + echo uinput | $sudo tee /etc/modules-load.d/openmouse-bridge.conf >/dev/null + $sudo modprobe uinput 2>/dev/null || true $sudo udevadm control --reload-rules 2>/dev/null || true $sudo udevadm trigger --subsystem-match=hidraw --action=change 2>/dev/null || true + $sudo udevadm trigger --subsystem-match=misc --sysname-match=uinput --action=change 2>/dev/null || true log "Unplug and replug the mouse (or reboot) so the new rule applies." fi else diff --git a/native-hid/README.md b/native-hid/README.md index 45d8c8e..fa406c3 100644 --- a/native-hid/README.md +++ b/native-hid/README.md @@ -48,6 +48,16 @@ fall through to the generic `AttackSharkHidClient` path, which drives the 0x04 six-stage table for model `0x55` (mouse-protocol #174), preserving the stages the request does not touch. +## Button listener + +`button-listener.mjs` is the one long-running helper. While a game's profile assigns +the side buttons of a Logitech mouse, Bridge (`src/buttons.rs`) starts it with +`{"brand": "Logitech"}` on stdin. It puts the mouse on the Host profile if it was +on an onboard one, turns the mouse's button events on, and prints +`{"event": "buttons", "mask": 16}` lines; Bridge presses the assigned key. Closing +stdin stops it: the events go off and the mouse returns to the mode it was in. +It needs an `@openmouse/protocol` release that has `startButtonListener`. + ## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`) - `0` — a device was found and the requested settings were applied. In diff --git a/native-hid/src/button-listener.mjs b/native-hid/src/button-listener.mjs new file mode 100644 index 0000000..07def5b --- /dev/null +++ b/native-hid/src/button-listener.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// Long-running helper Bridge spawns while a game profile has button +// assignments for a Logitech mouse running from software (Host mode). +// +// stdin: one JSON line to start, e.g. {"brand": "Logitech", "device": "PRO X 3 SUPERSTRIKE"} +// ("device" picks that mouse when several of the brand are connected); add +// "requireHost": true to listen only to a mouse already on Host. Closing stdin (or +// SIGTERM/SIGINT) stops it: the listener is switched off and the mouse goes back +// to the mode it was in, if this helper changed it. +// stdout: one JSON object per line. +// {"event": "ready", "priorMode": "Onboard", "buttons": [1, 2, 3, 4, 5]} +// {"event": "buttons", "mask": 16} — the buttons held right now +// {"event": "error", "message": "..."} +// {"event": "stopped"} +// +// Exit codes: 0 stopped cleanly, 3 no driver for the brand, 1 anything else. +// See apply.mjs for why `window` is aliased before any driver is imported. +globalThis.window ??= globalThis; + +import { createInterface } from "node:readline"; + +import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; +import { candidateDevices } from "./hid-device-adapter.mjs"; + +const EXIT_OK = 0; +const EXIT_FAILED = 1; +const EXIT_NO_DRIVER = 3; +const STEP_TIMEOUT_MS = 3000; + +const emit = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** Opens a candidate class on one device and reads its status, or rejects and closes it. */ +async function probe(device, candidate) { + const module = await import(candidate.module); + const client = new module[candidate.exportName](device); + try { + await withTimeout(client.open(), STEP_TIMEOUT_MS, `${candidate.exportName}.open()`); + const status = await withTimeout(client.readStatus(), STEP_TIMEOUT_MS, `${candidate.exportName}.readStatus()`); + return { client, status }; + } catch (error) { + await device.close().catch(() => undefined); + throw error; + } +} + +// `wantedName` is the mouse the profile names. With several mice of the brand +// connected only that one is used; with a single one it is used whatever its +// name, so a name spelled differently never leaves it without a listener. +async function findClient(entry, wantedName) { + const attempts = []; + const others = []; + const closeOthers = () => Promise.all(others.map((other) => other.client.close().catch(() => undefined))); + for (const vendorId of entry.vendorIds) { + for (const device of candidateDevices(vendorId)) { + for (const candidate of entry.classes) { + try { + const found = await probe(device, candidate); + // The listener only exists on a client built from a protocol release that has it. + if (typeof found.client.startButtonListener !== "function") { + await found.client.close().catch(() => undefined); + attempts.push(`${candidate.exportName}: this protocol version has no button listener`); + continue; + } + if (!wantedName || String(found.status?.name ?? "").toLowerCase() === wantedName.toLowerCase()) { + await closeOthers(); + return found; + } + others.push(found); + break; + } catch (error) { + attempts.push(`${candidate.exportName} on ${device.productName || "unknown device"}: ${error.message}`); + } + } + } + } + if (others.length === 1) return others[0]; + await closeOthers(); + if (others.length > 1) throw new Error(`none of the ${others.length} connected mice is named "${wantedName}"`); + throw new Error(`no mouse answered. ${attempts.join("; ")}`); +} + +async function main(input) { + const entry = BRAND_DRIVERS[brandKey(typeof input.brand === "string" ? input.brand : "")]; + if (!entry) { + emit({ event: "error", message: `no driver registered for brand "${input.brand}"` }); + process.exit(EXIT_NO_DRIVER); + } + + const { client, status } = await findClient(entry, typeof input.device === "string" ? input.device.trim() : ""); + const priorMode = status?.deviceMode ?? "Unknown"; + // The default profile only listens to a mouse already on Host; it never + // switches one over, so a mouse on an onboard profile is left alone. + if (input.requireHost === true && priorMode !== "Host") { + emit({ event: "error", message: "the mouse is not on the Host profile" }); + await client.close().catch(() => undefined); + process.exit(EXIT_FAILED); + } + let changedMode = false; + let stopping = false; + + // `code` is how the helper ends: a failed start is a failure, not a clean stop. + async function shutdown(code = EXIT_OK) { + if (stopping) return; + stopping = true; + await withTimeout(client.stopButtonListener(), STEP_TIMEOUT_MS, "stopButtonListener").catch(() => undefined); + if (changedMode) { + await withTimeout(client.setOnboardMode(priorMode), STEP_TIMEOUT_MS, "setOnboardMode").catch(() => undefined); + } + await client.close().catch(() => undefined); + emit({ event: "stopped" }); + process.exit(code); + } + + // Event handlers pass their own arguments (a signal name), which are not an exit code. + const stop = () => void shutdown(); + process.stdin.on("end", stop); + process.stdin.on("close", stop); + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + + try { + // The events only flow while the mouse runs from software; an unknown mode + // is left alone and the listener is tried as it is. + if (priorMode === "Onboard") { + // Marked first: the mouse may switch and then fail to confirm it, and it still has to go back. + changedMode = true; + await withTimeout(client.setOnboardMode("Host"), STEP_TIMEOUT_MS, "setOnboardMode"); + } + client.onButtonChange((mask) => emit({ event: "buttons", mask })); + const buttons = await withTimeout(client.startButtonListener(), STEP_TIMEOUT_MS, "startButtonListener"); + emit({ event: "ready", priorMode, buttons }); + } catch (error) { + emit({ event: "error", message: error.message }); + await shutdown(EXIT_FAILED); + } +} + +const lines = createInterface({ input: process.stdin }); +lines.once("line", (line) => { + let input; + try { + input = JSON.parse(line); + } catch { + emit({ event: "error", message: "the first line on stdin must be JSON" }); + process.exit(EXIT_FAILED); + } + main(input).catch((error) => { + emit({ event: "error", message: error.message }); + process.exit(EXIT_FAILED); + }); +}); diff --git a/src/api.rs b/src/api.rs index e92039f..bf5883f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,8 +2,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration}; use axum::{ Json, Router, - body::Body, - extract::{Path, State, ws::WebSocketUpgrade}, + body::{Body, Bytes}, + extract::{Path, Query, State, ws::WebSocketUpgrade}, http::{HeaderMap, HeaderValue, Method, Response, StatusCode, header}, routing::{delete, get, put}, }; @@ -60,11 +60,23 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/status", get(status)) .route("/v1/handshake", put(handshake)) .route("/v1/games", get(games)) + .route( + "/v1/custom-games", + get(custom_games).put(replace_custom_games), + ) + .route( + "/v1/custom-games/image", + get(custom_game_image).put(set_custom_game_image), + ) + .route("/v1/custom-games/image-from-url", get(image_from_link)) .route("/v1/applications", get(applications)) .route("/v1/applications/{icon_id}/icon", get(application_icon)) .route("/v1/running-applications", get(running_applications)) .route("/v1/profiles", get(profiles).put(replace_profiles)) - .route("/v1/default-profile", put(set_default_profile)) + .route( + "/v1/default-profile", + get(default_profile).put(set_default_profile), + ) .route("/v1/native/settings", put(apply_native_settings)) .route("/v1/battery", put(record_battery)) .route("/v1/battery/{device_id}", delete(forget_battery)) @@ -148,6 +160,74 @@ async fn games(State(service): State) -> Json> { Json(service.games().await) } +async fn custom_games(State(service): State) -> Json> { + Json(service.custom_games().await) +} + +async fn replace_custom_games( + State(service): State, + Json(games): Json>, +) -> Result>, (StatusCode, String)> { + service + .set_custom_games(games) + .await + .map(Json) + .map_err(internal_error) +} + +#[derive(Debug, Deserialize)] +struct GameImageQuery { + name: String, +} + +async fn custom_game_image( + State(service): State, + Query(query): Query, +) -> Result, StatusCode> { + let (image, kind) = service + .custom_game_image(&query.name) + .await + .ok_or(StatusCode::NOT_FOUND)?; + Response::builder() + .header(header::CONTENT_TYPE, kind) + // Revalidated every time: an image can be replaced under the same name. + .header(header::CACHE_CONTROL, "private, no-cache") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(Body::from(image)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +#[derive(Debug, Deserialize)] +struct ImageLinkQuery { + url: String, +} + +async fn image_from_link( + Query(query): Query, +) -> Result, (StatusCode, String)> { + let (image, kind) = crate::remote_image::fetch(&query.url) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Response::builder() + .header(header::CONTENT_TYPE, kind) + .header(header::CACHE_CONTROL, "no-store") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(Body::from(image)) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string())) +} + +async fn set_custom_game_image( + State(service): State, + Query(query): Query, + body: Bytes, +) -> Result, (StatusCode, String)> { + service + .set_custom_game_image(&query.name, &body) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(ApiResult { ok: true })) +} + async fn replace_profiles( State(service): State, Json(payload): Json, @@ -159,6 +239,10 @@ async fn replace_profiles( Ok(Json(ApiResult { ok: true })) } +async fn default_profile(State(service): State) -> Json> { + Json(service.default_profile().await) +} + async fn set_default_profile( State(service): State, Json(profile): Json, @@ -231,3 +315,155 @@ fn internal_error(error: anyhow::Error) -> (StatusCode, String) { tracing::error!(%error, "Bridge request failed"); (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) } + +#[cfg(test)] +mod tests { + use axum::{ + body::to_bytes, + http::{Method, Request}, + }; + use tower::ServiceExt; + + use super::*; + use crate::{config::BridgeConfig, remote_image::PNG_SIGNATURE}; + + fn app() -> (Router, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "bridge-api-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let service = BridgeService::new(BridgeConfig::default(), dir.join("config.json")); + (router(service, &[]), dir) + } + + async fn call( + app: &Router, + method: Method, + uri: &str, + content_type: &str, + body: Vec, + ) -> (StatusCode, Vec, Option) { + let request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(body)) + .expect("request"); + let response = app.clone().oneshot(request).await.expect("response"); + let status = response.status(); + let kind = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body") + .to_vec(); + (status, bytes, kind) + } + + #[tokio::test] + async fn a_custom_game_keeps_the_image_saved_for_it() { + let (app, dir) = app(); + let games = br#"[{"name":"My Game","executables":["MyGame.exe"]}]"#.to_vec(); + let (status, saved, _) = call( + &app, + Method::PUT, + "/v1/custom-games", + "application/json", + games, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + String::from_utf8_lossy(&saved).contains("mygame.exe"), + "the list comes back normalized" + ); + + let mut png = PNG_SIGNATURE.to_vec(); + png.extend_from_slice(&[1, 2, 3]); + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=My%20Game", + "image/png", + png.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, bytes, kind) = call( + &app, + Method::GET, + "/v1/custom-games/image?name=my%20game", + "", + vec![], + ) + .await; + assert_eq!( + (status, bytes, kind.as_deref()), + (StatusCode::OK, png, Some("image/png")) + ); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[tokio::test] + async fn an_image_is_refused_for_an_unknown_game_or_a_file_that_is_not_an_image() { + let (app, dir) = app(); + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=Nope", + "image/png", + PNG_SIGNATURE.to_vec(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "the game must exist"); + let games = br#"[{"name":"Game","executables":["game.exe"]}]"#.to_vec(); + call( + &app, + Method::PUT, + "/v1/custom-games", + "application/json", + games, + ) + .await; + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=Game", + "image/png", + b"".to_vec(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "only real images are kept"); + let (status, ..) = call( + &app, + Method::GET, + "/v1/custom-games/image?name=Game", + "", + vec![], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[tokio::test] + async fn a_link_into_the_local_network_is_refused() { + let (app, dir) = app(); + let (status, message, _) = call( + &app, + Method::GET, + "/v1/custom-games/image-from-url?url=http%3A%2F%2F192.168.1.1%2Flogo.png", + "", + vec![], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(String::from_utf8_lossy(&message).contains("cannot be reached")); + std::fs::remove_dir_all(dir).expect("cleanup"); + } +} diff --git a/src/buttons.rs b/src/buttons.rs new file mode 100644 index 0000000..96f5989 --- /dev/null +++ b/src/buttons.rs @@ -0,0 +1,1003 @@ +//! Button assignments for a Logitech mouse running from software (Host mode). +//! +//! A mouse on the Host profile stops sending its side buttons as ordinary +//! clicks; it reports them as events instead, and something on the PC has to +//! act on them (G HUB does). While a game's profile has assignments, Bridge +//! runs `native-hid/src/button-listener.mjs`, which puts the mouse on Host, turns +//! those events on and prints them, and then presses the assigned key, or the +//! side button's normal Back/Forward click when nothing is assigned. When the +//! game ends the helper stops the events and puts the mouse back in the mode +//! it was in. +//! +//! The assignments live in a game profile's `snapshot.hostButtons`: +//! `{"8": {"key": 58, "modifiers": 0}, "16": {...}}`, keyed by the button's bit +//! in the mouse's report (8 the back side button, 16 the front one), each a HID +//! keyboard usage and a modifier mask (1 ctrl, 2 shift, 4 alt, 8 win). + +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Write}, + process::{Child, ChildStdin}, + sync::{Arc, Mutex, PoisonError, TryLockError, mpsc}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use serde_json::Value; + +use crate::config::ApplicationProfile; + +/// The back side button's bit in the mouse's button report. +pub const BACK: u8 = 0x08; +/// The front side button's bit. +pub const FRONT: u8 = 0x10; +/// The only buttons a Host mode mouse stops sending as normal clicks. +const SIDE_BUTTONS: [u8; 2] = [BACK, FRONT]; + +/// How long the helper gets to stop the events and restore the mouse's mode: it +/// allows itself three seconds for each of the two, then closes the mouse. +const STOP_TIMEOUT: Duration = Duration::from_secs(8); +/// How long a starting helper may hold the mouse before Bridge stops waiting. +const START_TIMEOUT: Duration = Duration::from_secs(12); +/// How long to leave a mouse alone after the helper could not start or died. +const RETRY_DELAY: Duration = Duration::from_secs(15); + +/// A keyboard key and its modifiers, as a profile stores them (HID usage). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyAction { + pub usage: u8, + pub modifiers: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Key(KeyAction), + /// A media key, by its HID consumer usage (volume, play/pause, ...). + Media(u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + Down(Action), + Up(Action), + /// Press the side button's normal click: `BACK` is Back, `FRONT` is Forward. + SideDown(u8), + SideUp(u8), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Assignments { + /// The brand the profile names ("Logitech"), which the helper picks its driver by. + pub brand: String, + /// The mouse's name as the profile stores it ("PRO X 3 SUPERSTRIKE"), so the + /// helper listens to that mouse when several of the brand are connected. + pub device: String, + pub actions: BTreeMap, + /// Set for the default profile: it only listens to a mouse that is already + /// on the Host profile, and never switches one to it. + pub require_host: bool, +} + +impl Assignments { + pub fn from_profile(profile: &ApplicationProfile, is_default: bool) -> Option { + let (brand, device) = profile + .device + .id + .split_once(':') + .unwrap_or((profile.device.id.as_str(), "")); + if !brand.eq_ignore_ascii_case("logitech") { + return None; + } + let buttons = profile + .settings + .snapshot + .as_ref()? + .get("hostButtons")? + .as_object()?; + let mut actions = BTreeMap::new(); + for (bit, action) in buttons { + let bit: u8 = bit.parse().ok().filter(|bit| SIDE_BUTTONS.contains(bit))?; + let action = if let Some(usage) = action.get("consumer") { + let usage = u16::try_from(usage.as_u64()?).ok()?; + media_virtual_key(usage)?; + Action::Media(usage) + } else { + let usage = u8::try_from(action.get("key")?.as_u64()?).ok()?; + // A key this system cannot press would also swallow the button's normal click. + can_press(usage).then_some(())?; + let modifiers = + u8::try_from(action.get("modifiers").and_then(Value::as_u64).unwrap_or(0)) + .ok() + .filter(|modifiers| *modifiers <= 0x0f)?; + Action::Key(KeyAction { usage, modifiers }) + }; + actions.insert(bit, action); + } + (!actions.is_empty()).then(|| Self { + brand: brand.to_owned(), + device: device.to_owned(), + actions, + require_host: is_default, + }) + } +} + +/// What each held side button does, as it was when it went down: `None` is its +/// normal click. A press uses the assignments of that moment and its release the +/// same action, even if the profile changed while the button was held. +pub type HeldButtons = BTreeMap>; + +pub fn transitions( + held: &mut HeldButtons, + assignments: &Assignments, + previous: u8, + now: u8, +) -> Vec { + let mut steps = Vec::new(); + for bit in SIDE_BUTTONS { + let (was, is) = (previous & bit != 0, now & bit != 0); + if was == is { + continue; + } + if is { + let action = assignments.actions.get(&bit).copied(); + held.insert(bit, action); + steps.push(action.map_or(Step::SideDown(bit), Step::Down)); + } else { + let action = held + .remove(&bit) + .unwrap_or_else(|| assignments.actions.get(&bit).copied()); + steps.push(action.map_or(Step::SideUp(bit), Step::Up)); + } + } + steps +} + +/// The steps for a change of the held buttons, all under one set of assignments. +pub fn steps(assignments: &Assignments, previous: u8, now: u8) -> Vec { + let mut held = HeldButtons::new(); + for bit in SIDE_BUTTONS { + if previous & bit != 0 { + held.insert(bit, assignments.actions.get(&bit).copied()); + } + } + transitions(&mut held, assignments, previous, now) +} + +/// How many held assignments share each key code. A shared modifier goes down +/// once and comes up with the last assignment holding it, not the first. +#[derive(Default)] +pub struct HeldKeys(BTreeMap); + +impl HeldKeys { + pub const fn new() -> Self { + Self(BTreeMap::new()) + } + + /// True when the key was not down yet, so it has to be pressed. + pub fn press(&mut self, key: u16) -> bool { + let count = self.0.entry(key).or_default(); + *count += 1; + *count == 1 + } + + /// True when no other assignment still holds the key, so it has to be released. + pub fn release(&mut self, key: u16) -> bool { + match self.0.get_mut(&key) { + Some(count) if *count > 1 => { + *count -= 1; + false + } + Some(_) => { + self.0.remove(&key); + true + } + None => true, + } + } +} + +/// Whether Bridge can press the key on this system. A Mac has no key for Print +/// Screen, Scroll Lock, Pause or F21 to F24, which the other systems do. +fn can_press(usage: u8) -> bool { + if cfg!(target_os = "macos") { + keymap::mac_key(usage).is_some() + } else { + virtual_key(usage).is_some() + } +} + +/// The Windows virtual-key code for a HID keyboard usage, for the keys a +/// profile can hold. +pub fn virtual_key(usage: u8) -> Option { + Some(match usage { + 0x04..=0x1d => 0x41 + u16::from(usage - 0x04), // A-Z + 0x1e..=0x26 => 0x31 + u16::from(usage - 0x1e), // 1-9 + 0x27 => 0x30, // 0 + 0x28 => 0x0d, // Enter + 0x29 => 0x1b, // Escape + 0x2a => 0x08, // Backspace + 0x2b => 0x09, // Tab + 0x2c => 0x20, // Space + 0x2d => 0xbd, // - + 0x2e => 0xbb, // = + 0x2f => 0xdb, // [ + 0x30 => 0xdd, // ] + 0x31 => 0xdc, // \ + 0x33 => 0xba, // ; + 0x34 => 0xde, // ' + 0x35 => 0xc0, // ` + 0x36 => 0xbc, // , + 0x37 => 0xbe, // . + 0x38 => 0xbf, // / + 0x3a..=0x45 => 0x70 + u16::from(usage - 0x3a), // F1-F12 + 0x46 => 0x2c, // Print Screen + 0x47 => 0x91, // Scroll Lock + 0x48 => 0x13, // Pause + 0x49 => 0x2d, // Insert + 0x4a => 0x24, // Home + 0x4b => 0x21, // Page Up + 0x4c => 0x2e, // Delete + 0x4d => 0x23, // End + 0x4e => 0x22, // Page Down + 0x4f => 0x27, // Right + 0x50 => 0x25, // Left + 0x51 => 0x28, // Down + 0x52 => 0x26, // Up + 0x68..=0x73 => 0x7c + u16::from(usage - 0x68), // F13-F24 + _ => return None, + }) +} + +/// The Windows virtual-key code for a HID consumer usage (media key). +pub fn media_virtual_key(usage: u16) -> Option { + Some(match usage { + 0xe2 => 0xad, // Mute + 0xe9 => 0xaf, // Volume up + 0xea => 0xae, // Volume down + 0xcd => 0xb3, // Play / pause + 0xb5 => 0xb0, // Next track + 0xb6 => 0xb1, // Previous track + 0xb7 => 0xb2, // Stop + _ => return None, + }) +} + +/// The modifier keys a mask names, in the order they are pressed. +pub fn modifier_keys(modifiers: u8) -> Vec { + [(0x01, 0x11), (0x02, 0x10), (0x04, 0x12), (0x08, 0x5b)] // ctrl, shift, alt, win + .into_iter() + .filter(|(bit, _)| modifiers & bit != 0) + .map(|(_, vk)| vk) + .collect() +} + +#[derive(Default)] +pub struct ButtonListener { + running: Mutex>, + /// Not before this moment is a helper that failed started again. + retry_after: Mutex>, +} + +struct Running { + /// Shared with the listener thread, so new assignments reach it without a restart. + assignments: Arc>, + stop: mpsc::Sender<()>, + thread: JoinHandle<()>, +} + +impl Running { + /// Asks the helper to stop and waits for it to restore the mouse. + fn stop(self) { + let _ = self.stop.send(()); + let _ = self.thread.join(); + } + + fn matches(&self, desired: Option<&Assignments>) -> bool { + let mine = self + .assignments + .lock() + .unwrap_or_else(PoisonError::into_inner); + Some(&*mine) == desired && !self.thread.is_finished() + } + + fn retarget(&self, wanted: &Assignments) -> bool { + if self.thread.is_finished() { + return false; + } + let mut mine = self + .assignments + .lock() + .unwrap_or_else(PoisonError::into_inner); + if mine.brand != wanted.brand + || mine.device != wanted.device + || mine.require_host != wanted.require_host + { + return false; + } + if *mine != *wanted { + *mine = wanted.clone(); + tracing::info!("Updated the button assignments"); + } + true + } +} + +impl ButtonListener { + /// Makes the helper match `desired`: started for new assignments, stopped + /// when there are none. `may_start` is false while a control panel holds + /// the mouse, so a new helper does not open it underneath. Stopping and + /// starting wait on the helper, so they run off the caller's thread. + pub fn update(self: &Arc, desired: Option, may_start: bool) { + // New keys for a listener that is already running need no helper at + // all, so they are applied at once even while a panel holds the mouse. + // The caller is the async monitor loop, and a helper starting or stopping + // holds `running` for seconds, so a busy lock means "ask again next tick". + let Some(running) = self.try_running() else { + return; + }; + if let Some(wanted) = desired.as_ref() + && running + .as_ref() + .is_some_and(|current| current.retarget(wanted)) + { + return; + } + let in_step = running.as_ref().map_or(desired.is_none(), |current| { + current.matches(desired.as_ref()) + }); + let holds_old = running.is_some(); + drop(running); + if in_step { + return; + } + // A panel holding the mouse keeps a new helper from starting. One for a profile + // that is no longer the active one must not keep pressing keys meanwhile, so it + // is stopped, and the new one starts once the panel lets go. + let desired = if desired.is_some() && !may_start { + if !holds_old { + return; + } + None + } else { + desired + }; + let waiting = *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner); + if desired.is_some() && waiting.is_some_and(|until| Instant::now() < until) { + return; + } + let listener = Arc::clone(self); + thread::spawn(move || listener.apply(desired)); + } + + /// The running helper, or `None` while another thread is starting or stopping one. + fn try_running(&self) -> Option>> { + match self.running.try_lock() { + Ok(guard) => Some(guard), + Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), + Err(TryLockError::WouldBlock) => None, + } + } + + fn apply(&self, desired: Option) { + let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); + if running.as_ref().map_or(desired.is_none(), |current| { + current.matches(desired.as_ref()) + }) { + return; + } + let died = running + .as_ref() + .is_some_and(|current| current.thread.is_finished()); + if let Some(old) = running.take() { + old.stop(); + } + // The lock is let go before the helper starts: starting can take seconds, and the + // monitor loop reads this deadline to decide whether to ask again. + *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner) = died.then(|| Instant::now() + RETRY_DELAY); + // A listener that just died waits out the cooldown before the mouse is probed again. + if died && desired.is_some() { + return; + } + if let Some(assignments) = desired { + match start(assignments) { + Ok(started) => *running = Some(started), + Err(error) => { + tracing::warn!(%error, "Could not start the button listener"); + *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner) = + Some(Instant::now() + RETRY_DELAY); + } + } + } + } +} + +impl Drop for ButtonListener { + fn drop(&mut self) { + let running = self + .running + .get_mut() + .unwrap_or_else(PoisonError::into_inner); + if let Some(old) = running.take() { + old.stop(); + } + } +} + +enum Event { + Buttons(u8), + Ready, + Failed(String), + Closed, +} + +fn start(assignments: Assignments) -> Result { + // The helper talks to the mouse while it starts, and so does a profile + // apply, so they take turns: this is held until the helper is listening. + inject::prepare(); + let access = crate::drivers::device_access(); + let mut child = crate::drivers::spawn_button_listener(&assignments.brand)?; + let stdin = child + .stdin + .take() + .context("the button helper's stdin was unavailable")?; + let stdout = child + .stdout + .take() + .context("the button helper's stdout was unavailable")?; + let (stop, stopped) = mpsc::channel(); + let (ready, started) = mpsc::channel(); + let shared = Arc::new(Mutex::new(assignments)); + let for_thread = Arc::clone(&shared); + let thread = thread::Builder::new() + .name("openmouse-bridge-buttons".into()) + .spawn(move || run(child, stdin, stdout, &for_thread, &stopped, &ready)) + .context("could not start the button listener thread")?; + let _ = started.recv_timeout(START_TIMEOUT); + drop(access); + Ok(Running { + assignments: shared, + stop, + thread, + }) +} + +fn run( + mut child: Child, + mut stdin: ChildStdin, + stdout: std::process::ChildStdout, + shared: &Mutex, + stopped: &mpsc::Receiver<()>, + ready: &mpsc::Sender<()>, +) { + let current = |shared: &Mutex| { + shared + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + }; + let first = current(shared); + let request = format!( + "{{\"brand\":{},\"device\":{},\"requireHost\":{}}}\n", + Value::from(first.brand.as_str()), + Value::from(first.device.as_str()), + first.require_host + ); + if stdin + .write_all(request.as_bytes()) + .and_then(|()| stdin.flush()) + .is_err() + { + tracing::warn!("Could not start the button helper"); + } + + let (events, incoming) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let message: Value = serde_json::from_str(&line).unwrap_or(Value::Null); + let event = match message.get("event").and_then(Value::as_str) { + Some("buttons") => message + .get("mask") + .and_then(Value::as_u64) + .and_then(|mask| u8::try_from(mask).ok()) + .map(Event::Buttons), + Some("ready") => Some(Event::Ready), + Some("error") => Some(Event::Failed( + message + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_owned(), + )), + _ => None, + }; + if let Some(event) = event + && events.send(event).is_err() + { + return; + } + } + let _ = events.send(Event::Closed); + }); + + let mut held = 0u8; + // What each held button does, from the moment it went down, so a key that was + // pressed is the key that is released even if the profile changed while it was held. + let mut in_use = HeldButtons::new(); + loop { + if !matches!(stopped.try_recv(), Err(mpsc::TryRecvError::Empty)) { + break; + } + match incoming.recv_timeout(Duration::from_millis(100)) { + Ok(Event::Buttons(mask)) => { + for step in transitions(&mut in_use, ¤t(shared), held, mask) { + tracing::info!(mask, ?step, "Acting on a mouse button"); + execute(step); + } + held = mask; + } + Ok(Event::Ready) => { + tracing::info!("The mouse's buttons are being listened to"); + let _ = ready.send(()); + } + Ok(Event::Failed(message)) => { + tracing::warn!(%message, "The button helper reported a problem"); + let _ = ready.send(()); + } + Ok(Event::Closed) | Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = ready.send(()); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + + // Let go of anything still held, then close stdin: the helper stops the + // events and puts the mouse back in the mode it was in. + for step in transitions(&mut in_use, ¤t(shared), held, 0) { + execute(step); + } + drop(stdin); + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + tracing::warn!("The button helper did not stop in time; ending it"); + // Collected as well: a killed child nobody waits for stays a zombie on Unix. + if child.kill().is_ok() { + let _ = child.wait(); + } +} + +mod keymap; + +#[cfg(target_os = "linux")] +#[path = "buttons/linux.rs"] +mod inject; + +#[cfg(target_os = "macos")] +#[path = "buttons/macos.rs"] +mod inject; + +/// No other system presses keys yet. +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +mod inject { + use super::Step; + + pub fn prepare() {} + + pub fn execute(step: Step) { + tracing::debug!( + ?step, + "Button actions are not carried out on this system yet" + ); + } +} + +fn execute(step: Step) { + inject::execute(step); +} + +#[cfg(target_os = "windows")] +mod inject { + use std::{ + mem::size_of, + sync::{Mutex, PoisonError}, + }; + + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, + KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_XDOWN, + MOUSEEVENTF_XUP, MOUSEINPUT, MapVirtualKeyW, SendInput, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; + + use super::{ + Action, BACK, HeldKeys, KeyAction, Step, media_virtual_key, modifier_keys, virtual_key, + }; + + static HELD: Mutex = Mutex::new(HeldKeys::new()); + + /// `SendInput` needs no setup. + pub fn prepare() {} + + pub fn execute(step: Step) { + match step { + Step::Down(Action::Key(action)) => keys(action, true), + Step::Up(Action::Key(action)) => keys(action, false), + Step::Down(Action::Media(usage)) => media(usage, true), + Step::Up(Action::Media(usage)) => media(usage, false), + Step::SideDown(bit) => side(bit, true), + Step::SideUp(bit) => side(bit, false), + } + } + + /// Media keys have no scan code of their own, so they go by virtual key. + fn media(usage: u16, down: bool) { + let Some(vk) = media_virtual_key(usage) else { + return; + }; + // Two buttons on the same media key: it comes up with the last of them. + let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); + if !(if down { + held.press(vk) + } else { + held.release(vk) + }) { + return; + } + let flags = KEYEVENTF_EXTENDEDKEY | if down { 0 } else { KEYEVENTF_KEYUP }; + send(&[INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }]); + } + + /// Modifiers go down before the key and come up after it. + fn keys(action: KeyAction, down: bool) { + let Some(key) = virtual_key(action.usage) else { + tracing::warn!(usage = action.usage, "That key cannot be pressed by Bridge"); + return; + }; + let mut order = modifier_keys(action.modifiers); + order.push(key); + if !down { + order.reverse(); + } + let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); + for vk in order { + // A modifier two held buttons share stays down until both are let go. + if if down { + held.press(vk) + } else { + held.release(vk) + } { + send(&[key_input(vk, down)]); + } + } + } + + fn key_input(vk: u16, down: bool) -> INPUT { + // The scan code lets games that read it see the key; the high byte + // 0xE0 marks an extended key (arrows, Insert, Home, ...). + let scan = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC_EX) }; + let mut flags = if down { 0 } else { KEYEVENTF_KEYUP }; + if scan != 0 { + flags |= KEYEVENTF_SCANCODE; + } + if scan >> 8 == 0xe0 { + flags |= KEYEVENTF_EXTENDEDKEY; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: if scan != 0 { 0 } else { vk }, + wScan: (scan & 0xff) as u16, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + + fn side(bit: u8, down: bool) { + let button = if bit == BACK { XBUTTON1 } else { XBUTTON2 }; + send(&[INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: u32::from(button), + dwFlags: if down { + MOUSEEVENTF_XDOWN + } else { + MOUSEEVENTF_XUP + }, + time: 0, + dwExtraInfo: 0, + }, + }, + }]); + } + + fn send(inputs: &[INPUT]) { + let sent = unsafe { + SendInput( + inputs.len() as u32, + inputs.as_ptr(), + size_of::() as i32, + ) + }; + if sent as usize != inputs.len() { + tracing::warn!("Windows did not accept the button input"); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::config::{ProfileApplication, ProfileDevice, ProfileSettings}; + + fn profile(device: &str, snapshot: Value) -> ApplicationProfile { + ApplicationProfile { + application: ProfileApplication { + name: "Game".into(), + executable: String::new(), + path: String::new(), + }, + device: ProfileDevice { + id: device.into(), + name: "Mouse".into(), + }, + settings: ProfileSettings { + dpi: None, + polling_rate_hz: None, + snapshot: Some(snapshot), + }, + enabled: true, + } + } + + fn assignments(keys: &[(u8, u8, u8)]) -> Assignments { + Assignments { + brand: "Logitech".into(), + device: "Mouse".into(), + actions: keys + .iter() + .map(|&(bit, usage, modifiers)| (bit, Action::Key(KeyAction { usage, modifiers }))) + .collect(), + require_host: false, + } + } + + #[test] + fn a_profile_names_the_mouse_it_is_for() { + let parsed = Assignments::from_profile( + &profile( + "Logitech:PRO X 3 SUPERSTRIKE", + json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}}}), + ), + false, + ) + .expect("assignments"); + assert_eq!(parsed.device, "PRO X 3 SUPERSTRIKE"); + } + + #[test] + fn a_held_button_keeps_its_action_while_another_follows_the_new_profile() { + let old = assignments(&[(BACK, 0x04, 0)]); + let new = assignments(&[(BACK, 0x05, 0), (FRONT, 0x06, 0)]); + let key = |usage| { + Action::Key(KeyAction { + usage, + modifiers: 0, + }) + }; + let mut held = HeldButtons::new(); + assert_eq!( + transitions(&mut held, &old, 0, BACK), + [Step::Down(key(0x04))] + ); + // The profile changes while Back is held: Forward uses the new one, Back is let go as pressed. + assert_eq!( + transitions(&mut held, &new, BACK, BACK | FRONT), + [Step::Down(key(0x06))] + ); + assert_eq!( + transitions(&mut held, &new, BACK | FRONT, FRONT), + [Step::Up(key(0x04))] + ); + assert_eq!( + transitions(&mut held, &new, FRONT, 0), + [Step::Up(key(0x06))] + ); + } + + #[test] + fn a_key_two_held_buttons_share_is_released_by_the_last_of_them() { + let mut keys = HeldKeys::new(); + assert!(keys.press(0x11), "the first Ctrl goes down"); + assert!(!keys.press(0x11), "a second one is already down"); + assert!(!keys.release(0x11), "letting one go keeps Ctrl down"); + assert!(keys.release(0x11), "the last one releases it"); + assert!(keys.release(0x11), "a key nobody holds is released anyway"); + } + + #[test] + fn a_profile_names_its_assignments_by_button_bit() { + let parsed = Assignments::from_profile(&profile( + "Logitech:PRO X3 SUPERSTRIKE", + json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}, "8": {"key": 4, "modifiers": 3}}}), + ), false) + .expect("assignments"); + assert_eq!(parsed.brand, "Logitech"); + assert_eq!( + parsed.actions[&FRONT], + Action::Key(KeyAction { + usage: 104, + modifiers: 0 + }) + ); + assert_eq!( + parsed.actions[&BACK], + Action::Key(KeyAction { + usage: 4, + modifiers: 3 + }) + ); + } + + #[test] + fn a_profile_can_assign_a_media_key() { + let parsed = Assignments::from_profile( + &profile( + "Logitech:Mouse", + json!({"hostButtons": {"16": {"consumer": 233}}}), + ), + true, + ) + .expect("assignments"); + assert_eq!(parsed.actions[&FRONT], Action::Media(233)); + assert!( + parsed.require_host, + "the default profile only listens to a mouse already on Host" + ); + let unknown = Assignments::from_profile( + &profile( + "Logitech:Mouse", + json!({"hostButtons": {"16": {"consumer": 1}}}), + ), + false, + ); + assert_eq!( + unknown, None, + "a media key Bridge cannot press is not accepted" + ); + } + + #[test] + fn profiles_without_usable_assignments_start_nothing() { + let logitech = + |snapshot| Assignments::from_profile(&profile("Logitech:Mouse", snapshot), false); + assert_eq!(logitech(json!({})), None); + assert_eq!(logitech(json!({"hostButtons": {}})), None); + assert_eq!( + logitech(json!({"hostButtons": {"4": {"key": 4}}})), + None, + "the middle button is not assignable" + ); + assert_eq!(logitech(json!({"hostButtons": {"8": {"key": 999}}})), None); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 0}}})), + None, + "a key Bridge cannot press would swallow the button's normal click" + ); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 0x46}}})).is_some(), + !cfg!(target_os = "macos"), + "Print Screen has no key on a Mac" + ); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 4, "modifiers": 99}}})), + None + ); + let other = profile("Razer:Viper", json!({"hostButtons": {"8": {"key": 4}}})); + assert_eq!( + Assignments::from_profile(&other, false), + None, + "only Logitech has the button events" + ); + } + + #[test] + fn a_press_and_release_become_one_key_down_and_up() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + let action = Action::Key(KeyAction { + usage: 0x68, + modifiers: 0, + }); + assert_eq!(steps(&assigned, 0, FRONT), [Step::Down(action)]); + assert_eq!( + steps(&assigned, FRONT, FRONT), + [], + "holding changes nothing" + ); + assert_eq!(steps(&assigned, FRONT, 0), [Step::Up(action)]); + } + + #[test] + fn an_unassigned_side_button_keeps_its_normal_click() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + assert_eq!(steps(&assigned, 0, BACK), [Step::SideDown(BACK)]); + assert_eq!(steps(&assigned, BACK, 0), [Step::SideUp(BACK)]); + } + + #[test] + fn the_other_buttons_are_left_to_the_mouse() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + // left, right and middle already click on their own + assert_eq!(steps(&assigned, 0, 0x01 | 0x02 | 0x04), []); + } + + #[test] + fn two_buttons_pressed_together_are_both_handled() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + assert_eq!( + steps(&assigned, 0, BACK | FRONT), + [ + Step::SideDown(BACK), + Step::Down(Action::Key(KeyAction { + usage: 0x68, + modifiers: 0 + })) + ] + ); + } + + #[test] + fn keys_map_to_windows_virtual_keys() { + assert_eq!(virtual_key(0x04), Some(0x41), "A"); + assert_eq!(virtual_key(0x1d), Some(0x5a), "Z"); + assert_eq!(virtual_key(0x1e), Some(0x31), "1"); + assert_eq!(virtual_key(0x27), Some(0x30), "0"); + assert_eq!(virtual_key(0x3a), Some(0x70), "F1"); + assert_eq!(virtual_key(0x45), Some(0x7b), "F12"); + assert_eq!(virtual_key(0x68), Some(0x7c), "F13"); + assert_eq!(virtual_key(0x73), Some(0x87), "F24"); + assert_eq!(virtual_key(0x2c), Some(0x20), "Space"); + assert_eq!(virtual_key(0x52), Some(0x26), "Up"); + assert_eq!(virtual_key(0x01), None); + assert_eq!(media_virtual_key(0xe9), Some(0xaf), "volume up"); + assert_eq!(media_virtual_key(0xcd), Some(0xb3), "play / pause"); + assert_eq!(media_virtual_key(0x01), None); + assert_eq!(modifier_keys(0b0101), [0x11, 0x12], "ctrl and alt"); + assert_eq!(modifier_keys(0), Vec::::new()); + } +} diff --git a/src/buttons/keymap.rs b/src/buttons/keymap.rs new file mode 100644 index 0000000..a3d4494 --- /dev/null +++ b/src/buttons/keymap.rs @@ -0,0 +1,221 @@ +//! HID keyboard and consumer usages as the key codes of Linux (evdev) and macOS +//! (virtual keycodes), for the keys a profile can hold. `buttons::virtual_key` +//! is the same list for Windows, so the three cover the same keys. +//! +//! Plain functions with no system calls, so they are tested on every platform +//! even though each is only used on one. +#![allow(dead_code)] + +/// Linux `KEY_A` to `KEY_Z`, in HID order (usage 0x04 is A). +const LINUX_LETTERS: [u16; 26] = [ + 30, 48, 46, 32, 18, 33, 34, 35, 23, 36, 37, 38, 50, 49, 24, 25, 16, 19, 31, 20, 22, 47, 17, 45, + 21, 44, +]; + +/// macOS `kVK_ANSI_A` to `kVK_ANSI_Z`, in HID order. +const MAC_LETTERS: [u16; 26] = [ + 0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6, +]; + +/// macOS keycodes of the digits 1 to 9 and 0, in HID order (usage 0x1e is 1). +const MAC_DIGITS: [u16; 10] = [18, 19, 20, 21, 23, 22, 26, 28, 25, 29]; + +/// The Linux `KEY_*` code for a HID keyboard usage. +pub fn linux_key(usage: u8) -> Option { + Some(match usage { + 0x04..=0x1d => LINUX_LETTERS[usize::from(usage - 0x04)], + 0x1e..=0x27 => 2 + u16::from(usage - 0x1e), // 1-9, then 0 (KEY_0 is 11) + 0x28 => 28, // Enter + 0x29 => 1, // Escape + 0x2a => 14, // Backspace + 0x2b => 15, // Tab + 0x2c => 57, // Space + 0x2d => 12, // - + 0x2e => 13, // = + 0x2f => 26, // [ + 0x30 => 27, // ] + 0x31 => 43, // \ + 0x33 => 39, // ; + 0x34 => 40, // ' + 0x35 => 41, // ` + 0x36 => 51, // , + 0x37 => 52, // . + 0x38 => 53, // / + 0x3a..=0x43 => 59 + u16::from(usage - 0x3a), // F1-F10 + 0x44 => 87, // F11 + 0x45 => 88, // F12 + 0x46 => 99, // Print Screen (KEY_SYSRQ) + 0x47 => 70, // Scroll Lock + 0x48 => 119, // Pause + 0x49 => 110, // Insert + 0x4a => 102, // Home + 0x4b => 104, // Page Up + 0x4c => 111, // Delete + 0x4d => 107, // End + 0x4e => 109, // Page Down + 0x4f => 106, // Right + 0x50 => 105, // Left + 0x51 => 108, // Down + 0x52 => 103, // Up + 0x68..=0x73 => 183 + u16::from(usage - 0x68), // F13-F24 + _ => return None, + }) +} + +/// The Linux `KEY_*` code for a HID consumer usage (media key). +pub fn linux_media_key(usage: u16) -> Option { + Some(match usage { + 0xe2 => 113, // Mute + 0xe9 => 115, // Volume up + 0xea => 114, // Volume down + 0xcd => 164, // Play / pause + 0xb5 => 163, // Next track + 0xb6 => 165, // Previous track + 0xb7 => 166, // Stop + _ => return None, + }) +} + +/// The Linux modifier keys a mask names (ctrl, shift, alt, meta), in the order they are pressed. +pub fn linux_modifier_keys(modifiers: u8) -> Vec { + modifier_codes(modifiers, [29, 42, 56, 125]) +} + +/// The macOS virtual keycode for a HID keyboard usage. Print Screen, Scroll +/// Lock, Pause and F21 to F24 have no key on a Mac. +pub fn mac_key(usage: u8) -> Option { + Some(match usage { + 0x04..=0x1d => MAC_LETTERS[usize::from(usage - 0x04)], + 0x1e..=0x27 => MAC_DIGITS[usize::from(usage - 0x1e)], + 0x28 => 36, // Return + 0x29 => 53, // Escape + 0x2a => 51, // Delete (backspace) + 0x2b => 48, // Tab + 0x2c => 49, // Space + 0x2d => 27, // - + 0x2e => 24, // = + 0x2f => 33, // [ + 0x30 => 30, // ] + 0x31 => 42, // \ + 0x33 => 41, // ; + 0x34 => 39, // ' + 0x35 => 50, // ` + 0x36 => 43, // , + 0x37 => 47, // . + 0x38 => 44, // / + 0x3a => 122, // F1 + 0x3b => 120, + 0x3c => 99, + 0x3d => 118, + 0x3e => 96, + 0x3f => 97, + 0x40 => 98, + 0x41 => 100, + 0x42 => 101, + 0x43 => 109, // F10 + 0x44 => 103, // F11 + 0x45 => 111, // F12 + 0x49 => 114, // Insert (kVK_Help) + 0x4a => 115, // Home + 0x4b => 116, // Page Up + 0x4c => 117, // Forward delete + 0x4d => 119, // End + 0x4e => 121, // Page Down + 0x4f => 124, // Right + 0x50 => 123, // Left + 0x51 => 125, // Down + 0x52 => 126, // Up + 0x68 => 105, // F13 + 0x69 => 107, // F14 + 0x6a => 113, // F15 + 0x6b => 106, // F16 + 0x6c => 64, // F17 + 0x6d => 79, // F18 + 0x6e => 80, // F19 + 0x6f => 90, // F20 + _ => return None, + }) +} + +/// The macOS modifier keys a mask names (control, shift, option, command), in the order they are pressed. +pub fn mac_modifier_keys(modifiers: u8) -> Vec { + modifier_codes(modifiers, [59, 56, 58, 55]) +} + +fn modifier_codes(modifiers: u8, codes: [u16; 4]) -> Vec { + codes + .into_iter() + .enumerate() + .filter(|(bit, _)| modifiers & (1 << bit) != 0) + .map(|(_, code)| code) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::buttons::virtual_key; + + #[test] + fn linux_keys_follow_the_evdev_codes() { + assert_eq!(linux_key(0x04), Some(30), "A"); + assert_eq!(linux_key(0x1d), Some(44), "Z"); + assert_eq!(linux_key(0x10), Some(50), "M"); + assert_eq!(linux_key(0x1e), Some(2), "1"); + assert_eq!(linux_key(0x26), Some(10), "9"); + assert_eq!(linux_key(0x27), Some(11), "0"); + assert_eq!(linux_key(0x3a), Some(59), "F1"); + assert_eq!(linux_key(0x43), Some(68), "F10"); + assert_eq!(linux_key(0x44), Some(87), "F11"); + assert_eq!(linux_key(0x68), Some(183), "F13"); + assert_eq!(linux_key(0x73), Some(194), "F24"); + assert_eq!(linux_key(0x2c), Some(57), "Space"); + assert_eq!(linux_key(0x52), Some(103), "Up"); + assert_eq!(linux_key(0x01), None); + assert_eq!(linux_media_key(0xe9), Some(115), "volume up"); + assert_eq!(linux_media_key(0x01), None); + assert_eq!(linux_modifier_keys(0b0101), [29, 56], "ctrl and alt"); + assert_eq!(linux_modifier_keys(0b1010), [42, 125], "shift and meta"); + } + + #[test] + fn mac_keys_follow_the_virtual_keycodes() { + assert_eq!(mac_key(0x04), Some(0), "A"); + assert_eq!(mac_key(0x1d), Some(6), "Z"); + assert_eq!(mac_key(0x0b), Some(4), "H"); + assert_eq!(mac_key(0x1e), Some(18), "1"); + assert_eq!(mac_key(0x22), Some(23), "5"); + assert_eq!(mac_key(0x27), Some(29), "0"); + assert_eq!(mac_key(0x3a), Some(122), "F1"); + assert_eq!(mac_key(0x45), Some(111), "F12"); + assert_eq!(mac_key(0x68), Some(105), "F13"); + assert_eq!(mac_key(0x2c), Some(49), "Space"); + assert_eq!(mac_key(0x52), Some(126), "Up"); + assert_eq!(mac_key(0x46), None, "Print Screen has no Mac key"); + assert_eq!(mac_modifier_keys(0b1001), [59, 55], "control and command"); + assert_eq!(mac_modifier_keys(0), Vec::::new()); + } + + #[test] + fn linux_covers_every_key_windows_does() { + for usage in 0..=u8::MAX { + assert_eq!( + linux_key(usage).is_some(), + virtual_key(usage).is_some(), + "usage {usage:#x}" + ); + } + } + + #[test] + fn keys_do_not_share_a_code() { + let mut linux: Vec = (0..=u8::MAX).filter_map(linux_key).collect(); + let mut mac: Vec = (0..=u8::MAX).filter_map(mac_key).collect(); + for codes in [&mut linux, &mut mac] { + let before = codes.len(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), before, "two usages map to one key code"); + } + } +} diff --git a/src/buttons/linux.rs b/src/buttons/linux.rs new file mode 100644 index 0000000..a62a8e6 --- /dev/null +++ b/src/buttons/linux.rs @@ -0,0 +1,200 @@ +//! Presses keys and side buttons on Linux through virtual devices in `/dev/uinput`. +//! +//! uinput works the same on X11 and Wayland. The keyboard and the mouse are two +//! devices because a desktop only treats a device as a pointer when it has +//! relative axes; a button on a keyboard-only device would be ignored. + +use std::{ + fs::{File, OpenOptions}, + io::{Error, Write}, + mem::{size_of_val, zeroed}, + os::fd::AsRawFd, + sync::{Mutex, PoisonError}, + thread, + time::Duration, +}; + +use anyhow::{Context, Result}; + +use super::{Action, BACK, HeldKeys, KeyAction, Step, keymap}; + +const EV_SYN: u16 = 0x00; +const EV_KEY: u16 = 0x01; +const EV_REL: u16 = 0x02; +const REL_X: u16 = 0x00; +const REL_Y: u16 = 0x01; +const BTN_LEFT: u16 = 0x110; +const BTN_SIDE: u16 = 0x113; +const BTN_EXTRA: u16 = 0x114; +const BUS_VIRTUAL: u16 = 0x06; +/// Keyboard codes the keymap can produce all sit below this. +const KEY_LIMIT: u16 = 256; + +// ioctl requests from : UI_SET_EVBIT, UI_SET_KEYBIT, UI_SET_RELBIT, UI_DEV_SETUP, UI_DEV_CREATE. +const UI_SET_EVBIT: u32 = 0x4004_5564; +const UI_SET_KEYBIT: u32 = 0x4004_5565; +const UI_SET_RELBIT: u32 = 0x4004_5566; +const UI_DEV_SETUP: u32 = 0x405c_5503; +const UI_DEV_CREATE: u32 = 0x5501; + +#[repr(C)] +struct UinputSetup { + bustype: u16, + vendor: u16, + product: u16, + version: u16, + name: [u8; 80], + ff_effects_max: u32, +} + +struct Devices { + keyboard: File, + mouse: File, + /// The keys held down, counted, so a modifier two buttons share is not let go early. + held: HeldKeys, +} + +/// Made on first use and kept until Bridge exits; closing the files removes the devices. +static DEVICES: Mutex> = Mutex::new(None); + +/// Creates the virtual devices before the first button press, which the desktop +/// needs a moment to notice. +pub fn prepare() { + let mut devices = DEVICES.lock().unwrap_or_else(PoisonError::into_inner); + if devices.is_some() { + return; + } + match create() { + Ok(created) => *devices = Some(created), + Err(error) => tracing::warn!( + error = format!("{error:#}"), + "Could not create the virtual keyboard and mouse, so button assignments do nothing. \ + Bridge needs write access to /dev/uinput; install-linux.sh adds a udev rule for it" + ), + } +} + +fn create() -> Result { + let keyboard = device("OpenMouse Bridge keyboard", &[], 1..KEY_LIMIT)?; + let mouse = device( + "OpenMouse Bridge mouse", + &[REL_X, REL_Y], + [BTN_LEFT, BTN_SIDE, BTN_EXTRA], + )?; + thread::sleep(Duration::from_millis(200)); + Ok(Devices { + keyboard, + mouse, + held: HeldKeys::new(), + }) +} + +fn device(name: &str, axes: &[u16], keys: impl IntoIterator) -> Result { + let file = OpenOptions::new() + .write(true) + .open("/dev/uinput") + .context("could not open /dev/uinput")?; + let fd = file.as_raw_fd(); + set(fd, UI_SET_EVBIT, EV_KEY)?; + for key in keys { + set(fd, UI_SET_KEYBIT, key)?; + } + if !axes.is_empty() { + set(fd, UI_SET_EVBIT, EV_REL)?; + for axis in axes { + set(fd, UI_SET_RELBIT, *axis)?; + } + } + let mut setup = UinputSetup { + bustype: BUS_VIRTUAL, + vendor: 0, + product: 0, + version: 1, + name: [0; 80], + ff_effects_max: 0, + }; + let bytes = name.as_bytes(); + setup.name[..bytes.len().min(79)].copy_from_slice(&bytes[..bytes.len().min(79)]); + // SAFETY: `setup` is the struct UI_DEV_SETUP expects and lives through the call. + check(unsafe { libc::ioctl(fd, UI_DEV_SETUP as _, &setup) })?; + // SAFETY: UI_DEV_CREATE takes no argument. + check(unsafe { libc::ioctl(fd, UI_DEV_CREATE as _) })?; + Ok(file) +} + +fn set(fd: i32, request: u32, value: u16) -> Result<()> { + // SAFETY: these requests take their argument by value. + check(unsafe { libc::ioctl(fd, request as _, libc::c_int::from(value)) }) +} + +fn check(result: libc::c_int) -> Result<()> { + if result < 0 { + return Err(Error::last_os_error()).context("a /dev/uinput request failed"); + } + Ok(()) +} + +pub fn execute(step: Step) { + let mut devices = DEVICES.lock().unwrap_or_else(PoisonError::into_inner); + let Some(devices) = devices.as_mut() else { + return; + }; + match step { + Step::Down(action) => press(devices, action, true), + Step::Up(action) => press(devices, action, false), + Step::SideDown(bit) => write_key(&devices.mouse, side(bit), true), + Step::SideUp(bit) => write_key(&devices.mouse, side(bit), false), + } +} + +fn side(bit: u8) -> u16 { + if bit == BACK { BTN_SIDE } else { BTN_EXTRA } +} + +/// Modifiers go down before the key and come up after it. +fn press(devices: &mut Devices, action: Action, down: bool) { + let mut order = match action { + Action::Key(KeyAction { usage, modifiers }) => { + let Some(key) = keymap::linux_key(usage) else { + tracing::warn!(usage, "That key cannot be pressed by Bridge"); + return; + }; + let mut order = keymap::linux_modifier_keys(modifiers); + order.push(key); + order + } + Action::Media(usage) => keymap::linux_media_key(usage).into_iter().collect(), + }; + if !down { + order.reverse(); + } + for code in order { + let changes = if down { + devices.held.press(code) + } else { + devices.held.release(code) + }; + if changes { + write_key(&devices.keyboard, code, down); + } + } +} + +fn write_key(mut device: &File, code: u16, down: bool) { + let events = [event(EV_KEY, code, i32::from(down)), event(EV_SYN, 0, 0)]; + // SAFETY: `input_event` is plain integers, so its bytes are what the kernel reads. + let bytes = + unsafe { std::slice::from_raw_parts(events.as_ptr().cast::(), size_of_val(&events)) }; + if let Err(error) = device.write_all(bytes) { + tracing::warn!(%error, "Linux did not accept the button input"); + } +} + +fn event(kind: u16, code: u16, value: i32) -> libc::input_event { + // SAFETY: an all-zero `input_event` is valid (integers only); the timestamp is filled in by the kernel. + let mut event: libc::input_event = unsafe { zeroed() }; + event.type_ = kind; + event.code = code; + event.value = value; + event +} diff --git a/src/buttons/macos.rs b/src/buttons/macos.rs new file mode 100644 index 0000000..bf3c7ea --- /dev/null +++ b/src/buttons/macos.rs @@ -0,0 +1,93 @@ +//! Presses keys and side buttons on macOS by posting CoreGraphics events. +//! +//! macOS only lets a program post input once the user has allowed it under +//! System Settings > Privacy & Security > Accessibility; without that the +//! events are dropped silently, so `prepare` checks and asks for it. + +use std::sync::{Mutex, PoisonError}; + +use objc2_core_foundation::CGPoint; +use objc2_core_graphics::{ + CGEvent, CGEventField, CGEventTapLocation, CGEventType, CGMouseButton, + CGPreflightPostEventAccess, CGRequestPostEventAccess, +}; + +use super::{Action, BACK, HeldKeys, KeyAction, Step, keymap}; + +/// The keys held down, counted, so a modifier two buttons share is not let go early. +static HELD: Mutex = Mutex::new(HeldKeys::new()); + +/// Asks macOS to let Bridge post input, which shows its permission prompt the first time. +pub fn prepare() { + if !CGPreflightPostEventAccess() { + CGRequestPostEventAccess(); + tracing::warn!( + "macOS has not allowed Bridge to press keys, so button assignments do nothing until it is \ + allowed under System Settings > Privacy & Security > Accessibility" + ); + } +} + +pub fn execute(step: Step) { + match step { + Step::Down(action) => press(action, true), + Step::Up(action) => press(action, false), + Step::SideDown(bit) => side(bit, true), + Step::SideUp(bit) => side(bit, false), + } +} + +/// Modifiers go down before the key and come up after it. +fn press(action: Action, down: bool) { + let Action::Key(KeyAction { usage, modifiers }) = action else { + tracing::warn!("Media keys cannot be pressed by Bridge on macOS yet"); + return; + }; + let Some(key) = keymap::mac_key(usage) else { + tracing::warn!(usage, "That key cannot be pressed by Bridge"); + return; + }; + let mut order = keymap::mac_modifier_keys(modifiers); + order.push(key); + if !down { + order.reverse(); + } + let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); + for code in order { + let changes = if down { + held.press(code) + } else { + held.release(code) + }; + if !changes { + continue; + } + match CGEvent::new_keyboard_event(None, code, down) { + Some(event) => CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event)), + None => tracing::warn!("macOS did not create the key event"), + } + } +} + +/// The side buttons are the mouse's fourth (Back) and fifth (Forward) buttons. +fn side(bit: u8, down: bool) { + let button: u32 = if bit == BACK { 3 } else { 4 }; + let position = + CGEvent::new(None).map_or(CGPoint::ZERO, |event| CGEvent::location(Some(&event))); + let kind = if down { + CGEventType::OtherMouseDown + } else { + CGEventType::OtherMouseUp + }; + match CGEvent::new_mouse_event(None, kind, position, CGMouseButton::Center) { + Some(event) => { + CGEvent::set_integer_value_field( + Some(&event), + CGEventField::MouseEventButtonNumber, + i64::from(button), + ); + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event)); + } + None => tracing::warn!("macOS did not create the mouse event"), + } +} diff --git a/src/config.rs b/src/config.rs index 781e2b5..8bddf22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,10 @@ pub struct BridgeConfig { pub notification_volume: u8, #[serde(default)] pub games: Vec, + /// Games the user added by hand. Kept apart from `games` because the + /// catalog replaces that list on every start. + #[serde(default)] + pub custom_games: Vec, #[serde(default)] pub profiles: Vec, #[serde(default)] @@ -126,6 +130,7 @@ impl Default for BridgeConfig { notification_sound: true, notification_volume: DEFAULT_NOTIFICATION_VOLUME, games: Vec::new(), + custom_games: Vec::new(), profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), @@ -140,22 +145,8 @@ impl BridgeConfig { self.battery_threshold_percent = self.battery_threshold_percent.min(100); self.notification_volume = self.notification_volume.min(100); self.alert_cooldown_minutes = self.alert_cooldown_minutes.max(1); - for game in &mut self.games { - game.name = game.name.trim().to_owned(); - game.executables = game - .executables - .iter() - .map(|entry| entry.trim().to_ascii_lowercase()) - .filter(|entry| !entry.is_empty()) - .collect(); - game.executables.sort(); - game.executables.dedup(); - } - self.games - .retain(|game| !game.name.is_empty() && !game.executables.is_empty()); - self.games.sort_by(|left, right| left.name.cmp(&right.name)); - self.games - .dedup_by(|left, right| left.name.eq_ignore_ascii_case(&right.name)); + normalize_games(&mut self.games); + normalize_games(&mut self.custom_games); for profile in &mut self.profiles { profile.application.name = profile.application.name.trim().to_owned(); profile.application.executable = profile.application.executable.trim().to_owned(); @@ -193,6 +184,47 @@ impl BridgeConfig { self.allowed_origins.dedup(); self } + + pub fn tracked_games(&self) -> Vec { + let mut games = self.games.clone(); + games.extend( + self.custom_games + .iter() + .filter(|custom| { + !self + .games + .iter() + .any(|game| game.name.eq_ignore_ascii_case(&custom.name)) + }) + .cloned(), + ); + games + } +} + +const MAX_GAMES: usize = 200; +const MAX_NAME_CHARS: usize = 80; +const MAX_EXECUTABLES: usize = 20; +const MAX_EXECUTABLE_CHARS: usize = 260; + +fn normalize_games(games: &mut Vec) { + for game in games.iter_mut() { + game.name = game.name.trim().chars().take(MAX_NAME_CHARS).collect(); + game.executables = game + .executables + .iter() + .map(|entry| entry.trim().to_ascii_lowercase()) + .filter(|entry| !entry.is_empty() && entry.chars().count() <= MAX_EXECUTABLE_CHARS) + .collect(); + game.executables.sort(); + game.executables.dedup(); + game.executables.truncate(MAX_EXECUTABLES); + } + games.retain(|game| !game.name.is_empty() && !game.executables.is_empty()); + // Sorted by the folded name, so names that differ only in case sit side by side for the dedup. + games.sort_by_key(|game| game.name.to_ascii_lowercase()); + games.dedup_by(|left, right| left.name.eq_ignore_ascii_case(&right.name)); + games.truncate(MAX_GAMES); } pub fn config_path() -> Result { @@ -291,6 +323,50 @@ fn default_origins() -> Vec { mod tests { use super::*; + #[test] + fn names_that_differ_only_in_case_are_one_game_wherever_they_sort() { + let game = |name: &str| GameConfig { + name: name.into(), + executables: vec!["g.exe".into()], + }; + // Sorted by plain bytes these would be Alpha, Beta, alpha: the two Alphas apart. + let mut games = vec![game("alpha"), game("Beta"), game("Alpha")]; + normalize_games(&mut games); + assert_eq!(games.len(), 2); + } + + #[test] + fn game_lists_have_a_size_limit() { + let many = |count: usize| -> Vec { + (0..count) + .map(|i| GameConfig { + name: format!("game {i:04}"), + executables: vec![format!("g{i}.exe")], + }) + .collect() + }; + let mut games = many(MAX_GAMES + 50); + normalize_games(&mut games); + assert_eq!(games.len(), MAX_GAMES); + + let mut one = vec![GameConfig { + name: "n".repeat(500), + executables: (0..40) + .map(|i| format!("{i}.exe")) + .chain([format!("{}.exe", "x".repeat(300))]) + .collect(), + }]; + normalize_games(&mut one); + assert_eq!(one[0].name.chars().count(), MAX_NAME_CHARS); + assert_eq!(one[0].executables.len(), MAX_EXECUTABLES); + assert!( + one[0] + .executables + .iter() + .all(|entry| entry.chars().count() <= MAX_EXECUTABLE_CHARS) + ); + } + #[test] fn normalization_clamps_threshold_and_cleans_executables() { let config = BridgeConfig { @@ -303,6 +379,7 @@ mod tests { automatic_updates: false, notification_sound: true, notification_volume: 180, + custom_games: Vec::new(), profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), @@ -327,6 +404,30 @@ mod tests { ); } + #[test] + fn custom_games_are_tracked_beside_the_catalog_and_the_catalog_wins_name_clashes() { + let game = |name: &str, exe: &str| GameConfig { + name: name.into(), + executables: vec![exe.into()], + }; + let config = BridgeConfig { + games: vec![game("Valorant", "valorant.exe")], + custom_games: vec![ + game("My Game", " MyGame.EXE "), + game("valorant", "other.exe"), + ], + ..BridgeConfig::default() + } + .normalized(); + let names: Vec<_> = config + .tracked_games() + .into_iter() + .map(|game| game.name) + .collect(); + assert_eq!(names, ["Valorant", "My Game"]); + assert_eq!(config.custom_games[0].executables, ["mygame.exe"]); + } + #[test] fn profile_settings_keep_the_snapshot_and_accept_profiles_without_one() { let json = diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index 2b02005..0c7b003 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -22,7 +22,7 @@ pub mod delux; mod native_hid; pub mod pulsar; -pub use native_hid::NativeBattery; +pub use native_hid::{NativeBattery, spawn_button_listener}; /// Serializes Bridge's own device access. A profile switch and a battery read /// can start at the same moment (both run at startup), and on macOS the second @@ -30,7 +30,7 @@ pub use native_hid::NativeBattery; /// interleave requests on the same device. static DEVICE_ACCESS: Mutex<()> = Mutex::new(()); -fn device_access() -> MutexGuard<'static, ()> { +pub(crate) fn device_access() -> MutexGuard<'static, ()> { // The guarded value is (), so a panic while holding it leaves nothing // inconsistent behind. DEVICE_ACCESS diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index af54ec1..cfad0d3 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -161,6 +161,25 @@ fn run_helper(request: &impl Serialize) -> Result>> { } } +pub fn spawn_button_listener(brand: &str) -> Result { + let script = locate_apply_script()?.with_file_name("button-listener.mjs"); + let node = locate_node_binary(); + let mut command = Command::new(node.as_deref().unwrap_or_else(|| Path::new("node"))); + command + .arg(&script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + command.creation_flags(CREATE_NO_WINDOW); + command.spawn().with_context(|| { + format!( + "could not start {} for the {brand} button listener", + script.display() + ) + }) +} + /// Finds `native-hid/src/apply.mjs` next to the running executable — how a /// packaged Bridge release is expected to ship it — falling back to the /// checkout's own source tree so `cargo run`/`cargo test` work without a diff --git a/src/lib.rs b/src/lib.rs index 06984e2..8c55f2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ pub mod api; pub mod applications; +pub mod buttons; pub mod config; pub mod drivers; pub mod games; mod hid; pub mod platform; +pub mod remote_image; pub mod service; pub mod updater; diff --git a/src/remote_image.rs b/src/remote_image.rs new file mode 100644 index 0000000..580eae9 --- /dev/null +++ b/src/remote_image.rs @@ -0,0 +1,260 @@ +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use reqwest::{Url, redirect::Policy}; +use tokio::sync::Semaphore; + +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +/// A wallpaper can be a few MB; the page scales it down before it is saved. +const MAX_DOWNLOAD_BYTES: usize = 12 * 1024 * 1024; +const MAX_REDIRECTS: usize = 4; +const UNREACHABLE: &str = "that link cannot be reached"; +/// Each download can hold 12 MB, so only a couple run at once. +static DOWNLOADS: Semaphore = Semaphore::const_new(2); + +/// Whether `ip` is an address on the public internet. Loopback, private, link-local, +/// shared (carrier-grade NAT), multicast and unspecified addresses are not. +pub fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_v4(ip), + IpAddr::V6(ip) => is_public_v6(ip), + } +} + +fn is_public_v4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_multicast() + || a == 0 + || a >= 240 // reserved + || (a == 100 && (64..128).contains(&b)) // 100.64.0.0/10 + || (a == 192 && b == 0 && c == 0) // 192.0.0.0/24 + || (a == 198 && (18..20).contains(&b)) // 198.18.0.0/15, benchmarking + || matches!((a, b, c), (192, 0, 2) | (198, 51, 100) | (203, 0, 113))) // documentation +} + +fn v4_from(high: u16, low: u16) -> Ipv4Addr { + Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8) +} + +/// IPv6 forms that carry an IPv4 address are judged by that address, so a private one cannot hide inside. +fn is_public_v6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + let first = segments[0]; + let embedded = v4_from(segments[6], segments[7]); + match segments[..6] { + [0, 0, 0, 0, 0, 0] | [0, 0, 0, 0, 0, 0xffff] | [0x64, 0xff9b, 0, 0, 0, 0] => { + return is_public_v4(embedded); + } + _ => {} + } + if first == 0x2002 { + return is_public_v4(v4_from(segments[1], segments[2])); // 6to4 + } + !(ip.is_loopback() + || ip.is_multicast() + || first & 0xfe00 == 0xfc00 // unique local + || first & 0xffc0 == 0xfe80 // link-local + || (first == 0x2001 && segments[1] == 0) // Teredo + || (first == 0x2001 && segments[1] == 0x0db8)) // documentation +} + +pub const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + +pub fn image_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&PNG_SIGNATURE) { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.len() > 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +/// The public addresses `url`'s host resolves to. The download connects only to these, +/// so the host cannot answer differently the second time. +async fn check(url: &Url) -> Result> { + ensure!( + matches!(url.scheme(), "http" | "https"), + "only http and https links work" + ); + let host = url.host_str().context("the link has no host")?; + let port = url.port_or_known_default().unwrap_or(443); + // One message for "not found" and "not public", so a page cannot tell which names exist on the local network. + let addresses: Vec = tokio::net::lookup_host((host, port)) + .await + .map_err(|_| anyhow!(UNREACHABLE))? + .collect(); + ensure!( + !addresses.is_empty() && addresses.iter().all(|address| is_public_ip(address.ip())), + UNREACHABLE + ); + Ok(addresses) +} + +/// The whole download, however many redirects and name lookups it takes. A name +/// lookup has no time limit of its own, and the download's slot is held until it ends. +const TOTAL_TIMEOUT: Duration = Duration::from_secs(30); + +pub async fn fetch(link: &str) -> Result<(Vec, &'static str)> { + let _download = DOWNLOADS + .try_acquire() + .map_err(|_| anyhow!("too many downloads at once, try again in a moment"))?; + tokio::time::timeout(TOTAL_TIMEOUT, download(link)) + .await + .map_err(|_| anyhow!("the download took too long"))? +} + +async fn download(link: &str) -> Result<(Vec, &'static str)> { + let mut url = Url::parse(link).context("that is not a link")?; + for _ in 0..=MAX_REDIRECTS { + let addresses = check(&url).await?; + let client = reqwest::Client::builder() + // A proxy would resolve the name itself, past the addresses checked above. + .no_proxy() + .timeout(FETCH_TIMEOUT) + .user_agent(concat!("OpenMouse-Bridge/", env!("CARGO_PKG_VERSION"))) + .redirect(Policy::none()) + .resolve_to_addrs(url.host_str().unwrap_or_default(), &addresses) + .build() + .context("could not create the download client")?; + let mut response = client + .get(url.clone()) + .send() + .await + .with_context(|| format!("could not download {url}"))?; + if response.status().is_redirection() { + let next = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .context("the link redirected nowhere")?; + url = url + .join(next) + .context("the link redirected to something that is not a link")?; + continue; + } + ensure!( + response.status().is_success(), + "the site answered {}", + response.status() + ); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("the download was interrupted")? + { + bytes.extend_from_slice(&chunk); + ensure!(bytes.len() <= MAX_DOWNLOAD_BYTES, "that image is too big"); + } + let kind = image_type(&bytes).context("that link is not a PNG, JPEG, GIF or WebP image")?; + return Ok((bytes, kind)); + } + bail!("the link redirects too many times") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn public(ip: &str) -> bool { + is_public_ip(ip.parse().expect("an address")) + } + + #[test] + fn addresses_on_this_pc_or_the_local_network_are_refused() { + for ip in [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "172.31.255.254", + "192.168.1.1", + "169.254.169.254", + "0.0.0.0", + "100.64.0.1", + "224.0.0.1", + "255.255.255.255", + "::1", + "::", + "fc00::1", + "fd12::1", + "fe80::1", + "ff02::1", + "::ffff:192.168.0.5", + "::ffff:127.0.0.1", + "64:ff9b::c0a8:1", + "64:ff9b::7f00:1", + "2002:c0a8:101::1", + "2002:7f00:1::", + "::192.168.0.5", + "2001::1", + "2001:db8::1", + "198.18.0.1", + "198.19.255.1", + "192.0.0.8", + "192.0.2.1", + "198.51.100.1", + "203.0.113.1", + "240.0.0.1", + "250.1.1.1", + ] { + assert!(!public(ip), "{ip} should not be fetched"); + } + } + + #[test] + fn addresses_on_the_public_internet_are_allowed() { + for ip in [ + "1.1.1.1", + "8.8.8.8", + "93.184.216.34", + "172.32.0.1", + "100.63.0.1", + "2606:4700:4700::1111", + "::ffff:8.8.8.8", + "64:ff9b::808:808", + "2002:808:808::1", + "198.17.255.1", + "198.20.0.1", + ] { + assert!(public(ip), "{ip} should be fetched"); + } + } + + #[test] + fn only_real_images_are_recognised() { + assert_eq!( + image_type(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), + Some("image/png") + ); + assert_eq!(image_type(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); + assert_eq!(image_type(b"GIF89a......"), Some("image/gif")); + assert_eq!(image_type(b"RIFF\0\0\0\0WEBPVP8 ...."), Some("image/webp")); + assert_eq!(image_type(b"not an image"), None); + } + + #[tokio::test] + async fn a_link_to_this_pc_is_refused_before_anything_is_fetched() { + let error = fetch("http://127.0.0.1:17846/v1/status") + .await + .expect_err("refused"); + assert_eq!(error.to_string(), UNREACHABLE); + let error = fetch("file:///C:/Windows/win.ini") + .await + .expect_err("refused"); + assert!(error.to_string().contains("only http and https"), "{error}"); + assert!(fetch("not a link").await.is_err()); + } +} diff --git a/src/service.rs b/src/service.rs index bf08075..f24f25b 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,6 +1,6 @@ use std::{ - collections::HashMap, - path::PathBuf, + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -8,7 +8,7 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; @@ -132,8 +132,11 @@ fn active_profile_for( }); 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)) + let game = config + .tracked_games() + .into_iter() + .find(|game| &game.name == name)?; + enabled().find(|profile| profile_matches_game(profile, &game)) }) }; foreground @@ -162,6 +165,68 @@ fn profile_key(profile: &ApplicationProfile) -> String { } } +const MAX_IMAGE_BYTES: usize = 512 * 1024; + +fn image_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.len() > MAX_IMAGE_BYTES { + return None; + } + crate::remote_image::image_type(bytes).filter(|kind| *kind != "image/gif") +} + +fn game_image_dir(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("custom-game-images") +} + +fn game_image_file(dir: &Path, name: &str) -> PathBuf { + // FNV-1a: stable across runs, unlike the standard hasher. + let hash = name + .to_ascii_lowercase() + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }); + dir.join(format!("{hash:016x}.img")) +} + +fn prune_game_images(dir: &Path, keep: &[&str]) { + let wanted: HashSet = keep.iter().map(|name| game_image_file(dir, name)).collect(); + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "img") && !wanted.contains(&path) { + let _ = std::fs::remove_file(path); + } + } +} + +/// The profile `key` names as it is in the config now, and whether it is the +/// default profile. A profile keeps its key while its settings are edited, so +/// the copy Bridge applied earlier can be out of date. +fn current_profile<'a>( + config: &'a BridgeConfig, + key: &str, +) -> Option<(&'a ApplicationProfile, bool)> { + // Only an enabled profile is ever the active one, so a disabled copy of the same game is not it. + config + .profiles + .iter() + .find(|profile| profile.enabled && profile_key(profile) == key) + .map(|profile| (profile, false)) + .or_else(|| { + config + .default_profile + .as_ref() + .filter(|profile| profile_key(profile) == key) + .map(|profile| (profile, true)) + }) +} + /// Something worth telling the user about, already worded: a title such as the /// game's name and a short detail line. #[derive(Clone, Debug, PartialEq, Eq)] @@ -249,6 +314,7 @@ pub struct BridgeService { config_path: Arc, notice_listener: Arc>>, show_panel: Arc>>>, + buttons: Arc, } struct BridgeState { @@ -363,6 +429,7 @@ impl BridgeService { config_path: Arc::new(config_path), notice_listener: Arc::default(), show_panel: Arc::default(), + buttons: Arc::default(), } } @@ -440,6 +507,7 @@ impl BridgeService { .iter() .find(|application| application.foreground) .cloned(); + let tracked_games = state.config.tracked_games(); let active_profile = active_profile_for(&state.config, &state.applications, &state.active_games); let active_profile_is_default = @@ -472,16 +540,14 @@ impl BridgeService { linux_distribution: platform::linux_distribution(), uptime_seconds: state.started_at.elapsed().as_secs(), active_games: state.active_games.clone(), - games: state - .config - .games + games: tracked_games .iter() .map(|game| GameActivity { name: game.name.clone(), active: state.active_games.iter().any(|active| active == &game.name), }) .collect(), - tracked_game_count: state.config.games.len(), + tracked_game_count: tracked_games.len(), battery_threshold_percent: state.config.battery_threshold_percent, autostart_enabled: platform::autostart_enabled(), automatic_updates: state.config.automatic_updates, @@ -529,7 +595,59 @@ impl BridgeService { } pub async fn games(&self) -> Vec { - self.inner.read().await.config.games.clone() + self.inner.read().await.config.tracked_games() + } + + pub async fn custom_games(&self) -> Vec { + self.inner.read().await.config.custom_games.clone() + } + + pub async fn set_custom_games(&self, games: Vec) -> Result> { + let config = { + let mut state = self.inner.write().await; + state.config.custom_games = games; + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config)?; + let names: Vec<&str> = config + .custom_games + .iter() + .map(|game| game.name.as_str()) + .collect(); + prune_game_images(&game_image_dir(&self.config_path), &names); + Ok(config.custom_games) + } + + pub async fn custom_game_image(&self, name: &str) -> Option<(Vec, &'static str)> { + let bytes = tokio::fs::read(game_image_file(&game_image_dir(&self.config_path), name)) + .await + .ok()?; + let kind = image_type(&bytes)?; + Some((bytes, kind)) + } + + pub async fn set_custom_game_image(&self, name: &str, image: &[u8]) -> Result<()> { + let known = self + .inner + .read() + .await + .config + .custom_games + .iter() + .any(|game| game.name.eq_ignore_ascii_case(name)); + anyhow::ensure!(known, "{name} is not a custom game"); + anyhow::ensure!( + image_type(image).is_some(), + "the image must be a PNG, JPEG or WebP of at most {MAX_IMAGE_BYTES} bytes" + ); + let dir = game_image_dir(&self.config_path); + tokio::fs::create_dir_all(&dir) + .await + .context("could not create the image folder")?; + tokio::fs::write(game_image_file(&dir, name), image) + .await + .context("could not save the image") } pub async fn replace_profiles(&self, profiles: Vec) -> Result<()> { @@ -542,6 +660,10 @@ impl BridgeService { config::save(&self.config_path, &config) } + pub async fn default_profile(&self) -> Option { + self.inner.read().await.config.default_profile.clone() + } + pub async fn set_default_profile(&self, profile: ApplicationProfile) -> Result<()> { let config = { let mut state = self.inner.write().await; @@ -711,7 +833,7 @@ impl BridgeService { let mut interval = tokio::time::interval(Duration::from_secs(3)); loop { interval.tick().await; - let games = service.inner.read().await.config.games.clone(); + let games = service.inner.read().await.config.tracked_games(); let active = detector.detect(&games); let running = applications::visible_applications(); let applications = registered_applications(&running, &games); @@ -786,7 +908,20 @@ impl BridgeService { } } let default = state.config.default_profile.clone(); + // Read from the config each time: editing the active profile's + // keys does not change its key, so the applied copy goes stale. + let desired_buttons = state + .active_profile_key + .as_deref() + .and_then(|key| current_profile(&state.config, key)) + .and_then(|(profile, is_default)| { + crate::buttons::Assignments::from_profile(profile, is_default) + }); drop(state); + // A control panel holding the mouse is not interrupted by a new helper. + service + .buttons + .update(desired_buttons, !crate::hid::client_session_active()); if let Some((current, previous, announce)) = switch { let service = service.clone(); std::thread::spawn(move || { @@ -852,6 +987,7 @@ fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { #[cfg(test)] mod tests { use super::*; + use crate::remote_image::PNG_SIGNATURE; fn service(config: BridgeConfig) -> BridgeService { BridgeService::new(config, PathBuf::from("unused-test-config.json")) @@ -892,6 +1028,90 @@ mod tests { } } + #[test] + fn a_game_image_is_kept_under_a_stable_name_whatever_the_case() { + let dir = Path::new("icons"); + assert_eq!( + game_image_file(dir, "My Game"), + game_image_file(dir, "my game") + ); + assert_ne!( + game_image_file(dir, "My Game"), + game_image_file(dir, "Other") + ); + let file = game_image_file(dir, "../../evil\\name"); + assert_eq!( + file.parent(), + Some(dir), + "a hostile name cannot leave the image folder" + ); + } + + #[test] + fn only_a_png_jpeg_or_webp_of_a_sensible_size_is_kept_as_an_image() { + let mut png = PNG_SIGNATURE.to_vec(); + png.extend_from_slice(&[0; 32]); + assert_eq!(image_type(&png), Some("image/png")); + assert_eq!( + image_type(&[0xff, 0xd8, 0xff, 0xe0, 0, 0]), + Some("image/jpeg") + ); + let mut webp = b"RIFF\0\0\0\0WEBPVP8 ".to_vec(); + webp.extend_from_slice(&[0; 8]); + assert_eq!(image_type(&webp), Some("image/webp")); + assert_eq!(image_type(b"not an image"), None); + assert_eq!(image_type(b"GIF89a......"), None, "a GIF is not accepted"); + png.resize(MAX_IMAGE_BYTES + 1, 0); + assert_eq!(image_type(&png), None, "an oversized file is refused"); + } + + #[test] + fn removing_a_game_removes_its_image() { + let dir = std::env::temp_dir().join(format!("bridge-icons-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + for name in ["Kept", "Gone"] { + std::fs::write(game_image_file(&dir, name), PNG_SIGNATURE).expect("image"); + } + prune_game_images(&dir, &["Kept"]); + assert!(game_image_file(&dir, "Kept").exists()); + assert!(!game_image_file(&dir, "Gone").exists()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + #[test] + fn the_active_profile_is_read_from_the_config_so_edits_are_seen() { + let mut config = BridgeConfig { + default_profile: Some(test_profile("Default", "", Some(800), None)), + profiles: vec![test_profile("Valorant", "/games/valorant", Some(800), None)], + ..BridgeConfig::default() + }; + + let (game, is_default) = + current_profile(&config, "/games/valorant").expect("the game profile"); + assert_eq!( + (game.application.name.as_str(), is_default), + ("Valorant", false) + ); + let (default, is_default) = + current_profile(&config, "default").expect("the default profile"); + assert_eq!( + (default.application.name.as_str(), is_default), + ("Default", true) + ); + + // Editing a profile keeps its key, so the lookup finds the new settings. + config.default_profile = Some(test_profile("Default", "", Some(1600), None)); + let (edited, _) = current_profile(&config, "default").expect("the edited default profile"); + assert_eq!(edited.settings.dpi, Some(1600)); + assert!(current_profile(&config, "/games/other").is_none()); + + // A disabled profile of the same game is not the one that is applied. + let mut disabled = test_profile("Valorant", "/games/valorant", Some(400), None); + disabled.enabled = false; + config.profiles = vec![disabled]; + assert!(current_profile(&config, "/games/valorant").is_none()); + } + #[test] fn profile_devices_are_distinct_and_include_the_default() { let mut config = BridgeConfig::default();