From 08cdf070420243aa3a28da7f005468dec93a63ff Mon Sep 17 00:00:00 2001 From: jazzstack <68454330+jazzstack@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:43:39 +0530 Subject: [PATCH 1/3] feat(bridge): onboard-memory hybrid mode, startup discovery and tray device summary While the web panel is disconnected, Bridge now drives the mouse itself in onboard memory mode: the active profile's stored settings are pushed natively, the battery is read directly (a full status read that also reports the current DPI, polling rate and switch mode), and the panel takes the mouse over live the moment it connects. Startup discovery scans the HID bus for mice whose brands the native-hid helper has drivers for, so the tray shows the battery from the first second - no web panel connection and no saved profile required. A discovered mouse's reading is canonicalized onto the panel's ":" device id, so the same mouse never shows up twice. The tray icon gained a control-panel-chosen battery display style (the native Segoe battery glyph or the bold percentage number), and the tray panel shows a device summary under the profile row: DPI, polling rate and switch mode as read from the mouse's onboard memory. Both panel views now size themselves to their content, so nothing clips. The web panel's battery reporter sends the settings it reads too, and Bridge merges reports that omit them instead of blanking stored values. --- README.md | 27 +- native-hid/src/apply.mjs | 56 +++- src/config.rs | 36 +++ src/desktop.rs | 15 + src/drivers/mod.rs | 242 +++++++++++++- src/drivers/native_hid.rs | 177 ++++++++++- src/drivers/pulsar.rs | 39 +++ src/main.rs | 1 + src/platform/mod.rs | 114 ++++++- src/platform/windows.rs | 646 +++++++++++++++++++++++++++++++++++++- src/service.rs | 594 ++++++++++++++++++++++++++++++++--- 11 files changed, 1867 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index be85016..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 diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 52c9d39..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 @@ -138,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); @@ -205,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/src/config.rs b/src/config.rs index e16fd5e..a46b273 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,17 @@ 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 @@ -184,6 +195,8 @@ 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(), @@ -201,6 +214,13 @@ impl BridgeConfig { self.battery_threshold_percent = self.battery_threshold_percent.min(100); self.notification_volume = self.notification_volume.min(100); self.alert_cooldown_minutes = self.alert_cooldown_minutes.max(1); + // Only the two known tray styles are honored; anything else (a + // hand-edited config, an older panel) reads as the default glyph. + self.tray_battery_style = match self.tray_battery_style.trim().to_ascii_lowercase().as_str() + { + "percentage" => "percentage".to_owned(), + _ => "glyph".to_owned(), + }; normalize_games(&mut self.games, &CATALOG_LIMITS); normalize_games(&mut self.custom_games, &CUSTOM_GAME_LIMITS); for profile in &mut self.profiles { @@ -423,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(), @@ -581,6 +605,8 @@ mod tests { 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(), @@ -592,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() @@ -630,6 +657,15 @@ mod tests { 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 = diff --git a/src/desktop.rs b/src/desktop.rs index 2e8f4d1..bf2c0a6 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -2025,6 +2025,9 @@ mod tests { 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, @@ -2053,6 +2056,10 @@ mod tests { 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"); } @@ -2079,6 +2086,10 @@ mod tests { 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)"); } @@ -2162,6 +2173,10 @@ mod tests { 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; diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index 9888a14..3b10217 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -23,7 +23,148 @@ pub mod delux; mod native_hid; pub mod pulsar; -pub use native_hid::{NativeBattery, spawn_button_listener}; +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 @@ -116,20 +257,20 @@ pub(crate) fn reconcile_automatic( ) } -/// 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> { +/// 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(); - if brand.eq_ignore_ascii_case(pulsar::BRAND) { - return Ok(None); - } let _access = device_access(); ensure_native_ownership()?; - native_hid::read_battery(brand) + if brand.eq_ignore_ascii_case(pulsar::BRAND) { + return pulsar::read_status(); + } + native_hid::read_status(brand) } /// local Bridge. Native-only devices use this path when WebHID cannot access @@ -440,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 131d344..8a0514f 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -37,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)] @@ -52,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. @@ -68,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(), ) } @@ -79,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", @@ -100,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; @@ -112,7 +176,12 @@ 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()), }) } @@ -362,29 +431,105 @@ mod tests { 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/main.rs b/src/main.rs index 085c4e5..ceebbe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,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/platform/mod.rs b/src/platform/mod.rs index e985308..9452878 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -15,7 +15,10 @@ pub mod 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/service.rs b/src/service.rs index 479a0db..6d356c5 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, path::{Path, PathBuf}, sync::{ Arc, @@ -15,6 +15,7 @@ use tokio::sync::RwLock; use crate::{ applications::{self, ApplicationInfo}, config::{self, ApplicationProfile, BridgeConfig, GameConfig, LocalApplication}, + drivers, games::GameDetector, platform, }; @@ -98,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 @@ -440,6 +460,15 @@ pub struct BridgeService { 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, @@ -459,6 +488,28 @@ struct BridgeState { 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)] @@ -469,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 { @@ -476,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)] @@ -487,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 @@ -521,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, @@ -556,6 +673,7 @@ impl BridgeService { 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(), @@ -629,10 +747,8 @@ 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() @@ -652,12 +768,23 @@ impl BridgeService { .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)); @@ -719,6 +846,9 @@ impl BridgeService { .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, @@ -932,6 +1062,25 @@ impl BridgeService { .await } + pub async fn set_tray_battery_style(&self, style: &str) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + state.config.tray_battery_style = style.to_owned(); + state.config = state.config.clone().normalized(); + state.config.clone() + }; + config::save(&self.config_path, &config) + } + + pub async fn set_hybrid_mode(&self, enabled: bool) -> Result<()> { + let config = { + let mut state = self.inner.write().await; + 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; @@ -970,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 { @@ -988,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 @@ -1016,39 +1180,52 @@ 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; } } } @@ -1241,6 +1418,158 @@ impl BridgeService { } } +/// 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) => { + tracing::info!( + profile = %profile.application.name, + device = %profile.device.name, + "Applied the mouse profile natively" + ); + ApplyOutcome::Applied + } + Ok(false) => { + tracing::debug!( + profile = %profile.application.name, + device = %profile.device.name, + "No native driver for this device; not applying natively" + ); + ApplyOutcome::NoDriver + } + Err(error) => { + tracing::warn!( + %error, + profile = %profile.application.name, + device = %profile.device.name, + "Could not apply the mouse profile natively" + ); + ApplyOutcome::Failed + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1986,6 +2315,9 @@ mod tests { device_name: "Mouse".into(), percent: 90, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap() @@ -1997,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(); @@ -2051,6 +2476,9 @@ mod tests { device_name: id.into(), percent: 80, charging: false, + dpi: None, + polling_rate_hz: None, + switch_mode: None, }) .await .unwrap(); @@ -2149,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()) + ] + ); + } } From 9767f4a56e7f3b42541a08e4313f9438d9dabb97 Mon Sep 17 00:00:00 2001 From: jazzstack <68454330+jazzstack@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:43:57 +0530 Subject: [PATCH 2/3] Release v1.0.0-beta.19: onboard-memory hybrid mode, startup discovery and tray device summary --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 711d193..e39e436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2920,7 +2920,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openmouse-bridge" -version = "1.0.0-beta.18" +version = "1.0.0-beta.19" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0a1e674..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" From cab04a33e06851e5e968e0de62e93542670fcef5 Mon Sep 17 00:00:00 2001 From: jazzstack <68454330+jazzstack@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:41 +0530 Subject: [PATCH 3/3] feat(bridge): bring the battery tray icon and device summary into the redesigned panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redesigned tray panel (PR #41) replaced the old layout wholesale; this re-applies the panel features on top of it, in its idiom: The tray icon shows the mouse's battery again — the Segoe battery glyph or the bold percentage number, per the control panel's tray-icon style, tinted by charge state (green charged/charging, amber mid, red at the low threshold, gray stale) — and redraws only when the display would change. It returns to the static logo when no battery is known and after a resume recreates it. The Settings view gains "Device mode" and "Tray icon" panels: the hybrid switch with its caption, and the glyph/percentage picker. The Home view shows a device summary under the profile panel — the mouse's live onboard-memory settings as read from the hardware ("Onboard: 800 DPI · 1000 Hz · Hybrid"), profile or no profile — and the window grows to fit it. --- src/desktop.rs | 354 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 343 insertions(+), 11 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index bf2c0a6..a334e0a 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -15,11 +15,12 @@ use std::{ use crate::overlay::Overlay; use anyhow::{Context, Result, anyhow}; use eframe::egui::{ - self, Align, Button, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, ViewportCommand, + self, Align, Button, Color32, Frame, Layout, Pos2, RichText, Sense, Stroke, Vec2, + ViewportCommand, }; use openmouse_bridge::{ BRIDGE_PORT, api, config, platform, - service::{BridgeService, BridgeSnapshot, Notice}, + service::{BridgeService, BridgeSnapshot, DeviceBattery, Notice}, updater::{self, UpdateInfo}, }; #[cfg(target_os = "windows")] @@ -307,6 +308,8 @@ enum BridgeCommand { SetNotificationSound(bool), SetNotificationVolume(u8), SetAutomaticUpdates(bool), + SetHybridMode(bool), + SetTrayBatteryStyle(platform::TrayBatteryStyle), CheckForUpdates, InstallUpdate(UpdateInfo), AcknowledgeTrayHint, @@ -347,15 +350,47 @@ fn update_is_notice(state: &UpdateState) -> bool { ) } -fn panel_height(view: View, details: bool, saved_values: bool, notice: bool) -> f32 { +/// 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; } - (WINDOW_HEIGHT + if saved_values { 18.0 } else { 0.0 } + if notice { 40.0 } else { 0.0 }) - .min(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)] @@ -366,6 +401,85 @@ enum TrayAction { 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) @@ -453,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() } @@ -477,6 +618,9 @@ 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, @@ -543,6 +687,9 @@ 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, @@ -600,6 +747,9 @@ impl TrayApp { 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 { @@ -607,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. @@ -768,6 +986,8 @@ impl TrayApp { if let Err(error) = self.tray.recreate_icon() { tracing::error!(%error, "could not recreate the tray icon after resume"); } + // The fresh icon is the plain logo; put the battery display back. + self.refresh_tray_battery_display(true); } fn titlebar(&mut self, ui: &mut egui::Ui) { @@ -920,6 +1140,19 @@ impl TrayApp { 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)); } @@ -969,6 +1202,82 @@ impl TrayApp { } }); 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(MUTED), + ); + let mut enabled = self.hybrid_mode; + if n::switch_row(ui, "Hybrid", &mut enabled) + .on_hover_text( + "While the web panel is closed, Bridge applies the mouse's stored settings and reads the battery itself.", + ) + .changed() + && self.send_command( + BridgeCommand::SetHybridMode(enabled), + "Bridge settings service is unavailable", + ) + { + self.hybrid_mode = enabled; + } + ui.label( + RichText::new(if self.hybrid_mode { + "While the web panel is closed, Bridge applies the mouse's \ + stored settings and reads the battery itself" + } else { + "Only the web panel changes the mouse" + }) + .size(11.0) + .color(MUTED), + ); + divider(ui); + let mut selected_style = self.tray_style; + row(ui, "Battery display", |ui| { + let choice = egui::ComboBox::from_id_salt("tray-battery-style") + .width(110.0) + .selected_text( + RichText::new(match self.tray_style { + platform::TrayBatteryStyle::Glyph => "Battery glyph", + platform::TrayBatteryStyle::Percentage => "Percentage", + }) + .size(12.0), + ) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Glyph, + "Battery glyph", + ); + ui.selectable_value( + &mut selected_style, + platform::TrayBatteryStyle::Percentage, + "Percentage", + ); + }); + choice.response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::ComboBox, + ui.is_enabled(), + "Battery display", + ) + }); + }); + if selected_style != self.tray_style + && self.send_command( + BridgeCommand::SetTrayBatteryStyle(selected_style), + "Bridge settings service is unavailable", + ) + { + self.tray_style = selected_style; + // Redraw the tray at once; the service's next snapshot + // only confirms what is already on screen. + self.refresh_tray_battery_display(true); + } + }); + ui.add_space(6.0); n::panel(ui, false, 8, |ui| { ui.set_width(ui.available_width()); ui.label( @@ -1238,11 +1547,13 @@ impl TrayApp { .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 @@ -1627,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; } @@ -1974,17 +2298,25 @@ mod tests { #[test] fn content_growth_stays_bounded_and_settings_has_its_own_budget() { - assert_eq!(panel_height(View::Home, false, false, false), 280.0); - assert_eq!(panel_height(View::Home, false, true, false), 298.0); - assert_eq!(panel_height(View::Home, false, true, true), 338.0); - assert_eq!(panel_height(View::Home, true, false, false), 340.0); - assert_eq!(panel_height(View::Settings, true, true, true), 420.0); + 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) <= 340.0); + assert!(panel_height(View::Home, false, false, false, false) <= 340.0); } #[test]