From 2f0462047b19a36ed1d6f82e29fb2483ca8e59c3 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:25:07 -0600 Subject: [PATCH 1/9] fix(hid): open several paths when they share a verified serial 98294bc keyed each HID candidate by path and refused any candidate with more than one path ("refusing multi-path access", plus a matching "refusing report fan-out" guard). ed5bcd9 then made a non-empty serial the grouping identity, so every top-level collection of a device with a serial lands in one candidate again. On Windows each collection is its own path, so "list" failed for every serial-bearing multi-collection mouse. Allow several paths when every interface reports the same non-empty serial, and keep refusing otherwise. The report path already routes to the path that declares the report ID, so its guard now only rejects a device with no open path. Co-Authored-By: Claude Opus 5.5 --- src/hid.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/hid.rs b/src/hid.rs index 7858658..08622d9 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -915,8 +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()); + // An open device has several paths only when `inspect_candidate` verified + // they share one serial; the loop below sends to the path that declares + // the report. + if open.paths.is_empty() { + return Err("the HID interface has no open path".into()); } let _access = crate::drivers::manual_report_access(open.vendor_id) .map_err(|error| error.to_string())?; @@ -1113,6 +1116,14 @@ fn device_group_key(info: &hidapi::DeviceInfo) -> Vec { ) } +/// True when every interface reports the same non-empty serial. +fn shares_verified_serial<'a>(mut serials: impl Iterator>) -> bool { + match serials.next().flatten().filter(|serial| !serial.is_empty()) { + Some(first) => serials.all(|serial| serial == Some(first)), + None => false, + } +} + fn inspect_candidate( api: &HidApi, key: String, @@ -1129,7 +1140,11 @@ fn inspect_candidate( } } - if unique_paths.len() != 1 { + // Several paths are the collections of one device only when they share a + // verified serial; that is the one case `grouped_device_key` merges them. + if unique_paths.len() > 1 + && !shares_verified_serial(infos.iter().map(hidapi::DeviceInfo::serial_number)) + { return Err("HID physical identity is ambiguous; refusing multi-path access".into()); } @@ -1655,6 +1670,22 @@ mod tests { ); } + #[test] + fn several_paths_open_only_under_one_verified_serial() { + assert!(shares_verified_serial( + [Some("receiver-123"), Some("receiver-123")].into_iter() + )); + assert!(!shares_verified_serial( + [Some("receiver-123"), Some("receiver-456")].into_iter() + )); + assert!(!shares_verified_serial( + [Some("receiver-123"), None].into_iter() + )); + assert!(!shares_verified_serial([None, None].into_iter())); + assert!(!shares_verified_serial([Some(""), Some("")].into_iter())); + assert!(!shares_verified_serial(std::iter::empty())); + } + #[test] fn report_payload_never_reads_past_the_buffer() { // A full unnumbered Razer read: hidapi on Windows reports 92 bytes. From 6b8e75ab0d3f824ec0b76f97937a8387c470868e Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:25:07 -0600 Subject: [PATCH 2/9] feat(api): serve icons for saved local applications GET /v1/local-applications (and the PUT response) now gives each entry an `iconId`, computed with applications::icon_id(path). The field is response-only; saved lists are accepted with or without it. GET /v1/applications/{iconId}/icon serves a saved local application's icon whether or not it is running: on a cache miss it is extracted from the saved path, and prune_application_icons keeps saved applications' icons. It still 404s when extraction fails or off Windows. Co-Authored-By: Claude Opus 5.5 --- src/api.rs | 67 +++++++++++++++++++++++++++++++-- src/applications.rs | 6 +-- src/service.rs | 56 ++++++++++++++++++++++++--- tests/backend_local_profiles.rs | 15 ++++++-- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/api.rs b/src/api.rs index cf70ed8..9b12a8b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -307,8 +307,32 @@ async fn set_custom_game_image( Ok(Json(ApiResult { ok: true })) } -async fn local_applications(State(service): State) -> Json> { - Json(service.local_applications().await) +/// A saved local application as the API serves it, with the id its icon is +/// served under. The id is never read back from a saved list. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalApplicationEntry { + #[serde(flatten)] + application: LocalApplication, + icon_id: String, +} + +fn local_application_entries(applications: Vec) -> Vec { + applications + .into_iter() + .map(|application| LocalApplicationEntry { + icon_id: crate::applications::icon_id(&application.path), + application, + }) + .collect() +} + +async fn local_applications( + State(service): State, +) -> Json> { + Json(local_application_entries( + service.local_applications().await, + )) } async fn replace_local_applications( @@ -321,7 +345,7 @@ async fn replace_local_applications( .await .map_err(profile_error)?; Ok(Json( - serde_json::json!({"ok":true,"applications":applications}), + serde_json::json!({"ok":true,"applications":local_application_entries(applications)}), )) } @@ -510,6 +534,43 @@ mod tests { (status, bytes, kind) } + #[tokio::test] + async fn saved_local_applications_carry_their_icon_id() { + let (app, dir) = app(); + let path = r"C:\Apps\Editor.exe"; + let icon_id = crate::applications::icon_id(path); + let entry = serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":path,"kind":"app"}); + let (status, saved, _) = call( + &app, + Method::PUT, + "/v1/local-applications", + "application/json", + serde_json::to_vec(&serde_json::json!({"applications":[entry]})).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + let saved: serde_json::Value = serde_json::from_slice(&saved).unwrap(); + assert_eq!(saved["applications"][0]["iconId"], icon_id.as_str()); + + let (status, listed, _) = + call(&app, Method::GET, "/v1/local-applications", "", vec![]).await; + assert_eq!(status, StatusCode::OK); + let listed: serde_json::Value = serde_json::from_slice(&listed).unwrap(); + assert_eq!(listed[0]["iconId"], icon_id.as_str()); + + // A client may send back the list it was served, icon ids included. + let (status, ..) = call( + &app, + Method::PUT, + "/v1/local-applications", + "application/json", + serde_json::to_vec(&serde_json::json!({"applications":listed})).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + #[tokio::test] async fn a_custom_game_keeps_the_image_saved_for_it() { let (app, dir) = app(); diff --git a/src/applications.rs b/src/applications.rs index 848697b..6e476b3 100644 --- a/src/applications.rs +++ b/src/applications.rs @@ -15,11 +15,7 @@ pub struct ApplicationInfo { pub icon_id: String, } -#[cfg_attr( - not(any(target_os = "windows", target_os = "macos", target_os = "linux")), - allow(dead_code) -)] -fn icon_id(path: &str) -> String { +pub fn icon_id(path: &str) -> String { let mut hasher = DefaultHasher::new(); crate::games::path_key(path).hash(&mut hasher); format!("{:016x}", hasher.finish()) diff --git a/src/service.rs b/src/service.rs index 6d356c5..0c3e2e1 100644 --- a/src/service.rs +++ b/src/service.rs @@ -80,14 +80,20 @@ fn profile_applications( /// 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. +/// Icons of saved local applications stay, since they are served whether or +/// not the application is running. fn prune_application_icons( icons: &mut HashMap>>, running: &[ApplicationInfo], + local_applications: &[LocalApplication], ) { icons.retain(|icon_id, _| { running .iter() .any(|application| &application.icon_id == icon_id) + || local_applications + .iter() + .any(|target| &applications::icon_id(&target.path) == icon_id) }); } @@ -882,14 +888,32 @@ impl BridgeService { self.inner.read().await.running_applications.clone() } + /// Serves a cached icon, extracting a saved local application's icon from + /// its path the first time it is asked for, even when it is not running. pub async fn application_icon(&self, icon_id: &str) -> Option> { + let path = { + let state = self.inner.read().await; + if let Some(icon) = state.application_icons.get(icon_id) { + return icon.clone(); + } + state + .config + .local_applications + .iter() + .find(|target| applications::icon_id(&target.path) == icon_id)? + .path + .clone() + }; + let icon = tokio::task::spawn_blocking(move || applications::application_icon(&path)) + .await + .ok() + .flatten(); self.inner - .read() + .write() .await .application_icons - .get(icon_id) - .cloned() - .flatten() + .insert(icon_id.to_owned(), icon.clone()); + icon } pub async fn profiles(&self) -> Vec { @@ -1336,7 +1360,12 @@ impl BridgeService { state.processes = processes; state.applications = applications; state.application_icons.extend(icons); - prune_application_icons(&mut state.application_icons, &running); + let inner = &mut *state; + prune_application_icons( + &mut inner.application_icons, + &running, + &inner.config.local_applications, + ); state.running_applications = running; // Detect a debounced active-profile switch, push the new @@ -2562,12 +2591,27 @@ mod tests { ("closed".to_string(), None), ]); - prune_application_icons(&mut icons, &running); + prune_application_icons(&mut icons, &running, &[]); assert_eq!(icons.len(), 1); assert!(icons.contains_key("running")); } + #[test] + fn a_saved_local_application_keeps_its_icon_while_closed() { + let saved: LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"C:\\Apps\\Editor.exe","kind":"app"})).unwrap(); + let saved_icon = applications::icon_id(&saved.path); + let mut icons = HashMap::from([ + (saved_icon.clone(), Some(vec![1])), + ("closed".to_string(), Some(vec![2])), + ]); + + prune_application_icons(&mut icons, &[], &[saved]); + + assert_eq!(icons.len(), 1); + assert!(icons.contains_key(&saved_icon)); + } + #[tokio::test] async fn client_is_connected_only_after_a_heartbeat() { let bridge = service(BridgeConfig::default()); diff --git a/tests/backend_local_profiles.rs b/tests/backend_local_profiles.rs index 396956b..fc00a26 100644 --- a/tests/backend_local_profiles.rs +++ b/tests/backend_local_profiles.rs @@ -55,6 +55,13 @@ async fn server_with_config(storage_failure: bool, config: BridgeConfig) -> Test } } +/// A saved target as the local-applications endpoints serve it. +fn served(mut value: serde_json::Value) -> serde_json::Value { + let icon_id = openmouse_bridge::applications::icon_id(value["path"].as_str().unwrap()); + value["iconId"] = icon_id.into(); + value +} + fn target() -> serde_json::Value { serde_json::json!({"id":"local:editor","name":"Editor","executable":"editor.exe","path":"C:/Tools/editor.exe","kind":"app"}) } @@ -74,7 +81,7 @@ async fn activation_metadata_roundtrips_into_canonical_profiles() { assert_eq!(response.status(), 200); assert_eq!( response.json::().await.unwrap(), - serde_json::json!({"ok":true,"applications":[value.clone()]}) + serde_json::json!({"ok":true,"applications":[served(value.clone())]}) ); let stored = server.service.local_applications().await; assert_eq!(serde_json::to_value(&stored[0]).unwrap(), value); @@ -163,7 +170,7 @@ async fn saved_missing_canonical_bundle_survives_batch_crud_and_profile_readback .unwrap(); assert_eq!(response.status(), 200); let readback: serde_json::Value = response.json().await.unwrap(); - assert_eq!(readback["applications"][0], bundle); + assert_eq!(readback["applications"][0], served(bundle.clone())); let profiles = server.service.profiles().await; assert_eq!(profiles.len(), 1); assert_eq!( @@ -201,7 +208,7 @@ async fn local_http_roundtrip_reconciles_id_linked_profiles_and_restarts() { assert_eq!(response.status(), 200); assert_eq!( response.json::().await.unwrap(), - serde_json::json!({"ok":true,"applications":[target()]}) + serde_json::json!({"ok":true,"applications":[served(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}}); @@ -303,7 +310,7 @@ async fn local_http_validation_is_400_and_never_changes_state() { 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!(targets, serde_json::json!([served(target())])); } assert_eq!( client From eb346ff292fb406ba4df7599ab2358d8bccfab1d Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:42:28 -0600 Subject: [PATCH 3/9] fix(hid): send a report to one path and stop readers when a spawn fails Review follow-up on the multi-path fix. A device opened with several paths now sends each report to exactly one of them: the paths that declare the report (or every path, for Razer, whose descriptors are not parsed) are tried in order and the first that takes it is the only one written, so one command never reaches the device twice. start_reader stores the stop flag before spawning, and a later spawn failure stops and joins the readers already running instead of leaving them to block session cleanup. Co-Authored-By: Claude Opus 5.5 --- src/hid.rs | 96 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/src/hid.rs b/src/hid.rs index 08622d9..0e01322 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -843,6 +843,9 @@ impl HidSession { .get_mut(key) .ok_or_else(|| "the HID interface must be open before listening".to_owned())?; let stop = Arc::new(AtomicBool::new(false)); + // Stored before any reader starts, so a later spawn failure can stop and + // join the readers already running instead of leaving them to block cleanup. + open.stop = Some(Arc::clone(&stop)); for (index, (device, buffer_len, uses_report_ids)) in reader_specs.into_iter().enumerate() { let thread_stop = Arc::clone(&stop); let tx = self.input_tx.clone(); @@ -884,12 +887,16 @@ impl HidSession { } } } - }) - .map_err(|error| format!("could not start the HID input reader: {error}"))?; - open.readers.push(reader); + }); + match reader { + Ok(reader) => open.readers.push(reader), + Err(error) => { + open.stop_reader(); + return Err(format!("could not start the HID input reader: {error}")); + } + } } open.listening = true; - open.stop = Some(stop); Ok(()) } @@ -916,8 +923,7 @@ impl HidSession { .get(key) .ok_or_else(|| "the HID interface is not open".to_owned())?; // An open device has several paths only when `inspect_candidate` verified - // they share one serial; the loop below sends to the path that declares - // the report. + // they share one serial. A report still goes to exactly one of them. if open.paths.is_empty() { return Err("the HID interface has no open path".into()); } @@ -926,27 +932,10 @@ impl HidSession { let mut frame = Vec::with_capacity(data.len() + 1); frame.push(report_id); frame.extend_from_slice(&data); - let mut sent = false; let mut last_error = None; - let declared_path_exists = open.paths.iter().any(|path| { - let reports = if feature { - &path.layout.feature_bits - } else { - &path.layout.output_bits - }; - reports.contains_key(&report_id) - }); - for path in &open.paths { - if declared_path_exists { - let reports = if feature { - &path.layout.feature_bits - } else { - &path.layout.output_bits - }; - if !reports.contains_key(&report_id) { - continue; - } - } + let layouts: Vec<&ReportLayout> = open.paths.iter().map(|path| &path.layout).collect(); + for index in report_paths(&layouts, report_id, feature) { + let path = &open.paths[index]; let result = path .device .lock() @@ -959,20 +948,18 @@ impl HidSession { } .map_err(|error| error.to_string()) }); + // The first path that takes the report is the only one written, so a + // command never reaches the device twice. match result { - Ok(()) => sent = true, + Ok(()) => return Ok(()), Err(error) => last_error = Some(error), } } - if sent { - Ok(()) - } else { - let operation = if feature { "feature" } else { "output" }; - Err(format!( - "could not send {operation} report {report_id}: {}", - last_error.unwrap_or_else(|| "no HID path was open".to_owned()) - )) - } + let operation = if feature { "feature" } else { "output" }; + Err(format!( + "could not send {operation} report {report_id}: {}", + last_error.unwrap_or_else(|| "no HID path was open".to_owned()) + )) } fn receive_input_report(&self, key: &str, report_id: u8) -> Result, String> { @@ -1116,6 +1103,27 @@ fn device_group_key(info: &hidapi::DeviceInfo) -> Vec { ) } +/// The paths to try for a report, in order: those whose descriptor declares it, +/// or every path when none does (Razer descriptors are not parsed). The sender +/// stops at the first path that takes the report. +fn report_paths(layouts: &[&ReportLayout], report_id: u8, feature: bool) -> Vec { + let declares = |layout: &ReportLayout| { + if feature { + layout.feature_bits.contains_key(&report_id) + } else { + layout.output_bits.contains_key(&report_id) + } + }; + let declared: Vec = (0..layouts.len()) + .filter(|&index| declares(layouts[index])) + .collect(); + if declared.is_empty() { + (0..layouts.len()).collect() + } else { + declared + } +} + /// True when every interface reports the same non-empty serial. fn shares_verified_serial<'a>(mut serials: impl Iterator>) -> bool { match serials.next().flatten().filter(|serial| !serial.is_empty()) { @@ -1686,6 +1694,22 @@ mod tests { assert!(!shares_verified_serial(std::iter::empty())); } + #[test] + fn a_report_goes_to_the_paths_that_declare_it_first() { + let mut vendor = ReportLayout::default(); + vendor.feature_bits.insert(0x11, 160); + vendor.output_bits.insert(0x10, 48); + let mouse = ReportLayout::default(); + let mut second = ReportLayout::default(); + second.feature_bits.insert(0x11, 160); + let layouts = [&mouse, &vendor, &second]; + assert_eq!(report_paths(&layouts, 0x11, true), vec![1, 2]); + assert_eq!(report_paths(&layouts, 0x10, false), vec![1]); + // Undeclared (a Razer path without parsed descriptors): every path is a candidate, tried in order. + assert_eq!(report_paths(&layouts, 0x00, true), vec![0, 1, 2]); + assert!(report_paths(&[], 0x11, true).is_empty()); + } + #[test] fn report_payload_never_reads_past_the_buffer() { // A full unnumbered Razer read: hidapi on Windows reports 92 bytes. From 3d103f222eec2e46e58598016942c817542d69ff Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:42:28 -0600 Subject: [PATCH 4/9] fix(api): drop removed apps' icons and do not cache failed extractions Replacing the saved local applications prunes the icon cache, so a removed app's icon is no longer served. A failed extraction is not cached, so a path that comes back later gets its icon, and an icon read while the list changed is cached only if a saved app still owns its id. Co-Authored-By: Claude Opus 5.5 --- src/service.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/src/service.rs b/src/service.rs index 0c3e2e1..d3718eb 100644 --- a/src/service.rs +++ b/src/service.rs @@ -893,8 +893,8 @@ impl BridgeService { pub async fn application_icon(&self, icon_id: &str) -> Option> { let path = { let state = self.inner.read().await; - if let Some(icon) = state.application_icons.get(icon_id) { - return icon.clone(); + if let Some(Some(icon)) = state.application_icons.get(icon_id) { + return Some(icon.clone()); } state .config @@ -908,11 +908,22 @@ impl BridgeService { .await .ok() .flatten(); - self.inner - .write() - .await - .application_icons - .insert(icon_id.to_owned(), icon.clone()); + // A failed extraction is not cached, so a path that comes back later gets + // its icon. The saved list may have changed while the icon was read, so it + // is cached only while a saved application still owns this id. + if let Some(icon) = &icon { + let mut state = self.inner.write().await; + let owned = state + .config + .local_applications + .iter() + .any(|target| applications::icon_id(&target.path) == icon_id); + if owned { + state + .application_icons + .insert(icon_id.to_owned(), Some(icon.clone())); + } + } icon } @@ -1041,6 +1052,14 @@ impl BridgeService { Ok(()) }) .await?; + // A removed application's icon is not served any more. + let mut guard = self.inner.write().await; + let state = &mut *guard; + prune_application_icons( + &mut state.application_icons, + &state.running_applications, + &state.config.local_applications, + ); Ok(canonical) } @@ -2612,6 +2631,69 @@ mod tests { assert!(icons.contains_key(&saved_icon)); } + fn saved_editor() -> LocalApplication { + serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"C:\\Missing\\Editor.exe","kind":"app"})).unwrap() + } + + #[tokio::test] + async fn removing_a_saved_application_drops_its_cached_icon() { + let saved = saved_editor(); + let icon_id = applications::icon_id(&saved.path); + let path = + std::env::temp_dir().join(format!("openmouse-icon-test-{}.json", std::process::id())); + let bridge = BridgeService::new( + BridgeConfig { + local_applications: vec![saved], + ..BridgeConfig::default() + }, + path.clone(), + ); + bridge + .inner + .write() + .await + .application_icons + .insert(icon_id.clone(), Some(vec![1])); + assert_eq!(bridge.application_icon(&icon_id).await, Some(vec![1])); + + bridge.replace_local_applications(Vec::new()).await.unwrap(); + let _ = std::fs::remove_file(&path); + + assert!( + !bridge + .inner + .read() + .await + .application_icons + .contains_key(&icon_id) + ); + assert_eq!(bridge.application_icon(&icon_id).await, None); + } + + #[tokio::test] + async fn a_failed_icon_extraction_is_not_cached() { + let saved = saved_editor(); + let icon_id = applications::icon_id(&saved.path); + let bridge = service(BridgeConfig { + local_applications: vec![saved], + ..BridgeConfig::default() + }); + + assert_eq!( + bridge.application_icon(&icon_id).await, + None, + "the path does not exist" + ); + assert!( + !bridge + .inner + .read() + .await + .application_icons + .contains_key(&icon_id) + ); + } + #[tokio::test] async fn client_is_connected_only_after_a_heartbeat() { let bridge = service(BridgeConfig::default()); From 25915e1b2212205eaec17f77b12720caf6327774 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:24:06 -0600 Subject: [PATCH 5/9] chore(release): v1.0.0-beta.20 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e39e436..f00d19d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2920,7 +2920,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openmouse-bridge" -version = "1.0.0-beta.19" +version = "1.0.0-beta.20" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 4028620..d6f0346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openmouse-bridge" -version = "1.0.0-beta.19" +version = "1.0.0-beta.20" edition = "2024" description = "Local native HID and desktop companion for the OpenMouse control panel" license = "MIT" From a5e4623b5a2f33875d44dde2289313cb9491d18a Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:57:48 -0600 Subject: [PATCH 6/9] feat(macos): ship Bridge as a signed .app in a styled DMG; make the updater bundle-aware - packaging/macos: build OpenMouse Bridge.app (tray-only, icon, bundled native-hid) and a dmgbuild installer window - updater: replace the whole .app in a locked, rolling-back transaction; bare-binary installs keep the existing zip - updater: open archives with a known zero offset so the metadata budget no longer rejects every real (>16 MiB) release archive - release workflow: build, optionally Developer ID sign and notarize, and publish the DMG plus app update archive --- .github/workflows/release.yml | 61 ++++++- .gitignore | 1 + README.md | 9 + packaging/macos/Info.plist | 34 ++++ packaging/macos/build-app.sh | 67 ++++++++ packaging/macos/build-dmg.sh | 23 +++ packaging/macos/dmg-background.tiff | Bin 0 -> 72666 bytes packaging/macos/dmg-settings.py | 27 +++ packaging/macos/entitlements.plist | 14 ++ packaging/macos/icon-1024.png | Bin 0 -> 68784 bytes packaging/macos/make-assets.py | 175 +++++++++++++++++++ src/drivers/native_hid.rs | 11 +- src/platform/macos.rs | 35 +++- src/platform/mod.rs | 6 +- src/updater.rs | 250 ++++++++++++++++++++++++---- src/updater/tests.rs | 199 ++++++++++++++++++++++ 16 files changed, 870 insertions(+), 42 deletions(-) create mode 100644 packaging/macos/Info.plist create mode 100755 packaging/macos/build-app.sh create mode 100755 packaging/macos/build-dmg.sh create mode 100644 packaging/macos/dmg-background.tiff create mode 100644 packaging/macos/dmg-settings.py create mode 100644 packaging/macos/entitlements.plist create mode 100644 packaging/macos/icon-1024.png create mode 100644 packaging/macos/make-assets.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c4b3b..1ddad41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,15 @@ jobs: macos: name: macOS universal runs-on: macos-14 + # Signing and notarization are automatic when these secrets exist; + # without them the app is ad-hoc signed. + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -105,7 +114,7 @@ jobs: "node-$NATIVE_HID_NODE_VERSION-darwin-x64/bin/node" \ -output native-hid/node/bin/node chmod +x native-hid/node/bin/node - - name: Package universal binary + - name: Package universal binary (existing bare-binary installs) run: | lipo -create \ target/aarch64-apple-darwin/release/openmouse-bridge \ @@ -115,12 +124,62 @@ jobs: 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 + - name: Import signing certificate + if: env.MACOS_CERTIFICATE_BASE64 != '' + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/openmouse-signing.keychain-db" + keychain_password="$(uuidgen)" + echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$RUNNER_TEMP/certificate.p12" -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + echo "MACOS_SIGN_IDENTITY=$MACOS_SIGN_IDENTITY" >> "$GITHUB_ENV" + - name: Build OpenMouse Bridge.app + run: | + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + mkdir -p dist + packaging/macos/build-app.sh openmouse-bridge native-hid "$version" dist + - name: Notarize app + if: env.MACOS_CERTIFICATE_BASE64 != '' && env.APPLE_ID != '' + run: | + set -euo pipefail + ditto -c -k --keepParent "dist/OpenMouse Bridge.app" notarize-app.zip + xcrun notarytool submit notarize-app.zip --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" --wait + xcrun stapler staple "dist/OpenMouse Bridge.app" + - name: Package DMG and app update archive + run: | + set -euo pipefail + python3 -m pip install --quiet dmgbuild + packaging/macos/build-dmg.sh "dist/OpenMouse Bridge.app" OpenMouse-Bridge-macos-universal.dmg + # Plain Info-ZIP (not ditto): the updater's strict extractor expects ordinary zip entries. + (cd dist && zip -r -X -q "../openmouse-bridge-macos-app.zip" "OpenMouse Bridge.app") + shasum -a 256 openmouse-bridge-macos-app.zip > openmouse-bridge-macos-app.zip.sha256 + - name: Notarize DMG + if: env.MACOS_CERTIFICATE_BASE64 != '' && env.APPLE_ID != '' + run: | + set -euo pipefail + xcrun notarytool submit OpenMouse-Bridge-macos-universal.dmg --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" --wait + xcrun stapler staple OpenMouse-Bridge-macos-universal.dmg + - name: Verify DMG + run: | + set -euo pipefail + hdiutil attach OpenMouse-Bridge-macos-universal.dmg -nobrowse -readonly -mountpoint "$RUNNER_TEMP/dmg" + test -d "$RUNNER_TEMP/dmg/OpenMouse Bridge.app" + codesign --verify --deep --strict "$RUNNER_TEMP/dmg/OpenMouse Bridge.app" + hdiutil detach "$RUNNER_TEMP/dmg" - uses: actions/upload-artifact@v4 with: name: bridge-macos path: | openmouse-bridge-macos-universal.zip openmouse-bridge-macos-universal.zip.sha256 + openmouse-bridge-macos-app.zip + openmouse-bridge-macos-app.zip.sha256 + OpenMouse-Bridge-macos-universal.dmg linux: name: Linux x64 runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 1ca09c9..65bfcab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target native-hid/node_modules .DS_Store +__pycache__/ diff --git a/README.md b/README.md index 0c203ad..6bbe239 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,15 @@ cargo test cargo clippy --all-targets -- -D warnings ``` +## Install on macOS + +Download `OpenMouse-Bridge-macos-universal.dmg` from the latest release, open +it, and drag OpenMouse Bridge to Applications. It runs as a menu bar app with +no Terminal window. `packaging/macos/` holds the scripts that build the app +bundle and DMG; the release workflow signs and notarizes them when the +`MACOS_CERTIFICATE_BASE64`, `MACOS_CERTIFICATE_PASSWORD`, `MACOS_SIGN_IDENTITY`, +`APPLE_ID`, `APPLE_TEAM_ID` and `APPLE_APP_PASSWORD` secrets are set. + ## Install on Linux One-line install from the latest stable release (detects missing system diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 0000000..38621b3 --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + openmouse-bridge + CFBundleIconFile + OpenMouse + CFBundleIdentifier + io.OpenMouse.OpenMouse-Bridge + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenMouse Bridge + CFBundleDisplayName + OpenMouse Bridge + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @BUILD@ + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + MIT License + + diff --git a/packaging/macos/build-app.sh b/packaging/macos/build-app.sh new file mode 100755 index 0000000..b66d1fe --- /dev/null +++ b/packaging/macos/build-app.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Assembles "OpenMouse Bridge.app" from a built Bridge binary and a prepared +# native-hid directory (deps + bundled Node.js, see release.yml). +# +# build-app.sh +# +# Signs ad-hoc by default. Set MACOS_SIGN_IDENTITY to a Developer ID +# Application identity for a distributable (notarizable) build. +set -euo pipefail + +binary="$1" +native_hid="$2" +version="$3" +out_dir="$4" +identity="${MACOS_SIGN_IDENTITY:--}" + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +app="$out_dir/OpenMouse Bridge.app" + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + +cp "$binary" "$app/Contents/MacOS/openmouse-bridge" +chmod +x "$app/Contents/MacOS/openmouse-bridge" + +ditto "$native_hid" "$app/Contents/Resources/native-hid" +# Bridge's updater refuses packages that omit the bundled font's license. +cp "$repo/assets/fonts/DM-Sans-OFL.txt" "$app/Contents/Resources/DM-Sans-OFL.txt" +# npm's .bin entries are symlinks, which the update zip cannot carry. +find "$app/Contents/Resources/native-hid" -name .bin -type d -prune -exec rm -rf {} + +if [ -n "$(find "$app" -type l)" ]; then + echo "bundle contains symlinks:" >&2 + find "$app" -type l >&2 + exit 1 +fi + +# 1.0.0-beta.19 -> short version as-is, build number numeric. +build="${version%%-*}" +sed -e "s/@VERSION@/$version/" -e "s/@BUILD@/$build/" \ + "$here/Info.plist" > "$app/Contents/Info.plist" + +iconset="$(mktemp -d)/OpenMouse.iconset" +mkdir -p "$iconset" +icon="$here/icon-1024.png" # regenerate with make-assets.py +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon" --out "$iconset/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$icon" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$iconset" -o "$app/Contents/Resources/OpenMouse.icns" + +sign_flags=(--force --sign "$identity" --entitlements "$here/entitlements.plist") +if [ "$identity" != "-" ]; then + sign_flags+=(--options runtime --timestamp) +fi + +# Inside-out: nested Mach-O (Node, node-hid addon) first, then the app. +while IFS= read -r -d '' file; do + if file -b "$file" | grep -q "Mach-O"; then + codesign "${sign_flags[@]}" "$file" + fi +done < <(find "$app/Contents/Resources" -type f -print0) +codesign "${sign_flags[@]}" "$app" + +codesign --verify --deep --strict "$app" +echo "$app" diff --git a/packaging/macos/build-dmg.sh b/packaging/macos/build-dmg.sh new file mode 100755 index 0000000..c261ba2 --- /dev/null +++ b/packaging/macos/build-dmg.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Wraps the app in a styled drag-to-Applications disk image. Needs dmgbuild +# (pip install dmgbuild). +# +# build-dmg.sh +set -euo pipefail + +app="$1" +dmg="$2" +identity="${MACOS_SIGN_IDENTITY:--}" +here="$(cd "$(dirname "$0")" && pwd)" + +rm -f "$dmg" +dmgbuild -s "$here/dmg-settings.py" \ + -D app="$app" \ + -D icon="$app/Contents/Resources/OpenMouse.icns" \ + -D background="$here/dmg-background.tiff" \ + "OpenMouse Bridge" "$dmg" + +if [ "$identity" != "-" ]; then + codesign --force --sign "$identity" --timestamp "$dmg" +fi +echo "$dmg" diff --git a/packaging/macos/dmg-background.tiff b/packaging/macos/dmg-background.tiff new file mode 100644 index 0000000000000000000000000000000000000000..3cffb16ff39d522807973b14645edee0c46846c2 GIT binary patch literal 72666 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�|)Ea6WYvN+ES3(27YJr7YAm3 zhl-Ta+SBsSJq4X{ZTI4fz)d9PRKT+~45x8SC^is^pYim*WQ(Gvae;A*E+ofD*u+8u zAPC6OF;!DxIvbNKb;f2MXHe``q6{{h{bJ4`U}Gv{g91AB@HhjToDo3htZ)^iy8A^F z>q495?BPAi4h8)~uZBd0IRIo1YBFzUlG(?aDaS^pDrqRDO)d*7m(Zb1R0Z)kf`x?g z$hnQjS>QrOCf3}|0LVqbePA;za*p9Fg@;mEGX^a{md)UI#++v$BwR~Fvp2ybH-l;Gl|V7?2Guq&%%&YBQGF|mAk0-kv&-Z}~v?xM?KgEn%*P*CHh zHHig6AM}l4*#`Xu9aH0y*#~1^fAvt54iCCo+HZ!AToBUyg1$u*BHrm07wSRn2cS*` zwq>z`2*b(mJ70bbzx*5#{6&H4ggUS-QivN4NH-UhXCW7-^T0rO1?hSq8TM1_L_AB%qCS-mzdEsi>Udct^}1yx*pj|jC)CJ$dB00e};^5I_dY$@-xGe=Kt z`N}>e+Uv67pu~@k0!p2OsOX6k)&zo^8s>JY2Fl^gK2rfxPC`5vg(h`gTvW-Gd@i$F zCwy&IY-TdGjo0C^?_C0xi_8&h6f>~*I}XpRP4iLC{ucfFq0zBZ&$B&LzrV$UM*Eb< zHL2$D^%yA~E{qxSGLt1e<-c?km0fmkc8pAU+{{HyC}-o%_qcJ$sc8&>5TxmHmQDba zPyWi%d@@LhKiKJvqQJw#>kEZ*`q$1K1-)r#!p)xHtFCr$N$(Eob225p=qx@05D#pg z$`NdWfe0Tqd@$lzg&eBr<4BLKcpSrutK?J3f(7hJ=J{922vH_g@x3h>Id;i#qM5OL zaH$2$G%-s;?%!roDxTjMyFcfqjF!x6ICw1C!x@j;t#wteEyVIiK z`c&eBiB}O!uFXYc+PVx=pQ{ovbSXuec)Y}7GkcpaA-XfW4`Z6I2*8op!d$<*g;^J4 zJ4@x{>5Ro-T1GMJNC*28D!DADrD|x2?8>F;Y@l`UUnQfX0Kq5f+mg}da^N)F)~C{t zBDU-aZ^ZmiA|_~rWBS)4rZHo$G%)E5FE%T(N0PWx%ZQOCZvC5O6HSN>O>;etpvfJk z7t_0bBb+t6p&}^vG7AfYnpKEd-1Ma`pB4_19=pb^cOF)4F-;b;ZtkG6g57wp(#_Dc zAV7cb;Ct*Kf%5N^&o$2S&q1O5df8jf7x(gd;YyHO-oZr%1*S5_c)U@+c|Zu3Y~(F3 zvb;0;W%;n%+MGEs$92hc*3k5W8n4lKV0UTyo#Cj1E137w<90Z?^xjEbW$}YgvQCaP zHiuo{o0*ii7~rJ9_^I6^8Gqa2>iZvqeh&GsDhKBUuLb|Yj$RLm;C%d9d47te8mvhW zlp883|Bj@9LPsp(k7du|c|Arisl$#xx%KXpdxRSdsu*=5# zKa=AJo$Dv&UH^Y_oY}v1YSHx{3uYbrfFTvWoW7*sLo$dj{ z?!~Vp$N8HalZ=bzDfz6(5%-f4sFI@f4>bQF$En&nRD-0+PPBs=FPW$Zr^f?fLm3BQ zFNa8f#4-8WLo%k7Rlja}Eg`m)#l7d{xWS|5~EA{G@f-^xwXtv zOp8rqd8|s*`x~!Iz0Z1eDD%=N`$TRs>)@xtGoyK+;#4*tp>kO>C8_FkwkJ&7z&M;; zb0+&$hQ`d?gqY53hN_V693`QuLC@SzO`~~A9pjU^d24I41dO&3gRJ@0)WwDwpH5ly<9`p6GPcgR8EAJ8)^jS$f7@i(j zsLnj-Oz$#%5LUf@^^L0-E3wx*U#;Qn0Tb?3lGx^^aX3j1AE3d4 z<*vRr?pnA5Out_|1e_^KjLGkZMxlJahnK|Drh67vtyc!ot4sTFQ#!cP5CC^>i4X(y zy3QjP%Wck`q2L{F_V&t~|At9p&}hkIVOA0#8j&wDzi}EMQN-Uyo^f>(q0YhrEUlCl zAd3Q)J{Or@JoXd)SQpe6d37B=!2(hOv@6L_-hK>=jJ*Hq(@0OG=I?uP6>h}55#>5{ zSB*L<3biOQFnHkgn$8Q0)$ZaVY>BzIcWTqnkPg&~xf1E_+V^;6F|jqKb9UanCj1{v z`cJ3*Hv1$>X$X`0nGI-sAYh;$hi=?lcjZc`wDqd>qOmqTF`)`N(md?8O8>^BH*}}es|ar$ z<6G?Q1Hz9jJywCoA{U)WQ3#%k$UB@%{SNeMdk@@TgL^akC07ATMz=vK>Jb zi40*Vd5WE6np>nUl3CD%;vxY@Gix_e#vqV)*h=Fw=7Ni!6|`3{aPB+4NA>6v5ngQ$&DJJaw>7P6wj%4x2z?h!HLB5 zDC>hu7jZzSRK=?Z`e&if0~t73)v!UBtX5JmP*NTQ!0_7qDuiC=`#;$MtKooArLy!8 z%%VN%1X9$dicU2&$&dEgXEj_1qKk$y8a<^8n(_^gan9Ph?y-AvNW(<|7Ry&+iA4!g zflKd?oH>#Xbi5f_URnr#>e6xXh}mKpE56+Ijd)t6Xd3{BEUpa2u>p5sZUQ_B6|s1L zDl<=}k9z+d%ZE0uqt%{{&x?X4Y5nTPM~bN}Jql zU^Bv%8waeYuPOmPwQ;~(lUTiZ)Qs>EJ-~_-!y}ur?g8T4Eyl5+|%@P<}2l&^kPudI9KZnFY6adcSZ-j`+hx_`BdwDL|y-m#c_(ZAU*I2|vffi=LUW zx`#y*m8Q}!jkUYM-j^dvyFh^jL@}yY7RC4{AzV(Bk2ud}oIiEOv~M|Qe^AU#sh4@8H2R$w@>q|wIONu-9nS(D5(DJKt7w2=pN!(RopIzk1U zzR5i{{AH&tl_;czVSobC38IwLKnO)F#{SBk%=tNOr}Aq}4<<5q+Sp@;>z;l#ZCsyVZdVbDSNfGmSoK98KO^h=cFvlJvtO0T`{{@Rq z_kX5%3-aAwVV5Oypim5;p_5SO*ui!srz75 zuHkNcsG#+P-hSf49E!vq>#npnLkJpOWliAJ>aVIu1Bca}6JKaR~$2Nsa54{7A z-ck=<+loj%II6mQN4@>iw)6Utm(#_&#oL1_iyP@9j_r2^hTNi%XrCbBUK*7}t^Dc#rIy{gc{UPapoWRruTYvsYmH zaQWkJ->+3~6Bmpo(f*U#+-JdxQ_27Mew9c=Yy#!#Xr7ZhcBB2_1~#2 zxzd_$i1e@2w%-ziE449>2!~bumD;fG3U{^qNo`$6W5PcM{-n0? zC2bMe`9G;`rEfwcZ0k>IJ9w=t(slkPwf!j66Zr}KliCOprbOi^Z&RDcn#%NC%57@P z^klv}OL3dp1_HXJ^!}ta$m~2!=Qg!z%^J>|c;2QqA{`6!g`C^e))H9kYkiyAZfypW zpRu>8je8BbxS(^J+LDShoR_I?Q`-~(33I>qS8CIrf9+*OO*r6NL3KXjze=q$>2Gc3 zxf;Bb=d>RBDX;c^sLh9o^YGccf$K*Y8bUlMFz6RFdYwjlCknY}3X9($+ony}$gksL z-|%0r_hO)e>ytrfIxSK@w=ycEzrPya$+RqxS9!mc`_x(;)0FGrxgGkDz3`*O_SexEuYeEGioKZY(D zyLL?c|3U;@XNiPIY2L}XyOp^V@RshjT6jkOmk3zY&tg>bj|iAyAzJ?U9}!T#jPj|m z`(Gj;ZNCiiK5+YDsRlgvKl#ACK?<^^`mV;(>{Oxr z`35zAd|+}=l+lY@9~dv(H<{r6bd`^Q-f3`ZUJH=D3t{ufxxi8aa-94m! znzmWf%-p}YCs!q5(X!$tE>pbu(V!cnC;o$x!!~%})(19_XA&rm`h5D`ApEyptz25* zjp^GzKJbcv7^8G>@W@Woc{tf#2=?Js%+53M34K|C$dT4Cg)d zVgK@hZ@0Q~CBc7fb^RGUYefCie|+E!4{Pc{d|m^Z!Hid)^nd09y_)YzIMXoMpZSm` z`;cXXO#83-P}HgM?VtI;Glnx)!TXO$L14`{{*Op8cdeaewJPzRJb6p1Da&I|W|DmT zc0MGflR+b!4*!}Dgvyn(Kfx*nw_Bb140$)D#!PlCpVlmy38(gK_Mx2K9LcJL{#*{< z?R+4PRX3i``7<8~!_EGl4jqb7IC+Nk+)l2io+64c)a6Mu4l2!{DpOy+j4Gg zt=h6_cZ=u8+=FS4TLJi1q{vi7``ylm`M~}Ua!bLVs8hBe)*p%wAFff4O+Q#OJN)?w zLpX4a!sPQ4MmLx)PBhlZ>l?>CmP`Va>F>$bV#}|)_hp#wMusQFk-9YYkKbIH%IjE&whQq z`SkPMG4&_Kp5r0`f|H{z1e$|EpG{CO?1ZYB?kz^3`AW$0pI~FQ4Ag z)42ZL6YbcY`wbsFyKFpKZ&KcW3GV*m)3b56Sf!Y~qOrPdMcgC7Lf5j-Oh-kXU7Isd+8Q^zQT5rh%Txx}W~qlHOpq$c`#t?kzX)sDR$U)bxsO?+@-6E5n~wBp(C>aKlnJkhg|Te8FgNLFGu8 z4-LtT_7y=2_dx?G#Erw30qa^tpWI6_jt{>AXIv1Uvw5zh&9RgU8X0pfsa5dIB2zkz z^-7)u!rtI&F7@>>-rk$K_C=9o{=H!;1S{s3Qc4A)H=tE>yoN{NQGFXzUmb`nnLFRhW( z&jHiC;eieLE=QBck{aq|Id&x3<69VjEn6dy1WfY5FLE0VOo#n>69honc=uYLY|mn0BFFtxQMXF0aBTSh3}< z=(vn3w4f>%A zA8i#XdOP!N`;H^@jsYTEh?}+9Q`s248cC?TlJyNb#M9(6Niz#M$W_rN<^^_`gb zi}~fTdy0f17U?c@3|0>?e!2!@Qc>KmlkbXJ7<`py-AZ9{ghZ;Xs~E-? zCJJWd)p`gr$jV*yAD8*?Io3DhEBXjR>We6%;kNkL!vI9tzMOh#Dyb6XY1*}wGJpmG zK&>!^J0WEDNIemQAija;4fsG0ftabM6k7xp)|EPu*!91dLG1>(Dr|}gkqn-L zAulL4QVQsq-?ON2q3&VQW#gwIbhIQ#jwkfetY#YNe8%rc!a1B{VS<7=t` zBTK&^7Zg$J8(-Q*1xkn|RYI5~MQRH6UNusI>Ir}-(GnoJCmlDGH&Livd~o;~g?$)3 z*e8IdPx8J2&`pfk)$-MPDAXsFwqLpw0|O6VRcqI4(PLx@(Zez_8>ndAkrkQhm~T(B zmc#VQx%Z5P6AUzP=&+GzaBzqlfzJ9zJU2aVC#%|=O+G5-O&dkW1YoA0sn$q=1gGPV z5y!qp>#^yF0^5Wn`Liqr)ZbHNCTj0KZTb|3-jLySDr(6+_J~+qdK1hE*?{tUf58E^!DF=}Y;gQF04 zaqC+tdqZMLS!D0k1-bK=y!zBA9yGd)FFaajQq&mqV9X+lTx$idl{qJengv8e6p2wF z$gWhy@r6jH7o|JIE%}evDCZk zX3bX;{p6889#j!ri&aL7RXF=3h+}V;q52%FY{)mc9tUmHPliusZ+OadB7SE;wV4c( zZi5f%kz#UK2`2=~3aSf^(|`VPNSXk^?$=KvL;LVRzI+8Qpk$P;eHw4%74z zB;%gkM3wE!bw;dv8%QpjF(+y zk6P$QO_jj~Vi(&hbTkB@>E&|R`KNGnyA-x>^V`byMt7wE9bAKa;W$Msij8_pEX*7Y zraio@Je?w{)SS2%M` ziB@L_)4mZY4|gQv+?UD(1rJ(}UB^H0c2ng=am-GA=TzNYWfVJ0WE)*_;FAwKP7+zd znp_50nfdzmoi2QtY!*oJwRb_FJu7j=43h9#rnom_GNLphQb_WO2++?ZiP4)@zOJuIOGk05Q6r6^k$;$8_xocAmP z5yZVzND9X;fy5J4b7-UwBM~@3lPMY#UP;2muZ*f-0{1*5HPOZYm>;6O?iiD_?kWK0 zb{!e(QN^(XqlS@+&5_zX(AtU~9FYV<84RR>G{P0X=&>hDIG~;7*=W28rYt`t~oIb(WZyBZ}h8m z4G3r$9KyggMI^O@mVhDo5L_x zVn>`w*NZ;GOAy|rx*)xp<9EI1rjvCyf?O2GspnIf@TuI2<0mYoskn$Ign$Ji5TjPv z8zT53K`&V%!rU2?iN%lHtkY$6+QqpQMG&uuN;e3VXo}MrXeZR5$5TX@3lf4AJpLQp zqi5gipV9jilIWMi8}RYUNdXY?cTvuH@O{x6438>{}ue1 zJSAk4oX~y}n~N5hN|Va=IWmNr<5n_aB#RWqM?A+ZXD1zXqM95g(i<;(zsXFzvVetL z@S0)t^nup~kneJls~pgraLmN_B;O~YWVWK}`9z2q4pgEg1h^9+_K7`K@CYkd8x`1C zG+vMh2Xa!udxjNzh;G%S$G$@lbQ6?t1>Vs7$>0rB!`ty1mCKB80rF^NVJ+7#u_r zF1~J3*+EKV91U|=OSvqm9h?kpLm8=0)7Q_;(G0Xxp+H+TfM`en7ev*nLNh$Vc7)k+n zs5*Srub>?+vnXbYFWr26u4P2?9FZa%lS`b!8jG1a0Ke32vYb1~pZdntSSF#g{y!Bq zY-uTg2>buBruh4PMU2DgwPt<2GYSAOXRbxeMduD(28d(^7acSc#bYi7c0*RE5L}CYHY_pqv6epC)+IU zEyM_Gw%|$HDnEA|MeG^%~LKIQ%5AI zD2Yt}#Bmp3&GbHpdnbHuI9<;&(r0a-tCuht6zwGo`ZKfGWE$z z3YOy@%PN#T91UwcR4Y`yT@MY`J1q_844 z#FBfjf9t~0m@qhjgXE$RepIdGqf&=;fKoBG)VMA=cNV?8ZgMS9R^NYRN5S;88&V3N zx1)0J*s?P;oX63&r5W^n>Xfyk@W1viyg}*@t1v$ z=8*~ZTz54Y1?a|o9 zuXo1;*r0M{(qx1o7pW}Fq5VQV)3HNbFWX7V)GXJjBgi)2nG1QVOcdmL7r99N!-h&7 zdU}w=ajfvDTUNLCEtGM4-OIJWu_FXv^t@D!4AO_hNY`MdtxU2vUU-QnNA2%XJRenE zjKS+2e)9?iCjs5=C&&a=Tp`O`5(qQRC#f_*?^83%ShW@YUzwDeglj*)E3E>5WLHmdhIkqO zwsThrNydMU%dEo7H%#~3x1#+gL2xsB{M2_Q zzN+r>86vvAkN|HWTg)qi^4mwN--BAux(d$G#oo_O9$V1!c5KKxgGhp}qTL|TCGw!S zqBR(+Y*l8!D%Wt?T5B&>DBXo*eO18Ms`)ny zQR8G&3AC&X_K&m5mF$q$DInxsoNol3Q7RmSz+`?^*J28mIV1mVv8jQO5-R&_D`r4G zc~traOnKJEr@Yi4@k9bz+7}w_B2SFXg7qh&>aT0y$N(b*REJB*%N--q(AT7zAN{;i zmI=_pSRjkiP!&WeMN~5fFzTO`B@~9`m<)?PQr+t|tGlrISPb|28QaAI@n&Qt5ht#_ zxP8y!Wl7;V?c_>AL#s-q1!VPcQ=G4^)QFUe=uR7tm~ zX`*)@5GQVrqRjDuQ87kIm>1-|(xWc8{VF(uKR$9E5|@)VKANJ#R&Ik;^sMmsGmr1g{hw44oKMm)OgZsHWI1Hfpt*ply+TPKi}! zES;dV^Xigqc0a<{5JDE^s?YE_+b<=fUQ8+96dw_&nqUWEqTLdR$`Nib<}Nno&M|&5 zZ(j?->oBX1MKb5~HZ<*TD!r`re@*c0iO{~M%`F?Mr-Ei0eSz9}h{ZQhvBgB{b3-fr zkD#39nu$QFW!lx?22*GL#W5K4AU3lx*D`o1ir9^Jmc6;3YHs-VQpfzvUQcK&dTdF$XqNMlDeSLR1&6+^*>TUoWvL$EmGBD`% z0^J~Hzt-YaOKaN)x*cm{9k;skTSP{WZKo^uu>L}PXo;TFi4^v@@git)nO@z>W3{R! zsP)4~Iz4yK;*PeUi?#PM-acM(BOSrwx-0ie0=>GII(@zNRzD7fdduxa2gjYS(eH$N zcdm6`ZvVzGU`JZRe)e2RV6QW9%?Q-b$Y>GJ)a)jlMs`-z9=2ve!*DmP)m~9@u3sj{!4i9>kG!F z<%bd;2aZgQ7jLJRWXxO+yFa#F+H9@f#Cv@$-g$fZ4x-pu67p4^{$21S<+`jU^!VrR z?hqzMMcK6EZ;S%H*CjBFtoqY$HpHV3h=sPgKIFh7Y$v{Z%WVGmU3VrgF?~ag`;k$tCkz88QMa0c=x`4^PJZIa*)1A*Z@0^Yzyx*OdVDJ181fGNs4Ss*ex*qnr z=~N#3{mpOX)o_8nbNRNB8>W)OUnMPPzjpS2t~DKnGogPfP>=m6`FI+!Hg@y-^G}7- z>v#0ud+2Z8e)-YE^RtKi2Z9X?A#$bg@0Cc6m3DltP-6T4 zn0pJbsNQdXbY>X3ySux)yBleg?(S5&yCjtc=`IE7k}g3&K|nx30Ra(3?;cRU{?7TG zbI(2Zf1l?L&oeIfyfb^xo;~lZcdbv*4Di~E$QFv=0+93QSUkD`JlcUw=7G|CG7;r6 z){HW>>;pR-?YwC+XIz7L+-=@mvQ(iUDlH)ZAM?LdOo;(-vt2_siYYf8l7EMLOPQ|B ziiatt{OXYWrI-??Urz{AO!500_okbWjR`2GSbF?}dlOcJh5~zP{QtHT{af1Q|DOQ8 z|97ok_egH^!JAeu<`6&DP#PPo)vFqSJ**9DWX zS~+QADGU=hi9oA24F{3s1P?}0%rS|J2DEyU(4O-EoLHdMn=mvZaMQ|}jt_n# za@)#@6Z<4Fi-!)ha=fmQW#{nJ@wsLf&45-8Ug86Vc|BMw2kSiq1aJ#LAUF^R1cqb+ zci`I?TnpQ969bPaO@iTWw}aq;?FheO;K^W=Uor3^U?F9t9qS7-k>7RHAO!n97F#U>Q$H0BFUkqTsL}2m1 zw(H)+z@C&*V!U9-g{B0=3?MLi4)FRw4BN-Af0012KM=|&2!!he0wK49Ku{{+#7Kca z1PQ>vx4=l)<1-)-1#rBKmmm-u5eUR-2m%36cQEYF(vHL3)eZ#mcCfYe@pf>g^5x~{ z0-lDZq~_r22EhV>TwJ|9brfZ&42_Jb5T}8oMg}2(xIrKbD=&9-T?IX0Fi2TmmdXnl z1dIQ(O}!XZLI8p_~t};>gmV=u^4cw(d_<;E&qzW zZTx}b1c7AT+yguv?Cia%n5>woxP^sHSyo4n%F4&n7ZwBG3D_Mx2nB=*!UGY5C_uCzCJ;M_2P6m*1xbPAK`J0k zkRHegbQfd=vIn_!RTOIFfo`COb=!S^MHlG5@30-8dwKx1U3iTfnC8q;9zhB zI02jv&I1>NpMaadUEqH3ICu`c3f=-AfKS0!5I6`L1P?+EVSsQ#gdoxoRfrzM3}OfI zfCNG!A<2*&NC~7C(hli|ynrl0HX$D%=fF=4DjWeEH5@yf5S%QWCY&*xEu1IZeYkkI zY`7A*dbn=5QMd)TO}In2uTTUiE|dz&4i$weLGM7VpdQfs&_rk+vILwGxQKlo_)Z1@WJHuz!qCHP(V3j_oN0t7|`K?EfPBLqi;AcRDO zLWBl{K7=`hZG>|~L_}glRzz_`O+*VsZ^UTCT*O+$=ZN!&yNH)as7RDZJV**i#z?M6 z;Yis?)kwWa^GJJ0-;lA8>5)Z{HIc261CUdY%aFT~XOMT1zoB5GFrtW~=%F~GgrVf1 z)T0cetfPEJMMb4S6-L!YbwCY8%|UHM9Yfth{fdTz#)2k`W{T#6mW)=3)`zx=_8A== zodI16-5A{)JsJH8`XKsi^h*p}3=Rxs3~P)B82K3O81on(F_AIpF{Lr@Vg_PnW42(< zU>;&2V=-XKVp(9_$I8d*!g`5yj*W}Wjjf68f}Mz6jXjRNkAsNAfFqA%jT4DeiZh6_ zjSGiMiz|z3g&ToehC7VAi-&;6h^LI_h?js@hc}IPjE{rQhp&(CkDrg zfI^DGks_0#m*RjDmr{b#fijcwIpupQd@5-wXQ~{kA*y3)GHNAiAL?T2Y3gq@j5K#> z!f6_4HfT|3g=lSQGie8CkLf7s)aiohYUp0kBhw4f+tWX!AEp1oz{p_05W~>Hu+K=u zsLUA1Si`u^gw7<%=;$BHMPXOS13SAq8dZzt~wA1j{?-y^;ieq4Ta{uurN{%Zk20Z)N?fdfH0L36R@0DM>A9Az32%R*F{2TB<^7PnucUQMy+8qYRgfr%ap77g-V65ZOLCI5|bR zc)4kL9Qix)dGfCnXcX)eY7~wY`4xi{`;_37RF%?{UMiC-TPZ(LK2{M_2~inRMN`#P z%~O4=#-iq_)~yavS5ePUU)P}5aMkG01ZgU3W@v6`F=}~eJ=I3g*3!<`ey78&6Rb0; zi>rH2w_5i?Pew0AZ|x4#9iKY``k4Br`cL$~7|0o<8*CbK8ip9YFd{K>FzPf$G}bq+ zFg`btH_0;DHRU&rHeEJjH1jtbze{r0`EJiW%zGC1TFl|i4a}>}zguWplvtcwDq9v< z9$U#;J+%5@EoGf)ePAPQlWwzTD{h-^yKg6Mmtl8cFKM4`f9N3VkmqpXsO0$A@r#qD zQ>D{SX9MR37bF*Rmo8TvS0~pYHwrg@w>fuK_h|P`4-tf>;)titmH( zTizddK>Hx_!EUHxXjK?Wm{ZtPI9GUj_*sNOM0X^4WN74elwwp(G)A;%^vf90n8&fu zScll@INrG2xF7M>@e>JL3E2tX6Ri>_lDLy{l71%JCQqjbq&!MRNOetJPLoWlO2&uGu2%8buE%et2}p3Rs2_#w(epNE?{YB`;`bh)Xy-}CJAmhxrt8w$t@;tIYL zS{Kegl6qACnEY|Vtij zs)n8jK6z42R-IA#Z(43vZ60V5YN>Cf zZ!KuUZHsRQw+FNzci4Aqb{cigb*Xd>bc=SkJ>_^>`HcEmeh+?6N-s)pWbe=CfzQwT z-1`pu?fSO|?hULB8V)WFX%9^gs}7HiD2xn_N{>Dt6CZmvE;8OVAvDqPLf}RFB>!aF z6#rD)w7_)xjNnY?tnh62oY-9NyySfUg51K$qVnR4C5@%Ib_muw%Y!%jPGpV zT9tKpelcH<(}PZ z_@lDQ_6Jqz*!lAx)b$|NBtL8^F4ii8^<5tO*HZizRcSga#~lpg%KbuB!nks{M88qj zNwl}9%6RXvUrTWqR}NT;w_YlruEXT1U`ufrS8fU=@+lvHy8eZ#1W?!7{L>3CR3(fn z2V060v7V8m-z>#%xN?d$t?bkQR}QumhoP=VTZV5@*V;Fz%1*v>?K?|1s7m}K8>80D zEC-X9_)`GW4dBWdE#0E7VW>(1wFL83eE?N?gSvi|BA$E8m0Qa`>Tzqj=;?>aQ%%88 z*Nge424)*KsLFuX*->kOZwLX@^_z!B*HTmF$8tE~=2cwwxwg#LQEx6?0n{~7GJV{j zApvrP1(7~c)TR&xYQ&b1(RI9aEC>P*7cOl+3vj&gQKMxoW-lWZ=yBuaE$94)Z23u( z6|LsJLZjtr)0M5~1LP|1ux6{;%zup>vEj{Aw_OMrdgmkhNYifN3LYU!wnW>0F;Jc% zOSM9CaDbXgjfKd*d8Og~5FH67@*p!z3$nqeV%2#$NWNmpsolh7`CHV;u2;92>&u|w zcjrO9_uO87M?i#+7_e|(307dlPsn}w{*FqU#kFz0K{e4g0#)MtX*;jgYlIm2(s>8( zS0M_OcWRcMd|q8g103&HF1{;?J~6b`5z8MMd!}VZGuBT0*Y7JbX1#dl6|nvzMzMV1 zy-(oA{ZYHNwMBO?(J_HbQ$DiLK69nPsAsSIL^f!#J1+z_T?NQLzPvZMOL7(R9CP6v ztW~xRdOk~gk@&ok4D>@v^E)a49dpJU|cV z(e)k_()^tHQbfzEbwNy z8>O1w*%GP7oz`weG^C@2Or|Qm>gKsgQ!KGNcewl3okch5JBJ)a8DkW8c!b$X%uutG%F=R!yRF#r z)U7`U6y*7kJyNwhLxxU7$;N8g@x_L)gs4R?RO7v3d;3@yq*sIezV@QSH0x!}dEOC4 zhjGIzwhLpk`a#=T<9dOK9*te!Za|gyo6Gc9pDMFs!F&po+~_h(CjTctczlxccIdw{ zx8p2icW5_sBwT-BW}2D)OnA5qhgmx9;Ponz!Vk4};Y0H`wkZ>X)_IqiID<^Yr5-cS9!RO1z;J<-3&I!jB))oM)II?qfV!h-4!lD^$i^ zj}k$a%bOF4hbvPSMY^$7CA?@~r9F;R35y{UfSIcxhZpsi*yL26dG{!$9sb6Q!jDn?mE?v)j=vW_+p%z^IO#O!_7b&qdmhD9jCcKhV6vjN`#L zC4XkOTg*6&34Dtg$5+2|GrK9xEXu>lmu9kP8qVU~K{SBPZueFQ0L<$xW}E=VDS=_e zZ)dmcx)4)sK73*sDsR4@&db?tH{yFYv)hgM-tXD%Vck*CV)n3|S@C(>3BW1AfDgOD zjBgQX;KMNEx3e3?y_HAo3EM<4%y`a6@su563>apdLp`IKs9yV4LmGw|&wZC$4`9ZL zr+x0)7`+a8yibAwV8-(}erZS_WQ+t=yeCO6e*(jd*L)zE1~B7!N7R)c)-Z;f4)Zwx z%s8pWQrl6!OKGFaS~7qc&xeWckxrlYe9Dv9>OCgIgzpD1HKuGW{ezb4HE{V8#nM)fc7;_OzGI3*7+BIC+ZA>P5cbomXGTXM8rk z6n+YMb4h{m#`W?pXVUgp3eBwDuaDdc>3n}YC_lJ*)KCXt*k{_>uO!~|eENPPzW11O zDmdu5fRYRR0{(lWrz;mlnoEXVnC2u zA&1+H#17X|PnYUshTj|_j?i+&0MMKbo2`_QTFF4iSRHvwj#-5(8ee9`40S7#J6dZR zVF1we+8Pp$(fUXs`!WQ*_)I!h8-qs9FbR9DKq?NKgF$Yk5ch4QwhcB5v%Fy){@Wp= z1Z@{bd8IDGZKy?}b~2BG-Y3FRK8GYgxmICigLEPn44XW-{*QDi{`=I(8U@+(Keto< zeQJbq@gLhM{~5vuc;By`+)0T0`&i+ zMjjN8{}9spQh%pLHEuh^hyz%^32C>fQ34-1#*K`?U#U^zq2zBFf#0c7-(2wTF2%3O zBeP<3-t=E44@~F$KZKK8LR#euuUkUeuheL$MeR4?PHiLI67+pX6R`;8W;i zVhG3xd|o0D&!ROrgpatp>Nxu-T~v;k>0Q$WX61}5;nIm<-n2?)GPTWTw{ zCTsuZb^Q+n-X(m2PTqU`e=G0~<8}RKf%i`j*UkZf_sqrW9Jb#A?=W81zY4tnBGaD` zsWaRPy#FhizH7tvOa8{i8};Y60`HV*cYmv4?%|lT zU~I8e83`E!xY0aiH<*`1By>Lz7m1Fl!jtITR8`;<$K7bhJZp%^Jv0Tr=-GYG$=iRJ!d#%&(>@J;_R-6WtW z3d)A8@&pu&29L@JoM!q^D9%G8pH8d!er6b=|R=e zYvb3{6Pie+*IR1yxtoYo=H7}HZ2>OQBvMF|vE<#_|IQdmL!|GV&ES!NtxupUs)dqX z8+wP+=Q_e-rCmI|c==Abi>bk#x7W6-+rymB?v-e#;l20msT-kg|_;}t+;0&&%t zB|T<*C#YpJRXG5+lw4F0Cv)jdB$pt%yeB72!*$w_1LM(a!NLzg015r7cgwS#G;&9n z002xq30cjs5!QW6iTO5EiW**_%h)E@I?Z<>w#>d6EjkR{*V3fxZgkuBa`<(jUG7C# zF=LnOy_|Oe^=?s$*h_`c_Ib-4^-gUn5ZLthKM*edk18rEVPUCZ@@ftdtO>|=HRapf zMOw17+{pN3M#0Y>y`QhvzPt>X{o8+I$5})i7fUNjk(Ru(SB#~m1%;d-MY(*z;hb1) z+DWirdavc1>Buj&hmSD-jla9ezo$R_PCQ(}fBZKVZ58$)LIowfZVkq@d^n@JdJGJc z($riQnQ3MM_>)u5Z-k3I{J!C*PuaJ57Twqt^lweH-6rG*GF>?o_i+dR@Nqv~!lu7+ zFVS(nkR;{sEYZ z%22Gy$R3w#!VI!ZYAZLoZ0ea*S|1k~DqKI8DJf!)hbVMkuKAXa`zv>9!hDwdD|d=L zzRML;?B7RG@Xn&wEE%Z5fkLHFocZdfiY-5>v;Zr^3hd7>@WpuPCiAD*Ygf`HPA5A! z07}>!VR_!De^!QMKa>}5R8;ENo`B4SkXWROgec$%t8Fy=cC2e@6K$ zcWQhZ!#(x6-~&T@)r3rD1@lA{$y2pW@Q(*s1LGV$pEr^)$9j5XPL|Gl0)ykcQ6DID zd9O#mz%vkkc$+)5f4+OKaPJBUCJsY2W{;I8j~r`{JA%*huisn0{p)m0`!C-q3CBN& zQ0mYhT=hP0Ir`S{^7S`!=uPfKub2-umqYd{k^__*^B85Rt|@7c^`k79BoIR{bn4dM z6ACJsQt44EHlW*b(QYB4FH$f!hDP;`kwd96@xo^<(J3GPL@K_`orpUzF)g9ecAzs! z=&=k|zu_tYt&nx>mU~M)mobxh*he(RFIXl0#nF4cOv6Go_+zGWv2!%^a$^M#EZ2>0 zbEkEA>hEsJ+Ed1%jT~?Tsg;aCmni|%&VZQnj{JiVI-HgaHq_iGIyv=zO7T~6y0EAu z#&(&p09=|0=cKe90#U`-GZ5|0{Zt$jJ{h?^IvYT^Sf}nnk=TfJH(UjwxRWM4wE|rc zsgGnEDj9!Xg$v!Zf=Fee4$XNG+a!+D&#QfzM2_NS;2WcAo;H3F;Ki}~9xFG4lv5q91D>Qk|72-kUyn%uaK_7XL|Lu@?P4*M=jm{#KNcd?bZRH-uFx` z;wxo)YR01ByDoikj53<#s6}2SJ{sefHcVmkC3-aRS}5#NRXd+0@mxLUjnx9}@EObX z>Wt^om6b7*IOx3l(sXGl)zN$e#LqTUbxWi*k*+0Z&%r_ywli4vA>?J=_ao9(A{DEt zA1hw^bv`Wz!PCh%6yQEAQk8WTn)>^6tPxzraD zQ$}mzc1Q5Vt@_FH#)fcq?EFvK^y`<6*UX%bqMEezOIA&cU%~ZV;vo+N^I#NL3B!-M z;zi?8#$uHq4uF^m!L8cK#brWAP5Yf46g%rr^SwSMt%_Z|-7t9(;?9B3*zHHNYx<(l zn`7x9K#C8M_`_1r`oSbt1D5E36~HO#BSGkkHAQ&Q5Qu7s3D>8_h(a|fI_utsHV{Hg zOu8Gw*%X8Fc2=lV??H$L&n7%rjRXVxfdKu95puY%U6@BIt$g3BI0sy?xf*&nq6SKT zQsU^A7)xklFE)#z6Nbg1w6-ex=TQbx#2p^m6t0hy4|5`GEyYyhPBd6)a`E=IB6u?@ zC>Vx`s_dxI(o&j@iYB5SvtoZA;cQ_}eopxQ$Gs2JrJh4W6U1Hw=3=N}#~P^p9fK0W z39CUZAHzJ29`ofC31EPUWU-~qq}Z$oV5SIAX(9qNZ8Wx#{Z0~pVH<0fMDfG!SN6x6 z`*&!vrN!t7AIn~d-r?4ZW%|J9so5vO{H2dyL*fz5 zwUqd1@X?;jDvd*C&8?}-A7@Pyz*&ALAVgF~miYjvI~nER=J-cLZi;GwRA4iE54eah zkzkJM3UNu?cQox2{RQKS9PCiN0DB%@EI!;?hV-bo);XIN;@WpX($h;He7=z}R2;Og zV+)#0eS!3p^*cY!c_5JA5uNGRA=>ltXT;#!ldodE&Mhw^7R9HV$+m=&c~f_VQ&GFu zdO2|qpe39i=0HqZ39pv8*-S`Ao7oB|F=^aMKfy_AU7hapvaN~Cv;vP1%AZ795i59< zIg!fO7_X6zdh7&IDxN|ksFj?CSbjG2`HLaP?NUvLP}ueBGvP2ET-fBTGtC`EtwrQ<3yEumqO@x0J;9J|ncmF^Ek!a#w^hrvE}eKC&3Vte?{K zv%T;v2Vqs@nO$!enSymf!8*-s^ ztgghplu3>X%w=^hx{Q1~l3LZiB;V-9J)IvjO?cKe+~v5_=3!vq8`vfPBK@qNTP=gX z#?9ml{Gf-aAN@qFq9FfonZy(J_+A8Ap>P6Ji-|mkC-V8V#7Qzl`fLdAwjAwqhLoLn zmo?Ak2j{(<+3x~LFD#WrF4H244imFrlRg14K_OUrPFc}L*~)}|;l&SbYr1?HO|i=f z?aT=`&H}ofHZ14%Lg@yDmY36jy}$5~>s$ftESp?M?b5wG{RbBI zxdX-34F=^$!S@W_b=SYYif(&yH2&jWn0bx>c9d)WyKm3kz9YhUnUN8q4!VC=#2wZn zWgzQ`1MC@)qd?=S3J+nHIwF0?!ML$dstQ;>X3C1wX`&jv@ZlrXk#jReTH&4ZDZ_ej z=omG75|f818_sBh@^DE3U^#FW!l{`zRrr9c+t;wcLB@AtWV$zST(XGmO0Kf1&X0Uf z^EGwPm#{6y9|PZx{X#xH*nA{*ukB;dbjSST`!5=($2|)=xTl0bEGT^|49J^f-bvU) zlX1Eqf;HPdOCmcx)=ggaYa}H$mhANYF^F zXqMi7=uPuJp;~z0Ff9Xwy}*OlvWY(GNY6#~6B{7r1!z)AxWKZHPJVU;zm%&U&iyTR zzmPV9Rq$PO?P#i~M7kJvKxhY>6>GyuAfUeSsa$71?%PD&_;OpkhxQ-kw<9&;u~k^) zEjtKk7EKf8!npz*ldOVH7DT?SM&)~Lo_7%LWiYvL2Ahz%~& z-4Hq4of|@xh>Eq9Nk@}Yp9(EeP*SF(-XgP!nFF87G0q^r!yC-WAh06xm*sw^_aZk# zz>W_oeol2LI2kIW2Q4O(h$*_1-g3Vi;|>K&5%pk{6nQ45^)r-_h@ zRhE`L8ZZVZxiylE=b$2;={4i;au3m1Xv33>aey*^%uQe6H|ay6QJ{!kfAY}yUcUNiuHsGBgc(1qz)Ez-$-<%4T4XvpUxLjAS+7|@VLY8i zNw1FCuB~0-<*K2$!;yN84{zNH9;vM%tB-sAo%yE*QGH;L5&{EN6>gv5rEJXQHRWKc z6?%U=dvxqBHC}M`nhBi}dsKEq)>R+gQ+SJpJ0E%;wt1vt0gJu<|L2RnEJVc;OBB4S z9jgw)Q6IelE-Gr2m@!Di!TYazINB`-+|nxch=?+3t?5VgwKfa1yv${(ioF6cc<1ibcT!QS8YizoSt-B}0l`feeaWnWux9rgxMNfKEPz}< zHv)V0~@L4836@Hqm*@w``Vh?U1OKO?~P*D*d@!Kt5L4qlU_gfX0emz!e zg|?sXJwLo}ZRnV!QC(BRxd<~`{9Wq;Bs0kpsg&p_f0-@bwgEq+<~D!SmH8ea-6#jM zJOQL-cZtGfLlM7Iuw=ve;~;}=sZoqbhsU2ly6S4hsf#xO`cb@%;qr1vUAWj*Yi+*P zTAcOD5MX;XAajvt%Zz5!$zA3`rl`FWLc^QRmqxFg^3*bAjKdUDXvltjiEiAjB}A~) zGlFiRxd~2^S}Nmn0`n9G^pk+5j`yClKt|@4lAK0RG0xD5ngPM^NkR$>urh4_q6!!= z-A&#kLIFRH={j>K9O-H|&SNyH@)S}PRhI0WFRNoyRfcxyOacm*WTPI359IzTl*gXY z{?!Jo(Jg1&ek#?OqmgLOtwo4x90>bi>Vac&^^vYo$Z?pc7Vr z&%(P@z>kKB>&06xqC8cau8+oz+UmSd3sYNA%YJ9b3XYijdd-^cXz=;mbN!17s?SEp zeeQdU`uNJ6i|6Y4ArvwFy=Hk8Tdoe|75&rc_5mYFg$E+nvgV~16e62efY(j%o4|X& z3gstBJq<8%mD|N$j-)0L8XLjjqiAEa$dh#!ax6nUl(3RwV}YV!_ga-lG1A-j9lQM4 z4neSzK>cT3jd8}eB$1NwfQ_U<0<=wVt+Yo{7g!2DNP0)JG2{1o?E30eiWl*H6J^@h z9sUA-FJeLyA}O#!-uk~*%p$Ox!>Ij!EkT*E8uI#HRZ{TXK9q|X)zRmLQj13Bh~|m5#+Cx`sCK5lQ7R!O_wwCZsj9jyR&iZGrIM zPM~-(HO=_-Jy1ENNX@WL52VckrU-%b>9IaF+^|?=h&dNeWNbGnq}5jblpODEtw93q z2z#92p-d^7Rbo?NT+e9ltQOf_+Ia*X1zUC!q-TyvY28AKO#8|^+I{KTzX&{EG1Ag8 z6ajV@gUWi0shuUQTx=FDjklJG6c#kpTn<~>Xaj-AJpyyrx{(pht#fO+0|ahHCx}6J0u%8a^Er zA*^l--rZFYzi0%TW|?E+$%wL)IWYt*ryHGVDLL$JgmZEj2Try3hq@b4(vAhi&sNbR zZeCJ)1qRF*eUh+SUoGOp2%inamD$QADX5gmO3PD}X5#u;jK>m8>@=y#xkG8Vgbc$x|_Acb*`OD2O@e?dmo_u@VhC`LQE)(H2QP)%=Fu!J! z;H}~;ZQO34v$mG^)hq1IETKKhVez+zEYnf(P1z!2My$*>1yb-}b28WxAGoxz0q?d5 zv|?(qDDNVwFRn3bCQp6dZ9Hqq4xBY+Rd%S;#~v`$MB)4f7v(-eJZR{GaaWfJJ2KQ5 z4&UDb@#L-${N+ojB6Fg!<3u`{m1ENFS|j)qf$-xCqKtHv(uM9=Sx_t;q@Pa{LHbHU zakCDkLe>aC*Xi?HGu+G#+AgNo8uK&! zT|?ga3T`28(GBW59$D13RkX*Ld<%6ed|KcBB+JB3`#4{z)X>b~!}SG%I|W<5yK>xdC9GZd3VO&CTq8zJ!rfj6JNjLHx3 z%K!YxZzkZ|@y?Hw`9M?pv1`F6=XdL!FHi5n^&<-4Zl(IL-Zy2&=##{vmcxw1(skzw z#5iBZudO%x+=s-)kKT`m7bWE=G9gn@4`1)9MAN@fY>+$2=?_WPZcWtZY*fnN^EzHu5s0Dl_$LvT1EBo*dT6BsIHQ?Atm6#zPum@;}q(hvf0FJW=5QL2b< zql8haJTzPvI;#z@A&J1H3O&|^cV~pJLWJMJL>xv$g6~0+bpSkf5V7nKwVb2e6IqW_ zX&WCga+S!=X3}FhAiBFj-MtVR1K`~I(YS`9Q`uy+M$wMv(CJH2EV@w2L{Z*qqH^V_ zW38eO31dLiF|>MD8jtB3&mpPrzgvCA;*v6MLW)kkqD%dWJS&bR!;Q6zi%p}5?J9~R zBaf51gv-s3Yg2>PB#e6^jmH&%-GqwfL5-)a<=Lbm)ueBdW`a*gi2qiQK-+?#$%Wv= zefvb5kUM~|DVUbq0UbMn@Fdn4J6Z%g+1Mk4sFal0l9_le_tvE*%ERxsOHCJPQ_rnS z4VlN-Vp;=P(;PYX61m6fjZ4iI1@|t6#~#_q0maD)CHMJ_OU*UqDVT}}>OOZw<%v#p zicQUfPwh!e-Aqn>N=?#?O2flU)67nD%1z6|PwOd6+brhb$w+%DPiKvU(DxdtPY1=^ z9*}_2(aRC(cRWgqb0<%(BGH z5y;HLxGWHDCLLXRw^u)L%oyN07<3#M2GCj3<5&(E89jqon?p^S$5}l?SO-wqaB6?d zCq6zcr%ONJD50n0IpX9wW0N9k`B-vH1;PKs^C9KEzMVo|0kZaAxINz~+`_ z<_<^YETZK6#L2~r$f4tf=Wd2Qz{x!@%qP>v7b(m)AkVYF_1+75oPpHvnd)!>m)kj& zYC-Q4uJI=~wEYE40bP2Ys15-U+MHgwbe<=|EyW@<8i>%ZF<>MK zWtEf>7kBaB#C_eEF}B&#BZj*q(y}J_DqUq_XVT#5|7%e25$v3lw|zG){-nXjPXDqQ* z^r{N@lH~ZNC|kwV&X&|I@!F;MinmI-Yo0Zj-)BmQC$`A!rr}q6(yvit-1W9elXZlK zZ$krbrysdTGhANr%v{O`kB*;`clH4v9K2SMtOgrKL*}){Ika1zrR>39N~C zX0jxx*Cq-~*n0R}-HYSet*iQ;-0J)e`e!l~n~8P(pHx|r3s@9+k<0@aD>Z$r7xBZ( z+M;om;%V@G46=gjncH+|B(*an@k&#S!^KVHkWIoFjoy11RBt{(es08qZse0keco4< zRcad7V>)VL7_VdEvt@?AXQo1F;4^5}hHH54aqpB^e;I7I`elt6>V7u|vYuRwq; zZJF9qhSjEB)aIbymLbCmzTZxP-7b#S%Gb>5pvA@)+5V)|TtCghb05mM2M=F1SCM|mxq3nM+vuktfu=#Ro?Qh z!$W?8_ADcN1_!-g)$`o}dI3NVM z_?Ze^C?feVTfYoq7K}D}$wS@YiZ1AB7oC~+#llBW-?oH5K=7e=N1+d)O(1=Lz?T-^ zhvmK__}(h;-Vc5AzNC14nZq>@4G~L#R>!nC%%PnUxtHW2ofdF@FQbJVsOc6_qKG35A;~NHJcjHK{IM2R0@;D} zOs89nkd|ibQPf+?FsTw@3JvnLEs8~mMq!AzBpeUu46{xWPwD&tA%@W9 zoTL~yrd-xi=Mgx{5&ERcNN4yxi!~CnU&S%W)l4Op zO*3dp5f;i6=1pF8Ak^Z_?((31NbQk5>|`J0nSJg;qnvF=okLLSW~uzp9MxSoIMyhZ znwS>7i;=jClP{oJw(Akq*R0%!ML1$1~Sh2*?MFQD%zc**V-E?W=~l;2ed zB$kgn_^7A=6OaEzMIuKfdJjja2-f!{singiv63v3QH~qLQd1+cp)*kTapKXSpooiQ z(kV?#UcXzoq(@X#RV_f0uax?b6uY%ooRu}Z^A1LWV%hvb%Wbhjv+iNnwM@BQjXtZv zSl!hP#M2W^01h`|K<=b7k${sHdwkQwD7e>-GJ9I?7esmXz178gpva(Bsp9SD!oPQ? zBk2PN$+t&uySiVRw(}%C%08)j<7s7d*uZ)VOqQs;Mc-)czxwHc`GY!HLtprW+|4NqeuW zpT8Q+#yS_`GZ8yi5;KG6XpPBM0tfQ5;o?NC7{T@jBPUj<2u;&YH8|pcK{)^}o%GI; zb%(-3qyV?vEG2Bd7`(Bm&OA-D&{QQPsR%XCxcm1)YT2{vP`$mob9)0M)Cu#<_f$JTIKoH9$xh_A2}Bap0q$o3*lwiEE_EW zt5P!^y>fOnU400*k$zU2vM&8aRDi?F)7B<-)mXM3E;aSxCM`Yv4Sb*F4i^vCd4i-M zN8@qy!eAIM83x^hCTitSpaXKyFz8-vzHx^fkF$8xdV1#=fD_p=1RxjyY((#Zj*_J9 zjvllAz*6Lw_!mSY{K;0Ukb5yzIf`gu-+X^T_saY^PI_*9 zbB~d^5)~7q#%!@jpa+rmnG6gBl$ZnlGBohE`c&uMoD!a751C(h zkX%V!KI`G+3U!}WENY~0K9kEl&e8pMe}kB1709q$jwhdeM4Ak^jdw9S7gs&t?=_mKw<95jh1Aj@S~YiU5R~c z%*>3HXe~=^8O9@MaU@3!P9U|^m~)*N9)T}THHY$kbzkY8Woo*yg7g4Y0xAf2i_{n5 zF|&0jZw-S}2(0Cpnd!=Q(hCZWeSI9>CsgjRBx&R&X}_0OX2MM`*Lzi{`#!v{iiM?k zFpnY7F?YVYQl+*kzFm5M=WFp9LhM{#1G?UV85gCEZqjZ0ze z=azGxIb#!bha>qXoW6le(%%dtSW7*)v)0#*Pt{$Ldpg_NKfY#n@^N#8@9$LOnFoAx zJwD2hS1te86=k)b;RurGW``WKvR2P=^h`Z9aFJYp(dG9EeoKsy;N-5!RKOo~ppBqU zQ763y)9!aw4?;c-e|>K7@yETj>j%exQJNtpM1%Dx%v}Mw-;fi*e4RYvIQpg{an}^{ zwj$w%VX-bAbAmuUWSD{@n2s3dp};(B*g#;pZkBKo%?&s<_gVxyB%O>1cM}Y+iPp3v zHzP=o8DY{`n<)*5_WH#VNGjl6nIIul6H(+~U{z84XFfH2pFE3chckVK8&_ z1qNH|x|iY{BZ@Jv^HFpLZm6J9y@ue2bSljJ>kn$TGo|*Iwn#(689}|AMyvEM^RKiU zZIJ|KbOr4j)ULBi!>GsP-;npILv;+D{azwz7~F#q}u`A_G6`46E9kcnsibSo48 zD{pv>7=1%%g4swUyzIQs{SV}ybK8hQJ!6+x1CWW&by`Z_UHt?3N7c8uk%_;B{D%_1 zjw?MtreZ*e09`8J7ROwa1wTgYdhH8n&P{t#UZopjMoJ4Ypu!V>;x~-jjoxLOYKY@8ilVXE4Z|hyyJm zG?We{bW;lF0cGXK>cGMn?YWvl(|A3g&&*^>f~YjI=|yJywS5^b&z*gew4%Sl{}H06 z;Cf`pity_%KTuhpABz}0b0K++yc`W)pH;$x?**^D&Wjaz2X=kh&H3z8(0kN(?otiN zgOmk|_#+MyFx`OYS&2ZcfD!P-wPLUA%uST3NC_%r`Rrvm=&Ek z?$BXDq;#O6MFdoXhW3f=)#9;A2wp{vL=m3o&=~U#Qd+$Tkx|n_B&#$^tNjp6=^Zgd zsdr>r!EusWa>!xY2xM^X&XPDpDAMk{U@9gAVeD!?1g$H_NbU-7qbR(E97|Lr{-<7o z9-GVS?fkD<@w&StK2A*UlWI#*=G*c8oNuIOo%M9QJ? zpgs~RI7AVMQl=b<`^FYdj_HSuI6`07rM0|RQ&e!OASLa3P?ARluhRPS99|5|NiS|D zP|ZvrdoOp7U$Yp+%1Tg^N4IjvC=n$WC=aTlox-fjyoYQLH~RxEES>HM5wuTM8X4Ii z$__8Bj6=rtq#Yw1U4pR*!O1GDZbk-cw{MfwVfK-Pe3kJBn|^#p*{}QP2#aE4K^o5L zToFQ}&i+cK9&zSe5h4~7!Zc+5DN^ZdqX(Ur3?~G^=|z#64kr?o+b&d#@eDdfyDA8~ zEb(W}(enrB+V7<)Q+*gJus~20L}++_(Q&LA3n5zcVDSl3q6#Bk{BX%h?4+Y0S@ICX zLaPs;&iQs1Vi0_`MF416VDnk`Pz5FkSgP*b!kx{JzKX@vCs+jx1ZYAgbq}5DX2BI< zQhn}Kru@1XeXouftTqnM9}gqYdYtn?rmeZ0=D3kE^{`VljAsagnw=34;Re8|o6$vx ze)IW9?QJDQ4ETxQ(Af}|{T+9~yA6t|D8@+8@Va8VDCT#k{^pxS%8i}&A|btx*+n@X z7}PHJ!%5E}nF~;K%#eE|MZa)p_jBc6MuyKTdU*qNBq@y(?Vt!Qu&Kqv4Zw z6EV>k7nHS+f+8WWP6B)H75bwQBAV%JAm5wW`;ayC=DEn@hS5z*Rw^`!JRi1QRh-#c zZVVDEtOMa>A*OA~KDcXnM8eeI>!>7Iv9bG@eZaR-8n8aK!$gRL*GC&7Vnl@_?E9f6 zjTolHuO*aK>R{|!HYU}_poVG*9`nhD%;>wX@~fS3v7-$>9J-dA`TSKAf}cU488NF> z#(}nK&ZD0(v#o7&Uvr3<8liv1mm5I2L|OT2owdS+nfpegN0?vA5k>Zo=@UjYp@2(77*`Cm}UU|Ki9zVXKj$SJr zW0)>TWKcOd-fTHP+tF;@!_@aHf*vTeiWA+c4{3=Ht#s#7{M8=wq*qFN&N81`r)y!7 zu|jT@W6i5*4^%j+tqO^TQg)*L#za=Me%x-qtB19nSf zlpl28FMaa9Mx~8E1aSLL8IoXO% za;DYkH96;-W2x8Mg4R34No3naq8e$7)Gq=KJHv@Vgme(9hG0|)!jam9Mr_%>t^iD1 zI{Ch18t7OgTE8BFj4n?CV`-H;dVd3SPL!0~o_QOCitX;bj3lnU)#*^gl|JopGz8EJ zMg0&L54zA-s*!`QMBw^|TS%-9w5gtWz8|yGx8_M4KsY$F$k=HPa8Vd&zCwn07sP1j z)b8Qfvi2%MTO#qYjnxF`eoSyQlO_pG!Iug|Z%fH&oF2@hNRKI_QkRLvrtz@IR~wkn z(gmlc^Qb6~E3?vvG*QsO}1UXW-19i^bQHVsi8_I^eTiZ zReEm$L5iSBXbHUwNEZYP3Ib9T3{|ioARve$AYJ4EMFr~weTwhwJu~0lGsm9&9pC#m z-0QmU>t5@(&XYxr7d#f5&7s3w`sI&&2+tWMuLW(#Kzrd9EAWYcru=1o<_k>>E6p8@ zAbv##)cfQzv)6uTC;b85&Bx#PSZ0XXRdXUtm=zHbimtga3*?ITS@V|53e~ZW+6{Mu zGUZC=d}I|3+~)<&KfTa(X7fgxZF!I9VLzXd2T6IDG_O}bgQ%=~a322|j9HiV$eO%;p|Z=%eCVapYY*s@y-dc(>U`0UzF z*gR{klW<`{rpnUwA#6!=)i+!*yDFE8zzU`7YQk-@qtB#R81YN-kSMK&+_T4ggP+so zJhDw2_0{Alfoh>{1CnOc={=rJv?wDBJ`OFRi*#yqOQ?S5OA?1vmrPU@@1?`K%DQa=bs>(z6g8Wg1t=;DeUEXwa`S>CrJ0F!ge(Y+3Cta3GVyv3gB2@B5^o!e;?)m|6Yg+8OLc zvC=5+n7aaPhP9?&Dw`Z?)SXrAp9bBgernVk=ii^59ayU&kGT#ngsPP@Im}#lR|XS- zM^Y}SZtcbPYhbFecY|~i6lWiW19Gfc>7b?iusgGxW0mWYG(bRDZWahnQP+(rU-Re+ zE^Saij>wa)VbAmVHrEWHkX1(+!4FZ>Z_7ga7#QoTI^>!7ua;&-AbX8ncnC-=_rn$$ zcQj)nWlRlmac6{jCk>cUX6kA37Jwgr@EYG#WAUD{tC6iR_QCLG(0qc{v9iN$-F_v3 z`^{g8LBp~I-Vpaq-`6A^wCy!P#7Iq{X77pts3$CuuD@B;ipoSwlWQM>P$m;FDH)0RO8JUsFv}PckzM;CI!^4>cfw|+V$5lh(s3PbmX!% z11m%a7yOD={}LI&Q7>kpoC^IUbd+V)wLU}iATR-&C#BN?gm4CwX%VnJvx_s-xiD%D+ zGxlx&n8XP0$-{QO30-cusofO(!|f{&AGWwvmU>&)0mFt4h&zA2lbLYcJh}T> z53LaW>-a0**j&fwGGKC^^{Aootbhnr zp^;~O;<;Lhl-Tk>NxpTcg~_a@#PFTlHi1!=IUh!oUby$`E|)vpy--9NHn)Oi(?B?Z zAF(E_5jfFN#wKA7rY8@Dyk#>A%mGKxrJ@6`J`>5`$tZ7kx7%4+z!EFuX242yCUwlyTe zf1UJhiro<{lfV+2 z&19>qb{1Fdobn~h2Wq2}q$L~kwG_=6+=cR*mt_o7lNk zW-oT^%Iu<Y`C`GnY%f%k+{^po$+EO$hS!L<&# z`MrcDduBBUSJ^}c-LC7!{JKt>XHRnMeQyA3J<@r=7W5dOapEJx=ZC#@sGJ!tJ&f=$ z%@mA(@2C*76Mu}Sfgbsca+-#xf;nJ>`|_lCSxF6>vUp2ptfC=(x2ISL!Js2S`7Mw0 z8v^debv%m8gW0vFJX*T#uu1sY&G~J@WQfH*uD_dv!c!hE?%ZM^0C*OG$Uuc_Rgz03 z&C-Q!B~A}PTzb8Ctc7r*Gxf{VY6GMHlr8|YIk6AAd0y#yb-qka5ayKNin{ zgQNxVHCsNWb6Bqwo5ry_A#Zp;5+ytL5K&hD*J}^^- zSgM&zXV(|hYLfO~dN&6!`bej8>jgCn+R2IGA;TE^y!#430KrWe2m*d?HuoT$XYqT- zTVayn$CB*O0KSCngLjOmbY(;9C72dL<8(&&15nCsi2n$H$T$!=10Hr_}L^h9GNxMy3S%> zrTzSaWFNJ&VIME6XW``)9lD%9$kxMbJm}7S+qU55J5evXs;3i4y_Xu1r^bg1q&vif z1QnN#KA6RfMRmFF0$SWI!soyG%zbw;VYmp9-DCGK+#|kS2_*RPJO=b_{KH3DA=O&*%z3{mTUI4 z;0dmlXC~`{M*b0c#QZj~^X8~o4bGP7=ytoIS1*)OFXe?w1C$dy1@D)=WNHg}Y;x^e zV=VO28^XFKEZwdha@#*QbLWBU$;ijU1)(hW6v-@#`3vtaCOv232B-<0eQ_q}-aZ>_ z;eLJc&RZQ5ep?M1ytVQW1v)5R_Og-*%L-b5{{r7{2w>jXD^WN7Q0X75r^qGQ$;(;z zqMPL*yiW#vuu1a5@ow4g&N=C! z)E=drtDHtQp4rNQ0&ui|CWfmP-GChqAdUyvBmmq&z5$r4_cH*8ta*fML~!CX2pQ0h z0tl@GK^s7DCXl8RkR=-++6szugf_WCcRXm&J~X%hnx+t%op2c14T_C|H6_4y2yk>V z97l#XWx{uIY0-tyu~=GMIqgn09lDMVcZII0k#47%9^FRo$w=GONxwslEe$f@Mi`nN zAI6r@Q;gng^kcJ(JBv){6(-yUQ`1|fon2=1UJsrdSjWs94n)X75#h9mW=6y=3yT~F z3+@i{I1kG%id9aGHC&3d`3UQ-9Gi3_bJGttE6u~$lAHm1xCwjn3HDtqYFCv*&XJ?p zm1Ea~Q_hDoJb<%Fha)_MOD>8loElq7;Myf{%OzRwhI5B!a_{Ey$QAO0m+&-~bF!rK z?AGyyU*T)_YVK=@3Ez2F_x!-;ctJ7E%^vGe?!Vc zkrA}WTZgeFI(Z;U{?FKwu)LUXNCJxLiuo{%ln9j4~pdlh`$S$pt@=zVkMfT#3vFY<&z~N$db1*C3mtVS#zZ#N~CU;OTDX> zK9VnGT_=63QTko8jC`An-_FmGdiDQwYdqw5nT4NC=kotOAN(il>)${Nf&XoQ7Jt%k zPEK>ARomLzQ4Py~vc4DtPW?l}8Adwr!W}vPrr~6F7y3HgK5UI2vc6t0yuFG0i}f`X zb%jgdmqGQ=u>2(Yr(yZ$NPVJx2>91Vw|Oq%*GBgt>+9aWuhd^OoHrrqUbBDEaBis5 z{7u72b1w9C{F8>0O)XYXZ#Ce5c8>G!V*R<1@}G^q@*gZ#rEj@^qnm1XXYI=y|D59# zz2OS{rThH}rlYOv{b!Ej>l*!2_j?GY%YXak+~4RX9=Cn1ztBz0T5Lac;D^PkIOZ8M z{};L`ZCX9=&pA%0OUClSxj)fO3;ytb7wUz_|J5)-|3ih!ZNZl!(YYE|ZbUmAFIeBg z%1K6zY{dws%3Tc;T+7`YNbwva#tEe64*6U(YMzg6Mh!m~t?*M_2rCx9vKdujZXBj5_X;z4qpFrZXv<(R(Zhe-v;YFA#EHhMYHhy3OU-+Ut*xq3bO{`*!h zVg>Jyf5jJej@-hLDpF$?D$0x;hfXWhFB|Vj1qKSo3P^)4NAgLq)g}yNr}Z`0NDzji z%EYa1MU>KP?8lpp^KRWeW}N#Xv|N_%yBD9t>~?~F&L!lCu!P5r(|iWiRbdw{U-=kU zDbI*9p0F*KGM$+0mNS2PLe%fX)5&i-*hxD96YI$-H5CMufKg;kLLz8@HCKB-pW#o{ zD2Nrv0T;2Rdc4&%OOY3iSZl)sVRF-}P+@E!3yk1E1HzN8SDfY*|2i66 zK6Eg)3hkquVWUNnXu!^AsK5(;Uz66*3q#d2!DXZFz6vBA>{UL1yXHLkas4dCt#qri zBC^y-z>2iwP$8AJG}9xOF{rq$l=XUs8X{PB6g0s)BaxC!U`&%16L6t6EnRS}WPOX@ z&!jIxqu^{B2tbiM%9>a;Y>OZwXQVR(>tk0>OZ&;)z-iU@XHaF?l8Rgq%(^ct(F2YK z03@5&qbelW$HMPPjI5sRph&WU!POK~1gsh>%?>`xdgSr|4X^>nZP>XJQ!y9A2~GmR zt*IN9xT9R)xh0vF@}}J`mr|q77jesXsle7fS0R&$y-{V=rypG19H&3LJn!+80C3304t18KKu22x$nV4-|~9klh-N~ ztSHT!QqC?Y$UUs#ynuM~eT9oK>XR1weiKU%oNKa0z4Do@*Rwn-jt{;2#Q$0! zr+qvt96;SNV^ZM)p>wD~9$t|2qw85Z8OlRrEj+IayfbImw>(6}4!y^^#kScmsU7aI z9&SBuABM&1OTb6HbMiTgK+bUvA-xyz3ckTwuxqKRP+2-c{kt*D4mEJao|c`vu6Z9l zeHPYmHk)NV0+a6^@9xh{gd-6lW9|h_omysA<=3cA-%|ZbPU&UnL zvarYZnns2JcvTaI-BmrR&qPG$(U&<$14c#DvZ<@jLksP^BEe3XoZlF|m&^015DHTs zb$TE6x`K%cThkKT`%`!@$laeo-5m*~Q~`@dZKJX#3)GDy*JPmnOV=?pPy(NH?1C{f zfgC(DT(!;b)ewLL6h zw}|_>?AmJS^%a3jljm}**}A2qIK*lvD+;VjZG-V>IAD8r4p7Da8D8Tm5QL5t$lcU{ z<&FRrWFdqcENtGL0TlK%jk)=qRw`yjtsfi4EH|w_Y77Tbs8x2IqdRp^0S#WX+_Apk{OGgoI>Ej8v5hv!Ekf0G~$rAmMXU%&)L7>Q8y3i&TJ zdD40W82ZmX03lN*5*KV?y<-x91rfSx^AY%BaWEi9bO=GBqhJux z`q(p>)niBc9je_wCPLiHIEXu59RD9(%$GqRK$0HNy(3T&?p9;%D9&~^F6h9@d3_v(w1(-4hs>88yv#c&i_U!s=OH=6EG=|i- z$G$Bu4;+KR@SC{%n)jRvJNxw>Cx|f%Le#}{sHw5y@f|a1RmEBH8mDGmecN4c&IF%v z{)ZU7`iY7~w5Y;bF#qI&#k%}WjMl2R52S`1ApPMv8j`H3Pp@(uZPh?9KuS%Ec(t@j+ z6M*<{Sz{sU#Mrbb_4#r>W+v_>@6ab)yV%9N&M;1FwK|rJe z|FFDy?J@%m3ScObTn)gpjMH!9Lr@pSUlE=JT(?nAMq(vtF2Q0cQCtH+`*T7PbC+R>lw!ltBu}&h z71kGrj^^{z307j4SllJp#r(rmrHgfGM|tUULVmN; zxUzbko^Zi>!ty$x%9x1(vhX1fmIx@7!wVUyHdpcb?yg3aZ{r!D1i*|ti+Qi4Ftg@* z*JuKNN_a;&kNC*aX@MJfCK>{ACz9S`T$~P)g^@D&Vb>>ncXg2+esak$BWbR5JMwXuX))r*| zZG7!ZuaIzj9!WKJJ&!0`+x>iA^_piFqm1cFPTeaB)MP}d@5V$Zf9eOq`gNHq4W<-= z^b4jN>sP)51X6aNbnr>Y)rNhwr!OAF2V-7&rAR=7EI0+nNkpX3QP7M)i2GTl-qqvKrU^dJNq=U!2hTiS zG#6QqI+;0!Fro_DFIaD%GMh~}HYEuMx=Yd#c9gFJj2VF5?&lwkzfiqzyusI>5uHVt zQgDlXq(wO*RfxBR1F#4;h|Brx#qa7g`>Xtyu%V-bC(2BLP~f$g4kCsV${o2Z)W08@ z89t=qOxolFXGJ{DF9K1Tm`>s2g+MvHG^i3bp+7WEGwj|2-B%goXJ=o{p z)mJR3Wj{C96GO(bbaIYME>n;II6D(9G*M>}QEYpZ=Qnhd!E2V{Nvf+j+i01Uhds;W zA9T|NvsE^pg3$n_3{WqDwskvJ^C!BAgNbZ#$!deW)b41>iF%t&4&DM$IF!m!CsZ}z zRhZ}?mzbk3j!(P_?{Yb6G`INPCKU)+z;+a9@NmFEsHW>4n}5W1mS(OgIQD!#t94{- zjQalNPVVnsID&0Cq~YL0}p~bW;5F#KK_H80@T6a))!2XLUg({xcO5@&PTTw zGoA!PukXinw~jTT(M(8WT9P-x-^E4nxxwkI>E}~bFP&Q}w%2aLI$!<~{Z&wiD|=++ zee5^E%9A%|CCV(i?+IOavhm?d!VjU!XMY@g`yuK9aA8l>LF!Udi>%Vrtm4K@1pk`O z-Ecu8j~5@3s@G33(*V&B5WxQm^#xRK)f!%Qxwrh~sC$;0?6FWwyuPphQv)8L9} zI$Iq}VJ0=<+V7KIt6H3{eWPyie&D^PWmx|!Ez3P9SO*&^Mz4q6Ghi)*_Q4}>%EHO( z(G_U&K-9o)@BU|+nhp{ijHwZg6_AL;h6&L0_MHGc>j_bJ#;pG_{O%Xh+ zTO;;8o;)BK5J5$eEHJeDRH`(UB`vtpuXFD+t$s%jJ+x|5^b8+N^|oWl>TXP6rQg*# zgTv@klxwulT(s+_#K+pFj@P|8d+O8R`vkY>*Mo*u4?eTmRr*;^+$!_CvJ|V`xAR zYYaw7P&uR*2S=zcw3OKS(DIYp`E>ztz8C8QKBv?a&Bqm8yB7F)sHHI|ZmID6_&U!* zlt7Wyz!~aW_gkAqOo%qN$|!swa*$WGBSOb-Z!L4$uKMu3wfG2ke@(RkCp6B#R_IWq z{57()UZ5y~$9M5-LS;+E>7Y6F-BCZj_f}Qm+~Nb_kAk|5Y^I_T{G6XfZ4_OZIg?Ny zG*Bcky_j%VwXJGu&PO4=eFDOhU5}K@DWV^f%W5p80^F6B(BH3S_bjkU%e9v=h-v2Z z95*NDh8{E6@8$Ml_sYmEQZS4e=XGjz&7=Y|4EGoLy$VieZ1_>1$>JjUZ8x(60^0W%x=B~+>|f|6^Mh(d z-@L>U1&g2)vCYc9lXDmI&xEn^TsywT{<)u;aBj!B4_oC zrQ@!a-{hCd{Ez1?_ou|*Ny_PqG+DOCi{JZ1(?Y|Ol`+umB_G>)x z|4YZ0V4LFh7p$Kbga;A+U8(=Y$ngJJD)s*~oA`I3{_j1TsKP`(QTom9lw0=nPto!( zyA!>m`fp3r1tI)}V+Fq$-oNZl0@jKz|5g|rF<*Y#ruoZ%LY??#`p^*a?Wk9#f9y_5 z-~RTWm{|J#2eN~L~sQD+m>iSJVJbzsWU z%wPZN?J+|-zx*dFJf?4ReH=qwFZ}eMTz2#g_~}0>Un@~Ne6PAM{I9(Mu+D_4$WGt! zx&1P%!!#MB10DKDU>@7*baP)roQj+;c(L7)ak#rz^z0VTA#$FXvhtoPBRlgj?TLS+ zljfmuqk1)px0~8pLXHJ}bk?lBC)V~NG}@}%{?ND)a9cxA;%95gu;61MUfpXEU__kF83s_aG+^s0Qr*0LG3MtK^AVj~&BWU7oT%}4Zs_0>6~ z>BNJV5)ZAn?&g~2eWe;V$jhBnFSscW`ms%5y6lOkY1s;+HrJmjTTo?W>K6GgzR6g! zeS;KQunRC0TefO)AjcG`yLBhL5=fLUGH4(P)AM{VMj*a z-hHAo-ly@yqXb*?S4+0~$WM-Z?TIed1Hd{93{j!eV##4|2)= zDRrUw*b|kRt1DWT)f;!T&R@y-eCiJE>e&-UjoVA#pY-l_^!ykheRWfNznySG;_=5< z=Pefx+O^Mb#C&(_TKst4{MFWvlgS9Gyd!Sww*6hUL zfprN`QZJnTbznO)Qx48k+;I*+4{Wxp9QWOy%eMM|%<@wYY(d-411rQ)K6ZFuMO!55 z{!-+?p1h$R*vr$q^>d-o&K)U!vG*2m?so*a4iBvHC;ke*Lwlg_ez_nO($Ti^;fw2? zb6+}N?Ko3g4^(?#d*#8eD?97%m+w?Z1X~N$5#e3_2UU@ir*-Tr6&p$l`UBL9Fz0~Y zL@-blIn1`q@V@M82Xy_+Uj-QYbKVeW`^OJ^!5OADtb;6OjtB#*pc4AQ@$_qqLsr*= zyA5*hMhV={z8)Yr%u3hEKWr^y#%(-d<|Z^^9b8s6oEKjw^&r!wMEW7?18vEN)^DJq zW>4u+GLLLz+m4z)HJ?(t(IPr~e7Hq_QT6(J$cFljr;)pw1Nj99R7eM1lqS|zR!AE= zX#`kt^LP;S+;%7_&DAFc~RrvA&s|piOj>%>%4Z#3gSiK4e> zU(tPg`8TA4>eni2`$YWBuf>wZ{C7x$>etGcmdN_YueAc7)-G5n`Rn^K$8+lM@5^?A zwD0Wqzx`Tf)m0l#q2=|z{95UPC0GCSYi)Pmq%LUv<=5g)p&rycV#g%X;B|7Bp3yM^K!lG{s04gV&g3%rIjnaH3XlD{Q%J@1k_e^+d>nYJ7|Ve^|} z^DWQg{JXy?Hg(z*9@GA&*qop>U^M=1V3PY-!s1Vl6m=tgib+Y^cIt1%=5*TN$=?pi z)KoX&9|@hfgT3oNB9%GcUm}%XvAgo--(q+57k-P~wfq*l8$bD5>@N1V*jHQPCBOOlnsP@hOwO6qJM<49JrDy-(6CV8!OV7HXGJ8?K850G7ipj_S zlAckEwX@)@h^^gk#kykmTd_Xl_`O)AW_~Z$H~&(sFMluAhTn^||F>eD_`O)kzZL7f z-;34fw_`N!S9&~D$MTrA1$8+?^Azlcgzp84)4ipML+_cxU{}~ zMN(w`Mtz~H=~V|J!M&yP(en^Knmc9uFr9@wG4dk9kQJ~KFdbEHXqDr|QDDhGqWN*PU)lk4Oy;;q>Lmiet^Tjz;0FfFM;b_^=|mkJLcc zS;M~jAy6MMGmz15{6dsWjAIDq9jbb#mbo6&aV*2L`1%p#eU?5P<7@1F?W(3NEqhMV ztcyJdlt=4C!_96U#i2c-jgM7}UHK^|QinSaP2vHKeuWkK0h-=ud@sI6ub4V=&x>G{ zr|;`lw`^oxFga!pj~p<0)S}7dKYK0L!oH}=5Tcd^w(1DED5Y8LYqia%f>Do2zDDiC zTvO2WJnX}??$JNj;Ly!Q9wtaRd>+pPrSZXlGw-x=jO9X|IK=`^(pUK9xCUi<;nrb9 z0HGnERAhZe)s}<9#0FXay-XoPcuGEn4YF%WPh1!ba(<@tGS*F>la9_HXPGv`liOGs z0s#Ytl^p3$8WR(lOI5GcNIIOdu{AnA&(q^o1_xkrYEPZvVP8S%(6jsJ!0s17<(z1M z_<*s~Wpn&tiC|%k~B5w)x%I?o)r{?6M#nM8z0DMBFe^m_GjQ z{PxE;S4JO)wO9E@!xC8F_-XHC?TsvcfE5h|KbyUhiT*Fqv1@;B<@nr&Fw=?wfMJEXl&auM-=C+_th@&W-j`wauct*MgVMJgFaJV20?Rt)H_%JW$k9mlzP2 zr^W)-V=DqM_MFMfD1?!~TKIG7bH#O`GcG&kdF2cs4xm?!S>cH&xHS)VtBI4-h09W< zLHxrqYFYKNrrzl&6M5SU;wHBuD{pwe33#H7Ut%FJyHd9aoYuktF58ihlbAJA*M+*5>hXqLT%=~HOTk4-})Dg?muZ~aLP|bS19wP*YUhzK3mNX)L@i65tNvGSK`)( zpiZ1Qdwa*GAlbz0p2anIq#*2d!~4^yet3uUdlSiJb!LzCb0|gHB~iUxZxWURnti}K za^e@1IEM&|;Q?D| zoKHdYa#aT(O+orH68Zyy4~kFc^g10)?%foYUbMS9zFe=HV@nTgQb=KWpbA5PQa~|Q zY0VRAM(Scrz%h;M94YbH#rp6?NhAcB2E*?l0XHI}h2?4#hP;~Dcc$W5Raih(9^W}V z2ki5#B*hH?d|-T31Z-u%xEc?FH`Ab!uxb$)6&R3lf-_4Fe=K?CK6gK3R`WaKZg&#|Ddv4bMbSx-V1`4vdvI+{Z&YslrY zOGT%djY${LIKlq?WYKGlVAi6E=W{_}p-6+yuJ&{D&T|QvZ0b$gpZy0WVnH8sgF9^IGyqA)Rt?BFrALAZ1S@)oB6Zj`ub`04-PzEjdZ>@ z&%G+F@1M(B<4yHR8RrdRy+8~8MTDJ0yy8)?`|x_AXCG7VJ1d^o=DMWZR)pdH(&p>3 zsI1*p&BEKB4zm{x+BsKWI^T&6!_sTEKciW6Q74Co$~nMmWRC!+hZLcJKAt{*H!8>s z1X}1~2GvT!7dO>Xb0b4-+J*@=W~K(iDlp{k(qmR53GBvqR?Jj;<4&N70q!~nD8rI` zOOQjC;zDm+b#Yv03z@hc!v}T=%tP{n@i8eZp1Nq@lIc-yJ|H~$hGXGE14rLP4?}QF zd@`t5AHT{czp9m$nfg>f%N z0Q}MDPDg(jI#h%65oy+#Q~Kg$kT%jik4%sh860lU#gQc1gDJ8Ie942p%!M@i^>&EC z7uMGUG1>6bOryIC!Wbsw2aB{mVOJyspF?y~cix2|HE5qqpOPKCWn%mq;_1>{K=tkI z3C-cP#NWlcM!(8Q}qFqZrxJ@=i38N-3%>E*9A_tV9`Q@G(54~>jyE8+Tu79KD2;I;5k z^(vLUG@KwTGyEP*Xn)tfcHCALN`bxxFQqw_D<453=ZsZJ9vLk0B1!feVq6)Y$8YiT zf)UPXI_ui-6VaCk%PKFQzC#cirR^WSthS^fLEgFRF}hpHwD96b5`3QBa??h{aI3g- zJDna)w&VtMPA!Aqh1JxIv%cbnpcm9+?%^!xg_TyjpG_3Htyr;b7Mk|6#_s_YR?ms? ze$rXDx*leH*_NiWURIT%{X)dobWtZ8GjKQhMC){!jQx}d`yACi-G1S{pihkpv(j4p zr^-FSMTA5pn|Kn3?OT5L!2VhbwT~b!lU%wMT|^32S5n%nuRg8=ATG|E>L(z5VSSJF&1;|!A9&I=_%LgZ&c}!7%N1V9li0O-B z>Qdvp|HmJ~j*~)32=YzKom?J0q?ZFe8NsoyAfmpcUVO81SSbrBrR_*Zq!fSxt-vhb z;4xA}f8o?UI?`Eu7oe1;!#sO`JLQY)EeA!VITjQaeFYHN!!rqKI01z-v|YOUX5dEMxi*m zU<}?=GDK8Bag$u(x!-lSxBxG>bqaWOnn-u9^Y=bS>+B)v=}&54@-NN!ruh4Ca9i9$ zhtul4?lD^_|XgCQU-&mZPEkpOykHb<0P^PoUz_Wc>nD z{niHq<0AuGmSvGv{HD+8bV@WXjaxt#M%fofksTV#OQZVBqqZxf?)8KQC6FX1aX}yz z8wlfe0moS35kNfan;Q?_-q`#jooI@0GB42i4#Da{;9e4%BkNA{k-FS)~Dgv%_}F6;PrD)m7+3>vnRx(Xu0mad)2-w4|*ofpMyh8h7*n z$7pqtM1MuFg9<$62(dxDZHvB3>PN%+QN*KNc;coMuXVD&9TaU1gGXI4PR1~F~s3i@{Eghq<&JXFD;*u z2!qiRcQOboPQ=S6Vw6*6sOM27(V>2PO3>;9(i-76AtqvNAg2CS+@RsTDE49(An4Az zZ8F-5|JFHL_18~2FqR`6X#@9<`KVFAryvHJx$3)XA895|BaFPnSg+K6E)$v&k zTpc&n8FxJm&y0jZqpTYF@`xFL-uobvA)=}#pdq#GO&;_`5feHr{izG+3p40F3Ifmq zH&u1Tt3zt?NilDeVoa6Y0br6c82vis4gO?I+%vMpNj2kf^6+^1*=PPF6~+iFpZt^M z1t(7wo^*|;ZEcqJxKNX5)1kUc~nwu2n?p~nlt~{=w zkzIb=CJ|E zv(rqBx;)2`49bl;)M#p_v4T;V*GYgvVJIS3eN@IGp+ERR-^?VzOJ~msgL! zy;{F9*c?V zXcu2DNmdWNf>XPSxoAIT1ywnDr@`2h;@%L?j)!BRX#3k6kC^b*RPHZy+QPdEz1$S! zJ$OOwsEPM_?(+UoANa-DrwtFKBHJYk)UA8KBJQbnl3jq7WuI5}%0`bcMLbqTEZ-d0 zSk&wq(x~=0$yG#A5la#iYjGC4hmF*$?|=pFJa?%!cXbrJh}YvbKkS@<$4YB@!ku*J z-4{nQ*H^RFcaOcG$==}1-4M^q^M~S_V88}t5E=mju;(Lyq@0A*jszIMEv3mXX~7*< z6$!)NvVPY+CKg7PR!DYmNPeI&k0NEW}*I&U1*bC)dM{bu{#7E|x? zgY!G8cZ0_9JB}7R{!K~l#z~zpAo_|grRw&Uf-pezB`)>3QU$$|{-mp6Em}1(+?fvC zSmC&e2gniEWpsIZp z7LGg}?^;YwB&j7nWsodlOFBhNtN0%F2@p7TA#e%5xl`bDF3zc7=noD$c*R7B@??np zw9;s^ch6o(@{cW#?%+ty;FOWzDssr^%eNa0Z}+`JnHINYqtj_J=)PwL95>x-H`}AM z2QZuOty%1SvP`Fc)=m8ZM(BZ>B2uuS8IlZm6&T)18AfpiW3ez?sBrO3Eb;D46zR14 zUeV=UQsV1(4b?;kaUwZ6@s#l0@cz!G)DOeb_4Cdj)}5k$sM5`EZeL`P6Jev(+StCv z(Hv?l|N58$An$6E76hPsX+{P_65T?vS8KJ?B~=k;&%Sym6H;I(CI0*Z=R=34RP&6H zltvVIK?9tVy7iqAe)c<2MLChA&OdRFY~Z8Az|6ES@8M$L;h$?OaW15T%$IYNW1p`A zL&IlGR@!8S*4ZZQBpl$#EExGuq$|<&XFYwal;R9SlTVSVmI+KRyDvCykH@?cwY`^-m3Lzt zKe6>kOXfvuM}BfU^TjQ~po9G7d=&SmGtsyv+!Mx3BWCY;lQiHQa1%xS#Dfgxca_T1 z)f8SeS&1Dr!s4o2xzz_js?}IDEkabN^>G()jd)3Q;|HvgIsC5nX+!beBgHh_F|sZFyrwL?D-1^ zKk7fH8@K&v|N7(Ew;v0501^X0qY*Byf*fLy(<{rL1S{!4tcb?Rr{RqZ8#DZ8G=*}E zy|Gj)3&mdcg7cA-=7rUeTngSs$!YMk0#IP(?G^682E;hlUW&Pk&#n8TEsxW&-tB7Ik2dt-NCGfoHrk9O`})k-y}H2_t664Z(vt`(kM zT=ia!*6J1vlizT!21~HhrLalWbw;U`op1<69ng@tGg7WBPdNHE+ds)87XpG+1oTnN zkBZdU9|as|HA(ugv=Lfpd~LEos^e>G_Tu!Am>cBK^CNWtvdu(zPxrvYJ&PCP$R^&e6H9zIwnuh6PfMwqi)> zZ3qOL*^l9;QaM;`LQ<1_hwpStsycNlWhU% zPDrJ<>AufzufNBPD`rwxNR={AyU!|TwjJ@BWp51xB=^R6qDXzlIQ~?;Bm)>3%z~@q zzRW{R5Hfn4Z>Cg3e@aWh$Ye}0gShc6dib^@#<~!X6NFn2ln5Gd4KALxdsY~A-k~rZ`q6-9 zb`TJLUdZnc{xA2A%`L^IFAdj4SB@Ywn3F`wvKGi#;RN}Hz#}$Q!+nfZ$C>mxpGGl? zaCchO+B=#Y2fAeulmNg_11edG<8QyE_YK!*8!dblHEuwVfzlu0Y|kSA*m~M#j5Iwm ziTO3sD?>M4Tz{tyL~&tI@X(Tg+@Vt>em#|5Eh>^^8YMbVb+nc}1=DLR10`}B(6THg zfC0{Qq3o~u6AMoB$qR`3nQxXyn$G>TPg9Wgj(Pkmzr+Cmfu^?ZBr{oc-3ckFlU=E* zTFk&59WX9}Y1T&8XCPiZwHMUdyKtRDxWGCMrD0su0pkF5I9>0^B2Vir!Jp))XOkH? zbG){Y*h2?vs0Qkhdeny4pNsN#;z<8Dv{s6e*6Vm?lO*rUy2MXz7t;e7GOy&XSG~*3 ze;oUUlGAWBG9jY!KG)VXGA;QUQKZ|Vf5eEcr(F^`zM*tp19az*6jrmUDm zOfZw8c^C{a!(+0$M7t|Wc#sDn;i+<4;i@uPAUA3qcURO@N64(SV2VGJ>yn5jnuPo+ zNXzD(TFao3ik&y3sXHQgX);bLrz`k=t`NW-0ixAGOHe30U;}^KG?bP&iT5Gv63(iZ zA}rg6E0h+s!Mem3Nse2rkY)n z=xt8oz?rzElP)RtG3Xl$^%QDm9W*yi`26lLCT+bi&eJ`4h~#XtBUOnW zdXT$^;9sbk^o~LB=$@9>?#KM6-7G8u6VZMp>bb`74v12;G3CW+XuUR=eSn>BHz35zaBfhn31^pQ~Po z@qhT~)=@#x%~Xc`JB^GEy!!TYAXPgasL-|scO5$PytA!Ts#!`^_~k$U+schnb3feiYh4#K${i-1c((}7scD2C~!%kzV*otES$kNFa%F_dxQ^eh5U5Y)<`+GRa?+I7dO(y?)`I3SH6&=eG?}K!5+B7*^M4%Yj|Jf=6x61~ zBex%`(Sb6$$3tjjD~GoTcV!u`CQAQ&4eG6KTqa*zUyP8fZ~9O$+fcaUPz=vP9CFuT zT*eVRQzqV|emEg}ctCo@XbV6q&S?ty*H{v!8QEzyTr-u7lv!e5WA^Lj(IoOq;x5@^ z84A_e3G?}1=1aZ?TmaQ?H5lIyntm1uSOrWd{Tkd$0W?cZV4Q3iHq(GyYl^EmhL_E` zS5bQJvD%b)W5HS6a`n{}0nEy=!Byz-s_7)I`oTBGr{B^$1Z#9q&Tmltt5}>hstDZ5V-SDFxQdy{Fq&y4KzzOC=guMW7x^Ghvv@R*~;yWV1jTYy+TYNADOn3 z9It6Ua3n4xi-X3@bYY8#m@BMnL89_eXn-+p?pA01>FY>=EHV!>Cpwttn>UQfrIq9WP^&*B2rHKw>yc{t zndL-)oCNcqEimOqg1wdveIdRA#SP< znm`eI*s1<(aS6D%V?vsAqMee=G#+->05!7a$n@y6OY%{}*`s$|=aUcC5)LkyB&O&L z(9ISO$kuBFHm8=Tb;se-I~iu;TxBm%&N(e{(Mq`xx)uQ)EG(%g?HJZ#OY#r{w*F|X zvjzc`#-N2@EShI8zF@FJGQU1Xb~K(4WS@1B&;0NRFJYP?>-^{zc8*>#RGcwthK(7L zw56JpJ9Swh<7@a8E~DN9W@QgHmt9 z`ej422l-x=Q}{YQ*EHCx@Kq=)M|IPtGNfL%gFe|!Kikg$^3k(O)2nyXm)Enk3^0(e zCv7rj?>FW!G<w*q*RpHj+1Ix;(4(-bp`C{{7D*(}Pi+plvhj}D#O69IW=4HpG|bKy+$FbVU=SmW%A z+sXAAqb=Jtyv3)nt?om$ z&r+{%_<(QYE#DUm5Jd=zQ^7i!Amaj2aeT;WF(^(Jnx=y^8Te^){4#dD@g3|lRa~7m zoLXP`sY@Xy=cP4UF}>#z(!F|2H+L|*vr=6&NgYCDtX9HNQIL%5-wGAv|=ft*s(Xdw9&z0@AKIstTgwm^!m1>E#4g&c&F1Sv1)cFQut5? zM`bFxFGqApdmj0f-l>hCYllV~@;}ezCRDL9XBgH@syUUR%cOOkMD&Z$;iGc})Fde= zBZ{2lMTk`#KWW5NnG%{ifFul9o~6GvhwC_s517Qospy&Of^HGs;-Zd&^{hm-Q6zrb zbO*ud4ld3gbTEKh+jtTJS!eU;ECLstv9xJdOvX1&Z~6E*N#N`uOTD{&rci-uS*X;Z zXWbhUM~j&ljx~#oJ^64ymNHx+DMDRNzBmv%lf$WX%;v7M>`804o@4IgN*;=;9tYKf zoEn0f_=C3?1jCcqNvRN0mJqu=)IX$oT@_I( z8H^~8YN|etus&{r_ro$VXwES@MPa8C^q&SBxJlxblJkomCKoT;PnSnNNhVUJ7`AM} zCwHWEv}93ivuK@JE!q)ZON$A9A%;@YLS3pun@lr{!!lpp z7C*#@n;Ej!AiBFkH2%Rf~3s>Gjh!RkMUNGs{bTQh}Ac7;nrS9$?e?3?21-s@XyLKOy zj(NSli>UphlEVXBx-O7alz2ydd zg7kfbVSUq3rvZ0oVSImEdjHqxu9(b$n7o0uQ$B8XxKzRxCu=kg!C%tM1I4!~#{O)yA9oMz}a8; zWk?-8_7X5LdjaW6LSUi+zgt3|=FNmJ?-Q|%Y0R%`F8H{6eHx!>M?|7*LN zRi!9GnX{hwC7S)l#ZF>z@i&F~oRjVD4Wii_Db#h1?VUgUB-wY2p4~v9J}JC`LT&Ve zle$i!W>!Qd%;fs>u2HCkzRm1AE%j40JH1Te7sbt_>B*OEYkrbz6gT`{i`?gV>!R6; zadC05t=@me>i>%m0pfNX`1t(?f4U<>l8F34$L!4T?t-5e4;99DWmHMgcmL4L&X`kY zyeONX^@lVc?0s54Or~*O&-~$iItbG-Gv{LQ`erMCW13Y^2L8r0%lbRh?Bd^;X6%1s znq8N9^9R$6?#-V}vyUJCV46w&$uw*KgK5^Vk-Q`P2YKh-ALN}A8_7H65gV9h7dMc1 zhL!#m(~M&S)68N6d8aCG1Jg|FH+d(i2xgi=lz+Fuier+&CiOT6*{pMiU4w1c$o?CR zdcAENejD}vT}QIn{rDPpc%S_mC!+6Z@NXSSbK)n{ixYF<(GFK%tZ^dt#q9k-_CK;1 z3zPku9?pM`+3%=PKJmk-*QVMUHdz1rU`>7U^)QMmW(ixw-ph49S^iN0)4euWdq}@^ zB#k3+U4d0Hgl zA60-viY3L|d6Fp{IE0}4yL#_aNk)U**pf|Z{sox4sjyS5Xx*cuClt)AEa6A8Ek_m7 z9qg<;pY5?7XLWP49&cC-*gIjh8nnkchnPmYXZSF}$yzZi-PvxpTS>a@lKNrSso7g~ z-h0?i6$DO=>lKIW+3nC0YR|UV>wlJQwJ*ZH@!_3l2gQpMF%C`Xe}(UjSbQC}_p|Va zV8?w5U(+60CvqP6E0=*k{x2J8i9?Tacgh?tne>N}T$&SEQ(TqHNNKL0ucOo5lpjkR zbKZ(^%yfwq@i^|XRX^l}bEJF3NrAjXgFMfzWqS%dTdrH5@``-E{ft*DLbTYuRiyl^ z$5;L8a~`ekSIXVLCSI!$uq%TtWm>PFz38X%{7kjqGUj-#zp8*>osX*ie1mVa`==&f z)g)w#Pjor5y}0%aJN)Ro1#S#}^!>pI zZ*M@Ghf-hQN|NJ1U|YG*&487@fH7*j2meG^Y|`d?`_#*2?(bXe6MPt{{+RQx@NL-W znTY+O1E`%@`BefSNl%BiQWGq4VI`tu)d4BCffZW`n$pW zV)QkSugj;eEF75cPq=1qd-Y7g=lwFTP}lk%eJ_duv9ffy4s8;2W)K%wvkB_xS3(vM zm=a`-Ryz$1(8+2X@qym-o&6HnVqeO^z*NpILvDO=l^A8HlA){Lg1%TyHfiAobGKm{ zv1mm(d13ZocfTWJ@dM2yIXbZ(!vIoAn_;5dCdr--h^2(EE2Y+0xYw|cwZz0GUCx`@ z+dsj67G-h7Y`?tT!hpF7wiswHk8GvL79N`>Zgir|1`lkY=gxK)+U%eb7fBsWjE7wl}>Q)lv0^$yG6KSU!)!C1|qY$@VDL?gVf zXbo?eyappwEiB*sQtpz7-Dx@YZ%l0-K>$F806+o&AT;bG2|?=^X#-=#4-6|xGn|Hy z>v4dFP*Ish?7K_vLb1^`?zCP2&^a#(+2xI~EUC#J%f{U7-Z zr4ZLzDFA{GcogFPg>xXD4aX7tiP!2N_oG|}xU2+7eM8*h2Zrm<7@8CUumBJYVVngY zdu_>pSGd+c|Kq^gA3!~VS9N&(4gm2D08DT;P8n8PhcUf00Emn5S^>uv;1jilapn4Z zL^a%qweTwZGv{ZX#1;5`)p`sQ_A@sV04sk0>`VaIn*xAKG5|sFwu5jDQ#Am{kp!UF z4uDeleOV0v)kOfbd;y^CECAQweL9x`=!K8jKMcUl{Q%sS0AOeofR$qad{>3La27x~ z2LRDy*4hw2&K{cb`=kT?JwT|JyL(uumoFuJvw|#q>I6d*FaH1p699hxp~2<`+7ugG zI|}wT+@5%V0dfG)TtWhOSm;~A$-qcYhY|uOt>NFx()f>71b3wjcTp(6atRC$ zg}Y%FoW0rAEyM-J2{87J2n}3|FTj}W?6ZauYvYL=3|A1wuzocld#q!)wcwm}tht6= z{rp|woV7X!y85}U;T{+#hljbr7zwu^NDTLKi-7ScjD>u|{JdcN8OCHkH(ChX`-HW4 zsGEx?jDIv#u%)>UjMd*u_-7hqm%TMFFBigH^O6{X~*;pYFf{I?r_W&QVH zt#8*eKA8RJGl*^QckK7RzheO<04OiQZIkgk=9~vW(_sKOrhdnSiU43t0HE>ChVx-t z8!w)rp@AwgG7%9G(q3*Z(rXR+Yx(~s{L1{-U_*J*YvuiFJBqHGJ1xvNl(N>SE&;v) zVZoG;K$?piMe6T^_zG?oT*89G*D(B9h2C52K&8KSIEE~p^Xe$)|EKI%NG5!Hjbi<&_#puVArXdbi#S_N%{ zwn2NKBhZQH9CR7F0o{upLq9_=VK5jrj3`D4V~DZEcw?e5M=+-_)tC;OLvK`m+k@G2YPyX0s5`?S@Uer8}{ zkY+GqaAi2aP{7d0Fv9Sb5zi>hsKvOKF^VyVv5xT$;~W#7NrXvPdAfsjy?@RB$sF)WFe+$!lWSt2jB8YE=2fLseN-z|XVpa2+|)|do~R3`)6|RAr?*kIIc+Q2HmyO`plOt7Jk}J{ zbk!`=e4!<-<*ikv^;TO}J6O9x`;(4}POMIcE>hP(H&yqx9q(w&!nuY_iED*ras_Vu$gL<2xSg z6yE8-v&j^g8krt9eYi_>SJ192W*D5|Tu{Jktxoo{`o9(c6yY0&C7We4wIkD%Ny@GwB{kVgu z!#;1T=zYfflaHxSxzBfBYu{==oS&0li$Al! zkAF`9U%h_WL`R^Ny+!UD-`65a`>RdD`+6BJ%OO1(-nTgekE#439ci!K1K=44qfoE|Bap&Xd z;(g+89h5zob@1aM>qD&xya{m$PYxR#zL3a}7@Rnfq?&Xl8I|mnd^=@xN?yvhRM*rS zX|icKX)8xuj@(F>OV3UJe$@Tw?PH3^PG?{;{4>TgH8Rg#32`nx`+F;XRXf z=4+8x(PXhfabt-{NzPf!+3>T^OLv#{ol`znQN~r4R<>I1Up{@_{CrP^Qbk22Pvx-- zhznsCURK#x4PDf|*nCOqQgJm~by^Lm39ETk>r^{_dHdzAI_0`c^}_Xq4J-|*jZkA` z<3f{X({!_K^KgrCOINFEYr_@kE9Gs}wt{xn_G4G+t|nalel6fXIRYBYLtZ0FeUxYhXNg#EPrproyL|@5enr zKS+JZ^f2cU&!dvR#Q&@V&vRbzzo?j%pKW`o z|8nTno>$La2fkjOOMF9mQ}|ZwZT-CF{NRGk!n1e5@4ml3@`3Y1`Nu6EyB2pXK3eiy oTK<&!ne+4cFUnu~zFL2Mu^h32UCCV)Uv2%i{o8}@e&4_U7epSk5dZ)H literal 0 HcmV?d00001 diff --git a/packaging/macos/dmg-settings.py b/packaging/macos/dmg-settings.py new file mode 100644 index 0000000..4906c97 --- /dev/null +++ b/packaging/macos/dmg-settings.py @@ -0,0 +1,27 @@ +# dmgbuild settings for the installer window; see build-dmg.sh. +# defines: app (path to the .app), icon (volume icon .icns), background (.tiff) +import os + +app = defines["app"] +app_name = os.path.basename(app) + +format = "UDZO" +files = [app] +symlinks = {"Applications": "/Applications"} +background = defines["background"] +icon = defines["icon"] + +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +default_view = "icon-view" + +# Frame = 32pt title bar + 400pt content + 28pt path bar (bg is 660x428 so it +# also fills the frame when the path bar is hidden). Icon y matches make-assets.py. +window_rect = ((200, 120), (660, 460)) +icon_size = 112 +text_size = 13 +arrange_by = None +icon_locations = {app_name: (170, 185), "Applications": (490, 185)} diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..f83db85 --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/packaging/macos/icon-1024.png b/packaging/macos/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..e29283cafebae50bc0a60f21adc31134a21ccc3f GIT binary patch literal 68784 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μm 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 1: + continue + span = (1 - dy**n) ** (1 / n) + x0 = int(center - half * span) + x1 = int(center + half * span) + for x in range(max(x0, 0), min(x1, big)): + pixels[x, y] = 255 + return mask.resize((size, size), Image.LANCZOS) + + +def make_icon(): + size = 1024 + tile = superellipse_mask(size, margin=100) + + gradient = Image.new("RGB", (size, size)) + draw = ImageDraw.Draw(gradient) + for y in range(size): + t = y / (size - 1) + draw.line( + [(0, y), (size, y)], + fill=(int(34 - 16 * t), int(38 - 18 * t), int(46 - 22 * t)), + ) + + icon = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + + shadow = Image.new("L", (size, size), 0) + shadow.paste(tile, (0, 12)) + shadow = shadow.filter(ImageFilter.GaussianBlur(14)).point(lambda v: v * 0.45) + icon.paste(Image.new("RGBA", (size, size), (0, 0, 0, 255)), mask=shadow) + icon.paste(gradient, mask=tile) + + mouse = Image.open(SOURCE).convert("RGBA") + mouse = mouse.crop(mouse.getbbox()) + height = 520 + mouse = mouse.resize((round(mouse.width * height / mouse.height), height), Image.LANCZOS) + icon.alpha_composite(mouse, ((size - mouse.width) // 2, (size - mouse.height) // 2)) + icon.save(HERE / "icon-1024.png") + return icon + + +BANNER = r""" + ___ __ __ + / _ \ _ __ ___ _ _ | \/ |___ _ _ ___ ___ +| (_) | '_ \/ -_) ' \| |\/| / _ \ || (_-", f) + gap = 8 * scale + count = 5 + total = count * chevron + (count - 1) * gap + x = ((APP_X + APPS_X) * scale - total) / 2 + for i in range(count): + t = (i + 1) / count + color = tuple(int(DIM[c] + (GREEN[c] - DIM[c]) * t) for c in range(3)) + draw.text((x + i * (chevron + gap), (ICON_Y - 19) * scale), ">", font=f, fill=color) + + # Finder always draws DMG icon labels in black over a picture background, + # so give them light chips to sit on (label centre is ~74pt below the icon). + chip_w, chip_h, chip_y = 140, 22, ICON_Y + 74 + for cx in (APP_X, APPS_X): + draw.rounded_rectangle( + [(cx - chip_w / 2) * scale, (chip_y - chip_h / 2) * scale, + (cx + chip_w / 2) * scale, (chip_y + chip_h / 2) * scale], + radius=chip_h / 2 * scale, + fill=(226, 231, 238), + ) + + # Terminal-style caption with a cursor block. + f = mono(12) + caption = "drag OpenMouse Bridge.app -> /Applications" + prompt = "$ " + cursor = 8 * scale + total = text_width(prompt + caption, f) + 6 * scale + cursor + x = (width - total) / 2 + y = 330 * scale + draw.text((x, y), prompt, font=f, fill=GREEN) + draw.text((x + text_width(prompt, f), y), caption, font=f, fill=TEXT) + cx = x + text_width(prompt + caption, f) + 6 * scale + draw.rectangle([cx, y + 1 * scale, cx + cursor, y + 15 * scale], fill=GREEN) + return image + + +def main(): + make_icon() + one, two = background(1), background(2) + one.save(HERE / "bg.png", dpi=(72, 72)) + two.save(HERE / "bg@2x.png", dpi=(144, 144)) + subprocess.run( + ["tiffutil", "-cathidpicheck", HERE / "bg.png", HERE / "bg@2x.png", + "-out", HERE / "dmg-background.tiff"], + check=True, + ) + (HERE / "bg.png").unlink() + (HERE / "bg@2x.png").unlink() + + +if __name__ == "__main__": + main() diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index 8a0514f..d94417a 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -312,10 +312,15 @@ fn locate_node_binary() -> Option { } /// The `native-hid/` directory a packaged Bridge release ships next to its -/// executable, if the executable's location can be determined at all (it's -/// an `Option` rather than a hard error because the fallback paths this -/// module's callers use don't need it to succeed). +/// executable (or, in a macOS `.app`, under `Contents/Resources/`), if the +/// executable's location can be determined at all (it's an `Option` rather +/// than a hard error because the fallback paths this module's callers use +/// don't need it to succeed). fn installed_native_hid_dir() -> Option { + #[cfg(target_os = "macos")] + if let Some(bundle) = crate::platform::bundle_root() { + return Some(bundle.join("Contents/Resources/native-hid")); + } Some(std::env::current_exe().ok()?.parent()?.join("native-hid")) } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b970da8..f60d388 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,4 +1,7 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result}; use directories::BaseDirs; @@ -13,6 +16,22 @@ pub fn linux_distribution() -> Option { None } +/// The `.app` directory the running executable lives in, if it was launched +/// from a bundle (`X.app/Contents/MacOS/`) rather than as a bare binary. +pub fn bundle_root() -> Option { + bundle_root_of(&env::current_exe().ok()?) +} + +fn bundle_root_of(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + let contents = macos_dir.parent()?; + let bundle = contents.parent()?; + (macos_dir.file_name()? == "MacOS" + && contents.file_name()? == "Contents" + && bundle.extension()? == "app") + .then(|| bundle.to_path_buf()) +} + pub fn autostart_enabled() -> bool { launch_agent_path().is_ok_and(|path| path.exists()) } @@ -72,6 +91,20 @@ fn xml_escape(value: &str) -> String { mod tests { use super::*; + #[test] + fn bundle_root_is_found_only_inside_an_app() { + assert_eq!( + bundle_root_of(Path::new( + "/Applications/OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge" + )), + Some(PathBuf::from("/Applications/OpenMouse Bridge.app")) + ); + assert_eq!( + bundle_root_of(Path::new("/tmp/openmouse-bridge/openmouse-bridge")), + None + ); + } + #[test] fn executable_paths_are_escaped_for_plists() { assert_eq!(xml_escape("A&B "), "A&B <Bridge>"); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 9452878..04477d4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -21,7 +21,7 @@ pub use windows::{ }; #[cfg(target_os = "macos")] -pub use macos::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +pub use macos::{autostart_enabled, bundle_root, linux_distribution, platform_name, set_autostart}; #[cfg(target_os = "linux")] pub use linux::{autostart_enabled, linux_distribution, platform_name, set_autostart}; @@ -158,9 +158,7 @@ pub fn notify(summary: &str, body: &str) -> Result<()> { #[cfg(target_os = "macos")] fn macos_is_bundled() -> bool { - std::env::current_exe() - .map(|path| path.to_string_lossy().contains(".app/Contents/MacOS/")) - .unwrap_or(false) + bundle_root().is_some() } #[cfg(target_os = "macos")] diff --git a/src/updater.rs b/src/updater.rs index 282d4cb..6c84f67 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -22,6 +22,9 @@ const UPDATE_URL_ENV: &str = "OPENMOUSE_BRIDGE_UPDATE_API_URL"; const USER_AGENT: &str = "OpenMouse-Bridge-Updater"; const CHECK_TIMEOUT: Duration = Duration::from_secs(8); const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// The bundle a macOS app-update archive carries at its root. +#[cfg(any(target_os = "macos", test))] +const MACOS_APP_NAME: &str = "OpenMouse Bridge.app"; #[derive(Clone, Debug)] pub struct UpdateInfo { @@ -153,8 +156,14 @@ fn asset_url(assets: &[ReleaseAsset], name: &str) -> Result { } fn platform_archive_name() -> Result<&'static str> { + // Installs from the DMG run as a signed .app and are replaced as a whole + // bundle; bare-binary installs keep following the original zip. #[cfg(target_os = "macos")] - return Ok("openmouse-bridge-macos-universal.zip"); + return Ok(if crate::platform::bundle_root().is_some() { + "openmouse-bridge-macos-app.zip" + } else { + "openmouse-bridge-macos-universal.zip" + }); #[cfg(target_os = "windows")] return Ok("openmouse-bridge-windows-x64.zip"); #[cfg(target_os = "linux")] @@ -232,22 +241,80 @@ fn stage_archive(archive: &[u8], version: &str) -> Result<()> { 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" ); + // A bundled install replaces the whole .app so its code signature stays + // intact; the directory that must be writable is the one holding it. + #[cfg(target_os = "macos")] + if let Some(bundle) = crate::platform::bundle_root() { + let install_directory = bundle + .parent() + .context("Bridge bundle has no parent directory")?; + let bundle_name = bundle + .file_name() + .context("Bridge bundle has no file name")?; + verify_install_targets(install_directory, &[bundle_name])?; + spawn_bundle_install_helper(&staging.path, install_directory, bundle_name)?; + staging.handed_off = true; + return Ok(()); + } + let install_directory = current_executable + .parent() + .context("Bridge executable has no parent directory")?; verify_install_directory(install_directory)?; spawn_install_helper(&staging.path, install_directory, ¤t_executable)?; staging.handed_off = true; Ok(()) } +/// Where an update package keeps the files Bridge needs, relative to the +/// extracted archive: flat next to the executable, or inside a macOS bundle. +struct PackageLayout { + binary: std::path::PathBuf, + license: std::path::PathBuf, + native_hid: std::path::PathBuf, + extra: Vec, +} + +impl PackageLayout { + fn flat() -> Self { + Self { + binary: platform_binary_name().into(), + license: "DM-Sans-OFL.txt".into(), + native_hid: "native-hid".into(), + extra: Vec::new(), + } + } + + #[cfg(any(target_os = "macos", test))] + fn macos_bundle() -> Self { + let app = Path::new(MACOS_APP_NAME); + Self { + binary: app.join("Contents/MacOS/openmouse-bridge"), + license: app.join("Contents/Resources/DM-Sans-OFL.txt"), + native_hid: app.join("Contents/Resources/native-hid"), + extra: vec![app.join("Contents/Info.plist")], + } + } + + fn current() -> Self { + #[cfg(target_os = "macos")] + if crate::platform::bundle_root().is_some() { + return Self::macos_bundle(); + } + Self::flat() + } +} + fn verify_package(staging: &Path) -> Result<()> { + verify_package_with(staging, &PackageLayout::current()) +} + +fn verify_package_with(staging: &Path, layout: &PackageLayout) -> Result<()> { verify_tree(staging)?; - let license = staging.join("DM-Sans-OFL.txt"); + let license = staging.join(&layout.license); let expected = include_bytes!("../assets/fonts/DM-Sans-OFL.txt"); ensure!( reject_link(&license)?.is_file() && fs::metadata(&license)?.len() == expected.len() as u64, @@ -258,36 +325,41 @@ fn verify_package(staging: &Path) -> Result<()> { "update package contains an incomplete DM Sans license" ); let node = if cfg!(windows) { - "native-hid/node/node.exe" + layout.native_hid.join("node/node.exe") } else { - "native-hid/node/bin/node" + layout.native_hid.join("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 hid = &layout.native_hid; + let mut required = vec![ + layout.binary.clone(), + hid.join("src/apply.mjs"), + hid.join("src/brands.mjs"), + hid.join("src/hid-device-adapter.mjs"), + hid.join("src/attack-shark-x11.mjs"), + hid.join("package.json"), + node.clone(), + hid.join("node_modules/node-hid/package.json"), + hid.join("node_modules/@openmouse/protocol/package.json"), + hid.join("node_modules/@openmouse/keyboard-protocol/package.json"), + ]; + required.extend(layout.extra.iter().cloned()); + for name in &required { 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}"))?; + let metadata = reject_link(&path) + .with_context(|| format!("update package is missing {}", name.display()))?; ensure!( metadata.is_file() && metadata.len() > 0, - "update package file is empty or invalid: {name}" + "update package file is empty or invalid: {}", + name.display() ); #[cfg(unix)] - if name == platform_binary_name() || name == node { + if *name == layout.binary || *name == node { use std::os::unix::fs::PermissionsExt; ensure!( metadata.permissions().mode() & 0o500 == 0o500, - "update executable requires owner read and execute access: {name}" + "update executable requires owner read and execute access: {}", + name.display() ); } } @@ -458,8 +530,17 @@ fn extract_archive_with_limits( inner: Cursor::new(archive), metadata_budget: &metadata_budget, }; - let mut zip = zip::ZipArchive::new(reader) - .context("update archive is invalid or exceeds metadata budget")?; + // Update archives never carry prepended data. Without a known offset the + // zip crate scans the whole file forward looking for the central + // directory, which spends the entire metadata budget on any archive + // larger than it - that is, every real release. + let mut zip = zip::ZipArchive::with_config( + zip::read::Config { + archive_offset: zip::read::ArchiveOffset::Known(0), + }, + 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; @@ -566,25 +647,34 @@ fn extract_archive_with_limits( } } ensure!( - reject_link(&staging.join(platform_binary_name()))?.is_file(), + reject_link(&staging.join(&PackageLayout::current().binary))?.is_file(), "update archive does not contain the Bridge executable" ); Ok(()) } fn verify_install_directory(directory: &Path) -> Result<()> { + verify_install_targets( + directory, + &[ + std::ffi::OsStr::new(platform_binary_name()), + std::ffi::OsStr::new("native-hid"), + std::ffi::OsStr::new("openmouse-app-icon.png"), + std::ffi::OsStr::new("DM-Sans-OFL.txt"), + ], + ) +} + +/// Checks that `directory` is a plain, unlocked, writable directory and that +/// every existing entry named in `names` is a link-free tree. +fn verify_install_targets(directory: &Path, names: &[&std::ffi::OsStr]) -> Result<()> { verify_directory_chain(directory)?; match fs::symlink_metadata(directory.join(".openmouse-update.lock")) { Ok(_) => anyhow::bail!("Bridge update lock already exists"), Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error).context("could not inspect Bridge update lock"), } - for name in [ - platform_binary_name(), - "native-hid", - "openmouse-app-icon.png", - "DM-Sans-OFL.txt", - ] { + for name in names { let path = directory.join(name); match fs::symlink_metadata(&path) { Ok(_) => verify_tree(&path)?, @@ -741,6 +831,100 @@ fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) - Ok(()) } +/// Replaces one directory (a macOS `.app`) as a unit. Same lock, backup and +/// rollback discipline as `UNIX_TRANSACTION`: status 2 means nothing changed. +#[cfg(any(target_os = "macos", test))] +const BUNDLE_TRANSACTION: &str = r#"install_bundle() ( +set -eu +stage="$1" +destination="$2" +staged="$3" +installed="$4" +[ -d "$stage/$staged/Contents/MacOS" ] || 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 +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 + if [ -e "$transaction/old/$installed" ]; then + rm -rf "$destination/$installed" && mv "$transaction/old/$installed" "$destination/$installed" || failed=yes + elif [ -f "$transaction/installed/marker" ]; then + rm -rf "$destination/$installed" || failed=yes + fi + 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 +if [ -e "$destination/$installed" ] || [ -L "$destination/$installed" ]; then + special=$(find "$destination/$installed" ! -type f ! -type d -print) || exit 1 + [ -z "$special" ] || exit 1 +fi +cp -R "$stage/$staged" "$transaction/new/$installed" || exit 1 +chmod u+x "$transaction/new/$installed/Contents/MacOS/openmouse-bridge" || exit 1 +if [ -e "$destination/$installed" ]; then mv "$destination/$installed" "$transaction/old/$installed" || exit 1; fi +: > "$transaction/installed/marker" || exit 1 +mv "$transaction/new/$installed" "$destination/$installed" || exit 1 +committed=yes +) +"#; + +#[cfg(any(target_os = "macos", test))] +const BUNDLE_COMPLETION: &str = r#"complete_bundle_update() { + if install_bundle "$1" "$2" "$3" "$4"; then + : + else + status=$? + [ "$status" -eq 2 ] || return 1 + fi + "$5" "$2" "$4" +} +"#; + +#[cfg(target_os = "macos")] +fn spawn_bundle_install_helper( + staging: &Path, + destination: &Path, + installed: &std::ffi::OsStr, +) -> Result<()> { + let script = staging.join("install-update.sh"); + let contents = format!( + "#!/bin/sh\n{BUNDLE_TRANSACTION}\n{BUNDLE_COMPLETION}\npid=\"$1\"\nstage=\"$2\"\ndestination=\"$3\"\nstaged=\"$4\"\ninstalled=\"$5\"\ntrap 'rm -rf \"$stage\"' 0\nwhile kill -0 \"$pid\" 2>/dev/null; do sleep 1; done\nlaunch_bundle() {{ open \"$1/$2\"; }}\ncomplete_bundle_update \"$stage\" \"$destination\" \"$staged\" \"$installed\" launch_bundle\n" + ); + write_helper(&script, &contents)?; + Command::new("/bin/sh") + .arg(&script) + .arg(std::process::id().to_string()) + .arg(staging) + .arg(destination) + .arg(MACOS_APP_NAME) + .arg(installed) + .spawn() + .context("could not launch the update helper")?; + Ok(()) +} + #[cfg(windows)] const WINDOWS_TRANSACTION: &str = r#"function Assert-CleanTree([string]$Path) { $Pending = [System.Collections.Generic.Stack[string]]::new() diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 09c1849..2b9d016 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -720,3 +720,202 @@ fn checksum_must_match_archive() { .is_err() ); } + +#[test] +fn macos_bundle_package_layout_requires_every_bundled_file() { + let root = fixture(); + let layout = PackageLayout::macos_bundle(); + let node = layout.native_hid.join("node/bin/node"); + let mut names = vec![ + layout.binary.clone(), + layout.native_hid.join("src/apply.mjs"), + layout.native_hid.join("src/brands.mjs"), + layout.native_hid.join("src/hid-device-adapter.mjs"), + layout.native_hid.join("src/attack-shark-x11.mjs"), + layout.native_hid.join("package.json"), + node, + layout.native_hid.join("node_modules/node-hid/package.json"), + layout + .native_hid + .join("node_modules/@openmouse/protocol/package.json"), + layout + .native_hid + .join("node_modules/@openmouse/keyboard-protocol/package.json"), + ]; + names.extend(layout.extra.iter().cloned()); + 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_with(&root, &layout).is_err(), + "font license is mandatory inside the bundle" + ); + fs::write( + root.join(&layout.license), + include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), + ) + .unwrap(); + verify_package_with(&root, &layout).unwrap(); + assert!( + verify_package_with(&root, &PackageLayout::flat()).is_err(), + "a bundle is not a flat package" + ); + for name in &names { + let path = root.join(name); + fs::remove_file(&path).unwrap(); + assert!( + verify_package_with(&root, &layout).is_err(), + "accepted a bundle missing {}", + name.display() + ); + 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(); + } + } + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn bundle_archive_paths_are_accepted_by_the_extractor() { + let bytes = archive(&[ + // The extractor's executable check follows the running install's + // layout, which is flat under test. + (platform_binary_name(), b"flat"), + ("OpenMouse Bridge.app/Contents/Info.plist", b"plist"), + ( + "OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge", + b"bin", + ), + ]); + let root = fixture(); + extract_archive(&bytes, &root).unwrap(); + assert!( + root.join("OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge") + .is_file() + ); + fs::remove_dir_all(root).unwrap(); +} + +#[cfg(unix)] +#[test] +fn generated_bundle_transaction_replaces_or_restores_the_app() { + for case in [ + "success", + "existing-lock", + "copy-failure", + "commit-failure", + "rollback-failure", + ] { + let root = fixture(); + let stage = root.join("stage"); + let destination = root.join("install"); + let staged_exe = stage.join("OpenMouse Bridge.app/Contents/MacOS"); + let installed_exe = destination.join("Renamed Bridge.app/Contents/MacOS"); + for directory in [&staged_exe, &installed_exe] { + fs::create_dir_all(directory).unwrap(); + } + fs::write(root.join("sandbox-marker"), b"bridge-update-fixture-only").unwrap(); + fs::write(staged_exe.join("openmouse-bridge"), b"new-binary").unwrap(); + fs::write(installed_exe.join("openmouse-bridge"), b"old-binary").unwrap(); + fs::write(destination.join("sentinel"), b"untouched").unwrap(); + if case == "existing-lock" { + fs::create_dir(destination.join(".openmouse-update.lock")).unwrap(); + } + let script = root.join("sandbox.sh"); + let contents = format!( + "{BUNDLE_TRANSACTION}\n{BUNDLE_COMPLETION}\n{}", + r#" +root="$1" +fixture_case="$2" +[ "$(cat "$root/sandbox-marker")" = bridge-update-fixture-only ] || exit 90 +cp() { + if [ "$fixture_case" = copy-failure ]; then return 1; fi + command cp "$@" +} +mv() { + case "$1" in */new/*) if [ "$fixture_case" = commit-failure ] || [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac + case "$1" in */old/*) if [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac + command mv "$@" +} +record_launch() { cat "$1/$2/Contents/MacOS/openmouse-bridge" > "$root/launch-record"; } +complete_bundle_update "$root/stage" "$root/install" "OpenMouse Bridge.app" "Renamed Bridge.app" record_launch +"# + ); + write_helper(&script, &contents).unwrap(); + let output = Command::new("/bin/sh") + .arg(&script) + .arg(root.to_string_lossy().as_ref()) + .arg(case) + .output() + .unwrap(); + assert_eq!( + output.status.success(), + case != "rollback-failure", + "{case}: {}", + 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 expected: &[u8] = if case == "success" { + b"new-binary" + } else { + b"old-binary" + }; + assert_eq!(fs::read(root.join("launch-record")).unwrap(), expected); + assert_eq!( + fs::read(installed_exe.join("openmouse-bridge")).unwrap(), + expected, + "{case}: installed app" + ); + assert_eq!( + fs::read(destination.join("sentinel")).unwrap(), + b"untouched" + ); + assert!(!destination.join("OpenMouse Bridge.app").exists()); + assert_eq!( + fs::read_dir(&destination).unwrap().count(), + if case == "existing-lock" { 3 } else { 2 }, + "{case}: no transaction leftovers" + ); + fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn archives_larger_than_the_metadata_budget_still_open() { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer.start_file(platform_binary_name(), stored).unwrap(); + // Real release archives are tens of megabytes; opening one must not read + // the whole file as "metadata". + let block = vec![0x5a; 1024 * 1024]; + for _ in 0..(MAX_ZIP_METADATA_READ_BYTES / block.len() + 4) { + writer.write_all(&block).unwrap(); + } + let bytes = writer.finish().unwrap().into_inner(); + assert!(bytes.len() > MAX_ZIP_METADATA_READ_BYTES); + let root = fixture(); + extract_archive(&bytes, &root).unwrap(); + assert!(root.join(platform_binary_name()).is_file()); + fs::remove_dir_all(root).unwrap(); +} From a24a0c56d48d3af39bfeea939c69fdd375f653fe Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:03:01 -0600 Subject: [PATCH 7/9] Revert "Merge pull request #44 from OpenMouse-Project/main-rollback" This reverts commit 4c536857d8c23bcd584b5050c6e9bec61e5581f7, reversing changes made to 5ce921488077b92b732783daf70e08ba3463865d. --- .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 20 +- Cargo.lock | 61 +- Cargo.toml | 15 +- README.md | 117 +- assets/fonts/DM-Sans-OFL.txt | 93 ++ assets/fonts/DM-Sans-source.json | 17 + assets/fonts/DMSans-Variable.ttf | Bin 0 -> 240164 bytes install-linux.sh | 47 +- native-hid/README.md | 10 + native-hid/package-lock.json | 4 +- native-hid/package.json | 7 +- native-hid/src/apply.mjs | 68 +- native-hid/src/automation.mjs | 37 + native-hid/src/automation.test.mjs | 211 +++ native-hid/src/button-listener.mjs | 178 +++ native-hid/src/hid-device-adapter.mjs | 38 +- src/api.rs | 415 +++++- src/applications.rs | 14 +- src/buttons.rs | 1007 +++++++++++++ src/buttons/keymap.rs | 221 +++ src/buttons/linux.rs | 200 +++ src/buttons/macos.rs | 93 ++ src/config.rs | 393 +++++- src/desktop.rs | 1861 ++++++++++++++++++------- src/drivers/automation.rs | 342 +++++ src/drivers/automation/tests.rs | 498 +++++++ src/drivers/mod.rs | 546 +++++++- src/drivers/native_hid.rs | 376 ++++- src/drivers/pulsar.rs | 39 + src/games.rs | 374 ++++- src/hid.rs | 244 +++- src/lib.rs | 3 + src/local_targets.rs | 764 ++++++++++ src/main.rs | 3 + src/native_image.rs | 476 +++++++ src/native_ui.rs | 528 +++++++ src/overlay/linux.rs | 12 +- src/overlay/render.rs | 36 +- src/platform/linux.rs | 53 +- src/platform/mod.rs | 118 +- src/platform/windows.rs | 646 ++++++++- src/remote_image.rs | 260 ++++ src/service.rs | 1756 ++++++++++++++++++++--- src/updater.rs | 836 ++++++++--- src/updater/tests.rs | 722 ++++++++++ tests/backend_local_profiles.rs | 558 ++++++++ tests/linux_startup.sh | 83 ++ 48 files changed, 13305 insertions(+), 1109 deletions(-) create mode 100644 assets/fonts/DM-Sans-OFL.txt create mode 100644 assets/fonts/DM-Sans-source.json create mode 100644 assets/fonts/DMSans-Variable.ttf create mode 100644 native-hid/src/automation.mjs create mode 100644 native-hid/src/automation.test.mjs create mode 100644 native-hid/src/button-listener.mjs create mode 100644 src/buttons.rs create mode 100644 src/buttons/keymap.rs create mode 100644 src/buttons/linux.rs create mode 100644 src/buttons/macos.rs create mode 100644 src/drivers/automation.rs create mode 100644 src/drivers/automation/tests.rs create mode 100644 src/local_targets.rs create mode 100644 src/native_image.rs create mode 100644 src/native_ui.rs create mode 100644 src/remote_image.rs create mode 100644 src/updater/tests.rs create mode 100644 tests/backend_local_profiles.rs create mode 100644 tests/linux_startup.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1cd6c1..30ae1d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,13 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + # Builds and tests run dependency code; only windows-build needs write access. + permissions: + contents: read steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install Linux HID and GUI dependencies if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libudev-dev libgtk-3-dev libayatana-appindicator3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev pkg-config @@ -33,15 +38,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 +82,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..e39e436 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" @@ -2928,7 +2920,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openmouse-bridge" -version = "1.0.0-beta.18" +version = "1.0.0-beta.19" dependencies = [ "anyhow", "axum", @@ -2937,9 +2929,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 +2945,7 @@ dependencies = [ "skrifa", "sysinfo", "tokio", + "tower", "tower-http", "tracing", "tracing-appender", @@ -3627,9 +3623,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 +4111,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 +4228,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 +4545,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 +5821,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..4028620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openmouse-bridge" -version = "1.0.0-beta.18" +version = "1.0.0-beta.19" edition = "2024" description = "Local native HID and desktop companion for the OpenMouse control panel" license = "MIT" @@ -21,12 +21,12 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sysinfo = "0.37" -tokio = { version = "1.47", features = ["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" 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"] } @@ -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..0c203ad 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,33 @@ portable so additional desktop adapters do not require changes to the web app. The initial service provides: -- a compact tray panel for status, startup, battery, and update controls; +- a compact tray panel for status, startup, battery, and update controls, + with a device summary row set showing the mouse's current DPI, polling + rate, and switch mode as read from its onboard memory; +- a live tray icon that shows the connected mouse's battery, drawn square at + the taskbar's real pixel size with either the native Segoe Fluent Icons + battery glyph (Segoe MDL2 Assets on Windows 10) or the percentage number in + bold text — a control-panel choice, since the Windows 11 taskbar only + renders square tray icons faithfully — tinted by charge state: green while + charging (the variant with the built-in bolt) or above half, amber through + the mid band, alert red at or below the low-battery threshold, gray when + the reading is stale, and a level glyph for every 10% — with a tooltip that + spells everything out — `Device — 87% · Charging · full in 45 m` — + including the estimated time remaining from the recent trend; - process-based detection for configured game executables; +- startup discovery: Bridge scans the HID bus for mice whose brands it has + drivers for (the same vendor table the native-hid helper drives — Pulsar, + Logitech, Razer, Delux, VGN, and more), so the tray shows the battery from + the first second — no web panel connection and no saved profile required; +- hybrid device mode (toggle in the panel): while the OpenMouse web app is + disconnected, Bridge drives the mouse itself in onboard memory mode — the + active profile's stored DPI, polling rate, and switch mode are pushed + natively (switch mode wherever a driver supports it, e.g. Logitech + LightForce), and the battery is read directly (including a native Pulsar + battery reader) — and the moment the web app connects, it takes the mouse + over live, with Bridge restoring the stored settings and reading the + battery again on disconnect; without a saved profile Bridge leaves the + mouse's own onboard memory untouched and just monitors it; - discovery of visible applications and the foreground application (on Linux, foreground detection needs X11; on Wayland every entry reports `foreground: false`); - persistent application profiles tied to a specific mouse; - low-battery notifications with a configurable threshold and cooldown, from @@ -81,6 +106,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 +162,28 @@ 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"`), the user's custom + games (`source: "custom"`) and local targets (`source: "local"`, with `id`, + `path` and `kind`). +- `GET /v1/custom-games` returns the games the user added by hand; + `PUT /v1/custom-games` replaces them (`[{ name, executables }]`), persists + them under `customGames` in `config.json` and answers with the saved list. + They live apart from the catalog, which is replaced on every start. +- `GET /v1/custom-games/image?name=…` returns the image saved for a custom + game (404 when it has none); `PUT` with a PNG, JPEG or WebP body (at most + 512 KB) saves one. Images live in a `custom-game-images` folder beside + `config.json` and go away with the game. +- `GET /v1/local-applications` reads personal targets. `PUT` accepts + `{ applications: [{ id, name, executable, path, kind }] }` and atomically + validates/persists the complete local list. Limit: 256 targets, names up to 80 + characters, executable leaf names up to 255, full paths up to 1024. Invalid + input returns 400; failed persistence returns 500 without changing live state. +- `GET /v1/applications` lists relevant visible applications from the preset + catalog, custom games or local targets and identifies the foreground + application. Each item includes an `iconId`; requesting `GET /v1/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 +191,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 +204,40 @@ The listener binds only to loopback, never to a LAN or public interface. ## Current boundary -The HID WebSocket is active only while the control panel is connected. Battery -readings still come from that panel, so true battery alerts while the browser is -closed require a background native protocol reader. Game detection and saved -profile switching already run independently in the background. +The HID WebSocket is active only while the control panel is connected. Bridge +also reads battery levels every five minutes for devices in saved profiles when +no native HID client session is active. This requires a supported native battery +reader; Pulsar currently has no background battery reader. Readings older than +twelve minutes are marked as last known rather than live. + +Game detection and saved profile switching run independently in the background. +Native profile application writes DPI and polling rate for supported devices. +Full settings snapshots are stored by Bridge but applied and restored by an open +control panel. A selected profile is not proof that a hardware write succeeded. + +## Tray panel + +The native tray panel uses OpenMouse's lit surfaces, inset controls, raised +actions, trailing switches, Emerald palette and embedded DM Sans. Home starts at +360 by 280 logical points; Settings is 360 by 420. Real content can grow Home up +to 340 points high. Extended connection and battery reports live behind the info +controls rather than filling the default view. +Home separates the Bridge service, browser connection, live HID sessions, saved +profile selection, and battery readings instead of treating them as one status. +Settings keeps startup, battery alerts, notification sound, updates, and logs in +one scrollable view. + +Drag the header beside the OpenMouse name, or beside the Settings title, to move +the borderless window. Settings and Back leave its position unchanged. Clicking +the tray icon opens the panel near that icon, constrained to the monitor work +area on Windows. The titlebar minimize and close controls hide to the tray; +background operation continues. Escape also dismisses the panel. Quit Bridge +stops the process. + +`OPENMOUSE_BRIDGE_TEST_POSITION=x,y` starts the panel at an explicit logical +position without requesting focus, for isolated secondary-monitor UI checks. +Use `OPENMOUSE_BRIDGE_CONFIG` with a disposable configuration and set +`autostartConfigured` to `true` to avoid first-run startup registration in tests. ## Verify diff --git a/assets/fonts/DM-Sans-OFL.txt b/assets/fonts/DM-Sans-OFL.txt new file mode 100644 index 0000000..441171d --- /dev/null +++ b/assets/fonts/DM-Sans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/DM-Sans-source.json b/assets/fonts/DM-Sans-source.json new file mode 100644 index 0000000..7717e47 --- /dev/null +++ b/assets/fonts/DM-Sans-source.json @@ -0,0 +1,17 @@ +{ + "revision": "5b35b7208dd4100571326fdf37f030b32a524232", + "files": [ + { + "file": "DMSans-Variable.ttf", + "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/DMSans%5Bopsz%2Cwght%5D.ttf", + "sha256": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d", + "bytes": 240164 + }, + { + "file": "DM-Sans-OFL.txt", + "source": "https://raw.githubusercontent.com/google/fonts/5b35b7208dd4100571326fdf37f030b32a524232/ofl/dmsans/OFL.txt", + "sha256": "9af36190332437f5ecd09974de43c1f7c77a310a996cdd8ceb25628b458840e1", + "bytes": 4482 + } + ] +} diff --git a/assets/fonts/DMSans-Variable.ttf b/assets/fonts/DMSans-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c672f98060af9b2c85fca704ebd5ad9a8717c1df GIT binary patch literal 240164 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 z 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%0DOsst9s5938VCl(I9M4!axwi7) z?&7`0#ZUkHYZEp!){(?69Re57k-f(He>gjFZv5Q2F{Ye4|K`QZsEbQK``33(BY^%# z$0_*fPhdiSb8^yujVPP3OZWcota|F!1C)4s)uhCc{Vy?K06A0eOKs~U7HZ9CUT#TP z1o)U>OTt5(XN8N58OSlHgJDfC1VPJ>S2yofU;&;+c+Ra2R!i+H0-1r4 z?3q|h1X}DdmAA38Ix;?PNQ!SD_BNyuu4Cjj;97{!{>#oH2fjJsUBh(RKET%z){ zkVsxSS6q#?KIf%d5Oj&gU>K}nqX4dbSSMCxO*xWPce7uUAk>@n4XAkg+Q`Uoq%8Ff z&VrWA4d#tT1^z-KKK-auqrKF7wgzayM}nWkcu5$Z>0s7 zyl^u?`0oVQ8YZ~bFac@2@wSF)1S&M5qXv1X(Z#Q9{BeepVTO}|;(_wE<_^#f z&ik$KKE&$|_FEz&ELO}A+6xTxKt>P#IBd>ap7&J31;@;X? zS?tjKT`)TAjUrG)v{$2*{P(Wg+FE~tB6;a8tsg0`H~4q0pS1pjh!`PaOY2XFL9#*r zezkrY2m81d+Q*F{A+%79>s=>l_MBp`K7Opy+w7v(x72z|>wA?2VK@D=sR#pqsaD9J z$<~Br&&JM%$AhG$3d`w{;gR${DVY+JFj^j8SkHCOwKafO>A;+C1pJn4UDpBn*p{v< zyL+~Gw*k{PbzM2LegAmHf^ur*b)3uV6xS-JUTBrC z&^=w4#^85KN5j!g`|Jmd58G`VSi@|Qg*}dnYIf5<`-yDCI?&!mN5{s-&W?>~MYE-V z+B4SQplO06OQ=euUGDXgb~(6ptX=+HOZoqzrO>V$ZwJM%UU~HM0(x3>uB}@J;%%*& zLqeH0*VdXj+b2zBZdjVa8bpmnt)D9kFif?^5G*8NwsM#!xb#pXm4{RxPzb>T7D^r& zsM5=!{PpS6zkcyW(U6PV2ov{3&yYv1${sq?FQBtLLdxNy>^-Bq;^WlGk`NC;m!lZe zh;WY|l?HuM=&&@kKTyfn0|)CsRqd5=GO0e_vwjvAtd*5NuAsNutEp}~UbMUJ=Vqz! zT)RXSt`vDMG7n)o5uCtZ5n(e1_7CgxEyNYf*qy4FkS@$?g)|ClWg8Z%;3W4bM=&Wr zQaspu1s2;vU}4MNsgog&CyE0n6bSey!DCGX6jXf(G)OLMhE8jjY-@bTz75OTk z>?2RAqNMiF9EiWTyAULMh|$zEWpj^FUC*e<3A~!OneXK zxGC@?RS3QT2X~pkQP7FCv_gDB04dut~4vRG_{>m1NW$ky*-XZ~z|C115t$ zDIvF%*``K`VZzc8fdL`zp%noHr7jsVVznV&o5|j9U6B|smpZTWjdOCm4mtkzQtzcw zhciPx}O;JZ?G5$x5s;5pLG~ZoS6sarE_pz19gE_$E>-I@?ff1%Z!S>M)k_ zoeHtmL~BvEp^=eDzYqGQuRj7_Pj7z&I|q?|uN!z>3$PUvY7?;?(OOC|NYa@bm?};7 zRFWu1DS-uzKx9>L`m{To+v;$z&7QYfu!kFS&vPw!hAWL3l*VbUGzPl#H*R+ilN%j{|iv(anP^B&11hAH|4vffWratWB{0bYLIuM1qq#o;Oh1i4vKYExVRV9`XmX;iQz4$#l>wxBufsK4< z4F>;NRJ2Gz>tI`zh3_cPW-H1fSHqL>_G)%A-kv2US*K3aF`{xa<_D-w^S4Id3HT-~ z>$)MFeJ3EC@GI>ty1ZKGsnB_`f z7A0_s(+XW&8+UOfV8f5JcKgtRNPAy~NMR!JEg)&{%OELRGQI^S?R^<0g}TulDE$Cq X5WH|"$path" } # 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" </dev/null || true fi update_desktop_database @@ -301,7 +329,7 @@ main() { fi local binary="$INSTALL_DIR/openmouse-bridge" local link="$BIN_LINK_DIR/openmouse-bridge" - local desktop="$HOME/.config/autostart/io.openmouse.bridge.desktop" + local desktop="$(startup_config_home)/autostart/io.openmouse.bridge.desktop" # GNOME/KDE launcher: user-local always, so it never needs root and # --uninstall can remove it without sudo. Icon path is absolute because # launchers do not resolve relative to the .desktop location. @@ -419,8 +447,15 @@ main() { log "Installing hidraw udev rule..." write_udev_rule "$tmp/69-openmouse-bridge.rules" $sudo cp "$tmp/69-openmouse-bridge.rules" "$rule" + # /dev/uinput only exists once the uinput module is loaded. + # Best effort: button assignments need it, the rest of Bridge does not. + if ! { $sudo mkdir -p /etc/modules-load.d && echo uinput | $sudo tee /etc/modules-load.d/openmouse-bridge.conf >/dev/null; }; then + warn "could not configure uinput to load at boot; button assignments need /dev/uinput" + fi + $sudo modprobe uinput 2>/dev/null || true $sudo udevadm control --reload-rules 2>/dev/null || true $sudo udevadm trigger --subsystem-match=hidraw --action=change 2>/dev/null || true + $sudo udevadm trigger --subsystem-match=misc --sysname-match=uinput --action=change 2>/dev/null || true log "Unplug and replug the mouse (or reboot) so the new rule applies." fi else @@ -429,14 +464,14 @@ main() { # 6. Autostart. if [ "$WITH_AUTOSTART" = true ]; then - write_autostart "$desktop" "$binary" + write_autostart "$desktop" "$binary" || die "could not write autostart entry: invalid path or write failure" log "Login autostart enabled." fi # 7. App launcher entry + icon refresh. The zip is expected to ship # openmouse-app-icon.png next to the binary (see release.yml Package). if [ "$WITH_LAUNCHER" = true ]; then - write_launcher "$launcher" "$binary" "$icon" + write_launcher "$launcher" "$binary" "$icon" || die "could not write launcher entry: invalid path or write failure" update_desktop_database log "Launcher entry installed." else diff --git a/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/package-lock.json b/native-hid/package-lock.json index c2b03b3..d7d8df2 100644 --- a/native-hid/package-lock.json +++ b/native-hid/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.2", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.48.0", "node-hid": "^3.1.0" }, "engines": { @@ -26,7 +26,7 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#e53af6d57197074bb8afd31f0fc1522ece68c890", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#326f292bbe29ea73801cda1b2f0434f4fea62d18", "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" diff --git a/native-hid/package.json b/native-hid/package.json index 953b873..054a2ed 100644 --- a/native-hid/package.json +++ b/native-hid/package.json @@ -12,17 +12,18 @@ }, "dependencies": { "@openmouse/keyboard-protocol": "github:OpenMouse-Project/keyboard-protocol#v1.1.0", - "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.42.2", + "@openmouse/protocol": "github:OpenMouse-Project/mouse-protocol#v0.48.0", "node-hid": "^3.1.0" }, "allowScripts": { "github:OpenMouse-Project/keyboard-protocol#v1.1.0": true, "github:OpenMouse-Project/keyboard-protocol#cae3d7a74e8256a9a3cc5c7fe2311db1425bb977": true, - "github:OpenMouse-Project/mouse-protocol#v0.42.2": true, + "github:OpenMouse-Project/mouse-protocol#v0.48.0": true, "node-hid@3.4.0": true, "github:OpenMouse-Project/mouse-protocol#b69cd44b2498dcaaa65fd6f77ba10bf109e49322": true, "github:OpenMouse-Project/mouse-protocol#de188133bace18b1f75cd7bfd8360f17197222a5": true, "github:OpenMouse-Project/mouse-protocol#c3b1bc20866fbef3a27b37ad020887607d6dbbf2": true, - "github:OpenMouse-Project/mouse-protocol#e53af6d57197074bb8afd31f0fc1522ece68c890": true + "github:OpenMouse-Project/mouse-protocol#e53af6d57197074bb8afd31f0fc1522ece68c890": true, + "github:OpenMouse-Project/mouse-protocol#326f292bbe29ea73801cda1b2f0434f4fea62d18": true } } diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 2d591e9..4e3b592 100644 --- a/native-hid/src/apply.mjs +++ b/native-hid/src/apply.mjs @@ -6,9 +6,14 @@ // Usage: node apply.mjs < request.json // stdin: {"brand": "Razer", "dpi": 800, "pollingRateHz": 1000} // (dpi / pollingRateHz are each optional — omit to leave that setting alone) +// stdin: {"brand": "Razer", "dpi": 800, "settings": {"lightforceSwitchMode": "Optical"}} +// (settings carries the panel's stored MouseStatus fields; ones the driver +// supports are applied, the rest are reported and skipped) // stdin: {"brand": "Razer", "action": "status"} -// reads the mouse's status instead and prints its battery fields as JSON on -// stdout: {"batteryPercent": 41, "batteryState": "Discharging"} +// reads the mouse's status instead and prints it as JSON on stdout: +// {"batteryPercent": 41, "batteryState": "Discharging", +// "deviceName": "G Pro X Superlight 2", "dpi": 800, "pollingRateHz": 1000, +// "switchMode": "Optical"} (settings fields null when unreadable) // // Exit codes (Bridge's src/drivers/native_hid.rs depends on this contract): // 0 — a device was found and the requested settings were applied (or, in @@ -34,7 +39,9 @@ globalThis.window ??= globalThis; import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; import { applyX11PollingRate, x11DeviceInfos } from "./attack-shark-x11.mjs"; -import { candidateDevices } from "./hid-device-adapter.mjs"; +import { candidateDevices, probeVerifiedDevice } from "./hid-device-adapter.mjs"; +import { devices } from "node-hid"; +import { automate } from "./automation.mjs"; const EXIT_APPLIED = 0; const EXIT_FAILED = 1; @@ -82,6 +89,14 @@ async function main() { const brand = typeof input.brand === "string" ? input.brand : ""; const normalizedBrand = brandKey(brand); const entry = BRAND_DRIVERS[normalizedBrand]; + if (input.action === "automatic-read" || input.action === "automatic-apply") { + const reply = await automate(input, { + list: () => devices(), + probe: (info, identity) => probeVerifiedDevice(info, identity, (device) => probe(device, entry.classes[0])), + }); + process.stdout.write(JSON.stringify(reply)); + process.exit(EXIT_APPLIED); + } if (!entry) { console.error(`[native-hid] no driver registered for brand "${brand}"`); process.exit(EXIT_NO_DRIVER); @@ -128,6 +143,16 @@ async function main() { JSON.stringify({ batteryPercent: status?.batteryPercent ?? null, batteryState: status?.batteryState ?? null, + // The driver's own model name (MouseStatus.name) — better than the + // receiver-level name Bridge's discovery gets from the HID + // descriptor (a LIGHTSPEED receiver reports "USB Receiver"). + deviceName: status?.name ?? null, + // The settings currently in the device's onboard memory, for the + // tray panel's device summary. MouseStatus reports them whenever + // the driver can read them. + dpi: status?.dpi ?? null, + pollingRateHz: status?.pollingRateHz ?? null, + switchMode: status?.lightforceSwitchMode ?? null, }), ); await client.close().catch(() => undefined); @@ -195,6 +220,43 @@ async function main() { process.exit(EXIT_FAILED); } + // ── Panel snapshot fields ──────────────────────────────────────────── + // Bridge stores the panel's opaque MouseStatus fields alongside the + // numeric settings and hands them back here for onboard-memory mode. + // Only fields with a feature-detected driver method are applied; the + // rest are reported and skipped. + if (input.settings && typeof input.settings === "object" && !Array.isArray(input.settings)) { + const settings = input.settings; + const skipped = []; + if ( + typeof settings.lightforceSwitchMode === "string" && + typeof client.setLightforceSwitchMode === "function" + ) { + try { + await withTimeout( + client.setLightforceSwitchMode(settings.lightforceSwitchMode), + PROBE_TIMEOUT_MS, + "setLightforceSwitchMode", + ); + } catch (error) { + console.error(`[native-hid] could not apply lightforceSwitchMode: ${error.message}`); + await client.close().catch(() => undefined); + process.exit(EXIT_FAILED); + } + } else if (typeof settings.lightforceSwitchMode === "string") { + skipped.push("lightforceSwitchMode"); + } + const known = ["lightforceSwitchMode"]; + for (const field of Object.keys(settings)) { + if (!known.includes(field) && settings[field] !== null && settings[field] !== undefined) { + skipped.push(field); + } + } + if (skipped.length > 0) { + console.error(`[native-hid] no native support for: ${skipped.join(", ")} — skipped.`); + } + } + await client.close().catch(() => undefined); process.exit(EXIT_APPLIED); } diff --git a/native-hid/src/automation.mjs b/native-hid/src/automation.mjs 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/button-listener.mjs b/native-hid/src/button-listener.mjs new file mode 100644 index 0000000..1cb60fd --- /dev/null +++ b/native-hid/src/button-listener.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Long-running helper Bridge spawns while a game profile has button +// assignments for a Logitech mouse running from software (Host mode). +// +// stdin: one JSON line to start, e.g. {"brand": "Logitech", "device": "PRO X 3 SUPERSTRIKE"} +// ("device" picks that mouse when several of the brand are connected); add +// "requireHost": true to listen only to a mouse already on Host. Closing stdin (or +// SIGTERM/SIGINT) stops it: the listener is switched off and the mouse goes back +// to the mode it was in, if this helper changed it. +// stdout: one JSON object per line. +// {"event": "ready", "priorMode": "Onboard", "buttons": [1, 2, 3, 4, 5]} +// {"event": "buttons", "mask": 16} — the buttons held right now +// {"event": "error", "message": "..."} +// {"event": "stopped"} +// +// Exit codes: 0 stopped cleanly, 3 no driver for the brand, 1 anything else. +// See apply.mjs for why `window` is aliased before any driver is imported. +globalThis.window ??= globalThis; + +import { createInterface } from "node:readline"; + +import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; +import { candidateDevices } from "./hid-device-adapter.mjs"; + +const EXIT_OK = 0; +const EXIT_FAILED = 1; +const EXIT_NO_DRIVER = 3; +const STEP_TIMEOUT_MS = 3000; + +const emit = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); + +function withTimeout(promise, ms, label) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** Opens a candidate class on one device and reads its status, or rejects and closes it. */ +async function probe(device, candidate) { + const module = await import(candidate.module); + const client = new module[candidate.exportName](device); + try { + await withTimeout(client.open(), STEP_TIMEOUT_MS, `${candidate.exportName}.open()`); + const status = await withTimeout(client.readStatus(), STEP_TIMEOUT_MS, `${candidate.exportName}.readStatus()`); + return { client, status }; + } catch (error) { + await device.close().catch(() => undefined); + throw error; + } +} + +// `wantedName` is the mouse the profile names. With several mice of the brand +// connected only that one is used; with a single one it is used whatever its +// name, so a name spelled differently never leaves it without a listener. +async function findClient(entry, wantedName) { + const attempts = []; + const others = []; + const closeOthers = () => Promise.all(others.map((other) => other.client.close().catch(() => undefined))); + for (const vendorId of entry.vendorIds) { + for (const device of candidateDevices(vendorId)) { + for (const candidate of entry.classes) { + try { + const found = await probe(device, candidate); + // The listener only exists on a client built from a protocol release that has it. + if (typeof found.client.startButtonListener !== "function") { + await found.client.close().catch(() => undefined); + attempts.push(`${candidate.exportName}: this protocol version has no button listener`); + continue; + } + if (!wantedName || String(found.status?.name ?? "").toLowerCase() === wantedName.toLowerCase()) { + await closeOthers(); + return found; + } + others.push(found); + break; + } catch (error) { + attempts.push(`${candidate.exportName} on ${device.productName || "unknown device"}: ${error.message}`); + } + } + } + } + if (others.length === 1) return others[0]; + await closeOthers(); + if (others.length > 1) throw new Error(`none of the ${others.length} connected mice is named "${wantedName}"`); + throw new Error(`no mouse answered. ${attempts.join("; ")}`); +} + +async function main(input) { + const entry = BRAND_DRIVERS[brandKey(typeof input.brand === "string" ? input.brand : "")]; + if (!entry) { + emit({ event: "error", message: `no driver registered for brand "${input.brand}"` }); + process.exit(EXIT_NO_DRIVER); + } + + // Listening from the start: Bridge may close stdin while discovery is still + // probing, and an `end` emitted with no listener is not emitted again. + let stopRequested = false; + let stop = () => { + stopRequested = true; + }; + const onStop = () => stop(); + process.stdin.on("end", onStop); + process.stdin.on("close", onStop); + process.on("SIGTERM", onStop); + process.on("SIGINT", onStop); + + const { client, status } = await findClient(entry, typeof input.device === "string" ? input.device.trim() : ""); + if (stopRequested || process.stdin.readableEnded) { + // Nothing has been changed on the mouse yet. + await client.close().catch(() => undefined); + emit({ event: "stopped" }); + process.exit(EXIT_OK); + } + const priorMode = status?.deviceMode ?? "Unknown"; + // The default profile only listens to a mouse already on Host; it never + // switches one over, so a mouse on an onboard profile is left alone. + if (input.requireHost === true && priorMode !== "Host") { + emit({ event: "error", message: "the mouse is not on the Host profile" }); + await client.close().catch(() => undefined); + process.exit(EXIT_FAILED); + } + let changedMode = false; + let stopping = false; + + // `code` is how the helper ends: a failed start is a failure, not a clean stop. + async function shutdown(code = EXIT_OK) { + if (stopping) return; + stopping = true; + await withTimeout(client.stopButtonListener(), STEP_TIMEOUT_MS, "stopButtonListener").catch(() => undefined); + if (changedMode) { + await withTimeout(client.setOnboardMode(priorMode), STEP_TIMEOUT_MS, "setOnboardMode").catch(() => undefined); + } + await client.close().catch(() => undefined); + emit({ event: "stopped" }); + process.exit(code); + } + + // Event handlers pass their own arguments (a signal name), which are not an exit code. + stop = () => void shutdown(); + + try { + // The events only flow while the mouse runs from software; an unknown mode + // is left alone and the listener is tried as it is. + if (priorMode === "Onboard") { + // Marked first: the mouse may switch and then fail to confirm it, and it still has to go back. + changedMode = true; + await withTimeout(client.setOnboardMode("Host"), STEP_TIMEOUT_MS, "setOnboardMode"); + } + client.onButtonChange((mask) => emit({ event: "buttons", mask })); + const buttons = await withTimeout(client.startButtonListener(), STEP_TIMEOUT_MS, "startButtonListener"); + // A stop that came in while switching modes: put the mouse back now. + if (stopRequested || process.stdin.readableEnded) { + await shutdown(); + return; + } + emit({ event: "ready", priorMode, buttons }); + } catch (error) { + emit({ event: "error", message: error.message }); + await shutdown(EXIT_FAILED); + } +} + +const lines = createInterface({ input: process.stdin }); +lines.once("line", (line) => { + let input; + try { + input = JSON.parse(line); + } catch { + emit({ event: "error", message: "the first line on stdin must be JSON" }); + process.exit(EXIT_FAILED); + } + main(input).catch((error) => { + emit({ event: "error", message: error.message }); + process.exit(EXIT_FAILED); + }); +}); diff --git a/native-hid/src/hid-device-adapter.mjs b/native-hid/src/hid-device-adapter.mjs index 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..cf70ed8 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}, + body::{Body, Bytes}, + extract::{Path, Query, 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, GameConfig, 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,11 +72,27 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/status", get(status)) .route("/v1/handshake", put(handshake)) .route("/v1/games", get(games)) + .route( + "/v1/custom-games", + get(custom_games).put(replace_custom_games), + ) + .route( + "/v1/custom-games/image", + get(custom_game_image).put(set_custom_game_image), + ) + .route("/v1/custom-games/image-from-url", get(image_from_link)) + .route( + "/v1/local-applications", + get(local_applications).put(replace_local_applications), + ) .route("/v1/applications", get(applications)) .route("/v1/applications/{icon_id}/icon", get(application_icon)) .route("/v1/running-applications", get(running_applications)) .route("/v1/profiles", get(profiles).put(replace_profiles)) - .route("/v1/default-profile", 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)) @@ -76,10 +104,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 +184,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 +235,121 @@ async fn profiles(State(service): State) -> Json) -> Json> { +async fn games(State(service): State) -> Json> { Json(service.games().await) } +async fn custom_games(State(service): State) -> Json> { + Json(service.custom_games().await) +} + +async fn replace_custom_games( + State(service): State, + Json(games): Json>, +) -> Result>, (StatusCode, String)> { + service + .set_custom_games(games) + .await + .map(Json) + .map_err(internal_error) +} + +#[derive(Debug, Deserialize)] +struct GameImageQuery { + name: String, +} + +async fn custom_game_image( + State(service): State, + Query(query): Query, +) -> Result, StatusCode> { + let (image, kind) = service + .custom_game_image(&query.name) + .await + .ok_or(StatusCode::NOT_FOUND)?; + Response::builder() + .header(header::CONTENT_TYPE, kind) + // Revalidated every time: an image can be replaced under the same name. + .header(header::CACHE_CONTROL, "private, no-cache") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(Body::from(image)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +#[derive(Debug, Deserialize)] +struct ImageLinkQuery { + url: String, +} + +async fn image_from_link( + Query(query): Query, +) -> Result, (StatusCode, String)> { + let (image, kind) = crate::remote_image::fetch(&query.url) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Response::builder() + .header(header::CONTENT_TYPE, kind) + .header(header::CACHE_CONTROL, "no-store") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(Body::from(image)) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string())) +} + +async fn set_custom_game_image( + State(service): State, + Query(query): Query, + body: Bytes, +) -> Result, (StatusCode, String)> { + service + .set_custom_game_image(&query.name, &body) + .await + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; + Ok(Json(ApiResult { ok: true })) +} + +async fn local_applications(State(service): State) -> Json> { + Json(service.local_applications().await) +} + +async fn replace_local_applications( + State(service): State, + payload: Result, JsonRejection>, +) -> Result, (StatusCode, String)> { + let Json(payload) = payload.map_err(invalid_json)?; + let applications = service + .replace_local_applications(payload.applications) + .await + .map_err(profile_error)?; + Ok(Json( + serde_json::json!({"ok":true,"applications":applications}), + )) +} + async fn replace_profiles( State(service): State, - 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 default_profile(State(service): State) -> Json> { + Json(service.default_profile().await) +} + 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 +379,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 +391,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,7 +439,176 @@ async fn apply_native_settings( Ok(Json(ApiResult { ok: true })) } +fn invalid_json(error: JsonRejection) -> (StatusCode, String) { + (StatusCode::BAD_REQUEST, error.body_text()) +} + +fn profile_error(error: anyhow::Error) -> (StatusCode, String) { + if error + .downcast_ref::() + .is_some() + { + (StatusCode::BAD_REQUEST, error.to_string()) + } else { + internal_error(error) + } +} + fn internal_error(error: anyhow::Error) -> (StatusCode, String) { tracing::error!(%error, "Bridge request failed"); (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) } + +#[cfg(test)] +mod tests { + use axum::{ + body::to_bytes, + http::{Method, Request}, + }; + use tower::ServiceExt; + + use super::*; + use crate::{config::BridgeConfig, remote_image::PNG_SIGNATURE}; + + fn app() -> (Router, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "bridge-api-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let service = BridgeService::new(BridgeConfig::default(), dir.join("config.json")); + (router(service, &[]), dir) + } + + async fn call( + app: &Router, + method: Method, + uri: &str, + content_type: &str, + body: Vec, + ) -> (StatusCode, Vec, Option) { + let request = Request::builder() + .method(method) + .uri(uri) + // The request gate only serves loopback hosts. + .header(header::HOST, "127.0.0.1:43821") + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(body)) + .expect("request"); + let response = app.clone().oneshot(request).await.expect("response"); + let status = response.status(); + let kind = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body") + .to_vec(); + (status, bytes, kind) + } + + #[tokio::test] + async fn a_custom_game_keeps_the_image_saved_for_it() { + let (app, dir) = app(); + let games = br#"[{"name":"My Game","executables":["MyGame.exe"]}]"#.to_vec(); + let (status, saved, _) = call( + &app, + Method::PUT, + "/v1/custom-games", + "application/json", + games, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + String::from_utf8_lossy(&saved).contains("mygame.exe"), + "the list comes back normalized" + ); + + let mut png = PNG_SIGNATURE.to_vec(); + png.extend_from_slice(&[1, 2, 3]); + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=My%20Game", + "image/png", + png.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, bytes, kind) = call( + &app, + Method::GET, + "/v1/custom-games/image?name=my%20game", + "", + vec![], + ) + .await; + assert_eq!( + (status, bytes, kind.as_deref()), + (StatusCode::OK, png, Some("image/png")) + ); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[tokio::test] + async fn an_image_is_refused_for_an_unknown_game_or_a_file_that_is_not_an_image() { + let (app, dir) = app(); + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=Nope", + "image/png", + PNG_SIGNATURE.to_vec(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "the game must exist"); + let games = br#"[{"name":"Game","executables":["game.exe"]}]"#.to_vec(); + call( + &app, + Method::PUT, + "/v1/custom-games", + "application/json", + games, + ) + .await; + let (status, ..) = call( + &app, + Method::PUT, + "/v1/custom-games/image?name=Game", + "image/png", + b"".to_vec(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "only real images are kept"); + let (status, ..) = call( + &app, + Method::GET, + "/v1/custom-games/image?name=Game", + "", + vec![], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[tokio::test] + async fn a_link_into_the_local_network_is_refused() { + let (app, dir) = app(); + let (status, message, _) = call( + &app, + Method::GET, + "/v1/custom-games/image-from-url?url=http%3A%2F%2F192.168.1.1%2Flogo.png", + "", + vec![], + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(String::from_utf8_lossy(&message).contains("cannot be reached")); + std::fs::remove_dir_all(dir).expect("cleanup"); + } +} diff --git a/src/applications.rs b/src/applications.rs index 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/buttons.rs b/src/buttons.rs new file mode 100644 index 0000000..d809a17 --- /dev/null +++ b/src/buttons.rs @@ -0,0 +1,1007 @@ +//! Button assignments for a Logitech mouse running from software (Host mode). +//! +//! A mouse on the Host profile stops sending its side buttons as ordinary +//! clicks; it reports them as events instead, and something on the PC has to +//! act on them (G HUB does). While a game's profile has assignments, Bridge +//! runs `native-hid/src/button-listener.mjs`, which puts the mouse on Host, turns +//! those events on and prints them, and then presses the assigned key, or the +//! side button's normal Back/Forward click when nothing is assigned. When the +//! game ends the helper stops the events and puts the mouse back in the mode +//! it was in. +//! +//! The assignments live in a game profile's `snapshot.hostButtons`: +//! `{"8": {"key": 58, "modifiers": 0}, "16": {...}}`, keyed by the button's bit +//! in the mouse's report (8 the back side button, 16 the front one), each a HID +//! keyboard usage and a modifier mask (1 ctrl, 2 shift, 4 alt, 8 win). + +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Write}, + process::{Child, ChildStdin}, + sync::{Arc, Mutex, PoisonError, TryLockError, mpsc}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use serde_json::Value; + +use crate::config::ApplicationProfile; + +/// The back side button's bit in the mouse's button report. +pub const BACK: u8 = 0x08; +/// The front side button's bit. +pub const FRONT: u8 = 0x10; +/// The only buttons a Host mode mouse stops sending as normal clicks. +const SIDE_BUTTONS: [u8; 2] = [BACK, FRONT]; + +/// How long the helper gets to stop the events and restore the mouse's mode: it +/// allows itself three seconds for each of the two, then closes the mouse. +const STOP_TIMEOUT: Duration = Duration::from_secs(8); +/// How long a starting helper may hold the mouse before Bridge stops waiting. +const START_TIMEOUT: Duration = Duration::from_secs(12); +/// How long to leave a mouse alone after the helper could not start or died. +const RETRY_DELAY: Duration = Duration::from_secs(15); + +/// A keyboard key and its modifiers, as a profile stores them (HID usage). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyAction { + pub usage: u8, + pub modifiers: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Key(KeyAction), + /// A media key, by its HID consumer usage (volume, play/pause, ...). + Media(u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Step { + Down(Action), + Up(Action), + /// Press the side button's normal click: `BACK` is Back, `FRONT` is Forward. + SideDown(u8), + SideUp(u8), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Assignments { + /// The brand the profile names ("Logitech"), which the helper picks its driver by. + pub brand: String, + /// The mouse's name as the profile stores it ("PRO X 3 SUPERSTRIKE"), so the + /// helper listens to that mouse when several of the brand are connected. + pub device: String, + pub actions: BTreeMap, + /// Set for the default profile: it only listens to a mouse that is already + /// on the Host profile, and never switches one to it. + pub require_host: bool, +} + +impl Assignments { + pub fn from_profile(profile: &ApplicationProfile, is_default: bool) -> Option { + let (brand, device) = profile + .device + .id + .split_once(':') + .unwrap_or((profile.device.id.as_str(), "")); + if !brand.eq_ignore_ascii_case("logitech") { + return None; + } + let buttons = profile + .settings + .snapshot + .as_ref()? + .get("hostButtons")? + .as_object()?; + let mut actions = BTreeMap::new(); + for (bit, action) in buttons { + let bit: u8 = bit.parse().ok().filter(|bit| SIDE_BUTTONS.contains(bit))?; + let action = if let Some(usage) = action.get("consumer") { + let usage = u16::try_from(usage.as_u64()?).ok()?; + media_virtual_key(usage)?; + Action::Media(usage) + } else { + let usage = u8::try_from(action.get("key")?.as_u64()?).ok()?; + // A key this system cannot press would also swallow the button's normal click. + can_press(usage).then_some(())?; + let modifiers = + u8::try_from(action.get("modifiers").and_then(Value::as_u64).unwrap_or(0)) + .ok() + .filter(|modifiers| *modifiers <= 0x0f)?; + Action::Key(KeyAction { usage, modifiers }) + }; + actions.insert(bit, action); + } + (!actions.is_empty()).then(|| Self { + brand: brand.to_owned(), + device: device.to_owned(), + actions, + require_host: is_default, + }) + } +} + +/// What each held side button does, as it was when it went down: `None` is its +/// normal click. A press uses the assignments of that moment and its release the +/// same action, even if the profile changed while the button was held. +pub type HeldButtons = BTreeMap>; + +pub fn transitions( + held: &mut HeldButtons, + assignments: &Assignments, + previous: u8, + now: u8, +) -> Vec { + let mut steps = Vec::new(); + for bit in SIDE_BUTTONS { + let (was, is) = (previous & bit != 0, now & bit != 0); + if was == is { + continue; + } + if is { + let action = assignments.actions.get(&bit).copied(); + held.insert(bit, action); + steps.push(action.map_or(Step::SideDown(bit), Step::Down)); + } else { + let action = held + .remove(&bit) + .unwrap_or_else(|| assignments.actions.get(&bit).copied()); + steps.push(action.map_or(Step::SideUp(bit), Step::Up)); + } + } + steps +} + +/// The steps for a change of the held buttons, all under one set of assignments. +pub fn steps(assignments: &Assignments, previous: u8, now: u8) -> Vec { + let mut held = HeldButtons::new(); + for bit in SIDE_BUTTONS { + if previous & bit != 0 { + held.insert(bit, assignments.actions.get(&bit).copied()); + } + } + transitions(&mut held, assignments, previous, now) +} + +/// How many held assignments share each key code. A shared modifier goes down +/// once and comes up with the last assignment holding it, not the first. +#[derive(Default)] +pub struct HeldKeys(BTreeMap); + +impl HeldKeys { + pub const fn new() -> Self { + Self(BTreeMap::new()) + } + + /// True when the key was not down yet, so it has to be pressed. + pub fn press(&mut self, key: u16) -> bool { + let count = self.0.entry(key).or_default(); + *count += 1; + *count == 1 + } + + /// True when no other assignment still holds the key, so it has to be released. + pub fn release(&mut self, key: u16) -> bool { + match self.0.get_mut(&key) { + Some(count) if *count > 1 => { + *count -= 1; + false + } + Some(_) => { + self.0.remove(&key); + true + } + None => true, + } + } +} + +/// Whether Bridge can press the key on this system. A Mac has no key for Print +/// Screen, Scroll Lock, Pause or F21 to F24, which the other systems do. +fn can_press(usage: u8) -> bool { + if cfg!(target_os = "macos") { + keymap::mac_key(usage).is_some() + } else { + virtual_key(usage).is_some() + } +} + +/// The Windows virtual-key code for a HID keyboard usage, for the keys a +/// profile can hold. +pub fn virtual_key(usage: u8) -> Option { + Some(match usage { + 0x04..=0x1d => 0x41 + u16::from(usage - 0x04), // A-Z + 0x1e..=0x26 => 0x31 + u16::from(usage - 0x1e), // 1-9 + 0x27 => 0x30, // 0 + 0x28 => 0x0d, // Enter + 0x29 => 0x1b, // Escape + 0x2a => 0x08, // Backspace + 0x2b => 0x09, // Tab + 0x2c => 0x20, // Space + 0x2d => 0xbd, // - + 0x2e => 0xbb, // = + 0x2f => 0xdb, // [ + 0x30 => 0xdd, // ] + 0x31 => 0xdc, // \ + 0x33 => 0xba, // ; + 0x34 => 0xde, // ' + 0x35 => 0xc0, // ` + 0x36 => 0xbc, // , + 0x37 => 0xbe, // . + 0x38 => 0xbf, // / + 0x3a..=0x45 => 0x70 + u16::from(usage - 0x3a), // F1-F12 + 0x46 => 0x2c, // Print Screen + 0x47 => 0x91, // Scroll Lock + 0x48 => 0x13, // Pause + 0x49 => 0x2d, // Insert + 0x4a => 0x24, // Home + 0x4b => 0x21, // Page Up + 0x4c => 0x2e, // Delete + 0x4d => 0x23, // End + 0x4e => 0x22, // Page Down + 0x4f => 0x27, // Right + 0x50 => 0x25, // Left + 0x51 => 0x28, // Down + 0x52 => 0x26, // Up + 0x68..=0x73 => 0x7c + u16::from(usage - 0x68), // F13-F24 + _ => return None, + }) +} + +/// The Windows virtual-key code for a HID consumer usage (media key). +pub fn media_virtual_key(usage: u16) -> Option { + Some(match usage { + 0xe2 => 0xad, // Mute + 0xe9 => 0xaf, // Volume up + 0xea => 0xae, // Volume down + 0xcd => 0xb3, // Play / pause + 0xb5 => 0xb0, // Next track + 0xb6 => 0xb1, // Previous track + 0xb7 => 0xb2, // Stop + _ => return None, + }) +} + +/// The modifier keys a mask names, in the order they are pressed. +pub fn modifier_keys(modifiers: u8) -> Vec { + [(0x01, 0x11), (0x02, 0x10), (0x04, 0x12), (0x08, 0x5b)] // ctrl, shift, alt, win + .into_iter() + .filter(|(bit, _)| modifiers & bit != 0) + .map(|(_, vk)| vk) + .collect() +} + +#[derive(Default)] +pub struct ButtonListener { + running: Mutex>, + /// Not before this moment is a helper that failed started again. + retry_after: Mutex>, +} + +struct Running { + /// Shared with the listener thread, so new assignments reach it without a restart. + assignments: Arc>, + stop: mpsc::Sender<()>, + thread: JoinHandle<()>, +} + +impl Running { + /// Asks the helper to stop and waits for it to restore the mouse. + fn stop(self) { + let _ = self.stop.send(()); + let _ = self.thread.join(); + } + + fn matches(&self, desired: Option<&Assignments>) -> bool { + let mine = self + .assignments + .lock() + .unwrap_or_else(PoisonError::into_inner); + Some(&*mine) == desired && !self.thread.is_finished() + } + + fn retarget(&self, wanted: &Assignments) -> bool { + if self.thread.is_finished() { + return false; + } + let mut mine = self + .assignments + .lock() + .unwrap_or_else(PoisonError::into_inner); + if mine.brand != wanted.brand + || mine.device != wanted.device + || mine.require_host != wanted.require_host + { + return false; + } + if *mine != *wanted { + *mine = wanted.clone(); + tracing::info!("Updated the button assignments"); + } + true + } +} + +impl ButtonListener { + /// Makes the helper match `desired`: started for new assignments, stopped + /// when there are none. `may_start` is false while a control panel holds + /// the mouse, so a new helper does not open it underneath. Stopping and + /// starting wait on the helper, so they run off the caller's thread. + pub fn update(self: &Arc, desired: Option, may_start: bool) { + // New keys for a listener that is already running need no helper at + // all, so they are applied at once even while a panel holds the mouse. + // The caller is the async monitor loop, and a helper starting or stopping + // holds `running` for seconds, so a busy lock means "ask again next tick". + let Some(running) = self.try_running() else { + return; + }; + if let Some(wanted) = desired.as_ref() + && running + .as_ref() + .is_some_and(|current| current.retarget(wanted)) + { + return; + } + let in_step = running.as_ref().map_or(desired.is_none(), |current| { + current.matches(desired.as_ref()) + }); + let holds_old = running.is_some(); + drop(running); + if in_step { + return; + } + // A panel holding the mouse keeps a new helper from starting. One for a profile + // that is no longer the active one must not keep pressing keys meanwhile, so it + // is stopped, and the new one starts once the panel lets go. + let desired = if desired.is_some() && !may_start { + if !holds_old { + return; + } + None + } else { + desired + }; + let waiting = *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner); + if desired.is_some() && waiting.is_some_and(|until| Instant::now() < until) { + return; + } + let listener = Arc::clone(self); + thread::spawn(move || listener.apply(desired)); + } + + /// The running helper, or `None` while another thread is starting or stopping one. + fn try_running(&self) -> Option>> { + match self.running.try_lock() { + Ok(guard) => Some(guard), + Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), + Err(TryLockError::WouldBlock) => None, + } + } + + fn apply(&self, desired: Option) { + let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); + if running.as_ref().map_or(desired.is_none(), |current| { + current.matches(desired.as_ref()) + }) { + return; + } + let died = running + .as_ref() + .is_some_and(|current| current.thread.is_finished()); + if let Some(old) = running.take() { + old.stop(); + } + // The lock is let go before the helper starts: starting can take seconds, and the + // monitor loop reads this deadline to decide whether to ask again. + *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner) = died.then(|| Instant::now() + RETRY_DELAY); + // A listener that just died waits out the cooldown before the mouse is probed again. + if died && desired.is_some() { + return; + } + if let Some(assignments) = desired { + match start(assignments) { + Ok(started) => *running = Some(started), + Err(error) => { + tracing::warn!(%error, "Could not start the button listener"); + *self + .retry_after + .lock() + .unwrap_or_else(PoisonError::into_inner) = + Some(Instant::now() + RETRY_DELAY); + } + } + } + } +} + +impl Drop for ButtonListener { + fn drop(&mut self) { + let running = self + .running + .get_mut() + .unwrap_or_else(PoisonError::into_inner); + if let Some(old) = running.take() { + old.stop(); + } + } +} + +enum Event { + Buttons(u8), + Ready, + Failed(String), + Closed, +} + +fn start(assignments: Assignments) -> Result { + // The helper talks to the mouse while it starts, and so does a profile + // apply, so they take turns: this is held until the helper is listening. + inject::prepare(); + let access = crate::drivers::device_access(); + let mut child = crate::drivers::spawn_button_listener(&assignments.brand)?; + let stdin = child + .stdin + .take() + .context("the button helper's stdin was unavailable")?; + let stdout = child + .stdout + .take() + .context("the button helper's stdout was unavailable")?; + let (stop, stopped) = mpsc::channel(); + let (ready, started) = mpsc::channel(); + let shared = Arc::new(Mutex::new(assignments)); + let for_thread = Arc::clone(&shared); + let thread = thread::Builder::new() + .name("openmouse-bridge-buttons".into()) + .spawn(move || run(child, stdin, stdout, &for_thread, &stopped, &ready)) + .context("could not start the button listener thread")?; + let _ = started.recv_timeout(START_TIMEOUT); + drop(access); + Ok(Running { + assignments: shared, + stop, + thread, + }) +} + +fn run( + mut child: Child, + mut stdin: ChildStdin, + stdout: std::process::ChildStdout, + shared: &Mutex, + stopped: &mpsc::Receiver<()>, + ready: &mpsc::Sender<()>, +) { + let current = |shared: &Mutex| { + shared + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + }; + let first = current(shared); + let request = format!( + "{{\"brand\":{},\"device\":{},\"requireHost\":{}}}\n", + Value::from(first.brand.as_str()), + Value::from(first.device.as_str()), + first.require_host + ); + if stdin + .write_all(request.as_bytes()) + .and_then(|()| stdin.flush()) + .is_err() + { + tracing::warn!("Could not start the button helper"); + } + + let (events, incoming) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let message: Value = serde_json::from_str(&line).unwrap_or(Value::Null); + let event = match message.get("event").and_then(Value::as_str) { + Some("buttons") => message + .get("mask") + .and_then(Value::as_u64) + .and_then(|mask| u8::try_from(mask).ok()) + .map(Event::Buttons), + Some("ready") => Some(Event::Ready), + Some("error") => Some(Event::Failed( + message + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown error") + .to_owned(), + )), + _ => None, + }; + if let Some(event) = event + && events.send(event).is_err() + { + return; + } + } + let _ = events.send(Event::Closed); + }); + + let mut held = 0u8; + // What each held button does, from the moment it went down, so a key that was + // pressed is the key that is released even if the profile changed while it was held. + let mut in_use = HeldButtons::new(); + loop { + if !matches!(stopped.try_recv(), Err(mpsc::TryRecvError::Empty)) { + break; + } + match incoming.recv_timeout(Duration::from_millis(100)) { + Ok(Event::Buttons(mask)) => { + for step in transitions(&mut in_use, ¤t(shared), held, mask) { + tracing::info!(mask, ?step, "Acting on a mouse button"); + execute(step); + } + held = mask; + } + Ok(Event::Ready) => { + tracing::info!("The mouse's buttons are being listened to"); + let _ = ready.send(()); + } + Ok(Event::Failed(message)) => { + tracing::warn!(%message, "The button helper reported a problem"); + let _ = ready.send(()); + } + Ok(Event::Closed) | Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = ready.send(()); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + + // Let go of anything still held, then close stdin: the helper stops the + // events and puts the mouse back in the mode it was in. + for step in transitions(&mut in_use, ¤t(shared), held, 0) { + execute(step); + } + drop(stdin); + let deadline = Instant::now() + STOP_TIMEOUT; + while Instant::now() < deadline { + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + tracing::warn!("The button helper did not stop in time; ending it"); + // Collected as well: a killed child nobody waits for stays a zombie on Unix. + if child.kill().is_ok() { + let _ = child.wait(); + } +} + +mod keymap; + +#[cfg(target_os = "linux")] +#[path = "buttons/linux.rs"] +mod inject; + +#[cfg(target_os = "macos")] +#[path = "buttons/macos.rs"] +mod inject; + +/// No other system presses keys yet. +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +mod inject { + use super::Step; + + pub fn prepare() {} + + pub fn execute(step: Step) { + tracing::debug!( + ?step, + "Button actions are not carried out on this system yet" + ); + } +} + +fn execute(step: Step) { + inject::execute(step); +} + +#[cfg(target_os = "windows")] +mod inject { + use std::{ + mem::size_of, + sync::{Mutex, PoisonError}, + }; + + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, + KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_XDOWN, + MOUSEEVENTF_XUP, MOUSEINPUT, MapVirtualKeyW, SendInput, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; + + use super::{ + Action, BACK, HeldKeys, KeyAction, Step, media_virtual_key, modifier_keys, virtual_key, + }; + + static HELD: Mutex = Mutex::new(HeldKeys::new()); + + /// `SendInput` needs no setup. + pub fn prepare() {} + + pub fn execute(step: Step) { + match step { + Step::Down(Action::Key(action)) => keys(action, true), + Step::Up(Action::Key(action)) => keys(action, false), + Step::Down(Action::Media(usage)) => media(usage, true), + Step::Up(Action::Media(usage)) => media(usage, false), + Step::SideDown(bit) => side(bit, true), + Step::SideUp(bit) => side(bit, false), + } + } + + /// Media keys have no scan code of their own, so they go by virtual key. + fn media(usage: u16, down: bool) { + let Some(vk) = media_virtual_key(usage) else { + return; + }; + // Two buttons on the same media key: it comes up with the last of them. + let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); + if !(if down { + held.press(vk) + } else { + held.release(vk) + }) { + return; + } + let flags = KEYEVENTF_EXTENDEDKEY | if down { 0 } else { KEYEVENTF_KEYUP }; + send(&[INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }]); + } + + /// Modifiers go down before the key and come up after it. + fn keys(action: KeyAction, down: bool) { + let Some(key) = virtual_key(action.usage) else { + tracing::warn!(usage = action.usage, "That key cannot be pressed by Bridge"); + return; + }; + let mut order = modifier_keys(action.modifiers); + order.push(key); + if !down { + order.reverse(); + } + let mut held = HELD.lock().unwrap_or_else(PoisonError::into_inner); + for vk in order { + // A modifier two held buttons share stays down until both are let go. + if if down { + held.press(vk) + } else { + held.release(vk) + } { + send(&[key_input(vk, down)]); + } + } + } + + fn key_input(vk: u16, down: bool) -> INPUT { + // The scan code lets games that read it see the key; the high byte + // 0xE0 marks an extended key (arrows, Insert, Home, ...). + let scan = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC_EX) }; + let mut flags = if down { 0 } else { KEYEVENTF_KEYUP }; + if scan != 0 { + flags |= KEYEVENTF_SCANCODE; + } + if scan >> 8 == 0xe0 { + flags |= KEYEVENTF_EXTENDEDKEY; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: if scan != 0 { 0 } else { vk }, + wScan: (scan & 0xff) as u16, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + + fn side(bit: u8, down: bool) { + let button = if bit == BACK { XBUTTON1 } else { XBUTTON2 }; + send(&[INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: u32::from(button), + dwFlags: if down { + MOUSEEVENTF_XDOWN + } else { + MOUSEEVENTF_XUP + }, + time: 0, + dwExtraInfo: 0, + }, + }, + }]); + } + + fn send(inputs: &[INPUT]) { + let sent = unsafe { + SendInput( + inputs.len() as u32, + inputs.as_ptr(), + size_of::() as i32, + ) + }; + if sent as usize != inputs.len() { + tracing::warn!("Windows did not accept the button input"); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::config::{ProfileApplication, ProfileDevice, ProfileSettings}; + + fn profile(device: &str, snapshot: Value) -> ApplicationProfile { + ApplicationProfile { + application: ProfileApplication { + name: "Game".into(), + executable: String::new(), + path: String::new(), + local_id: None, + activation: None, + bundle_path: None, + bundle_identifier: None, + }, + device: ProfileDevice { + id: device.into(), + name: "Mouse".into(), + }, + settings: ProfileSettings { + dpi: None, + polling_rate_hz: None, + snapshot: Some(snapshot), + }, + enabled: true, + } + } + + fn assignments(keys: &[(u8, u8, u8)]) -> Assignments { + Assignments { + brand: "Logitech".into(), + device: "Mouse".into(), + actions: keys + .iter() + .map(|&(bit, usage, modifiers)| (bit, Action::Key(KeyAction { usage, modifiers }))) + .collect(), + require_host: false, + } + } + + #[test] + fn a_profile_names_the_mouse_it_is_for() { + let parsed = Assignments::from_profile( + &profile( + "Logitech:PRO X 3 SUPERSTRIKE", + json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}}}), + ), + false, + ) + .expect("assignments"); + assert_eq!(parsed.device, "PRO X 3 SUPERSTRIKE"); + } + + #[test] + fn a_held_button_keeps_its_action_while_another_follows_the_new_profile() { + let old = assignments(&[(BACK, 0x04, 0)]); + let new = assignments(&[(BACK, 0x05, 0), (FRONT, 0x06, 0)]); + let key = |usage| { + Action::Key(KeyAction { + usage, + modifiers: 0, + }) + }; + let mut held = HeldButtons::new(); + assert_eq!( + transitions(&mut held, &old, 0, BACK), + [Step::Down(key(0x04))] + ); + // The profile changes while Back is held: Forward uses the new one, Back is let go as pressed. + assert_eq!( + transitions(&mut held, &new, BACK, BACK | FRONT), + [Step::Down(key(0x06))] + ); + assert_eq!( + transitions(&mut held, &new, BACK | FRONT, FRONT), + [Step::Up(key(0x04))] + ); + assert_eq!( + transitions(&mut held, &new, FRONT, 0), + [Step::Up(key(0x06))] + ); + } + + #[test] + fn a_key_two_held_buttons_share_is_released_by_the_last_of_them() { + let mut keys = HeldKeys::new(); + assert!(keys.press(0x11), "the first Ctrl goes down"); + assert!(!keys.press(0x11), "a second one is already down"); + assert!(!keys.release(0x11), "letting one go keeps Ctrl down"); + assert!(keys.release(0x11), "the last one releases it"); + assert!(keys.release(0x11), "a key nobody holds is released anyway"); + } + + #[test] + fn a_profile_names_its_assignments_by_button_bit() { + let parsed = Assignments::from_profile(&profile( + "Logitech:PRO X3 SUPERSTRIKE", + json!({"hostButtons": {"16": {"key": 104, "modifiers": 0}, "8": {"key": 4, "modifiers": 3}}}), + ), false) + .expect("assignments"); + assert_eq!(parsed.brand, "Logitech"); + assert_eq!( + parsed.actions[&FRONT], + Action::Key(KeyAction { + usage: 104, + modifiers: 0 + }) + ); + assert_eq!( + parsed.actions[&BACK], + Action::Key(KeyAction { + usage: 4, + modifiers: 3 + }) + ); + } + + #[test] + fn a_profile_can_assign_a_media_key() { + let parsed = Assignments::from_profile( + &profile( + "Logitech:Mouse", + json!({"hostButtons": {"16": {"consumer": 233}}}), + ), + true, + ) + .expect("assignments"); + assert_eq!(parsed.actions[&FRONT], Action::Media(233)); + assert!( + parsed.require_host, + "the default profile only listens to a mouse already on Host" + ); + let unknown = Assignments::from_profile( + &profile( + "Logitech:Mouse", + json!({"hostButtons": {"16": {"consumer": 1}}}), + ), + false, + ); + assert_eq!( + unknown, None, + "a media key Bridge cannot press is not accepted" + ); + } + + #[test] + fn profiles_without_usable_assignments_start_nothing() { + let logitech = + |snapshot| Assignments::from_profile(&profile("Logitech:Mouse", snapshot), false); + assert_eq!(logitech(json!({})), None); + assert_eq!(logitech(json!({"hostButtons": {}})), None); + assert_eq!( + logitech(json!({"hostButtons": {"4": {"key": 4}}})), + None, + "the middle button is not assignable" + ); + assert_eq!(logitech(json!({"hostButtons": {"8": {"key": 999}}})), None); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 0}}})), + None, + "a key Bridge cannot press would swallow the button's normal click" + ); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 0x46}}})).is_some(), + !cfg!(target_os = "macos"), + "Print Screen has no key on a Mac" + ); + assert_eq!( + logitech(json!({"hostButtons": {"8": {"key": 4, "modifiers": 99}}})), + None + ); + let other = profile("Razer:Viper", json!({"hostButtons": {"8": {"key": 4}}})); + assert_eq!( + Assignments::from_profile(&other, false), + None, + "only Logitech has the button events" + ); + } + + #[test] + fn a_press_and_release_become_one_key_down_and_up() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + let action = Action::Key(KeyAction { + usage: 0x68, + modifiers: 0, + }); + assert_eq!(steps(&assigned, 0, FRONT), [Step::Down(action)]); + assert_eq!( + steps(&assigned, FRONT, FRONT), + [], + "holding changes nothing" + ); + assert_eq!(steps(&assigned, FRONT, 0), [Step::Up(action)]); + } + + #[test] + fn an_unassigned_side_button_keeps_its_normal_click() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + assert_eq!(steps(&assigned, 0, BACK), [Step::SideDown(BACK)]); + assert_eq!(steps(&assigned, BACK, 0), [Step::SideUp(BACK)]); + } + + #[test] + fn the_other_buttons_are_left_to_the_mouse() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + // left, right and middle already click on their own + assert_eq!(steps(&assigned, 0, 0x01 | 0x02 | 0x04), []); + } + + #[test] + fn two_buttons_pressed_together_are_both_handled() { + let assigned = assignments(&[(FRONT, 0x68, 0)]); + assert_eq!( + steps(&assigned, 0, BACK | FRONT), + [ + Step::SideDown(BACK), + Step::Down(Action::Key(KeyAction { + usage: 0x68, + modifiers: 0 + })) + ] + ); + } + + #[test] + fn keys_map_to_windows_virtual_keys() { + assert_eq!(virtual_key(0x04), Some(0x41), "A"); + assert_eq!(virtual_key(0x1d), Some(0x5a), "Z"); + assert_eq!(virtual_key(0x1e), Some(0x31), "1"); + assert_eq!(virtual_key(0x27), Some(0x30), "0"); + assert_eq!(virtual_key(0x3a), Some(0x70), "F1"); + assert_eq!(virtual_key(0x45), Some(0x7b), "F12"); + assert_eq!(virtual_key(0x68), Some(0x7c), "F13"); + assert_eq!(virtual_key(0x73), Some(0x87), "F24"); + assert_eq!(virtual_key(0x2c), Some(0x20), "Space"); + assert_eq!(virtual_key(0x52), Some(0x26), "Up"); + assert_eq!(virtual_key(0x01), None); + assert_eq!(media_virtual_key(0xe9), Some(0xaf), "volume up"); + assert_eq!(media_virtual_key(0xcd), Some(0xb3), "play / pause"); + assert_eq!(media_virtual_key(0x01), None); + assert_eq!(modifier_keys(0b0101), [0x11, 0x12], "ctrl and alt"); + assert_eq!(modifier_keys(0), Vec::::new()); + } +} diff --git a/src/buttons/keymap.rs b/src/buttons/keymap.rs 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..a46b273 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,8 +28,25 @@ pub struct BridgeConfig { pub notification_sound: bool, #[serde(default = "default_notification_volume")] pub notification_volume: u8, + /// What the tray battery icon shows: `"glyph"` (the native battery icon) + /// or `"percentage"` (just the number). Other values read as `"glyph"`. + #[serde(default = "default_tray_battery_style")] + pub tray_battery_style: String, + /// Hybrid device mode: while the OpenMouse web panel is disconnected, + /// Bridge drives the mouse itself — the active profile's stored settings + /// (DPI, polling rate, switch mode) go to the onboard memory and the + /// battery is read natively — and hands the mouse over to the panel the + /// moment it connects, applying the stored settings again on disconnect. + #[serde(default = "enabled_by_default")] + pub hybrid_mode: bool, #[serde(default)] pub games: Vec, + /// Games the user added by hand. Kept apart from `games` because the + /// catalog replaces that list on every start. + #[serde(default)] + pub custom_games: Vec, + #[serde(default)] + pub local_applications: Vec, #[serde(default)] pub profiles: Vec, #[serde(default)] @@ -53,6 +70,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 +133,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 +157,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)] @@ -125,7 +195,11 @@ impl Default for BridgeConfig { automatic_updates: false, notification_sound: true, notification_volume: DEFAULT_NOTIFICATION_VOLUME, + tray_battery_style: default_tray_battery_style(), + hybrid_mode: true, games: Vec::new(), + custom_games: Vec::new(), + local_applications: Vec::new(), profiles: Vec::new(), default_profile: None, allowed_origins: default_origins(), @@ -140,22 +214,15 @@ 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)); + // Only the two known tray styles are honored; anything else (a + // hand-edited config, an older panel) reads as the default glyph. + self.tray_battery_style = match self.tray_battery_style.trim().to_ascii_lowercase().as_str() + { + "percentage" => "percentage".to_owned(), + _ => "glyph".to_owned(), + }; + normalize_games(&mut self.games, &CATALOG_LIMITS); + normalize_games(&mut self.custom_games, &CUSTOM_GAME_LIMITS); for profile in &mut self.profiles { profile.application.name = profile.application.name.trim().to_owned(); profile.application.executable = profile.application.executable.trim().to_owned(); @@ -163,15 +230,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(); @@ -193,6 +266,70 @@ impl BridgeConfig { self.allowed_origins.dedup(); self } + + pub fn tracked_games(&self) -> Vec { + let mut games = self.games.clone(); + games.extend( + self.custom_games + .iter() + .filter(|custom| { + !self + .games + .iter() + .any(|game| game.name.eq_ignore_ascii_case(&custom.name)) + }) + .cloned(), + ); + games + } +} + +const MAX_GAMES: usize = 200; +const MAX_NAME_CHARS: usize = 80; +const MAX_EXECUTABLES: usize = 20; +const MAX_EXECUTABLE_CHARS: usize = 260; + +struct GameLimits { + games: usize, + name_chars: usize, + executables: usize, + executable_chars: usize, +} + +/// What a user may add by hand. +const CUSTOM_GAME_LIMITS: GameLimits = GameLimits { + games: MAX_GAMES, + name_chars: MAX_NAME_CHARS, + executables: MAX_EXECUTABLES, + executable_chars: MAX_EXECUTABLE_CHARS, +}; + +/// The catalog is already bounded when it is fetched; these match that parser. +const CATALOG_LIMITS: GameLimits = GameLimits { + games: crate::games::MAX_CATALOG_GAMES, + name_chars: crate::games::MAX_CATALOG_STRING, + executables: crate::games::MAX_EXECUTABLES, + executable_chars: crate::games::MAX_CATALOG_STRING, +}; + +fn normalize_games(games: &mut Vec, limits: &GameLimits) { + for game in games.iter_mut() { + game.name = game.name.trim().chars().take(limits.name_chars).collect(); + game.executables = game + .executables + .iter() + .map(|entry| entry.trim().to_ascii_lowercase()) + .filter(|entry| !entry.is_empty() && entry.chars().count() <= limits.executable_chars) + .collect(); + game.executables.sort(); + game.executables.dedup(); + game.executables.truncate(limits.executables); + } + games.retain(|game| !game.name.is_empty() && !game.executables.is_empty()); + // Sorted by the folded name, so names that differ only in case sit side by side for the dedup. + games.sort_by_key(|game| game.name.to_ascii_lowercase()); + games.dedup_by(|left, right| left.name.eq_ignore_ascii_case(&right.name)); + games.truncate(limits.games); } pub fn config_path() -> Result { @@ -256,12 +393,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 { @@ -276,6 +443,10 @@ const fn default_notification_volume() -> u8 { DEFAULT_NOTIFICATION_VOLUME } +fn default_tray_battery_style() -> String { + "glyph".to_owned() +} + fn default_origins() -> Vec { vec![ "https://control.openmouse.app".to_owned(), @@ -291,6 +462,136 @@ fn default_origins() -> Vec { mod tests { use super::*; + #[test] + fn names_that_differ_only_in_case_are_one_game_wherever_they_sort() { + let game = |name: &str| GameConfig { + name: name.into(), + executables: vec!["g.exe".into()], + }; + // Sorted by plain bytes these would be Alpha, Beta, alpha: the two Alphas apart. + let mut games = vec![game("alpha"), game("Beta"), game("Alpha")]; + normalize_games(&mut games, &CUSTOM_GAME_LIMITS); + assert_eq!(games.len(), 2); + } + + #[test] + fn game_lists_have_a_size_limit() { + let many = |count: usize| -> Vec { + (0..count) + .map(|i| GameConfig { + name: format!("game {i:04}"), + executables: vec![format!("g{i}.exe")], + }) + .collect() + }; + let mut games = many(MAX_GAMES + 50); + normalize_games(&mut games, &CUSTOM_GAME_LIMITS); + assert_eq!(games.len(), MAX_GAMES); + + let mut one = vec![GameConfig { + name: "n".repeat(500), + executables: (0..40) + .map(|i| format!("{i}.exe")) + .chain([format!("{}.exe", "x".repeat(300))]) + .collect(), + }]; + normalize_games(&mut one, &CUSTOM_GAME_LIMITS); + assert_eq!(one[0].name.chars().count(), MAX_NAME_CHARS); + assert_eq!(one[0].executables.len(), MAX_EXECUTABLES); + assert!( + one[0] + .executables + .iter() + .all(|entry| entry.chars().count() <= MAX_EXECUTABLE_CHARS) + ); + } + + #[test] + fn the_catalog_is_not_held_to_the_custom_game_limits() { + let config = BridgeConfig { + games: (0..MAX_GAMES + 50) + .map(|i| GameConfig { + name: format!("game {i:04}"), + executables: (0..MAX_EXECUTABLES + 5) + .map(|e| format!("g{i}-{e}.exe")) + .collect(), + }) + .collect(), + ..BridgeConfig::default() + } + .normalized(); + assert_eq!(config.games.len(), MAX_GAMES + 50); + assert_eq!(config.games[0].executables.len(), MAX_EXECUTABLES + 5); + } + + #[test] + fn saving_replaces_the_file_instead_of_truncating_an_existing_reader() { + use std::io::Read; + let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("atomic-config-test-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("config.json"); + fs::write(&path, b"old configuration").unwrap(); + let mut original = fs::File::open(&path).unwrap(); + save(&path, &BridgeConfig::default()).unwrap(); + let mut bytes = Vec::new(); + original.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, b"old configuration"); + assert!(serde_json::from_slice::(&fs::read(&path).unwrap()).is_ok()); + drop(original); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn local_profile_metadata_is_canonical_after_config_reload() { + let json = serde_json::json!({"localApplications":[{"id":"local:editor","name":" Editor ","executable":"editor.exe","path":"C:/Tools/editor.exe","kind":"app"}],"profiles":[{"application":{"localId":"local:editor","name":"Old name","executable":"old.exe","path":"C:/Old/old.exe"},"device":{"id":"d","name":"Mouse"},"settings":{"dpi":800}}]}); + let config: BridgeConfig = serde_json::from_value(json).unwrap(); + let config = config.normalized(); + assert_eq!( + config.profiles[0].application.name, + config.local_applications[0].name + ); + assert_eq!(config.profiles[0].application.executable, "editor.exe"); + assert_eq!(config.profiles[0].application.path, "C:/Tools/editor.exe"); + } + + #[test] + fn local_profile_identity_is_not_its_display_name_or_executable() { + let json = serde_json::json!({"profiles": [ + {"application":{"name":"Editor","executable":"editor.exe","path":"","localId":"local:one"}, "device":{"id":"d","name":"Mouse"},"settings":{}}, + {"application":{"name":"Editor","executable":"editor.exe","path":"","localId":"local:two"}, "device":{"id":"d","name":"Mouse"},"settings":{}}, + {"application":{"name":"Editor","executable":"editor.exe","path":""}, "device":{"id":"d","name":"Mouse"},"settings":{}} + ]}); + let config: BridgeConfig = serde_json::from_value(json).unwrap(); + let saved = serde_json::to_value(config.normalized()).unwrap(); + assert_eq!(saved["profiles"].as_array().unwrap().len(), 3); + assert!( + saved["profiles"] + .as_array() + .unwrap() + .iter() + .any(|p| p["application"]["localId"] == "local:one") + ); + } + + #[test] + fn local_targets_survive_config_roundtrip_and_catalog_refresh() { + let json = serde_json::json!({"localApplications": [{"id":"local:editor", "name":"Editor", "executable":"editor.exe", "path":"C:/Tools/editor.exe", "kind":"app"}]}); + let mut config: BridgeConfig = serde_json::from_value(json.clone()).unwrap(); + config.games = vec![GameConfig { + name: "Preset".into(), + executables: vec!["preset.exe".into()], + }]; + let saved = serde_json::to_value(config.normalized()).unwrap(); + assert_eq!(saved["localApplications"], json["localApplications"]); + let legacy: BridgeConfig = serde_json::from_str("{}").unwrap(); + assert_eq!( + serde_json::to_value(legacy).unwrap()["localApplications"], + serde_json::json!([]) + ); + } + #[test] fn normalization_clamps_threshold_and_cleans_executables() { let config = BridgeConfig { @@ -303,9 +604,13 @@ mod tests { automatic_updates: false, notification_sound: true, notification_volume: 180, + custom_games: Vec::new(), + tray_battery_style: " percentage ".to_owned(), + hybrid_mode: true, profiles: Vec::new(), default_profile: None, allowed_origins: Vec::new(), + local_applications: Vec::new(), autostart_configured: false, tray_hint_shown: false, } @@ -313,6 +618,7 @@ mod tests { assert_eq!(config.battery_threshold_percent, 100); assert_eq!(config.alert_cooldown_minutes, 1); assert_eq!(config.notification_volume, 100); + assert_eq!(config.tray_battery_style, "percentage"); let valorant = config .games .iter() @@ -327,6 +633,39 @@ mod tests { ); } + #[test] + fn custom_games_are_tracked_beside_the_catalog_and_the_catalog_wins_name_clashes() { + let game = |name: &str, exe: &str| GameConfig { + name: name.into(), + executables: vec![exe.into()], + }; + let config = BridgeConfig { + games: vec![game("Valorant", "valorant.exe")], + custom_games: vec![ + game("My Game", " MyGame.EXE "), + game("valorant", "other.exe"), + ], + ..BridgeConfig::default() + } + .normalized(); + let names: Vec<_> = config + .tracked_games() + .into_iter() + .map(|game| game.name) + .collect(); + assert_eq!(names, ["Valorant", "My Game"]); + assert_eq!(config.custom_games[0].executables, ["mygame.exe"]); + } + + #[test] + fn hybrid_mode_defaults_to_on_and_normalizes_to_a_bool() { + let config: BridgeConfig = serde_json::from_str("{}").expect("empty config parses"); + assert!(config.hybrid_mode); + let config: BridgeConfig = + serde_json::from_str(r#"{"hybridMode": false}"#).expect("off config parses"); + assert!(!config.hybrid_mode); + } + #[test] fn profile_settings_keep_the_snapshot_and_accept_profiles_without_one() { let json = @@ -351,6 +690,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..a334e0a 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -20,7 +20,7 @@ use eframe::egui::{ }; use openmouse_bridge::{ BRIDGE_PORT, api, config, platform, - service::{BridgeService, BridgeSnapshot, Notice}, + service::{BridgeService, BridgeSnapshot, DeviceBattery, Notice}, updater::{self, UpdateInfo}, }; #[cfg(target_os = "windows")] @@ -123,20 +123,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 +146,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 +221,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,11 +296,20 @@ enum View { Settings, } +fn forget_battery_button(ui: &mut egui::Ui, stale: bool) -> Option { + stale.then(|| { + crate::native_ui::button(ui, "Remove", None, false, 60.0) + .on_hover_text("Forget this stale battery report") + }) +} + enum BridgeCommand { SetBatteryThreshold(u8), SetNotificationSound(bool), SetNotificationVolume(u8), SetAutomaticUpdates(bool), + SetHybridMode(bool), + SetTrayBatteryStyle(platform::TrayBatteryStyle), CheckForUpdates, InstallUpdate(UpdateInfo), AcknowledgeTrayHint, @@ -281,18 +340,158 @@ 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(_) + ) +} + +/// Height added to the Home view while a device-summary row is showing +/// (the mouse's live onboard-memory settings under the profile panel). +const DEVICE_SUMMARY_HEIGHT: f32 = 22.0; + +fn panel_height( + view: View, + details: bool, + saved_values: bool, + notice: bool, + device_summary: bool, +) -> f32 { + if view == View::Settings { + return 420.0; + } + if details { + return 340.0; + } + let base = + (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) + .min(340.0); + base + if device_summary { + DEVICE_SUMMARY_HEIGHT + } else { + 0.0 + } +} + +/// The battery entry whose onboard-memory settings the Home view renders +/// under the profile panel — the freshest non-stale one carrying any. Both +/// the renderer and the window sizing go through this so they stay in step. +fn device_summary_battery(snapshot: Option<&BridgeSnapshot>) -> Option<&DeviceBattery> { + let snapshot = snapshot?; + snapshot + .batteries + .iter() + .find(|battery| !battery.stale) + .filter(|battery| { + battery.dpi.is_some() + || battery.polling_rate_hz.is_some() + || battery.switch_mode.is_some() + }) } #[derive(Clone, Copy)] enum TrayAction { + ShowWindow, ToggleWindow { rect: TrayRect }, Restart, Exit, } +/// The battery display currently swapped into the tray icon: `None` keeps +/// the static OpenMouse logo. Every field here feeds the drawing or the +/// tooltip, so comparing against the next snapshot redraws exactly when +/// the level, charge state, estimate, or size changed — and stops the +/// per-second updates from churning the icon when nothing did. +#[derive(Clone, PartialEq, Eq)] +struct TrayBattery { + device_name: String, + percent: u8, + charging: bool, + stale: bool, + /// The Segoe glyph's ink, from `battery_ink` (green charged/charging, + /// amber mid, alert red when low, gray when stale). + ink: Color32, + minutes_remaining: Option, + /// The notification area's icon size in physical pixels at the current + /// DPI; Windows reports it, so a display-scaling change redraws. + size: u32, + /// What fills the square: the battery glyph or the percentage number + /// (the control panel's tray-icon style). + style: platform::TrayBatteryStyle, +} + +/// The battery glyph's ink, by charge state: green while charging or above +/// half, amber through the mid band, the alert red at or below the +/// low-battery threshold, and gray when the reading is stale. +fn battery_ink(percent: u8, charging: bool, stale: bool, threshold: u8) -> Color32 { + use crate::native_ui::{ACCENT, DANGER, MUTED, WARNING}; + if stale { + MUTED + } else if charging { + ACCENT + } else if percent <= threshold { + DANGER + } else if percent <= 50 { + WARNING + } else { + ACCENT + } +} + +/// Renders the live tray icon from a cached battery display: either the +/// Segoe Fluent battery glyph (the charging variant with the bolt when +/// plugged in) or the percentage number, per the panel's tray-icon style, +/// tinted by charge state — green charged/charging, amber mid, red low, +/// gray stale. +fn battery_tray_icon(battery: &TrayBattery) -> Result { + let (rgba, width, height) = platform::battery_icon_rgba( + battery.percent, + battery.charging, + battery.style, + battery.size, + (battery.ink.r(), battery.ink.g(), battery.ink.b()), + )?; + Icon::from_rgba(rgba, width, height).context("could not create the battery tray icon") +} + +/// Formats a rough duration for the tooltip: "45 m", "3 h 20 m", "1 d 4 h". +fn remaining_text(minutes: u32) -> String { + let days = minutes / (24 * 60); + let hours = minutes % (24 * 60) / 60; + let minutes = minutes % 60; + if days > 0 { + if hours > 0 { + format!("{days} d {hours} h") + } else { + format!("{days} d") + } + } else if hours > 0 { + if minutes > 0 { + format!("{hours} h {minutes} m") + } else { + format!("{hours} h") + } + } else { + format!("{minutes} m") + } +} + +fn tray_menu_action(open: bool, restart: bool, quit: bool) -> Option { + if open { + Some(TrayAction::ShowWindow) + } else if restart { + Some(TrayAction::Restart) + } else if quit { + Some(TrayAction::Exit) + } else { + None + } +} + struct TrayState { icon: TrayIcon, menu: Menu, @@ -312,8 +511,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 +546,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); @@ -364,6 +567,33 @@ impl TrayState { Ok(()) } + /// Swaps the tray icon for the live battery display: the battery glyph + /// drawn from `battery`'s level, theme, and state colors, with a tooltip + /// that spells the percentage out on hover. The previous icon's HICON is + /// destroyed by the tray backend when the new one replaces it, so + /// redrawing never leaks GDI handles. + fn set_battery_display(&mut self, battery: &TrayBattery, tooltip: &str) -> Result<()> { + let icon = battery_tray_icon(battery)?; + self.icon + .set_icon(Some(icon)) + .context("could not swap in the battery tray icon")?; + self.icon + .set_tooltip(Some(tooltip.to_owned())) + .context("could not update the battery tooltip")?; + Ok(()) + } + + /// Restores the static OpenMouse logo and its plain tooltip. + fn set_default_display(&mut self) -> Result<()> { + self.icon + .set_icon(Some(tray_icon()?)) + .context("could not restore the OpenMouse tray icon")?; + self.icon + .set_tooltip(Some("OpenMouse Bridge")) + .context("could not restore the tray tooltip")?; + Ok(()) + } + fn next_action(&self) -> Option { self.events.try_recv().ok() } @@ -379,6 +609,8 @@ struct TrayApp { overlay: Option, snapshot: Option, view: View, + details_open: bool, + requested_height: f32, visible: bool, visibility_initialized: bool, panel_had_focus: bool, @@ -386,13 +618,15 @@ struct TrayApp { keep_running: Arc, autostart: bool, battery_threshold: u8, + tray_battery: Option, + tray_style: platform::TrayBatteryStyle, + hybrid_mode: bool, automatic_updates: bool, notification_sound: bool, notification_volume: u8, // While the volume slider is dragged, snapshots would snap it back to the // saved value; it is saved once released. adjusting_volume: bool, - tray_rect: Option, last_tick: SystemTime, panel_requests: Receiver<()>, update_state: UpdateState, @@ -424,32 +658,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 +678,8 @@ impl TrayApp { overlay, snapshot: None, view: View::Home, + details_open: false, + requested_height: WINDOW_HEIGHT, visible, visibility_initialized: false, panel_had_focus: false, @@ -476,11 +687,13 @@ impl TrayApp { keep_running, autostart: platform::autostart_enabled(), battery_threshold: 20, + tray_battery: None, + tray_style: platform::TrayBatteryStyle::Glyph, + hybrid_mode: true, automatic_updates: false, notification_sound: true, notification_volume: 60, adjusting_volume: false, - tray_rect: None, last_tick: SystemTime::now(), panel_requests, update_state: UpdateState::Idle, @@ -494,7 +707,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,38 +719,37 @@ 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) { while let Ok(snapshot) = self.snapshots.try_recv() { self.autostart = snapshot.autostart_enabled; self.battery_threshold = snapshot.battery_threshold_percent; + self.tray_style = + platform::TrayBatteryStyle::from_config_str(&snapshot.tray_battery_style); + self.hybrid_mode = snapshot.hybrid_mode; self.automatic_updates = snapshot.automatic_updates; self.notification_sound = snapshot.notification_sound; if !self.adjusting_volume { @@ -543,6 +757,74 @@ impl TrayApp { } self.snapshot = Some(snapshot); } + self.refresh_tray_battery_display(false); + } + + /// Refreshes the tray icon: a live battery glyph showing the mouse's + /// charge (or the percentage number, per the panel's tray-icon style) + /// while any battery is known, the static OpenMouse logo otherwise. + /// Redraws only when the display would change unless `force` — used + /// after icon recreation, when the fresh icon is the plain logo again. + fn refresh_tray_battery_display(&mut self, force: bool) { + let battery = self.snapshot.as_ref().and_then(|snapshot| { + snapshot + .batteries + .iter() + .find(|battery| !battery.stale) + .or_else(|| snapshot.batteries.first()) + }); + let shown = battery.map(|battery| TrayBattery { + device_name: battery.device_name.clone(), + percent: battery.percent.min(100), + charging: battery.charging, + stale: battery.stale, + ink: battery_ink( + battery.percent.min(100), + battery.charging, + battery.stale, + self.battery_threshold, + ), + minutes_remaining: battery.minutes_remaining, + size: platform::tray_icon_size(), + style: self.tray_style, + }); + if !force && shown == self.tray_battery { + return; + } + let outcome = match &shown { + Some(battery) => { + let status = match (battery.charging, battery.stale) { + (true, _) => " · Charging", + (false, true) => " · Not updating", + (false, false) => "", + }; + // Windows' tooltip buffer tops out at 128 UTF-16 units, and + // device names can be long. + let eta = battery.minutes_remaining.map(|minutes| { + if battery.charging { + format!("full in {}", remaining_text(minutes)) + } else { + format!("{} left", remaining_text(minutes)) + } + }); + let tooltip = match eta { + Some(eta) => { + format!( + "{} — {}%{status} · {eta}", + battery.device_name, battery.percent + ) + } + None => format!("{} — {}%{status}", battery.device_name, battery.percent), + }; + self.tray + .set_battery_display(battery, &tooltip.chars().take(120).collect::()) + } + None => self.tray.set_default_display(), + }; + match outcome { + Ok(()) => self.tray_battery = shown, + Err(error) => tracing::warn!(%error, "Could not update the tray battery display"), + } } /// Shows pending notices and advances the banner's fade. @@ -598,10 +880,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 +904,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 +914,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 +934,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 +956,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), + } + }); }); } } @@ -682,369 +986,593 @@ impl TrayApp { if let Err(error) = self.tray.recreate_icon() { tracing::error!(%error, "could not recreate the tray icon after resume"); } + // The fresh icon is the plain logo; put the battery display back. + self.refresh_tray_battery_display(true); } - fn 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)); } + } + // The mouse's own onboard-memory settings, read natively + // wherever its driver can see them — the details someone + // changing DPI or polling rate actually needs, profile or + // no profile. + if let Some(device) = device_summary_battery(self.snapshot.as_ref()) { + let mut values = Vec::new(); + if let Some(dpi) = device.dpi { values.push(format!("{dpi} DPI")); } + if let Some(rate) = device.polling_rate_hz { values.push(format!("{rate} Hz")); } + if let Some(mode) = &device.switch_mode { values.push(mode.clone()); } + if !values.is_empty() { + ui.label(RichText::new(format!("Onboard: {}", values.join(" · "))).size(12.0).color(MUTED)); + } + } + 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("Device mode") .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 enabled = self.hybrid_mode; + if n::switch_row(ui, "Hybrid", &mut enabled) + .on_hover_text( + "While the web panel is closed, Bridge applies the mouse's stored settings and reads the battery itself.", + ) + .changed() + && self.send_command( + BridgeCommand::SetHybridMode(enabled), + "Bridge settings service is unavailable", + ) + { + self.hybrid_mode = enabled; + } + ui.label( + RichText::new(if self.hybrid_mode { + "While the web panel is closed, Bridge applies the mouse's \ + stored settings and reads the battery itself" + } else { + "Only the web panel changes the mouse" + }) + .size(11.0) + .color(MUTED), + ); + divider(ui); + let mut selected_style = self.tray_style; + row(ui, "Battery display", |ui| { + let choice = egui::ComboBox::from_id_salt("tray-battery-style") + .width(110.0) + .selected_text( + RichText::new(match self.tray_style { + platform::TrayBatteryStyle::Glyph => "Battery glyph", + platform::TrayBatteryStyle::Percentage => "Percentage", + }) + .size(12.0), + ) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Glyph, + "Battery glyph", + ); + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Percentage, + "Percentage", + ); + }); + choice.response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::ComboBox, + ui.is_enabled(), + "Battery display", + ) + }); + }); + if selected_style != self.tray_style + && self.send_command( + BridgeCommand::SetTrayBatteryStyle(selected_style), + "Bridge settings service is unavailable", + ) + { + self.tray_style = selected_style; + // Redraw the tray at once; the service's next snapshot + // only confirms what is already on screen. + self.refresh_tray_battery_display(true); + } + }); ui.add_space(6.0); - 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("Notifications") + .size(12.0) + .strong() + .color(MUTED), + ); + let mut threshold = self.battery_threshold; + row(ui, "Low battery alert", |ui| { + let choice = egui::ComboBox::from_id_salt("battery-threshold") + .width(62.0) + .selected_text(format!("{threshold}%")) + .show_ui(ui, |ui| { + for percent in BATTERY_THRESHOLDS { + if ui + .selectable_value( + &mut threshold, + percent, + format!("{percent}%"), + ) + .clicked() + { + ui.close(); + } + } + }); + choice.response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::ComboBox, + ui.is_enabled(), + "Low battery alert", + ) + }); + choice + .response + .on_hover_text("Notify at or below this reported charge."); + }); + if threshold != self.battery_threshold + && self.send_command( + BridgeCommand::SetBatteryThreshold(threshold), + "Battery alert setting could not be sent", + ) + { + self.battery_threshold = threshold; + } + divider(ui); + let mut sound = self.notification_sound; + if n::switch_row(ui, "Notification sound", &mut sound) + .on_hover_text("Play a chime with profile and battery notices.") + .changed() + && self.send_command( + BridgeCommand::SetNotificationSound(sound), + "Sound setting could not be sent", + ) + { + self.notification_sound = sound; + } + divider(ui); + let mut released = false; + row(ui, "Volume", |ui| { + ui.add_enabled_ui(self.notification_sound, |ui| { + ui.label( + RichText::new(format!("{}%", self.notification_volume)) + .size(12.0) + .color(MUTED), + ); + ui.visuals_mut().selection.bg_fill = ACCENT; + ui.spacing_mut().slider_width = 108.0; + let response = ui.add( + egui::Slider::new(&mut self.notification_volume, 0..=100) + .show_value(false) + .trailing_fill(true), + ); + response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Slider, + ui.is_enabled(), + "Notification volume", + ) + }); + self.adjusting_volume = response.is_pointer_button_down_on(); + released = response.drag_stopped() + || (response.changed() && !self.adjusting_volume); + }); + }); + if released { + self.send_command( + BridgeCommand::SetNotificationVolume(self.notification_volume), + "Volume setting could not be sent", + ); + } + divider(ui); + let mut test = false; + row(ui, "Test notification", |ui| { + test = n::button(ui, "Test", None, false, 48.0).clicked(); + }); + if test { + self.present(&Notice { + title: "OpenMouse Bridge".into(), + detail: "This is how notifications look".into(), + }); + ui.ctx().request_repaint(); + } + }); + ui.add_space(6.0); + n::panel(ui, false, 8, |ui| { + ui.set_width(ui.available_width()); + ui.label(RichText::new("Updates").size(12.0).strong().color(MUTED)); + let mut automatic = self.automatic_updates; + if n::switch_row(ui, "Automatic updates", &mut automatic) + .on_hover_text( + "Download, install and restart Bridge when an update is found.", + ) + .changed() + && self.send_command( + BridgeCommand::SetAutomaticUpdates(automatic), + "Update preference could not be sent", + ) + { + self.automatic_updates = automatic; + } + divider(ui); + self.update_row(ui); + }); + if self.details_open { + self.diagnostics(ui); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if n::button(ui, "Open logs", Some(n::Icon::Logs), false, 94.0).clicked() { + self.last_error = open_logs().err().map(|e| e.to_string()); } + ui.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 has_summary = device_summary_battery(self.snapshot.as_ref()).is_some(); + let height = panel_height( + self.view, + self.details_open, + has_values, + update_is_notice(&self.update_state), + has_summary, + ); + if height != self.requested_height { + let origin = context + .input(|i| i.viewport().outer_rect.map(|rect| rect.min)) + .or_else(test_position); + context.send_viewport_cmd(ViewportCommand::InnerSize(Vec2::new(WINDOW_WIDTH, height))); + if let Some(origin) = origin { + let scale = f64::from(context.pixels_per_point()); + let anchor = TrayRect { + position: (f64::from(origin.x) * scale, f64::from(origin.y) * scale).into(), + size: (1, 1).into(), + }; + let position = tray_work_area(anchor, scale).map_or(origin, |work| { + clamp_panel(origin, Vec2::new(WINDOW_WIDTH, height), work) }); - }); - if let Some(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 +1706,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 +1717,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( @@ -1407,6 +1938,19 @@ fn run_server( } } } + BridgeCommand::SetHybridMode(enabled) => { + if let Err(error) = command_service.set_hybrid_mode(enabled).await { + tracing::error!(%error, "could not save Bridge settings"); + } + } + BridgeCommand::SetTrayBatteryStyle(style) => { + if let Err(error) = command_service + .set_tray_battery_style(style.as_config_str()) + .await + { + tracing::error!(%error, "could not save Bridge settings"); + } + } BridgeCommand::CheckForUpdates => { check_for_updates(false, &update_events).await; } @@ -1538,6 +2082,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 +2154,370 @@ fn wide(value: &str) -> Vec { mod tests { use super::*; + #[test] + fn escape_keeps_panel_open_until_popup_closes() { + let context = egui::Context::default(); + context + .run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: Some(egui::Key::Escape), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let popup = egui::Id::new("escape-review-popup"); + egui::Popup::open_id(ui.ctx(), popup); + assert!(egui::Popup::is_any_open(ui.ctx())); + assert!(!should_hide_on_escape(true, ui.ctx())); + assert!( + egui::Popup::new(popup, ui.ctx().clone(), egui::Pos2::ZERO, ui.layer_id(),) + .open_memory(None) + .show(|ui| ui.label("Popup")) + .is_some() + ); + assert!(!egui::Popup::is_any_open(ui.ctx())); + }, + ) + .drop_without_applying_deltas(); + } + + #[test] + fn escape_requires_visible_panel_and_keypress() { + for (visible, pressed, expected) in [ + (true, true, true), + (false, true, false), + (true, false, false), + (false, false, false), + ] { + egui::Context::default() + .run_ui( + egui::RawInput { + events: if pressed { + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: Some(egui::Key::Escape), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }] + } else { + vec![] + }, + ..Default::default() + }, + |ui| assert_eq!(should_hide_on_escape(visible, ui.ctx()), expected), + ) + .drop_without_applying_deltas(); + } + } + + fn simulated_header_drag(start: Pos2, end: Pos2) -> bool { + let context = egui::Context::default(); + let rect = egui::Rect::from_min_max(Pos2::new(20.0, 20.0), Pos2::new(260.0, 60.0)); + let frames = [ + vec![], + vec![ + egui::Event::PointerMoved(start), + egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }, + ], + vec![egui::Event::PointerMoved(end)], + ]; + let mut started = false; + for events in frames { + let output = context.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + Pos2::ZERO, + Vec2::new(372.0, 484.0), + )), + events, + ..Default::default() + }, + |ui| drag_header(ui, rect, "test-drag"), + ); + started |= output.viewport_output.values().any(|viewport| { + viewport + .commands + .iter() + .any(|command| matches!(command, ViewportCommand::StartDrag)) + }); + output.drop_without_applying_deltas(); + } + started + } + + #[test] + fn tray_menu_can_recover_the_window_without_click_coordinates() { + assert!(matches!( + tray_menu_action(true, false, false), + Some(TrayAction::ShowWindow) + )); + assert!(tray_menu_action(false, false, false).is_none()); + } + + #[test] + fn tray_event_pump_is_bounded_and_drains_pending_events() { + let remaining = std::cell::Cell::new(40); + drain_tray_events( + || remaining.get() > 0, + || remaining.set(remaining.get() - 1), + ); + assert_eq!(remaining.get(), 8); + drain_tray_events( + || remaining.get() > 0, + || remaining.set(remaining.get() - 1), + ); + assert_eq!(remaining.get(), 0); + } + + #[test] + fn header_drag_requests_a_native_window_move() { + assert!(simulated_header_drag( + Pos2::new(80.0, 35.0), + Pos2::new(140.0, 75.0) + )); + } + + #[test] + fn dragging_outside_header_leaves_window_in_place() { + assert!(!simulated_header_drag( + Pos2::new(300.0, 35.0), + Pos2::new(340.0, 75.0) + )); + } + + #[test] + fn content_growth_stays_bounded_and_settings_has_its_own_budget() { + assert_eq!(panel_height(View::Home, false, false, false, false), 280.0); + assert_eq!(panel_height(View::Home, false, true, false, false), 298.0); + assert_eq!(panel_height(View::Home, false, true, true, false), 338.0); + assert_eq!(panel_height(View::Home, true, false, false, false), 340.0); + assert_eq!(panel_height(View::Settings, true, true, true, false), 420.0); + } + + #[test] + fn device_summary_rows_grow_the_home_view() { + assert_eq!(panel_height(View::Home, false, false, false, true), 302.0); + assert_eq!(panel_height(View::Home, false, true, false, true), 320.0); + // The details view scrolls, so its budget stays fixed. + assert_eq!(panel_height(View::Home, true, false, false, true), 340.0); + } + + #[test] + fn idle_window_is_a_compact_companion_not_a_settings_sheet() { + assert_eq!(WINDOW_WIDTH, 360.0); + assert!(panel_height(View::Home, false, false, false, false) <= 340.0); + } + + #[test] + fn missing_snapshot_is_starting_not_ready() { + assert_eq!(service_label(None), "Starting Bridge…"); + } + + #[test] + fn native_failure_and_pending_restore_are_visible_without_an_applied_claim() { + let mut snapshot = empty_snapshot(); + snapshot.native_automation_error = + Some("manual intent superseded automatic settings".into()); + assert_eq!( + profile_label(Some(&snapshot)), + "Native settings not applied: manual intent superseded automatic settings" + ); + snapshot.pending_native_restores = 1; + assert_eq!( + profile_label(Some(&snapshot)), + "Native restore pending (1): manual intent superseded automatic settings" + ); + snapshot.native_automation_error = None; + assert_eq!(profile_label(Some(&snapshot)), "Native restore pending (1)"); + } + + #[test] + fn missing_profile_is_not_an_invented_default() { + assert_eq!(profile_label(None), "No game profile active"); + } + + fn empty_snapshot() -> BridgeSnapshot { + BridgeSnapshot { + version: "test", + platform: "test", + linux_distribution: None, + uptime_seconds: 0, + active_games: vec![], + games: vec![], + tracked_game_count: 0, + battery_threshold_percent: 20, + tray_battery_style: "glyph".to_owned(), + hybrid_mode: true, + device_mode: "onboard".to_owned(), + autostart_enabled: false, + automatic_updates: false, + notification_sound: true, + notification_volume: 60, + foreground_application: None, + active_profile: None, + active_profile_is_default: false, + native_automation_error: None, + pending_native_restores: 0, + visible_application_count: 0, + profile_count: 0, + client_connected: false, + active_devices: vec![], + batteries: vec![], + } + } + + #[test] + fn remembered_battery_does_not_become_a_live_mouse() { + let mut snapshot = empty_snapshot(); + snapshot + .batteries + .push(openmouse_bridge::service::DeviceBattery { + device_id: "old".into(), + device_name: "Remembered mouse".into(), + percent: 60, + charging: false, + stale: true, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }); + assert_eq!(device_label(Some(&snapshot)), "No mouse session"); + } + + #[test] + fn stale_battery_removal_remains_available_in_compact_details() { + let context = egui::Context::default(); + let mut shown = Vec::new(); + for stale in [false, true] { + context + .run_ui(egui::RawInput::default(), |ui| { + shown.push(forget_battery_button(ui, stale).is_some()); + }) + .drop_without_applying_deltas(); + } + assert_eq!(shown, vec![false, true]); + } + + #[test] + fn stale_battery_explicitly_says_last_known_without_current_charging_claim() { + let battery = openmouse_bridge::service::DeviceBattery { + device_id: "old".into(), + device_name: "Remembered mouse".into(), + percent: 60, + charging: true, + stale: true, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }; + assert_eq!(battery_text(&battery), "60% · Last known (was charging)"); + } + + #[test] + fn panel_anchor_respects_negative_monitor_work_area() { + let work = egui::Rect::from_min_max(Pos2::new(-1920.0, -1080.0), Pos2::new(0.0, -40.0)); + assert_eq!( + clamp_panel(Pos2::new(-2000.0, -1200.0), Vec2::new(372.0, 484.0), work), + Pos2::new(-1912.0, -1072.0) + ); + assert_eq!( + clamp_panel(Pos2::new(100.0, 100.0), Vec2::new(372.0, 484.0), work), + Pos2::new(-380.0, -532.0) + ); + } + + #[test] + fn explicit_test_position_accepts_secondary_monitor_coordinates() { + assert_eq!(parse_position("-1500,40"), Some(Pos2::new(-1500.0, 40.0))); + for value in ["", "1", "1,2,3", "NaN,2", "inf,0"] { + assert_eq!(parse_position(value), None); + } + } + + #[test] + fn ready_service_does_not_claim_panel_connection_or_a_default() { + let mut snapshot = empty_snapshot(); + assert_eq!(service_label(Some(&snapshot)), "Bridge service ready"); + assert_eq!(profile_label(Some(&snapshot)), "No game profile active"); + snapshot.client_connected = true; + assert_eq!(service_label(Some(&snapshot)), "Bridge service ready"); + assert_eq!(device_label(Some(&snapshot)), "No mouse session"); + } + + #[test] + fn profile_target_is_remembered_and_selected_not_live_or_applied() { + let mut snapshot = empty_snapshot(); + snapshot.active_profile = Some(config::ApplicationProfile { + application: config::ProfileApplication { + name: "Game".into(), + executable: "game.exe".into(), + path: String::new(), + local_id: None, + activation: None, + bundle_path: None, + bundle_identifier: None, + }, + device: config::ProfileDevice { + id: "saved".into(), + name: "Saved mouse".into(), + }, + settings: config::ProfileSettings { + dpi: Some(800), + polling_rate_hz: Some(1000), + snapshot: None, + }, + enabled: true, + }); + assert_eq!(profile_label(Some(&snapshot)), "Game selected"); + assert_eq!(device_label(Some(&snapshot)), "No mouse session"); + snapshot.active_profile_is_default = true; + assert_eq!(profile_label(Some(&snapshot)), "Default profile selected"); + snapshot + .active_devices + .push(openmouse_bridge::service::ActiveDevice { + key: "live".into(), + name: "Wired mouse".into(), + vendor_id: 1, + product_id: 2, + }); + assert_eq!(device_label(Some(&snapshot)), "Wired mouse"); + assert!(snapshot.batteries.is_empty()); + } + + #[test] + fn fresh_battery_keeps_reported_charge_and_charging_state() { + let mut battery = openmouse_bridge::service::DeviceBattery { + device_id: "live".into(), + device_name: "Mouse".into(), + percent: 42, + charging: false, + stale: false, + minutes_remaining: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }; + assert_eq!(battery_text(&battery), "42%"); + battery.charging = true; + assert_eq!(battery_text(&battery), "42% · Charging"); + battery.stale = true; + battery.charging = false; + assert_eq!(battery_text(&battery), "42% · Last known"); + } + #[test] fn user_exit_does_not_relaunch() { assert_eq!( diff --git a/src/drivers/automation.rs b/src/drivers/automation.rs 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..3b10217 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -18,26 +18,187 @@ use anyhow::Result; use crate::config::ApplicationProfile; +pub(crate) mod automation; pub mod delux; mod native_hid; pub mod pulsar; -pub use native_hid::NativeBattery; +pub use native_hid::{NativeBattery, NativeStatus, spawn_button_listener}; + +/// A mouse Bridge discovered by itself, by scanning the HID bus for vendor +/// ids its drivers recognize — no saved profile required. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetectedMouse { + /// Stable profile-style id, `":"` — the same + /// `":"` prefix convention the OpenMouse web app writes. + pub device_id: String, + /// Human-readable product name from the HID descriptor. + pub name: String, +} + +/// Brand keys and vendor ids, mirroring the helper's `native-hid/src/brands.mjs` +/// (the same drivers the web panel drives, and the same normalization +/// `apply.mjs` gives `device.id` prefixes: trim + lowercase). When several +/// brands share a vendor id, the entry names the brand whose driver class +/// list covers the most sibling hardware — Delux's list includes the Attack +/// Shark and Lamzu clients — or `mouse-protocol`'s registry.ts first match; +/// the loser's devices merely fail their silent battery probe later. +const KNOWN_BRANDS: &[(&str, &[u16])] = &[ + ("Delux", &[0x1d57, 0x248a, 0x373e]), + ("Logitech", &[0x046d]), + ("Razer", &[0x1532]), + ("Zaunkoenig", &[0x0483]), + ("Finalmouse", &[0x361d]), + ("Endgame Gear", &[0x3367]), + ("Teevolution", &[0x3554]), + ("WLMouse", &[0x36a7]), + ("moddoMOUSE", &[0x2fe3]), + ("Ninjutso", &[0x093a, 0x1915]), + ("ATK", &[0x373b]), + ("Attack Shark", &[0x25a7]), + ("Keychron", &[0x3434]), + ("Fantech", &[0x3151]), + ("Wooting", &[0x31e3]), + ("Microsoft", &[0x045e]), + ("WALLHACK", &[0x3879, 0x1caa]), +]; + +/// Logitech is special: every Logitech-branded device (keyboards, headsets) +/// shares vendor id 0x046d and the HID++ transport (`mouse-protocol`'s +/// `drivers/logitech/hidpp.ts` has the same limitation — "whether it is a +/// *mouse* cannot be told from descriptors"), so discovery keeps the HID++ +/// collections — short/long reports on usage page 0xFF00, Bluetooth on +/// 0xFF43 — and a keyboard merely fails its battery probe silently later. +const LOGITECH_VENDOR_ID: u16 = 0x046d; +const HIDPP_USAGE_PAGE: u16 = 0xff00; +const HIDPP_BLUETOOTH_USAGE_PAGE: u16 = 0xff43; + +/// The brand a discovered HID interface belongs to, or `None` when the +/// interface should be skipped (other vendors' hardware, Logitech non-HID++ +/// interfaces, Noir's undriven receiver PID). +fn discovered_brand(info: &hidapi::DeviceInfo) -> Option<&'static str> { + let vendor_id = info.vendor_id(); + if vendor_id == pulsar::VENDOR_ID { + return Some("Pulsar"); + } + if vendor_id == LOGITECH_VENDOR_ID { + let usage_page = info.usage_page(); + let hidpp_transport = (usage_page == HIDPP_USAGE_PAGE + && matches!(info.usage(), 0x01 | 0x02)) + || usage_page == HIDPP_BLUETOOTH_USAGE_PAGE; + return hidpp_transport.then_some("Logitech"); + } + if vendor_id == 0x3554 { + // Teevolution and VGN share this vendor page; their product ids are + // disjoint (pulsar-hid.ts's CLAIMED_VGN_PRODUCT_IDS). Noir Gear's + // 0xf500 rides it too but has no native driver. + return match info.product_id() { + 0xfb56 | 0xfb57 => Some("VGN"), + 0xf500 => None, + _ => Some("Teevolution"), + }; + } + KNOWN_BRANDS + .iter() + .find(|(_, ids)| ids.contains(&vendor_id)) + .map(|(brand, _)| *brand) +} + +/// Every HID interface on the bus whose vendor id belongs to a brand with a +/// Bridge driver, deduplicated to one entry per physical mouse (several +/// interfaces share the same vendor/product/serial). +/// +/// Deliberately does not open any device — enumeration only — so it is safe +/// to call while a web panel session or another driver operation runs. +pub fn detect_mice() -> Vec { + let api = match hidapi::HidApi::new() { + Ok(api) => api, + Err(error) => { + tracing::warn!(%error, "could not enumerate HID devices"); + return Vec::new(); + } + }; + let mut mice: Vec = Vec::new(); + let mut seen: Vec<(u16, u16, String)> = Vec::new(); + for info in api.device_list() { + let vendor_id = info.vendor_id(); + let Some(brand) = discovered_brand(info) else { + continue; + }; + // One physical mouse exposes several HID interfaces (boot mouse, + // consumer control, vendor config, ...). Dedup on the identity + // triple so the same mouse is not listed three times. + let tail = identity_tail(&( + vendor_id, + info.product_id(), + info.serial_number().unwrap_or_default(), + )); + let identity = (vendor_id, info.product_id(), tail); + if seen.contains(&identity) { + continue; + } + seen.push(identity.clone()); + mice.push(DetectedMouse { + device_id: format!("{brand}:{}", identity.2), + name: product_name(info.product_string().unwrap_or_default(), brand), + }); + } + mice +} + +/// The stable per-mouse tail of a discovered device id: the serial number +/// when the device reports one, else `vid:pid`. +fn identity_tail(identity: &(u16, u16, &str)) -> String { + if identity.2.is_empty() { + format!("{:04x}:{:04x}", identity.0, identity.1) + } else { + identity.2.to_owned() + } +} + +/// The HID product string, or the brand name when the device reports none. +fn product_name(product_string: &str, brand: &str) -> String { + let trimmed = product_string.trim(); + if trimmed.is_empty() { + format!("{brand} Mouse") + } else { + trimmed.to_owned() + } +} /// Serializes Bridge's own device access. A profile switch and a battery read /// can start at the same moment (both run at startup), and on macOS the second /// open of a receiver fails with "exclusive access"; elsewhere the two would /// interleave requests on the same device. -static DEVICE_ACCESS: Mutex<()> = Mutex::new(()); +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 +221,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,24 +244,41 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result { } } -/// Reads the battery of the mouse `device_id` names (`":"`) over -/// native HID. `Ok(None)` means Bridge has no battery reader for the brand or -/// the mouse reports no battery; `Err` means a driver exists but the mouse -/// did not answer (e.g. asleep or disconnected). -/// -/// The Rust Pulsar driver only writes settings, so Pulsar has no reader yet. -pub fn read_battery(device_id: &str) -> Result> { +pub(crate) fn reconcile_automatic( + automation: &mut automation::NativeAutomation, + desired: Option<&ApplicationProfile>, +) -> Result<()> { + let access = device_access(); + automation.observe_manual_intent(&access); + ensure_native_ownership()?; + automation.reconcile( + desired, + &mut automation::HelperIo(|request: &serde_json::Value| native_hid::run_helper(request)), + ) +} + +/// Reads the onboard-memory state of the mouse `device_id` names +/// (`":"`) over native HID — battery, and the current DPI / +/// polling rate / switch mode wherever the driver can read them. +/// `Ok(None)` means Bridge has no reader for the brand or the mouse reports +/// no battery; `Err` means a driver exists but the mouse did not answer +/// (e.g. asleep or disconnected). +pub fn read_status(device_id: &str) -> Result> { let brand = device_id.split(':').next().unwrap_or_default(); + let _access = device_access(); + ensure_native_ownership()?; if brand.eq_ignore_ascii_case(pulsar::BRAND) { - return Ok(None); + return pulsar::read_status(); } - let _access = device_access(); - native_hid::read_battery(brand) + native_hid::read_status(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 +293,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 +311,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 @@ -144,4 +581,81 @@ mod tests { "expected an error with no Razer mouse connected, got {outcome:?}" ); } + + /// Discovery must see every vendor id the helper can drive, and every + /// brand discovery writes into `device.id` must normalize to a helper + /// brand key — otherwise a mouse is discovered but its battery or + /// settings can never be read. Parses `native-hid/src/brands.mjs` (the + /// helper's own table) so the two cannot drift apart silently. + #[test] + fn discovery_table_matches_the_native_hid_helper_brand_table() { + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/native-hid/src/brands.mjs" + )) + .expect("the native-hid checkout ships next to Cargo.toml"); + let mut after_table = false; + let mut helper_keys = Vec::new(); + let mut helper_vendor_ids = Vec::new(); + for line in source.lines() { + if line.starts_with("export const BRAND_DRIVERS") { + after_table = true; + continue; + } + if !after_table { + continue; + } + if let Some(start) = line.find("vendorIds: [") { + let rest = &line[start + "vendorIds: [".len()..]; + let end = rest.find(']').expect("vendorIds lists close on their line"); + for value in rest[..end].split(',') { + let value = value.trim(); + if value.is_empty() { + continue; + } + helper_vendor_ids.push( + u16::from_str_radix(value.trim_start_matches("0x"), 16) + .expect("brands.mjs vendor ids are hex"), + ); + } + } else if let Some(key) = line.strip_suffix(": {") { + helper_keys.push(key.trim().trim_matches('"').to_owned()); + } + } + assert!( + !helper_vendor_ids.is_empty(), + "parsed no vendor ids — did brands.mjs's format change?" + ); + let discoverable = |id: u16| { + // The special-cased vendor ids in `discovered_brand`: the shared + // Teevolution/VGN page, Logitech's HID++ transport, and Pulsar's + // own Rust-driver vendor id. + [pulsar::VENDOR_ID, 0x3554, LOGITECH_VENDOR_ID].contains(&id) + || delux::SUPPORTED_VENDOR_IDS.contains(&id) + || KNOWN_BRANDS.iter().any(|(_, ids)| ids.contains(&id)) + }; + for id in helper_vendor_ids { + assert!( + discoverable(id), + "vendor id 0x{id:04x} is in brands.mjs but discovery cannot see it" + ); + } + for (brand, _) in KNOWN_BRANDS { + let key = brand.trim().to_lowercase(); + assert!( + helper_keys.contains(&key), + "discovery brand {brand:?} has no {key:?} key in brands.mjs" + ); + } + } + + #[test] + #[ignore = "prints what discovery sees on live hardware; needs a supported mouse connected"] + fn discovery_lists_the_connected_mice() { + let mice = detect_mice(); + for mouse in &mice { + println!("{mouse:?}"); + } + assert!(!mice.is_empty(), "discovery found no mice"); + } } diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index af54ec1..8a0514f 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; @@ -35,6 +37,11 @@ struct ApplyRequest<'a> { dpi: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "pollingRateHz")] polling_rate_hz: Option, + /// The profile's opaque panel settings (`ProfileSettings.snapshot`, + /// e.g. `{"lightforceSwitchMode":"Optical"}`). The helper applies the + /// fields its driver supports and skips the rest. + #[serde(skip_serializing_if = "Option::is_none")] + settings: Option<&'a serde_json::Value>, } #[derive(Serialize)] @@ -50,13 +57,49 @@ struct StatusRequest<'a> { struct StatusReply { battery_percent: Option, battery_state: Option, + /// The driver's own model name (`MouseStatus.name`), when the driver + /// reports one — better than the receiver-level name discovery gets from + /// the HID descriptor. + device_name: Option, + /// The settings currently in the device's onboard memory, when the + /// driver can read them. `dpi` is a number or a per-stage list in + /// `MouseStatus`; anything else parses as `None`. + dpi: Option, + polling_rate_hz: Option, + switch_mode: Option, } /// A battery level read from the mouse over native HID. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct NativeBattery { pub percent: u8, pub charging: bool, + /// The model name the driver itself reported, when it did. + pub reported_name: Option, +} + +/// Everything the mouse reports about its own onboard-memory state: the +/// battery plus the settings currently in effect. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NativeStatus { + pub battery: NativeBattery, + pub dpi: Option, + pub polling_rate_hz: Option, + /// Logitech's LightForce switch mode; `None` for brands without one. + pub switch_mode: Option, +} + +/// `MouseStatus.dpi` is either one number or a per-stage list; the stages +/// have no reliable "active" marker, so a list collapses to its first +/// stage — the one most drivers treat as the default. +fn coerce_dpi(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(number) => number.as_u64().map(|dpi| dpi as u32), + serde_json::Value::Array(stages) => stages + .iter() + .find_map(|stage| stage.as_u64().map(|dpi| dpi as u32)), + _ => None, + } } /// Pushes a profile's DPI/polling rate to the mouse via the Node helper. @@ -66,10 +109,11 @@ pub struct NativeBattery { /// apply itself failed (including the helper or Node.js being unavailable). pub fn apply(profile: &ApplicationProfile) -> Result { let brand = profile.device.id.split(':').next().unwrap_or_default(); - apply_settings( + apply_settings_with_snapshot( brand, profile.settings.dpi, profile.settings.polling_rate_hz, + profile.settings.snapshot.as_ref(), ) } @@ -77,18 +121,28 @@ pub fn apply(profile: &ApplicationProfile) -> Result { /// the exact helper path used by automatic profiles, so native device control /// and profile switching cannot drift into separate protocol implementations. pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { + apply_settings_with_snapshot(brand, dpi, polling_rate_hz, None) +} + +fn apply_settings_with_snapshot( + brand: &str, + dpi: Option, + polling_rate_hz: Option, + settings: Option<&serde_json::Value>, +) -> Result { let request = ApplyRequest { brand, dpi, polling_rate_hz, + settings, }; Ok(run_helper(&request)?.is_some()) } -/// Reads the battery of a `brand` mouse via the Node helper. `Ok(None)` means +/// Reads a `brand` mouse's status via the Node helper. `Ok(None)` means /// the helper has no driver for the brand or the mouse reports no battery /// (a wired mouse); `Err` means a driver exists but no mouse answered. -pub fn read_battery(brand: &str) -> Result> { +pub fn read_status(brand: &str) -> Result> { let request = StatusRequest { brand, action: "status", @@ -98,10 +152,22 @@ pub fn read_battery(brand: &str) -> Result> { }; let reply: StatusReply = serde_json::from_slice(&stdout) .context("the native-hid helper returned an unreadable status")?; - Ok(parse_battery(reply)) + Ok(parse_status(reply)) } -fn parse_battery(reply: StatusReply) -> Option { +fn parse_status(reply: StatusReply) -> Option { + let battery = parse_battery(&reply)?; + Some(NativeStatus { + battery, + dpi: coerce_dpi(reply.dpi.as_ref()), + polling_rate_hz: reply + .polling_rate_hz + .and_then(|rate| u32::try_from(rate.round() as i64).ok()), + switch_mode: reply.switch_mode.filter(|mode| !mode.trim().is_empty()), + }) +} + +fn parse_battery(reply: &StatusReply) -> Option { let percent = reply.battery_percent?; if !percent.is_finite() { return None; @@ -110,16 +176,56 @@ fn parse_battery(reply: StatusReply) -> Option { percent: percent.round().clamp(0.0, 100.0) as u8, charging: reply .battery_state + .as_ref() .is_some_and(|state| state.starts_with("Charging")), + reported_name: reply + .device_name + .clone() + .filter(|name| !name.trim().is_empty()), }) } +#[cfg(test)] +pub(super) type TestHelper = Box Result>>>; + +#[cfg(test)] +thread_local! { + static TEST_HELPER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +pub(super) fn with_test_helper(helper: TestHelper, operation: impl FnOnce() -> R) -> R { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + TEST_HELPER.with(|helper| *helper.borrow_mut() = None); + } + } + TEST_HELPER.with(|slot| *slot.borrow_mut() = Some(helper)); + let _reset = Reset; + operation() +} + /// Runs `apply.mjs` with `request` on stdin. Returns its stdout on success, /// `None` when it has no driver for the brand, and `Err` otherwise. -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 +235,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 +246,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), @@ -161,6 +258,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 @@ -203,75 +319,217 @@ 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, battery_state: state.map(str::to_owned), + device_name: None, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + } + } + + fn named_reply(name: &str) -> StatusReply { + StatusReply { + battery_percent: Some(41.0), + battery_state: Some("Discharging".into()), + device_name: Some(name.to_owned()), + dpi: None, + polling_rate_hz: None, + switch_mode: None, } } #[test] fn parses_battery_from_status() { assert_eq!( - parse_battery(reply(Some(41.0), Some("Discharging"))), - Some(NativeBattery { - percent: 41, - charging: false + parse_status(reply(Some(41.0), Some("Discharging"))), + Some(NativeStatus { + battery: NativeBattery { + percent: 41, + charging: false, + reported_name: None, + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) ); assert_eq!( - parse_battery(reply(Some(87.6), Some("Charging slowly"))), - Some(NativeBattery { - percent: 88, - charging: true + parse_status(reply(Some(87.6), Some("Charging slowly"))), + Some(NativeStatus { + battery: NativeBattery { + percent: 88, + charging: true, + reported_name: None, + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) ); - assert_eq!(parse_battery(reply(None, Some("Unknown"))), None); + assert_eq!(parse_status(reply(None, Some("Unknown"))), None); assert_eq!( - parse_battery(reply(Some(140.0), None)).map(|battery| battery.percent), + parse_status(reply(Some(140.0), None)).map(|status| status.battery.percent), Some(100) ); + // The driver's model name rides along for better tray labels. + assert_eq!( + parse_status(named_reply("G Pro X Superlight 2")), + Some(NativeStatus { + battery: NativeBattery { + percent: 41, + charging: false, + reported_name: Some("G Pro X Superlight 2".into()), + }, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }) + ); + // A blank name is treated as no name. + assert_eq!( + parse_status(named_reply(" ")).and_then(|status| status.battery.reported_name), + None + ); + } + + #[test] + fn parses_the_onboard_settings_from_status() { + let mut logitech = reply(Some(81.0), Some("Discharging")); + logitech.device_name = Some("G Pro X Superlight 2".into()); + logitech.dpi = Some(serde_json::json!(800)); + logitech.polling_rate_hz = Some(1000.0); + logitech.switch_mode = Some("Optical".into()); + assert_eq!( + parse_status(logitech), + Some(NativeStatus { + battery: NativeBattery { + percent: 81, + charging: false, + reported_name: Some("G Pro X Superlight 2".into()), + }, + dpi: Some(800), + polling_rate_hz: Some(1000), + switch_mode: Some("Optical".into()), + }) + ); + // Drivers that report a stage list collapse to the first stage, and + // blank switch modes read as none. + let mut staged = reply(Some(81.0), None); + staged.dpi = Some(serde_json::json!([400, 800, 1600])); + staged.switch_mode = Some(" ".into()); + let status = parse_status(staged).expect("staged status parses"); + assert_eq!(status.dpi, Some(400)); + assert_eq!(status.switch_mode, None); } } diff --git a/src/drivers/pulsar.rs b/src/drivers/pulsar.rs index a4f0b2e..89a1843 100644 --- a/src/drivers/pulsar.rs +++ b/src/drivers/pulsar.rs @@ -17,6 +17,7 @@ use std::{ use anyhow::{Context, Result, anyhow, bail}; use hidapi::HidApi; +use super::{NativeBattery, NativeStatus}; use crate::config::ApplicationProfile; /// This driver's slot in `crate::drivers`' brand registry — matches the @@ -37,6 +38,43 @@ pub fn apply(profile: &ApplicationProfile) -> Result<()> { Ok(()) } +/// Opens the connected Pulsar mouse and reads its onboard-memory state — +/// the battery via the same `batteryLevel` query (command `0x04`, percent in +/// response byte 5, charge flag in byte 6) the WebHID driver's `readStatus` +/// uses, plus the active DPI stage and polling rate from flash, all inside +/// one host-control handshake. `Ok(None)` means the mouse answered but +/// reported no battery (byte 5 above 100). +pub fn read_status() -> Result> { + let device = PulsarDevice::open()?; + device.with_device_control(|| { + // The same flash reads `set_dpi`/`set_polling_rate` confirm their + // writes with, so the decode paths are hardware-verified. + let polling_rate_hz = decode_polling_rate(device.read_flash(flash::REPORT_RATE, 2)?[0]); + let stage = device.read_flash(flash::CURRENT_DPI, 2)?[0].min(7) as u16; + let dpi = decode_dpi(&device.read_flash(flash::DPI_VALUES + stage * 4, 4)?); + let response = device.query(command::BATTERY_LEVEL, &[])?; + assert_accepted(&response, "battery query")?; + let percent = response[5]; + if percent > 100 { + return Ok(None); + } + Ok(Some(NativeStatus { + battery: NativeBattery { + percent, + charging: response[6] == 1, + // The report-8 battery query carries no model name; the HID + // product string discovery already read is the best + // available. + reported_name: None, + }, + dpi, + polling_rate_hz, + // The report-8 protocol has no LightForce equivalent. + switch_mode: None, + })) + }) +} + /// Real Pulsar-branded mice and dongles. The Pulsar 4K Wireless Receiver /// shares a report-8 16-byte protocol but enumerates under a different, /// shared vendor id (0x3554) that other brands also use — out of scope here. @@ -49,6 +87,7 @@ const EXCHANGE_TIMEOUT: Duration = Duration::from_millis(1200); mod command { pub const ENCRYPTION_DATA: u8 = 0x01; pub const DEVICE_ONLINE: u8 = 0x03; + pub const BATTERY_LEVEL: u8 = 0x04; pub const WRITE_FLASH_DATA: u8 = 0x07; pub const READ_FLASH_DATA: u8 = 0x08; } diff --git a/src/games.rs b/src/games.rs index 784ff6c..0e13ab0 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; +pub(crate) const MAX_CATALOG_GAMES: usize = 10_000; +pub(crate) const MAX_EXECUTABLES: usize = 32; +pub(crate) const MAX_CATALOG_STRING: usize = 256; #[derive(Deserialize)] struct CatalogResponse { + #[serde(deserialize_with = "deserialize_games")] games: Vec, } +#[derive(Deserialize)] +struct CatalogGame { + name: String, + #[serde(deserialize_with = "deserialize_executables")] + executables: Vec, +} + +fn deserialize_executables<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + struct ExecutablesVisitor; + impl<'de> serde::de::Visitor<'de> for ExecutablesVisitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bounded array of executable names") + } + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut executables = Vec::new(); + while let Some(entry) = seq.next_element::()? { + let serde_json::Value::String(name) = entry else { + continue; + }; + if name.len() > MAX_CATALOG_STRING + || name.trim().is_empty() + || name.chars().any(char::is_control) + { + continue; + } + if executables.len() >= MAX_EXECUTABLES { + return Err(serde::de::Error::custom( + "catalog executable count exceeds limit", + )); + } + executables.push(name); + } + Ok(executables) + } + } + deserializer.deserialize_seq(ExecutablesVisitor) +} + +fn deserialize_games<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + struct GamesVisitor; + impl<'de> serde::de::Visitor<'de> for GamesVisitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bounded array of game entries") + } + fn visit_seq>( + self, + mut seq: A, + ) -> std::result::Result { + let mut games = Vec::new(); + let mut game_count = 0; + while let Some(game) = seq.next_element::()? { + if game_count >= MAX_CATALOG_GAMES + || game.name.len() > MAX_CATALOG_STRING + || game.name.trim().is_empty() + || game.name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom( + "game catalog exceeds item limits or contains invalid strings", + )); + } + game_count += 1; + if !game.executables.is_empty() { + games.push(GameConfig { + name: game.name, + executables: game.executables, + }); + } + } + Ok(games) + } + } + deserializer.deserialize_seq(GamesVisitor) +} + pub fn catalog_url() -> Cow<'static, str> { env::var(GAMES_URL_ENV) .map(Cow::Owned) @@ -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..7858658 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,32 @@ 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 { + // A serial is a verified physical identity, so every collection of that + // device is one candidate. Without one, collections stay apart by path. + let serial = serial.filter(|serial| !serial.is_empty()); + let identity = serial.map_or(path, str::as_bytes); + let mut key = Vec::with_capacity(13 + identity.len()); + key.extend_from_slice(&vendor_id.to_le_bytes()); + key.extend_from_slice(&product_id.to_le_bytes()); + key.push(u8::from(serial.is_some())); + key.extend_from_slice(&(identity.len() as u64).to_le_bytes()); + key.extend_from_slice(identity); + key } fn 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 +1129,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 +1400,7 @@ struct ActiveSessionGuard; impl ActiveSessionGuard { fn new() -> Self { + let _access = crate::drivers::device_access(); ACTIVE_SESSIONS.fetch_add(1, Ordering::AcqRel); Self } @@ -1375,12 +1414,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 +1433,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 +1498,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 +1528,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 +1618,40 @@ 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")) + fn opaque_interfaces_do_not_merge_without_verified_physical_identity() { + assert_ne!( + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), + grouped_device_key(0x046d, 0xc54d, b"opaque-B", None) ); assert_eq!( - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None) + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None), + grouped_device_key(0x046d, 0xc54d, b"opaque-A", None) ); 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(0x1532, 0xc54d, b"opaque-A", None) + ); + assert_ne!( + grouped_device_key(0x046d, 0xc54d, b"opaque-A", Some("")), + grouped_device_key(0x046d, 0xc54d, b"opaque-B", Some("")), + "an empty serial is no identity" + ); + } + + #[test] + fn collections_of_one_serial_bearing_device_group_together() { + assert_eq!( + grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-123")), + grouped_device_key(0x1532, 0x00b0, b"col02", Some("receiver-123")) ); assert_ne!( - grouped_device_key(RAZER_VENDOR_ID, 0x00b0, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0x00b0, None) + grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-123")), + grouped_device_key(0x1532, 0x00b0, b"col01", Some("receiver-456")) ); + // A serial that happens to equal another device's path is still a different identity. assert_ne!( - grouped_device_key(LOGITECH_VENDOR_ID, 0xc54d, None), - grouped_device_key(LOGITECH_VENDOR_ID, 0xc548, None) + grouped_device_key(0x1532, 0x00b0, b"same", Some("same")), + grouped_device_key(0x1532, 0x00b0, b"same", None) ); } @@ -1586,7 +1710,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..d6e72e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,13 @@ pub mod api; pub mod applications; +pub mod buttons; pub mod config; pub mod drivers; pub mod games; mod hid; +pub mod local_targets; pub mod platform; +pub mod remote_image; pub mod service; pub mod updater; diff --git a/src/local_targets.rs b/src/local_targets.rs 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..ceebbe1 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; @@ -62,6 +64,7 @@ async fn run() -> Result<()> { // application icons over the API, so always keep them extracted. service.start_game_monitor(Arc::new(AtomicBool::new(true))); service.start_battery_monitor(); + service.start_mode_monitor(); let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), BRIDGE_PORT); let listener = TcpListener::bind(address) .await diff --git a/src/native_image.rs b/src/native_image.rs 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..9452878 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -8,14 +8,17 @@ 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; #[cfg(target_os = "windows")] -pub use windows::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +pub use windows::{ + autostart_enabled, battery_icon_rgba, light_tray_theme, linux_distribution, platform_name, + set_autostart, tray_icon_size, +}; #[cfg(target_os = "macos")] pub use macos::{autostart_enabled, linux_distribution, platform_name, set_autostart}; @@ -26,6 +29,115 @@ pub use linux::{autostart_enabled, linux_distribution, platform_name, set_autost #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] pub use portable::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +/// The pixel size a tray icon should be drawn at, in physical pixels. +/// Windows reports the notification area's small-icon size for the current +/// system DPI (16 at 100%, 20/24/32 as scaling rises); other desktops get a +/// fixed size that reads well in menu bars and appindicator hosts. +#[cfg(not(target_os = "windows"))] +pub fn tray_icon_size() -> u32 { + 22 +} + +/// Whether the shell paints its surfaces light. Only Windows reports it; +/// macOS menu bars and Linux appindicators are dark in practice. +#[cfg(not(target_os = "windows"))] +pub fn light_tray_theme() -> bool { + false +} + +/// What the tray battery icon shows. The Windows 11 taskbar only renders +/// square tray icons correctly, so the glyph and the number are two +/// mutually exclusive styles chosen in the control panel, not one wide +/// display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayBatteryStyle { + /// The native Segoe battery glyph alone, filling the square icon. + Glyph, + /// The percentage number alone in bold text, filling the square icon. + Percentage, +} + +impl TrayBatteryStyle { + /// The config value this style is stored as. + pub fn as_config_str(self) -> &'static str { + match self { + Self::Glyph => "glyph", + Self::Percentage => "percentage", + } + } + + /// Reads a stored config value; anything unknown falls back to the + /// default glyph. + pub fn from_config_str(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "percentage" => Self::Percentage, + _ => Self::Glyph, + } + } +} + +/// Renders the tray battery display as an RGBA buffer in `ink`. Windows +/// draws either the Segoe Fluent battery glyph or the percentage number in +/// bold UI text, per `style`; other desktops get a simple monochrome +/// fallback: a bold outline battery with the charge fill, no anti-aliasing +/// (the style is ignored there). +#[cfg(not(target_os = "windows"))] +pub fn battery_icon_rgba( + percent: u8, + charging: bool, + style: TrayBatteryStyle, + size: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + let _ = style; + let size = size.max(8); + let mut rgba = vec![0u8; (size * size * 4) as usize]; + let mut put = |x0: u32, y0: u32, x1: u32, y1: u32| { + for y in y0..y1.min(size) { + for x in x0..x1.min(size) { + let index = ((y * size + x) * 4) as usize; + rgba[index] = ink.0; + rgba[index + 1] = ink.1; + rgba[index + 2] = ink.2; + rgba[index + 3] = u8::MAX; + } + } + }; + let stroke = (size / 10).max(1); + let body_height = size * 2 / 3; + let body_top = (size - body_height) / 2; + let body_bottom = body_top + body_height; + let nub_width = (size / 16).max(1); + let body_width = size.saturating_sub(nub_width + 2); + // The shell, a nub on the right, and the charge fill inside. + put(0, body_top, body_width, body_top + stroke); + put(0, body_bottom - stroke, body_width, body_bottom); + put(0, body_top, stroke, body_bottom); + put(body_width - stroke, body_top, body_width, body_bottom); + put( + body_width + 1, + size / 3, + body_width + 1 + nub_width, + size * 2 / 3, + ); + let fill = if charging { + 100 + } else { + u32::from(percent.min(100)) + }; + let inner_x0 = stroke; + let inner_x1 = body_width - stroke; + let inner_width = inner_x1 - inner_x0; + let fill_columns = fill * inner_width / 100; + put( + inner_x0, + body_top + stroke, + inner_x0 + fill_columns, + body_bottom - stroke, + ); + Ok((rgba, size, size)) +} + pub fn notify(summary: &str, body: &str) -> Result<()> { // notify-rust resolves a bundle identifier on macOS. Run as a bare binary // (e.g. `cargo run`) there is none, and macOS pops a blocking "Choose diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 742aed0..3b4270c 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1,14 +1,23 @@ use std::{env, ffi::OsStr, mem::size_of, os::windows::ffi::OsStrExt, ptr::null_mut}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use windows_sys::Win32::{ Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, WIN32_ERROR}, + Graphics::Gdi::{ + ANTIALIASED_QUALITY, CLIP_DEFAULT_PRECIS, CreateCompatibleDC, CreateFontW, DEFAULT_CHARSET, + DEFAULT_PITCH, DeleteDC, DeleteObject, FF_DONTCARE, FIXED, FW_BOLD, FW_NORMAL, GDI_ERROR, + GGO_GRAY8_BITMAP, GGO_METRICS, GLYPHMETRICS, GetGlyphOutlineW, HDC, HFONT, MAT2, + OUT_TT_PRECIS, SelectObject, + }, System::Registry::{ - HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_SZ, RegDeleteKeyValueW, RegGetValueW, RegSetKeyValueW, + HKEY_CURRENT_USER, REG_SZ, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegDeleteKeyValueW, + RegGetValueW, RegSetKeyValueW, }, + UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}, }; const RUN_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; +const PERSONALIZE_SUBKEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; const VALUE_NAME: &str = "OpenMouse Bridge"; pub const fn platform_name() -> &'static str { @@ -19,6 +28,519 @@ pub fn linux_distribution() -> Option { None } +/// The notification area's icon size in physical pixels at the current +/// system DPI: 16 at 100% scaling, 20/24/32 as the display scaling rises. +pub fn tray_icon_size() -> u32 { + // SAFETY: a trivial metrics query with no side effects. + let width = unsafe { GetSystemMetrics(SM_CXSMICON) }; + width.max(16) as u32 +} + +/// Whether Windows paints system surfaces (the taskbar) light. The taskbar +/// follows `SystemUsesLightTheme`, falling back to `AppsUseLightTheme` when +/// the system value has never been written; anything unreadable means dark, +/// the Windows default. +pub fn light_tray_theme() -> bool { + let subkey = wide(PERSONALIZE_SUBKEY); + for value in ["SystemUsesLightTheme", "AppsUseLightTheme"] { + let value_name = wide(value); + let mut data = 0u32; + let mut byte_count = size_of::() as u32; + let result = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + value_name.as_ptr(), + RRF_RT_REG_DWORD, + null_mut(), + &mut data as *mut u32 as *mut _, + &mut byte_count, + ) + }; + if result == ERROR_SUCCESS { + return data != 0; + } + } + false +} + +/// Segoe icon-font code points for the battery glyphs, verified against +/// Microsoft's Segoe Fluent Icons documentation: `Battery0`..`Battery9` +/// (E850..E859) cover 0–90% in 10% steps, `Battery10` (E83F) is full, and +/// every level below full has a charging variant carrying the bolt +/// (`BatteryCharging0`..8 at E85A..E862, `BatteryCharging9` at E83E). +pub fn battery_glyph(percent: u8, charging: bool) -> u32 { + let level = u32::from(percent.min(100)) / 10; + if !charging { + return if level == 10 { 0xE83F } else { 0xE850 + level }; + } + match level { + 0..=8 => 0xE85A + level, + 9 => 0xE83E, + // The font has no charging glyph for a full battery; the filled + // Battery10 is what Windows itself falls back to. + _ => 0xE83F, + } +} + +/// Renders the tray battery display as a square `size` × `size` RGBA +/// buffer, tinted with `ink` — the Windows 11 taskbar only renders square +/// tray icons faithfully. The `style` picks what fills the square: the +/// Segoe Fluent battery glyph for the level (the charging variant with the +/// bolt when `charging`), or the percentage number in bold UI text (the +/// `%` is left off — at tray sizes it would shrink the digits to a +/// squint; the tooltip spells the full `NN%` out). Returns the buffer with +/// its width and height. `Segoe Fluent Icons` ships with Windows 11; +/// `Segoe MDL2 Assets` carries the same glyphs on Windows 10. +pub fn battery_icon_rgba( + percent: u8, + charging: bool, + style: super::TrayBatteryStyle, + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + let codepoint = battery_glyph(percent, charging); + // The label carries only the digits for the percentage style; the + // glyph style never rasterizes text. + let label: Vec = match style { + super::TrayBatteryStyle::Percentage => { + percent.min(100).to_string().encode_utf16().collect() + } + super::TrayBatteryStyle::Glyph => Vec::new(), + }; + for family in ["Segoe Fluent Icons", "Segoe MDL2 Assets"] { + match render_battery_icon(codepoint, &label, family, height, ink) { + Ok(icon) => return Ok(icon), + // A missing family or glyph means the fallback font should be + // tried; anything else is a real error. + Err(error) if MISSING_GLYPH.with(|flag| flag.get()) => { + tracing::debug!(family, %error, "icon font unusable, trying the next"); + MISSING_GLYPH.with(|flag| flag.set(false)); + } + Err(error) => return Err(error), + } + } + Err(anyhow!( + "no Segoe icon font could render the battery display" + )) +} + +thread_local! { + /// Set by the renderer when the font family or glyph is missing, telling + /// `battery_icon_rgba` to fall through to the next font family. + static MISSING_GLYPH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// One rasterized glyph: GDI's 8-bit coverage bitmap plus the metrics that +/// place it. +struct Raster { + metrics: GLYPHMETRICS, + coverage: Vec, +} + +impl Raster { + fn width(&self) -> u32 { + self.metrics.gmBlackBoxX + } + + fn height(&self) -> u32 { + self.metrics.gmBlackBoxY + } + + /// The bitmap's bytes per row (DWORD-aligned, as GDI reports). + fn stride(&self) -> usize { + ((self.metrics.gmBlackBoxX + 3) & !3) as usize + } + + /// The coverage compacted to tight rows (no DWORD padding). + fn tight(&self) -> Vec { + let stride = self.stride(); + let width = self.width() as usize; + let mut out = Vec::with_capacity(width * self.height() as usize); + for row in 0..self.height() as usize { + out.extend_from_slice(&self.coverage[row * stride..row * stride + width]); + } + out + } +} + +/// The label's text face: the system UI font in bold, so the percentage +/// reads clearly at tray sizes. +const TEXT_FAMILY: &str = "Segoe UI"; + +fn render_battery_icon( + codepoint: u32, + label: &[u16], + family: &str, + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + MISSING_GLYPH.with(|flag| flag.set(false)); + let family = wide(family); + let text_family = wide(TEXT_FAMILY); + // SAFETY: the GDI calls below use their own memory DC, every handle is + // deleted on the way out, and the coverage buffers are sized to what + // `GetGlyphOutlineW` reports. + unsafe { + let dc = CreateCompatibleDC(null_mut()); + if dc.is_null() { + return Err(anyhow!("could not create a memory device context")); + } + let result = + compose_battery_display(dc, codepoint, label, &family, &text_family, height, ink); + DeleteDC(dc); + result + } +} + +fn compose_battery_display( + dc: HDC, + codepoint: u32, + label: &[u16], + family: &[u16], + text_family: &[u16], + height: u32, + ink: (u8, u8, u8), +) -> Result<(Vec, u32, u32)> { + // An empty label is the glyph style: the battery fills the square. + // Otherwise the digits fill it. + if label.is_empty() { + // A landscape glyph in a square icon: fitting its width to all but + // 1px of the canvas — the footprint the old square renderer used — + // is the largest it can be, and the height lands near half. + let battery = fit_and_rasterize( + dc, + codepoint, + family, + FW_NORMAL as i32, + height.saturating_sub(1).max(8), + ) + .ok_or_else(|| anyhow!("could not rasterize the battery glyph U+{codepoint:04X}"))?; + let mut canvas = Canvas { + rgba: vec![0u8; (height * height * 4) as usize], + width: height, + height, + }; + canvas.blit( + (height - battery.width()) / 2, + (height - battery.height()) / 2, + &battery.tight(), + battery.width(), + ink, + ); + return Ok((canvas.rgba, height, height)); + } + + // The digits are drawn as tall as the square allows — a 2px margin — + // and squeezed proportionally when the string is wider than tall + // ("100" is, at every size that matters). + let mut target = height.saturating_sub(4).max(8); + let mut raster = rasterize_label(dc, label, text_family, target) + .ok_or_else(|| anyhow!("could not rasterize the battery label"))?; + if raster.width > height.saturating_sub(2) { + target = + (f64::from(target) * f64::from(height - 2) / f64::from(raster.width)).max(6.0) as u32; + raster = rasterize_label(dc, label, text_family, target) + .ok_or_else(|| anyhow!("could not re-fit the battery label"))?; + } + let mut canvas = Canvas { + rgba: vec![0u8; (height * height * 4) as usize], + width: height, + height, + }; + canvas.blit( + (height - raster.width) / 2, + (height - raster.height) / 2, + &raster.coverage, + raster.width, + ink, + ); + Ok((canvas.rgba, height, height)) +} + +/// A laid-out label: one tight coverage bitmap for the whole string. +struct LabelRaster { + coverage: Vec, + width: u32, + height: u32, +} + +/// Rasterizes the label into one tight coverage bitmap in a bold UI face, +/// sized so a digit stands `target` pixels tall. Characters are placed by +/// the font's own advances and side bearings, top-aligned — digits and +/// `%` share the cap height, so the row lines up. +fn rasterize_label(dc: HDC, label: &[u16], family: &[u16], target: u32) -> Option { + if label.is_empty() { + return None; + } + // SAFETY: `dc` is a live memory DC and the font created here is + // deselected and deleted on every exit path. + unsafe { + let mut font = create_glyph_font(family, target as i32, FW_BOLD as i32); + if font.is_null() { + return None; + } + let previous_font = SelectObject(dc, font); + let mut font_height = target as i32; + for _ in 0..3 { + let Some(metrics) = glyph_metrics(dc, u32::from(b'0')) else { + break; + }; + let size = metrics.gmBlackBoxY; + if size == 0 || size.abs_diff(target) <= 1 { + break; + } + let scaled = + (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; + if scaled == font_height { + break; + } + font_height = scaled; + let replacement = create_glyph_font(family, font_height, FW_BOLD as i32); + SelectObject(dc, replacement); + DeleteObject(font); + font = replacement; + if font.is_null() { + SelectObject(dc, previous_font); + return None; + } + } + + let mut rasters = Vec::with_capacity(label.len()); + for &character in label { + match glyph_metrics(dc, u32::from(character)) { + // `glyph_bitmap` writes the bitmap's real bounds back into + // the metrics, and the raster needs those. + Some(mut metrics) => match glyph_bitmap(dc, u32::from(character), &mut metrics) { + Some(coverage) => rasters.push(Raster { metrics, coverage }), + None => break, + }, + None => break, + } + } + SelectObject(dc, previous_font); + DeleteObject(font); + if rasters.len() != label.len() { + return None; + } + + let height = rasters.iter().map(Raster::height).max().unwrap_or(0); + let width: u32 = rasters + .iter() + .map(|r| r.metrics.gmCellIncX.max(0) as u32) + .sum(); + let mut coverage = vec![0u8; (width * height) as usize]; + let mut pen_x = 0u32; + for raster in &rasters { + let tight = raster.tight(); + let x = pen_x + u32::try_from(raster.metrics.gmptGlyphOrigin.x.max(0)).unwrap_or(0); + for row in 0..raster.height() as usize { + for column in 0..raster.width() as usize { + let level = tight[row * raster.width() as usize + column]; + if level == 0 { + continue; + } + let index = row * width as usize + x as usize + column; + if index < coverage.len() { + coverage[index] = coverage[index].max(level); + } + } + } + pen_x += raster.metrics.gmCellIncX.max(0) as u32; + } + Some(LabelRaster { + coverage, + width, + height, + }) + } +} + +/// The RGBA canvas the display is composed onto. +struct Canvas { + rgba: Vec, + width: u32, + height: u32, +} + +impl Canvas { + /// Stamps a tight coverage bitmap at (x0, y0), tinted with `ink`. + /// Coverage levels are normalized so the raster's densest pixel is + /// fully opaque (`GGO_GRAY8_BITMAP` does not promise a 255 peak). + fn blit(&mut self, x0: u32, y0: u32, coverage: &[u8], width: u32, ink: (u8, u8, u8)) { + let peak = coverage.iter().copied().max().unwrap_or(0); + if peak == 0 { + return; + } + let height = coverage.len() as u32 / width.max(1); + for row in 0..height as usize { + let y = y0 as usize + row; + if y as u32 >= self.height { + break; + } + for column in 0..width as usize { + let x = x0 as usize + column; + if x as u32 >= self.width { + break; + } + let level = coverage[row * width as usize + column]; + if level == 0 { + continue; + } + let alpha = ((u16::from(level) * 255) / u16::from(peak)).min(255) as u8; + let index = (y as u32 * self.width + x as u32) as usize * 4; + self.rgba[index] = ink.0; + self.rgba[index + 1] = ink.1; + self.rgba[index + 2] = ink.2; + self.rgba[index + 3] = alpha; + } + } + } +} + +/// Fits the font so the battery glyph's visible bounds span `target` +/// pixels wide — the right fit for a landscape glyph in a square icon (the +/// fit loop accepts within a pixel) — then rasterizes the glyph as an +/// 8-bit coverage bitmap. `None` when the font or glyph is missing. +fn fit_and_rasterize( + dc: HDC, + codepoint: u32, + family: &[u16], + weight: i32, + target: u32, +) -> Option { + // SAFETY: `dc` is a live memory DC and the font created here is + // deselected and deleted on every exit path. + unsafe { + let mut font = create_glyph_font(family, target as i32, weight); + if font.is_null() { + MISSING_GLYPH.with(|flag| flag.set(true)); + return None; + } + let previous_font = SelectObject(dc, font); + let mut font_height = target as i32; + let mut raster = None; + for _ in 0..4 { + let Some(mut metrics) = glyph_metrics(dc, codepoint) else { + MISSING_GLYPH.with(|flag| flag.set(true)); + break; + }; + let size = metrics.gmBlackBoxX; + if size == 0 { + break; + } + if size.abs_diff(target) <= 1 { + raster = glyph_bitmap(dc, codepoint, &mut metrics) + .map(|coverage| Raster { metrics, coverage }); + break; + } + let scaled = + (f64::from(font_height) * f64::from(target) / f64::from(size)).round() as i32; + if scaled == font_height { + raster = glyph_bitmap(dc, codepoint, &mut metrics) + .map(|coverage| Raster { metrics, coverage }); + break; + } + // Recreate the font at the scaled height and keep fitting. + font_height = scaled; + let replacement = create_glyph_font(family, font_height, weight); + SelectObject(dc, replacement); + DeleteObject(font); + font = replacement; + if font.is_null() { + MISSING_GLYPH.with(|flag| flag.set(true)); + break; + } + } + SelectObject(dc, previous_font); + DeleteObject(font); + raster + } +} + +/// Rasterizes the glyph as an 8-bit grayscale coverage bitmap +/// (`GGO_GRAY8_BITMAP`): GDI's own anti-aliased glyph raster, no drawing +/// surface involved. GDI writes the bitmap's *actual* bounds back into +/// `metrics` — which can come back a pixel wider than `GGO_METRICS` +/// reported (the anti-aliased edge column), so the caller must lay the +/// buffer out by the written-back values or every row skews. +fn glyph_bitmap(dc: HDC, codepoint: u32, metrics: &mut GLYPHMETRICS) -> Option> { + // SAFETY: `dc` is a live DC with a font selected; the metrics and + // matrix structs are valid for the call and the buffer is sized to hold + // the reported black box (rows are DWORD-aligned) plus slack — GDI + // rejects an exact-size buffer for some glyphs, asking for a few bytes + // more than stride × rows. + unsafe { + let stride = (metrics.gmBlackBoxX + 3) & !3; + let byte_count = stride as usize * metrics.gmBlackBoxY as usize; + for slack in [64usize, 1024, 8192] { + let mut buffer = vec![0u8; byte_count + slack]; + let mut matrix: MAT2 = std::mem::zeroed(); + matrix.eM11 = FIXED { fract: 0, value: 1 }; + matrix.eM22 = FIXED { fract: 0, value: 1 }; + let filled = GetGlyphOutlineW( + dc, + codepoint, + GGO_GRAY8_BITMAP, + metrics, + buffer.len() as u32, + buffer.as_mut_ptr().cast(), + &matrix, + ); + if filled != GDI_ERROR as u32 && filled as usize <= buffer.len() { + return Some(buffer); + } + } + None + } +} + +fn create_glyph_font(family: &[u16], height: i32, weight: i32) -> HFONT { + // SAFETY: a plain font creation; the face name is null-terminated and + // outlives the call. + unsafe { + CreateFontW( + -height, + 0, + 0, + 0, + weight, + 0, + 0, + 0, + u32::from(DEFAULT_CHARSET), + u32::from(OUT_TT_PRECIS), + u32::from(CLIP_DEFAULT_PRECIS), + u32::from(ANTIALIASED_QUALITY), + u32::from(DEFAULT_PITCH | FF_DONTCARE), + family.as_ptr(), + ) + } +} + +/// The glyph's measured visible bounds (`GGO_METRICS`): `None` when the +/// selected font cannot draw the character at all. +fn glyph_metrics(dc: HDC, codepoint: u32) -> Option { + // SAFETY: `dc` is a live DC with a font selected, and the metrics + // struct and transform matrix are valid for the call. + unsafe { + let mut matrix: MAT2 = std::mem::zeroed(); + matrix.eM11 = FIXED { fract: 0, value: 1 }; + matrix.eM22 = FIXED { fract: 0, value: 1 }; + let mut metrics: GLYPHMETRICS = std::mem::zeroed(); + let ok = GetGlyphOutlineW( + dc, + codepoint, + GGO_METRICS, + &mut metrics, + 0, + null_mut(), + &matrix, + ); + (ok != GDI_ERROR as u32).then_some(metrics) + } +} + pub fn autostart_enabled() -> bool { let subkey = wide(RUN_SUBKEY); let value_name = wide(VALUE_NAME); @@ -70,3 +592,123 @@ fn wide(value: impl AsRef) -> Vec { fn registry_error(code: WIN32_ERROR) -> std::io::Error { std::io::Error::from_raw_os_error(code as i32) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_glyph_matches_the_level_in_ten_percent_steps() { + assert_eq!(battery_glyph(0, false), 0xE850); + assert_eq!(battery_glyph(9, false), 0xE850); + assert_eq!(battery_glyph(10, false), 0xE851); + assert_eq!(battery_glyph(45, false), 0xE854); + assert_eq!(battery_glyph(95, false), 0xE859); + assert_eq!(battery_glyph(100, false), 0xE83F); + assert_eq!(battery_glyph(140, false), 0xE83F); + assert_eq!(battery_glyph(0, true), 0xE85A); + assert_eq!(battery_glyph(45, true), 0xE85E); + assert_eq!(battery_glyph(90, true), 0xE83E); + assert_eq!(battery_glyph(100, true), 0xE83F); + } + + #[test] + fn the_glyph_style_fills_the_square() { + for size in [16u32, 20, 24, 32] { + let (rgba, width, height) = battery_icon_rgba( + 87, + false, + super::super::TrayBatteryStyle::Glyph, + size, + (255, 255, 255), + ) + .expect("Segoe battery glyph should render"); + assert_eq!((width, height), (size, size), "square at {size}px"); + assert_eq!(rgba.len(), (size * size * 4) as usize); + let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; + // The glyph's visible bounds fill the canvas: across the + // middle row, the covered span (first to last covered pixel — + // the battery's body-to-nub gap doesn't count against it) is + // at most two blank columns short of the full width. + let row = size / 2; + let covered: Vec = (0..size).filter(|x| coverage(*x, row) > 0).collect(); + let span = covered + .last() + .and_then(|last| covered.first().map(|first| last - first + 1)); + assert!( + span.is_some_and(|span| span + 2 >= size), + "glyph spans {span:?} of {size}px at {size}px" + ); + // The battery is a horizontal glyph, so it clears the top and + // bottom rows. + assert_eq!(coverage(size / 2, 0), 0); + assert_eq!(coverage(size / 2, size - 1), 0); + } + } + + #[test] + fn the_percentage_style_fills_the_square_with_the_number() { + for size in [16u32, 20, 24, 32] { + for percent in [87u8, 100] { + let (rgba, width, height) = battery_icon_rgba( + percent, + false, + super::super::TrayBatteryStyle::Percentage, + size, + (255, 255, 255), + ) + .expect("Segoe battery number should render"); + assert_eq!((width, height), (size, size), "square at {size}px"); + let coverage = |x: u32, y: u32| rgba[((y * size + x) * 4 + 3) as usize]; + let row = size / 2; + // The digits fill the square vertically and reach past + // half of the middle row ("100" is squeezed to fit its + // width inside). + let first = (0..width).find(|x| coverage(*x, row) > 0); + let last = (0..width).rev().find(|x| coverage(*x, row) > 0); + assert!( + first + .zip(last) + .is_some_and(|(first, last)| last + 1 - first >= size / 2), + "digits span {first:?}..{last:?} of {size}px at {size}px" + ); + // No content in the margin rows. + assert_eq!((0..size).filter(|x| coverage(*x, 0) > 0).count(), 0); + } + } + } + + #[test] + fn charging_renders_a_bolt_glyph_at_every_level() { + for percent in [0u8, 45, 90, 100] { + let (rgba, _, _) = battery_icon_rgba( + percent, + true, + super::super::TrayBatteryStyle::Glyph, + 16, + (255, 255, 255), + ) + .expect("charging display"); + assert!( + rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] > 0), + "charging at {percent}% must paint" + ); + } + } + + #[test] + fn the_ink_color_carries_through() { + for style in [ + super::super::TrayBatteryStyle::Glyph, + super::super::TrayBatteryStyle::Percentage, + ] { + let (rgba, _, _) = + battery_icon_rgba(12, false, style, 16, (250, 100, 50)).expect("display"); + for pixel in rgba.as_chunks::<4>().0 { + if pixel[3] > 0 { + assert_eq!((pixel[0], pixel[1], pixel[2]), (250, 100, 50)); + } + } + } + } +} diff --git a/src/remote_image.rs b/src/remote_image.rs 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..6d356c5 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,6 +1,6 @@ use std::{ - collections::HashMap, - path::PathBuf, + collections::{HashMap, HashSet, VecDeque}, + path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -8,13 +8,14 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::{ applications::{self, ApplicationInfo}, - config::{self, ApplicationProfile, BridgeConfig, GameConfig}, + config::{self, ApplicationProfile, BridgeConfig, GameConfig, LocalApplication}, + drivers, games::GameDetector, platform, }; @@ -43,6 +44,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 +56,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( @@ -75,13 +99,32 @@ const PROFILE_DEBOUNCE: Duration = Duration::from_millis(2500); /// without the control panel open. Each read spawns the native-hid helper. const BATTERY_POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); -/// A reading older than this is shown as stale: more than two missed polls. +/// A reading this old is shown as stale: more than two missed polls. const BATTERY_STALE_AFTER: Duration = Duration::from_secs(12 * 60); /// A reading this old belongs to a mouse that is gone, not just asleep: it is /// dropped from the list instead of lingering as a ghost device. const BATTERY_EVICT_AFTER: Duration = Duration::from_secs(24 * 60 * 60); +/// How many samples of each device's percent history are kept for the tray +/// tooltip's time-remaining estimate. +const BATTERY_HISTORY_SAMPLES: usize = 48; + +/// Percent samples older than this no longer count toward a trend, so a +/// mouse's days-old readings never poison the estimate. +const BATTERY_TREND_WINDOW: Duration = Duration::from_secs(6 * 60 * 60); + +/// A trend needs at least this much history before an estimate is worth +/// showing: two samples 5 minutes apart are mostly quantization noise. +const BATTERY_MIN_TREND_SPAN: Duration = Duration::from_secs(30 * 60); + +/// The smallest drift (percent per minute) that counts as a trend, about 1% +/// per 3 hours — the pace a mouse battery actually moves at. +const BATTERY_TREND_EPSILON: f64 = 0.005; + +/// Estimates beyond this are noise for a mouse battery, not a plan. +const BATTERY_MAX_ESTIMATE_MINUTES: u32 = 48 * 60; + /// Drops battery entries that can no longer describe a live mouse: anything /// past `BATTERY_EVICT_AFTER`, and anything outside the saved profiles that /// has also gone stale (a renamed or removed device, or an id a client pushed @@ -109,41 +152,132 @@ 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 { + // The catalog plus the user's custom games, so a custom game's profile activates too. + let tracked_games = config.tracked_games(); + let eligible = |profile: &&ApplicationProfile| { + if !profile.enabled { + return false; + } + if let Some(id) = &profile.application.local_id { + return config + .local_applications + .iter() + .find(|target| &target.id == id) + .is_some_and(|target| { + if target.path.is_empty() { + return false; + } + match target.effective_activation() { + config::LocalApplicationActivation::Running => { + processes.iter().any(|process| { + crate::games::matches_target( + &target.path, + &target.executable, + &process.path, + &process.executable, + ) + }) + } + config::LocalApplicationActivation::Focused => { + applications.iter().any(|application| { + application.foreground + && crate::games::matches_target( + &target.path, + &target.executable, + &application.path, + &application.executable, + ) + }) + } + } + }); + } + let application = &profile.application; + let focused = applications.iter().any(|running| { + running.foreground + && if !application.path.is_empty() { + crate::games::matches_target( + &application.path, + &application.executable, + &running.path, + &running.executable, + ) + } else if !application.executable.is_empty() { + application + .executable + .eq_ignore_ascii_case(&running.executable) + } else { + !application.name.is_empty() + && application.name.eq_ignore_ascii_case(&running.name) + } }); - 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 + || tracked_games.iter().any(|game| { + active_games.contains(&game.name) + && if application.path.is_empty() { + profile_matches_game(profile, game) + } else { + processes.iter().any(|process| { + crate::games::matches_target( + &application.path, + &application.executable, + &process.path, + &process.executable, + ) && game.executables.iter().any(|executable| { + executable.eq_ignore_ascii_case(&process.executable) + }) + }) + } + }) }; - 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 +289,76 @@ 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) +} + +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 @@ -229,13 +428,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 +448,31 @@ 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>>, + buttons: Arc, + native_automation: Arc>, +} + +/// A control panel is connected when it holds a HID WebSocket session or +/// handshook within the last few seconds. +fn panel_connected(state: &BridgeState) -> bool { + crate::hid::client_session_active() + || state + .last_client_heartbeat + .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)) } 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,10 +482,34 @@ struct BridgeState { // Debounced active-profile tracking for switch notifications. active_profile_key: Option, applied_profile: Option, + native_automation_error: Option, + pending_native_restores: usize, pending_profile: Option<(Option, Instant)>, profile_seeded: bool, started_at: Instant, last_client_heartbeat: Option, + /// Who currently drives the mouse: Bridge in onboard memory mode or the + /// web panel in live mode. Tracked by the mode monitor. + device_mode: DeviceMode, +} + +/// Who currently drives the mouse. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeviceMode { + /// Bridge drives the mouse itself: the active profile's stored settings + /// live in the onboard memory and the battery is read natively. + Onboard, + /// The OpenMouse web panel holds the HID session and drives the mouse. + Live, +} + +impl DeviceMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Onboard => "onboard", + Self::Live => "live", + } + } } #[derive(Clone, Debug, Deserialize)] @@ -277,6 +520,15 @@ pub struct BatteryReading { pub percent: u8, #[serde(default)] pub charging: bool, + /// The settings currently in the mouse's onboard memory, when the + /// reader could see them — native reads report them; a control panel's + /// `PUT /v1/battery` may not. + #[serde(default)] + pub dpi: Option, + #[serde(default)] + pub polling_rate_hz: Option, + #[serde(default)] + pub switch_mode: Option, } struct BatteryState { @@ -284,7 +536,46 @@ struct BatteryState { device_name: String, percent: u8, charging: bool, + dpi: Option, + polling_rate_hz: Option, + switch_mode: Option, updated_at: Instant, + // Recent (reading time, percent) samples, oldest first, for the tray + // tooltip's time-remaining estimate. + history: VecDeque<(Instant, u8)>, +} + +/// Estimates the minutes left until the battery is empty (discharging) or +/// full (charging) from the percent history: the level's drift across the +/// samples inside the trend window. `None` while there is no confident +/// trend yet — too little history, or the level is holding still. +fn estimate_minutes_remaining( + history: &VecDeque<(Instant, u8)>, + percent: u8, + charging: bool, +) -> Option { + let now = Instant::now(); + let recent = |at: &Instant| now.duration_since(*at) <= BATTERY_TREND_WINDOW; + let (first_at, first_percent) = history.iter().find(|(at, _)| recent(at))?; + let (last_at, last_percent) = history.iter().rev().find(|(at, _)| recent(at))?; + if last_at <= first_at { + return None; + } + let span = last_at.duration_since(*first_at); + if span < BATTERY_MIN_TREND_SPAN { + return None; + } + let span_minutes = span.as_secs_f64() / 60.0; + let rate = f64::from(*last_percent as i16 - *first_percent as i16) / span_minutes; + let raw_minutes = if charging { + (rate > BATTERY_TREND_EPSILON).then(|| f64::from(100 - percent) / rate)? + } else { + (rate < -BATTERY_TREND_EPSILON).then(|| f64::from(percent) / -rate)? + }; + // Round to 5-minute granularity so the estimate holds still instead of + // jittering every snapshot. + let minutes = (raw_minutes / 5.0).round() as u32 * 5; + (minutes > 0 && minutes <= BATTERY_MAX_ESTIMATE_MINUTES).then_some(minutes) } #[derive(Clone, Debug, Serialize)] @@ -295,6 +586,18 @@ pub struct DeviceBattery { pub percent: u8, pub charging: bool, pub stale: bool, + /// Minutes until the battery is empty (discharging) or full (charging), + /// from the recent trend; `None` while there is no confident estimate. + #[serde(skip_serializing_if = "Option::is_none")] + pub minutes_remaining: Option, + /// The settings currently in the mouse's onboard memory, from the most + /// recent native status read; `None` when unreadable. + #[serde(skip_serializing_if = "Option::is_none")] + pub dpi: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polling_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub switch_mode: Option, } /// A mouse the control panel currently holds open over native HID, most @@ -329,6 +632,12 @@ pub struct BridgeSnapshot { pub games: Vec, pub tracked_game_count: usize, pub battery_threshold_percent: u8, + pub tray_battery_style: String, + pub hybrid_mode: bool, + /// Who drives the mouse right now: `"onboard"` (Bridge applies the + /// stored settings and reads the battery) or `"live"` (the web panel + /// holds the HID session). + pub device_mode: String, pub autostart_enabled: bool, pub automatic_updates: bool, pub notification_sound: bool, @@ -336,6 +645,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,20 +660,26 @@ impl BridgeService { inner: Arc::new(RwLock::new(BridgeState { config, active_games: Vec::new(), + processes: Vec::new(), applications: Vec::new(), running_applications: Vec::new(), application_icons: HashMap::new(), battery: HashMap::new(), active_profile_key: None, applied_profile: None, + native_automation_error: None, + pending_native_restores: 0, pending_profile: None, profile_seeded: false, started_at: Instant::now(), last_client_heartbeat: None, + device_mode: DeviceMode::Onboard, })), config_path: Arc::new(config_path), notice_listener: Arc::default(), show_panel: Arc::default(), + buttons: Arc::default(), + native_automation: Arc::default(), } } @@ -387,15 +704,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 @@ -431,29 +747,44 @@ impl BridgeService { pub async fn snapshot(&self) -> BridgeSnapshot { let state = self.inner.read().await; - let client_connected = crate::hid::client_session_active() - || state - .last_client_heartbeat - .is_some_and(|heartbeat| heartbeat.elapsed() < Duration::from_secs(20)); + let client_connected = panel_connected(&state); + let device_mode = state.device_mode; let foreground_application = state .applications .iter() .find(|application| application.foreground) .cloned(); - let active_profile = - active_profile_for(&state.config, &state.applications, &state.active_games); + let tracked_games = state.config.tracked_games(); + let active_profile = resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + state.applied_profile.as_ref(), + ); let active_profile_is_default = active_profile.is_some() && active_profile == state.config.default_profile; let mut batteries = state .battery .iter() .filter(|(_, entry)| entry.updated_at.elapsed() < BATTERY_EVICT_AFTER) - .map(|(device_id, entry)| DeviceBattery { - device_id: device_id.clone(), - device_name: entry.device_name.clone(), - percent: entry.percent, - charging: entry.charging, - stale: entry.updated_at.elapsed() >= BATTERY_STALE_AFTER, + .map(|(device_id, entry)| { + let stale = entry.updated_at.elapsed() >= BATTERY_STALE_AFTER; + DeviceBattery { + device_id: device_id.clone(), + device_name: entry.device_name.clone(), + percent: entry.percent, + charging: entry.charging, + minutes_remaining: if stale { + None + } else { + estimate_minutes_remaining(&entry.history, entry.percent, entry.charging) + }, + dpi: entry.dpi, + polling_rate_hz: entry.polling_rate_hz, + switch_mode: entry.switch_mode.clone(), + stale, + } }) .collect::>(); batteries.sort_by(|a, b| a.device_id.cmp(&b.device_id)); @@ -471,18 +802,53 @@ 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(), - games: state - .config - .games + active_games: state + .active_games + .iter() + .filter_map(|active| { + state + .config + .local_applications + .iter() + .find(|target| &target.id == active) + .map(|target| target.name.clone()) + .or_else(|| { + tracked_games + .iter() + .find(|game| &game.name == active) + .map(|game| game.name.clone()) + }) + }) + .collect(), + games: tracked_games .iter() .map(|game| GameActivity { name: game.name.clone(), active: state.active_games.iter().any(|active| active == &game.name), }) + .chain( + state + .config + .local_applications + .iter() + .filter(|target| target.kind == config::LocalApplicationKind::Game) + .map(|target| GameActivity { + name: target.name.clone(), + active: state.active_games.contains(&target.id), + }), + ) .collect(), - tracked_game_count: state.config.games.len(), + tracked_game_count: tracked_games.len() + + state + .config + .local_applications + .iter() + .filter(|target| target.kind == config::LocalApplicationKind::Game) + .count(), battery_threshold_percent: state.config.battery_threshold_percent, + tray_battery_style: state.config.tray_battery_style.clone(), + hybrid_mode: state.config.hybrid_mode, + device_mode: device_mode.as_str().to_owned(), autostart_enabled: platform::autostart_enabled(), automatic_updates: state.config.automatic_updates, notification_sound: state.config.notification_sound, @@ -490,6 +856,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 +896,148 @@ 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; + let tracked = state.config.tracked_games(); + tracked.iter().map(|game| { + let custom = !state.config.games.iter().any(|preset| preset.name == game.name); + serde_json::json!({"name":game.name,"executables":game.executables,"source":if custom { "custom" } else { "preset" }}) + }) + .chain(state.config.local_applications.iter().map(|target| { + let mut entry = serde_json::json!({"id":target.id,"name":target.name,"executables":[target.executable],"source":"local","path":target.path,"kind":target.kind}); + if let Some(activation) = target.activation { entry["activation"] = serde_json::json!(activation); } + if let Some(path) = &target.bundle_path { entry["bundlePath"] = path.clone().into(); } + if let Some(identifier) = &target.bundle_identifier { entry["bundleIdentifier"] = identifier.clone().into(); } + entry + })).collect() } - pub async fn 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 custom_games(&self) -> Vec { + self.inner.read().await.config.custom_games.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) + pub async fn set_custom_games(&self, games: Vec) -> Result> { + self.update_config(|config| { + config.custom_games = games; + Ok(()) + }) + .await?; + let saved = self.custom_games().await; + let names: Vec<&str> = saved.iter().map(|game| game.name.as_str()).collect(); + prune_game_images(&game_image_dir(&self.config_path), &names); + Ok(saved) + } + + pub async fn custom_game_image(&self, name: &str) -> Option<(Vec, &'static str)> { + let bytes = tokio::fs::read(game_image_file(&game_image_dir(&self.config_path), name)) + .await + .ok()?; + let kind = image_type(&bytes)?; + Some((bytes, kind)) + } + + pub async fn set_custom_game_image(&self, name: &str, image: &[u8]) -> Result<()> { + let known = self + .inner + .read() + .await + .config + .custom_games + .iter() + .any(|game| game.name.eq_ignore_ascii_case(name)); + anyhow::ensure!(known, "{name} is not a custom game"); + anyhow::ensure!( + image_type(image).is_some(), + "the image must be a PNG, JPEG or WebP of at most {MAX_IMAGE_BYTES} bytes" + ); + let dir = game_image_dir(&self.config_path); + tokio::fs::create_dir_all(&dir) + .await + .context("could not create the image folder")?; + tokio::fs::write(game_image_file(&dir, name), image) + .await + .context("could not save the image") + } + + pub async fn local_applications(&self) -> Vec { + self.inner.read().await.config.local_applications.clone() + } + + pub async fn default_profile(&self) -> Option { + self.inner.read().await.config.default_profile.clone() + } + + async fn update_config( + &self, + update: impl FnOnce(&mut BridgeConfig) -> Result<()>, + ) -> Result<()> { + let mut state = self.inner.write().await; + let mut next = state.config.clone(); + update(&mut next)?; + next = next.normalized(); + for profile in next + .profiles + .iter_mut() + .chain(next.default_profile.iter_mut()) + { + crate::local_targets::canonicalize_profile(profile, &next.local_applications)?; + } + config::save(&self.config_path, &next)?; + state.config = next; + Ok(()) + } + + pub async fn replace_local_applications( + &self, + applications: Vec, + ) -> Result> { + let mut canonical = Vec::new(); + self.update_config(|config| { + let applications = crate::local_targets::canonicalize_against( + &applications, + &config.local_applications, + )?; + canonical = applications.clone(); + config.profiles.retain(|profile| { + profile + .application + .local_id + .as_ref() + .is_none_or(|id| applications.iter().any(|target| &target.id == id)) + }); + if config + .default_profile + .as_ref() + .and_then(|profile| profile.application.local_id.as_ref()) + .is_some_and(|id| !applications.iter().any(|target| &target.id == id)) + { + config.default_profile = None; + } + config.local_applications = applications; + Ok(()) + }) + .await?; + Ok(canonical) + } + + pub async fn 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 +1047,62 @@ 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<()> { + self.update_config(|config| { + config.battery_threshold_percent = percent; + Ok(()) + }) + .await + } + + pub async fn set_tray_battery_style(&self, style: &str) -> Result<()> { let config = { let mut state = self.inner.write().await; - state.config.battery_threshold_percent = percent; + state.config.tray_battery_style = style.to_owned(); state.config = state.config.clone().normalized(); state.config.clone() }; config::save(&self.config_path, &config) } - pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { + pub async fn set_hybrid_mode(&self, enabled: bool) -> Result<()> { let config = { let mut state = self.inner.write().await; - state.config.automatic_updates = enabled; + state.config.hybrid_mode = enabled; state.config.clone() }; config::save(&self.config_path, &config) } + pub async fn set_automatic_updates(&self, enabled: bool) -> Result<()> { + self.update_config(|config| { + config.automatic_updates = enabled; + Ok(()) + }) + .await + } + pub async fn set_notification_sound(&self, enabled: bool) -> Result<()> { - 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. @@ -619,13 +1119,24 @@ impl BridgeService { let mut state = self.inner.write().await; let threshold = state.config.battery_threshold_percent; let cooldown = Duration::from_secs(state.config.alert_cooldown_minutes * 60); - let previous_alert = state - .battery - .get(&reading.device_id) - .and_then(|entry| entry.last_alert); + let previous = state.battery.get(&reading.device_id); + let previous_alert = previous.and_then(|entry| entry.last_alert); let should_alert = !reading.charging && reading.percent <= threshold && previous_alert.is_none_or(|last| last.elapsed() >= cooldown); + let mut history = previous + .map(|entry| entry.history.clone()) + .unwrap_or_default(); + history.push_back((Instant::now(), reading.percent)); + while history.len() > BATTERY_HISTORY_SAMPLES { + history.pop_front(); + } + // A control panel's `PUT /v1/battery` reports the charge level + // only; keep the settings a previous native status read saw + // instead of blanking them until the next native poll. + let (previous_dpi, previous_rate, previous_mode) = previous + .map(|entry| (entry.dpi, entry.polling_rate_hz, entry.switch_mode.clone())) + .unwrap_or((None, None, None)); state.battery.insert( reading.device_id.clone(), BatteryState { @@ -637,7 +1148,11 @@ impl BridgeService { device_name: reading.device_name.clone(), percent: reading.percent, charging: reading.charging, + dpi: reading.dpi.or(previous_dpi), + polling_rate_hz: reading.polling_rate_hz.or(previous_rate), + switch_mode: reading.switch_mode.or(previous_mode), updated_at: Instant::now(), + history, }, ); should_alert @@ -665,56 +1180,135 @@ impl BridgeService { interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - let devices = profile_devices(&service.inner.read().await.config); - // Pruning is in-memory only, so it runs even while the - // control panel holds a HID session and reads are paused. - prune_batteries(&mut service.inner.write().await.battery, &devices); - if crate::hid::client_session_active() { + poll_batteries_once(service.clone()).await; + } + }); + } + + /// Watches the connection state and flips between the two halves of + /// hybrid mode: while the OpenMouse web panel is disconnected, Bridge + /// drives the mouse in onboard memory mode (stored settings applied, + /// battery read natively); the moment the panel connects it takes the + /// mouse over live, and when it leaves, Bridge restores the stored + /// settings and reads the battery right away. + pub fn start_mode_monitor(&self) { + let service = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + let mode = { + let state = service.inner.read().await; + if panel_connected(&state) { + DeviceMode::Live + } else { + DeviceMode::Onboard + } + }; + let previous = { + let mut state = service.inner.write().await; + let previous = state.device_mode; + state.device_mode = mode; + previous + }; + if previous == mode { continue; } - for (device_id, device_name) in devices { - let id = device_id.clone(); - let outcome = - tokio::task::spawn_blocking(move || crate::drivers::read_battery(&id)) - .await; - match outcome { - Ok(Ok(Some(battery))) => { - let reading = BatteryReading { - device_id, - device_name, - percent: battery.percent, - charging: battery.charging, - }; - if let Err(error) = service.record_battery(reading).await { - tracing::warn!(%error, "Could not record the mouse battery"); - } - } - Ok(Ok(None)) => { - tracing::debug!(%device_id, "No native battery reader for this device"); - } - Ok(Err(error)) => { - tracing::debug!(%error, %device_id, "Could not read the mouse battery"); - } - Err(error) => { - tracing::warn!(%error, "The battery reader stopped unexpectedly"); + match mode { + DeviceMode::Live => { + tracing::info!("the web panel connected — it drives the mouse live now"); + } + DeviceMode::Onboard => { + tracing::info!("the web panel disconnected — resuming onboard memory mode"); + let hybrid = service.inner.read().await.config.hybrid_mode; + if !hybrid { + continue; } + resume_onboard(&service).await; } } } }); } + async fn reconcile_native_with( + &self, + operation: impl FnOnce(Option) -> (Option, usize) + Send + 'static, + ) -> Option { + let mut state = self.inner.clone().write_owned().await; + let selected = resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + state.applied_profile.as_ref(), + ); + if selected.is_some() && selected != state.applied_profile { + return None; + } + let desired = selected; + let result = tokio::task::spawn_blocking(move || { + let has_profile = desired.is_some(); + let (error, pending) = operation(desired); + let outcome = match &error { + Some(message) if message.contains("unsupported") => Some(ApplyOutcome::NoDriver), + Some(_) => Some(ApplyOutcome::Failed), + None if has_profile => Some(ApplyOutcome::Applied), + None => None, + }; + state.native_automation_error = error; + state.pending_native_restores = pending; + outcome + }) + .await; + match result { + Ok(outcome) => outcome, + Err(error) => { + tracing::warn!(%error, "Native automation worker failed without publishing a stale completion"); + Some(ApplyOutcome::Failed) + } + } + } + + async fn reconcile_native(&self) -> Option { + let automation = self.native_automation.clone(); + self.reconcile_native_with(move |desired| { + let mut automation = automation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let result = crate::drivers::reconcile_automatic(&mut automation, desired.as_ref()); + ( + result.err().map(|error| format!("{error:#}")), + automation.pending_restores_for(desired.as_ref()), + ) + }) + .await + } + pub fn start_game_monitor(&self, extract_icons: Arc) { let service = self.clone(); tokio::spawn(async move { let mut detector = GameDetector::default(); let mut interval = tokio::time::interval(Duration::from_secs(3)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - let games = service.inner.read().await.config.games.clone(); - let active = detector.detect(&games); + let (games, local_applications) = { + let state = service.inner.read().await; + ( + state.config.tracked_games(), + state.config.local_applications.clone(), + ) + }; + let mut active = detector.detect(&games); + let processes = detector.running_processes(); + active.extend(crate::games::matches_local_games( + &local_applications, + &processes, + )); let 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 +1333,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 +1343,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 +1366,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!( @@ -786,39 +1388,158 @@ 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); + // Read from the config each time: editing the active profile's + // keys does not change its key, so the applied copy goes stale. + let desired_buttons = state + .active_profile_key + .as_deref() + .and_then(|key| current_profile(&state.config, key)) + .and_then(|(profile, is_default)| { + crate::buttons::Assignments::from_profile(profile, is_default) }); + drop(state); + let panel_connected = crate::hid::client_session_active(); + // A control panel holding the mouse is not interrupted by a new helper. + service.buttons.update(desired_buttons, !panel_connected); + let outcome = service.reconcile_native().await; + if let Some((current, previous, true)) = switch + && let Some(message) = switch_message( + previous.as_ref(), + current.as_ref(), + default.as_ref(), + outcome, + panel_connected, + ) + { + service.announce(message); } } }); } } +/// One battery-polling pass: covers every device named by the saved profiles +/// plus every mouse Bridge discovered on the HID bus whose brand no profile +/// already names, reads each mouse's battery natively (skipped while the web +/// panel holds the session — it reports the battery itself over +/// `PUT /v1/battery`) and records it. Used by the five-minute monitor and by +/// onboard-mode handovers. +async fn poll_batteries_once(service: BridgeService) { + let profiled = profile_devices(&service.inner.read().await.config); + let discovered = tokio::task::spawn_blocking(drivers::detect_mice) + .await + .unwrap_or_default(); + let devices = merge_detected_mice(profiled, &discovered); + // Pruning is in-memory only, so it runs even while the + // control panel holds a HID session and reads are paused. + prune_batteries(&mut service.inner.write().await.battery, &devices); + if crate::hid::client_session_active() { + return; + } + for (device_id, device_name) in devices { + let id = device_id.clone(); + let outcome = tokio::task::spawn_blocking(move || crate::drivers::read_status(&id)).await; + match outcome { + Ok(Ok(Some(status))) => { + // The web panel reports batteries as `:` (the + // driver's own model name), while discovery ids use the HID + // descriptor's tail (a LIGHTSPEED receiver reads + // `Logitech:046d:c547`). Recording under the panel's name + // for the same physical mouse keeps one entry instead of + // duplicate rows; the discovery-shaped entry is dropped. + let record_id = status + .battery + .reported_name + .as_ref() + .map(|name| canonical_battery_identity(&device_id, name)) + .unwrap_or_else(|| device_id.clone()); + if record_id != device_id { + service.inner.write().await.battery.remove(&device_id); + } + let record_name = status.battery.reported_name.clone().unwrap_or(device_name); + let reading = BatteryReading { + device_id: record_id, + device_name: record_name, + percent: status.battery.percent, + charging: status.battery.charging, + dpi: status.dpi, + polling_rate_hz: status.polling_rate_hz, + switch_mode: status.switch_mode, + }; + if let Err(error) = service.record_battery(reading).await { + tracing::warn!(%error, "Could not record the mouse battery"); + } + } + Ok(Ok(None)) => { + tracing::debug!(%device_id, "No native battery reader for this device"); + } + Ok(Err(error)) => { + tracing::debug!(%error, %device_id, "Could not read the mouse battery"); + } + Err(error) => { + tracing::warn!(%error, "The battery reader stopped unexpectedly"); + } + } + } +} + +/// The device id the web panel would use for the same physical mouse: +/// `:`, from the driver's own `MouseStatus.name`. +fn canonical_battery_identity(device_id: &str, reported_name: &str) -> String { + let brand = device_id.split(':').next().unwrap_or_default(); + format!("{brand}:{reported_name}") +} + +/// The devices battery polling should cover: the saved profiles' devices plus +/// discovered mice whose brand no profile already names — discovery fills the +/// gap before the panel has ever saved a profile, without double-reporting a +/// mouse a profile already covers (profile and discovery ids for the same +/// physical mouse differ, so brand-level dedup keeps one battery entry). +fn merge_detected_mice( + mut profiled: Vec<(String, String)>, + detected: &[drivers::DetectedMouse], +) -> Vec<(String, String)> { + for mouse in detected { + let brand = mouse.device_id.split(':').next().unwrap_or_default(); + let already_covered = profiled.iter().any(|(id, _)| { + id.split(':') + .next() + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(brand)) + }); + if !already_covered { + profiled.push((mouse.device_id.clone(), mouse.name.clone())); + } + } + profiled +} + +/// Enters onboard memory mode: pushes the stored settings to the mouse's +/// onboard memory (when a default profile exists — without one, the device +/// simply keeps the memory it already has) and reads the battery right away. +/// Shared by the startup seed and the panel-disconnect handover. +async fn resume_onboard(service: &BridgeService) { + let profile = { + let state = service.inner.read().await; + resolve_profile_retaining( + &state.config, + &state.applications, + &state.active_games, + &state.processes, + None, + ) + }; + if let Some(profile) = profile { + tracing::info!( + profile = %profile.application.name, + "applying the stored settings to the onboard memory" + ); + std::thread::spawn(move || { + apply_natively(&profile); + }); + } + tokio::spawn(poll_batteries_once(service.clone())); +} + fn apply_natively(profile: &ApplicationProfile) -> ApplyOutcome { match crate::drivers::apply_profile(profile) { Ok(true) => { @@ -852,6 +1573,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")) @@ -868,6 +1590,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 +1618,512 @@ mod tests { } } + #[tokio::test] + async fn failed_native_join_does_not_publish_unowned_stale_completion() { + let bridge = service(BridgeConfig::default()); + bridge.inner.write().await.native_automation_error = Some("owned status".into()); + assert_eq!( + bridge + .reconcile_native_with(|_| panic!("inert worker failure")) + .await, + Some(ApplyOutcome::Failed) + ); + assert_eq!( + bridge.inner.read().await.native_automation_error.as_deref(), + Some("owned status") + ); + } + + #[tokio::test] + async fn native_worker_retains_serialization_after_waiter_cancellation() { + let bridge = service(BridgeConfig::default()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let running = bridge.clone(); + let waiter = tokio::spawn(async move { + running + .reconcile_native_with(move |_| { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + (Some("retained completion".into()), 1) + }) + .await + }); + started_rx.await.unwrap(); + waiter.abort(); + let _ = waiter.await; + assert!(bridge.inner.try_write().is_err()); + release_tx.send(()).unwrap(); + let state = tokio::time::timeout(Duration::from_secs(5), bridge.inner.write()) + .await + .unwrap(); + assert_eq!( + state.native_automation_error.as_deref(), + Some("retained completion") + ); + assert_eq!(state.pending_native_restores, 1); + } + + #[tokio::test] + async fn native_pending_handoff_keeps_original_baseline_without_old_writes() { + let old = test_profile("Old", "/old", Some(1600), None); + let next = test_profile("Next", "/next", Some(3200), None); + let bridge = service(BridgeConfig { + profiles: vec![old.clone(), next], + ..BridgeConfig::default() + }); + { + let mut state = bridge.inner.write().await; + state.applied_profile = Some(old); + state.applications = vec![test_app("Next", "/next", true)]; + } + assert_eq!(bridge.reconcile_native_with(|_| panic!("pending handoff must retain journal without applying/restoring until arbitration settles")).await, None); + } + + #[tokio::test] + async fn native_selection_rechecks_disabled_profile_before_hardware() { + let game = test_profile("Game", "/game", Some(1600), None); + let bridge = service(BridgeConfig { + profiles: vec![game.clone()], + ..BridgeConfig::default() + }); + { + let mut state = bridge.inner.write().await; + state.applied_profile = Some(game); + state.applications = vec![test_app("Game", "/game", true)]; + state.config.profiles[0].enabled = false; + } + bridge + .reconcile_native_with(|desired| { + assert!( + desired.is_none(), + "disabled profile must never reach the native writer" + ); + (None, 0) + }) + .await; + } + + #[tokio::test] + async fn native_reconcile_serializes_configuration_and_reports_truthful_failure() { + let game = test_profile("Game", "/game", Some(1600), None); + let bridge = service(BridgeConfig { + profiles: vec![game.clone()], + ..BridgeConfig::default() + }); + { + let mut state = bridge.inner.write().await; + state.applied_profile = Some(game.clone()); + state.applications = vec![test_app("Game", "/game", true)]; + } + let checked = bridge.clone(); + bridge + .reconcile_native_with(move |desired| { + assert_eq!(desired, Some(game)); + assert!( + checked.inner.try_write().is_err(), + "configuration must not change during the bounded HID operation" + ); + (Some("unsupported acknowledged readback".into()), 1) + }) + .await; + let snapshot = bridge.snapshot().await; + assert_eq!( + snapshot.native_automation_error.as_deref(), + Some("unsupported acknowledged readback") + ); + assert_eq!(snapshot.pending_native_restores, 1); + assert!(snapshot.active_profile.is_some()); + } + + #[tokio::test] + async fn running_arbitration_retains_current_then_uses_persisted_order() { + let mut first = test_profile("Zed", "/opt/zed", Some(800), None); + first.application.local_id = Some("local:zed".into()); + let mut second = test_profile("Alpha", "/opt/alpha", Some(1600), None); + second.application.local_id = Some("local:alpha".into()); + let config = BridgeConfig { + local_applications: serde_json::from_value(serde_json::json!([ + {"id":"local:zed","name":"Zed","executable":"zed","path":"/opt/zed","kind":"app","activation":"running"}, + {"id":"local:alpha","name":"Alpha","executable":"alpha","path":"/opt/alpha","kind":"app","activation":"running"} + ])).unwrap(), + profiles: vec![first.clone(), second.clone()], + ..BridgeConfig::default() + }.normalized(); + let first = config.profiles[0].clone(); + let second = config.profiles[1].clone(); + let bridge = service(config); + { + let mut state = bridge.inner.write().await; + state.active_games = vec!["local:alpha".into(), "local:zed".into()]; + state.processes = vec![ + crate::games::RunningProcess { + executable: "zed".into(), + path: "/opt/zed".into(), + }, + crate::games::RunningProcess { + executable: "alpha".into(), + path: "/opt/alpha".into(), + }, + ]; + } + assert_eq!(bridge.snapshot().await.active_profile, Some(first.clone())); + bridge.inner.write().await.applied_profile = Some(second.clone()); + assert_eq!(bridge.snapshot().await.active_profile, Some(second)); + bridge.inner.write().await.config.profiles[1].enabled = false; + assert_eq!(bridge.snapshot().await.active_profile, Some(first)); + bridge.inner.write().await.active_games.clear(); + bridge.inner.write().await.processes.clear(); + assert_eq!(bridge.snapshot().await.active_profile, None); + } + + #[test] + fn local_running_ids_cannot_override_changed_executable_identity() { + let target: config::LocalApplication = serde_json::from_value(serde_json::json!({ + "id":"local:editor","name":"Editor","executable":"editor","path":"/new/editor","kind":"app","activation":"running" + })).unwrap(); + let mut profile = test_profile("Editor", "/new/editor", Some(800), None); + profile.application.local_id = Some(target.id.clone()); + let config = BridgeConfig { + local_applications: vec![target], + profiles: vec![profile], + ..BridgeConfig::default() + }; + assert_eq!( + resolve_profile( + &config, + &[], + &["local:editor".into()], + &[crate::games::RunningProcess { + executable: "editor".into(), + path: "/old/editor".into() + }] + ), + None + ); + } + + #[test] + fn full_path_never_falls_back_to_name_or_executable() { + let profile = test_profile("Editor", "C:/Trusted/Editor.exe", Some(800), None); + let config = BridgeConfig { + profiles: vec![profile.clone()], + ..BridgeConfig::default() + }; + assert_eq!( + active_profile_for( + &config, + &[test_app("Editor", "C:/Other/Editor.exe", true)], + &[] + ), + None + ); + assert_eq!( + active_profile_for( + &config, + &[test_app("Editor", r"c:\trusted\EDITOR.exe", true)], + &[] + ), + Some(profile) + ); + let profile = test_profile("Editor", "/Apps/Editor", Some(800), None); + let config = BridgeConfig { + profiles: vec![profile], + ..BridgeConfig::default() + }; + assert_eq!( + active_profile_for(&config, &[test_app("Editor", "/apps/editor", true)], &[]), + None + ); + } + + #[test] + fn local_game_background_activity_is_id_linked_and_not_catalog_gated() { + let mut profile = test_profile("Editor", "/opt/Editor.exe", Some(1200), None); + profile.application.local_id = Some("local:editor".into()); + let config = BridgeConfig { + local_applications: vec![serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"/opt/Editor.exe","kind":"game"})).unwrap()], + profiles: vec![profile.clone()], + default_profile: Some(test_profile("Default", "", Some(800), None)), + ..BridgeConfig::default() + }; + assert_eq!( + resolve_profile( + &config, + &[], + &["local:editor".into()], + &[crate::games::RunningProcess { + executable: "Editor.exe".into(), + path: "/opt/Editor.exe".into() + }] + ), + Some(profile) + ); + } + + #[test] + fn local_foreground_apps_bypass_catalog_but_do_not_stay_active_in_background() { + let mut profile = test_profile("Editor", "C:/Tools/Editor.exe", Some(1200), None); + profile.application.local_id = Some("local:editor".into()); + let target = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"Editor.exe","path":"C:/Tools/Editor.exe","kind":"app"})).unwrap(); + let config = BridgeConfig { + local_applications: vec![target], + profiles: vec![profile.clone()], + ..BridgeConfig::default() + }; + let application = test_app("Editor", "C:/Tools/Editor.exe", true); + let selected = profile_applications( + std::slice::from_ref(&application), + &config.games, + &config.local_applications, + ); + assert_eq!(selected, [application]); + assert_eq!(active_profile_for(&config, &selected, &[]), Some(profile)); + let background = test_app("Editor", "C:/Tools/Editor.exe", false); + assert_eq!( + active_profile_for(&config, &[background], &["local:editor".into()]), + None + ); + } + + #[tokio::test] + async fn local_activity_status_uses_display_names_not_internal_ids() { + let target = serde_json::from_value(serde_json::json!({"id":"local:game","name":"Custom Game","executable":"custom.exe","path":"","kind":"game"})).unwrap(); + let bridge = service(BridgeConfig { + local_applications: vec![target], + ..BridgeConfig::default() + }); + bridge.inner.write().await.active_games = vec!["local:game".into()]; + let status = bridge.snapshot().await; + assert_eq!(status.active_games, ["Custom Game"]); + assert_eq!(status.tracked_game_count, 1); + assert!( + status + .games + .iter() + .any(|game| game.name == "Custom Game" && game.active) + ); + } + + #[test] + fn local_display_name_does_not_activate_an_unrelated_preset() { + let local: config::LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:editor","name":"Editor","executable":"custom.exe","path":"","kind":"app"})).unwrap(); + let preset = test_profile("Editor", "", Some(800), None); + let config = BridgeConfig { + games: vec![GameConfig { + name: "Editor".into(), + executables: vec!["Editor.exe".into()], + }], + local_applications: vec![local], + profiles: vec![preset], + ..BridgeConfig::default() + }; + let mut application = test_app("Editor", "C:/Tools/custom.exe", true); + application.executable = "custom.exe".into(); + let candidates = + profile_applications(&[application], &config.games, &config.local_applications); + assert_eq!(active_profile_for(&config, &candidates, &[]), None); + } + + #[test] + fn explicit_local_paths_win_and_disabled_locals_return_to_default() { + let broad: config::LocalApplication = serde_json::from_value(serde_json::json!({"id":"local:broad","name":"Same","executable":"Editor.exe","path":"","kind":"game"})).unwrap(); + let exact = config::LocalApplication { + id: "local:exact".into(), + path: "C:/Tools/Editor.exe".into(), + ..broad.clone() + }; + let mut broad_profile = test_profile("Same", "", Some(800), None); + broad_profile.application.local_id = Some(broad.id.clone()); + let mut exact_profile = test_profile("Same", &exact.path, Some(1600), None); + exact_profile.application.local_id = Some(exact.id.clone()); + let default = test_profile("Default", "", Some(400), None); + let mut config = BridgeConfig { + local_applications: vec![broad, exact], + profiles: vec![exact_profile.clone(), broad_profile], + default_profile: Some(default.clone()), + ..BridgeConfig::default() + }; + let foreground = test_app("Editor", "C:/Tools/Editor.exe", true); + assert_eq!( + resolve_profile( + &config, + std::slice::from_ref(&foreground), + &[], + &[crate::games::RunningProcess { + executable: foreground.executable.clone(), + path: foreground.path.clone() + }] + ), + Some(exact_profile.clone()) + ); + let processes = vec![crate::games::RunningProcess { + path: foreground.path.clone(), + executable: foreground.executable.clone(), + }]; + let active = crate::games::matches_local_games(&config.local_applications, &processes); + assert_eq!( + resolve_profile(&config, &[], &active, &processes), + Some(exact_profile) + ); + for profile in &mut config.profiles { + profile.enabled = false; + } + assert_eq!( + resolve_profile(&config, &[foreground], &active, &processes), + Some(default) + ); + } + + #[test] + fn background_preset_matching_never_ignores_a_saved_full_path() { + let profile = test_profile("Editor", "C:/Trusted/Editor.exe", Some(800), None); + let config = BridgeConfig { + games: vec![GameConfig { + name: "Editor".into(), + executables: vec!["Editor.exe".into()], + }], + profiles: vec![profile.clone()], + ..BridgeConfig::default() + }; + let wrong = crate::games::RunningProcess { + executable: "Editor.exe".into(), + path: "C:/Other/Editor.exe".into(), + }; + assert_eq!( + resolve_profile(&config, &[], &["Editor".into()], &[wrong]), + None + ); + let matching = crate::games::RunningProcess { + executable: "Editor.exe".into(), + path: r"c:\trusted\EDITOR.exe".into(), + }; + assert_eq!( + resolve_profile(&config, &[], &["Editor".into()], &[matching]), + Some(profile) + ); + } + + #[test] + fn local_and_preset_same_name_have_distinct_switch_keys() { + let preset = test_profile("Editor", "", Some(800), None); + let mut local = preset.clone(); + local.application.local_id = Some("local:editor".into()); + assert_ne!(profile_key(&preset), profile_key(&local)); + let mut other_device = local.clone(); + other_device.device.id = "other-device".into(); + assert_ne!(profile_key(&local), profile_key(&other_device)); + let mut other = local.clone(); + other.application.local_id = Some("local:other".into()); + assert_ne!(profile_key(&local), profile_key(&other)); + } + + #[test] + fn a_game_image_is_kept_under_a_stable_name_whatever_the_case() { + let dir = Path::new("icons"); + assert_eq!( + game_image_file(dir, "My Game"), + game_image_file(dir, "my game") + ); + assert_ne!( + game_image_file(dir, "My Game"), + game_image_file(dir, "Other") + ); + let file = game_image_file(dir, "../../evil\\name"); + assert_eq!( + file.parent(), + Some(dir), + "a hostile name cannot leave the image folder" + ); + } + + #[test] + fn only_a_png_jpeg_or_webp_of_a_sensible_size_is_kept_as_an_image() { + let mut png = PNG_SIGNATURE.to_vec(); + png.extend_from_slice(&[0; 32]); + assert_eq!(image_type(&png), Some("image/png")); + assert_eq!( + image_type(&[0xff, 0xd8, 0xff, 0xe0, 0, 0]), + Some("image/jpeg") + ); + let mut webp = b"RIFF\0\0\0\0WEBPVP8 ".to_vec(); + webp.extend_from_slice(&[0; 8]); + assert_eq!(image_type(&webp), Some("image/webp")); + assert_eq!(image_type(b"not an image"), None); + assert_eq!(image_type(b"GIF89a......"), None, "a GIF is not accepted"); + png.resize(MAX_IMAGE_BYTES + 1, 0); + assert_eq!(image_type(&png), None, "an oversized file is refused"); + } + + #[test] + fn removing_a_game_removes_its_image() { + let dir = std::env::temp_dir().join(format!("bridge-icons-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + for name in ["Kept", "Gone"] { + std::fs::write(game_image_file(&dir, name), PNG_SIGNATURE).expect("image"); + } + prune_game_images(&dir, &["Kept"]); + assert!(game_image_file(&dir, "Kept").exists()); + assert!(!game_image_file(&dir, "Gone").exists()); + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + #[test] + fn the_active_profile_is_read_from_the_config_so_edits_are_seen() { + let mut config = BridgeConfig { + default_profile: Some(test_profile("Default", "", Some(800), None)), + profiles: vec![test_profile("Valorant", "/games/valorant", Some(800), None)], + ..BridgeConfig::default() + }; + let game_key = profile_key(&config.profiles[0]); + let default_key = profile_key(config.default_profile.as_ref().unwrap()); + + let (game, is_default) = current_profile(&config, &game_key).expect("the game profile"); + assert_eq!( + (game.application.name.as_str(), is_default), + ("Valorant", false) + ); + let (default, is_default) = + current_profile(&config, &default_key).expect("the default profile"); + assert_eq!( + (default.application.name.as_str(), is_default), + ("Default", true) + ); + + // Editing a profile keeps its key, so the lookup finds the new settings. + config.default_profile = Some(test_profile("Default", "", Some(1600), None)); + let (edited, _) = + current_profile(&config, &default_key).expect("the edited default profile"); + assert_eq!(edited.settings.dpi, Some(1600)); + let other_key = profile_key(&test_profile("Other", "/games/other", None, None)); + assert!(current_profile(&config, &other_key).is_none()); + + // A disabled profile of the same game is not the one that is applied. + let mut disabled = test_profile("Valorant", "/games/valorant", Some(400), None); + disabled.enabled = false; + config.profiles = vec![disabled]; + assert!(current_profile(&config, &game_key).is_none()); + } + + #[test] + fn a_custom_games_profile_activates_while_the_game_runs() { + let mut profile = test_profile("My Game", "", Some(1600), None); + profile.application.executable = "mygame.exe".into(); + let config = BridgeConfig { + custom_games: vec![GameConfig { + name: "My Game".into(), + executables: vec!["mygame.exe".into()], + }], + profiles: vec![profile], + ..BridgeConfig::default() + } + .normalized(); + let active = resolve_profile_retaining(&config, &[], &["My Game".into()], &[], None) + .expect("the custom game's profile"); + assert_eq!(active.settings.dpi, Some(1600)); + assert!(resolve_profile_retaining(&config, &[], &[], &[], None).is_none()); + } + #[test] fn profile_devices_are_distinct_and_include_the_default() { let mut config = BridgeConfig::default(); @@ -999,6 +2231,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)); @@ -1066,6 +2315,9 @@ mod tests { device_name: "Mouse".into(), percent: 90, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap() @@ -1077,22 +2329,115 @@ mod tests { device_name: "Mouse".into(), percent: 10, charging: true, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap() ); } + #[tokio::test] + async fn settings_less_panel_reports_keep_the_stored_settings() { + let bridge = service(BridgeConfig::default()); + // Native status read: battery plus onboard settings. + bridge + .record_battery(BatteryReading { + device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), + device_name: "PRO X SUPERLIGHT 2 SE".into(), + percent: 81, + charging: false, + dpi: Some(800), + polling_rate_hz: Some(1000), + switch_mode: Some("Hybrid".into()), + }) + .await + .unwrap(); + // The panel's PUT carries the charge level only. + bridge + .record_battery(BatteryReading { + device_id: "Logitech:PRO X SUPERLIGHT 2 SE".into(), + device_name: "PRO X SUPERLIGHT 2 SE".into(), + percent: 80, + charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, + }) + .await + .unwrap(); + let battery = bridge + .snapshot() + .await + .batteries + .into_iter() + .next() + .expect("battery entry survives"); + assert_eq!(battery.percent, 80); + assert_eq!(battery.dpi, Some(800)); + assert_eq!(battery.polling_rate_hz, Some(1000)); + assert_eq!(battery.switch_mode.as_deref(), Some("Hybrid")); + } + fn battery_entry(age: Duration) -> BatteryState { BatteryState { last_alert: None, device_name: "Mouse".into(), percent: 50, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, updated_at: Instant::now() - age, + history: VecDeque::new(), } } + fn history(minutes_ago_percent: &[(u64, u8)]) -> VecDeque<(Instant, u8)> { + let now = Instant::now(); + minutes_ago_percent + .iter() + .map(|(minutes_ago, percent)| (now - Duration::from_secs(minutes_ago * 60), *percent)) + .collect() + } + + #[test] + fn a_steady_drain_estimates_time_to_empty() { + let samples = history(&[(60, 50), (30, 48), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), Some(540)); + } + + #[test] + fn a_steady_charge_estimates_time_to_full() { + let samples = history(&[(60, 20), (0, 80)]); + assert_eq!(estimate_minutes_remaining(&samples, 80, true), Some(20)); + } + + #[test] + fn a_flat_level_has_no_estimate() { + let samples = history(&[(60, 50), (0, 50)]); + assert_eq!(estimate_minutes_remaining(&samples, 50, false), None); + } + + #[test] + fn too_little_history_has_no_estimate() { + let samples = history(&[(10, 50), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); + } + + #[test] + fn samples_outside_the_trend_window_are_ignored() { + let samples = history(&[(10 * 60, 50), (0, 45)]); + assert_eq!(estimate_minutes_remaining(&samples, 45, false), None); + } + + #[test] + fn an_estimate_past_two_days_is_noise() { + let samples = history(&[(60, 50), (0, 49)]); + assert_eq!(estimate_minutes_remaining(&samples, 49, false), None); + } + #[test] fn prune_evicts_old_entries_and_unknown_stale_ones() { let mut battery = HashMap::new(); @@ -1131,6 +2476,9 @@ mod tests { device_name: id.into(), percent: 80, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap(); @@ -1229,4 +2577,78 @@ mod tests { assert!(bridge.snapshot().await.client_connected); } + + #[tokio::test] + async fn the_mode_flips_to_live_when_the_panel_handshakes() { + let bridge = service(BridgeConfig::default()); + assert_eq!(bridge.snapshot().await.device_mode, "onboard"); + + let monitored = bridge.clone(); + monitored.start_mode_monitor(); + bridge.record_client_heartbeat().await; + // The mode monitor ticks every second; give it a beat to spot the + // handshake and flip the mode. + tokio::time::sleep(Duration::from_millis(1400)).await; + + assert_eq!(bridge.snapshot().await.device_mode, "live"); + assert!(bridge.snapshot().await.hybrid_mode); + } + + #[test] + fn the_discovered_battery_id_becomes_the_panel_style_id() { + // The panel reports `:`; discovery's descriptor tail + // (a LIGHTSPEED receiver) must canonicalize onto that id so the + // same mouse never appears as two rows. + assert_eq!( + canonical_battery_identity("Logitech:046d:c547", "PRO X SUPERLIGHT 2 SE"), + "Logitech:PRO X SUPERLIGHT 2 SE" + ); + // Brands with no model name keep their discovered id. + assert_eq!( + canonical_battery_identity("Logitech:046d:c547", ""), + "Logitech:" + ); + } + + #[test] + fn discovered_mice_fill_the_gap_without_shadowing_profiled_devices() { + let detected = |device_id: &str, name: &str| drivers::DetectedMouse { + device_id: device_id.into(), + name: name.into(), + }; + // No profiles: every discovered mouse is polled. + assert_eq!( + merge_detected_mice( + Vec::new(), + &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] + ), + vec![( + "Pulsar:0d1f0200".to_owned(), + "Pulsar X2 CrazyLight".to_owned() + )] + ); + // A profile already names the brand: discovery adds nothing, so the + // same physical mouse is never reported under two ids. + assert_eq!( + merge_detected_mice( + vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())], + &[detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight")] + ), + vec![("Pulsar:X2 CrazyLight".to_owned(), "X2".to_owned())] + ); + // Brand matching is case-insensitive; other brands still merge. + assert_eq!( + merge_detected_mice( + vec![("pulsar:X2".to_owned(), "X2".to_owned())], + &[ + detected("Pulsar:0d1f0200", "Pulsar X2 CrazyLight"), + detected("Delux:M800", "Delux M800") + ] + ), + vec![ + ("pulsar:X2".to_owned(), "X2".to_owned()), + ("Delux:M800".to_owned(), "Delux M800".to_owned()) + ] + ); + } } diff --git a/src/updater.rs b/src/updater.rs index 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' From 4f52c2c2673d33babe7c26c083bc41a1983c8eae Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:04:28 -0600 Subject: [PATCH 8/9] test(updater): use the platform's Node path in the bundle layout test --- src/updater/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 2b9d016..331c2c8 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -725,7 +725,11 @@ fn checksum_must_match_archive() { fn macos_bundle_package_layout_requires_every_bundled_file() { let root = fixture(); let layout = PackageLayout::macos_bundle(); - let node = layout.native_hid.join("node/bin/node"); + let node = layout.native_hid.join(if cfg!(windows) { + "node/node.exe" + } else { + "node/bin/node" + }); let mut names = vec![ layout.binary.clone(), layout.native_hid.join("src/apply.mjs"), From 603e679a594687ef3606f43ba05ca1d9b396cc5e Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:09:43 -0600 Subject: [PATCH 9/9] fix(updater): compile the bundle transaction scripts only where they are used --- src/updater.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/updater.rs b/src/updater.rs index 6c84f67..321b429 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -833,7 +833,7 @@ fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) - /// Replaces one directory (a macOS `.app`) as a unit. Same lock, backup and /// rollback discipline as `UNIX_TRANSACTION`: status 2 means nothing changed. -#[cfg(any(target_os = "macos", test))] +#[cfg(any(target_os = "macos", all(unix, test)))] const BUNDLE_TRANSACTION: &str = r#"install_bundle() ( set -eu stage="$1" @@ -890,7 +890,7 @@ committed=yes ) "#; -#[cfg(any(target_os = "macos", test))] +#[cfg(any(target_os = "macos", all(unix, test)))] const BUNDLE_COMPLETION: &str = r#"complete_bundle_update() { if install_bundle "$1" "$2" "$3" "$4"; then :