From f282aa1817d24e6cb2900507d76fa8f31f1752ce Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:08 +0800 Subject: [PATCH 01/17] style: carry reasons on remaining lint suppressions, drop section banners Every allow/expect in the workspace now states its reason inline (the four stragglers had it in a loose comment or not at all), and the one remaining dashed section banner is reduced to plain prose. --- crates/openlogi-agent-core/src/hardware.rs | 2 -- crates/openlogi-hook/examples/print_events.rs | 5 ++++- crates/openlogi-hook/src/linux.rs | 12 ++++++++---- xtask/src/commands/release/latest_json.rs | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 416f15a1..1687625b 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -364,13 +364,11 @@ fn lighting_rgb(lighting: &Lighting) -> (u8, u8, u8) { (scale(r), scale(g), scale(b)) } -// --------------------------------------------------------------------------- // Async, awaitable variants used by the IPC server (the GUI routes "apply now" // / "read" device commands through the agent, which awaits and reports the // result). Writes reuse the capture session's open channel when it targets the // same device, exactly like the fire-and-forget `*_in_background` helpers, so // the daemon never opens a second channel to a device it already holds. -// --------------------------------------------------------------------------- /// Apply `dpi` to `route`, reusing the capture session's channel when possible. pub async fn apply_dpi( diff --git a/crates/openlogi-hook/examples/print_events.rs b/crates/openlogi-hook/examples/print_events.rs index 7221e443..931c1017 100644 --- a/crates/openlogi-hook/examples/print_events.rs +++ b/crates/openlogi-hook/examples/print_events.rs @@ -40,7 +40,10 @@ fn main() { // Block until Ctrl-C. let (tx, rx) = std::sync::mpsc::channel(); - #[allow(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "example binary; aborting on a failed handler install is fine" + )] ctrlc::set_handler(move || { let _ = tx.send(()); }) diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index f353d5c1..3e42e79a 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -244,8 +244,10 @@ fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option { - #[allow(clippy::cast_precision_loss)] - // scroll deltas fit comfortably in f32 mantissa + #[expect( + clippy::cast_precision_loss, + reason = "scroll deltas fit comfortably in the f32 mantissa" + )] let v = value as f32; if hires_scroll { match axis { @@ -286,8 +288,10 @@ fn key_to_button(key: KeyCode) -> Option { } } -// All params are owned: path/cb/stop/stop_rx are moved into the thread and must not be refs. -#[allow(clippy::needless_pass_by_value)] +#[expect( + clippy::needless_pass_by_value, + reason = "path/cb/stop/stop_rx are moved into the spawned thread and must not be refs" +)] fn device_thread( path: std::path::PathBuf, mut device: Device, diff --git a/xtask/src/commands/release/latest_json.rs b/xtask/src/commands/release/latest_json.rs index 31b67031..7433ba8a 100644 --- a/xtask/src/commands/release/latest_json.rs +++ b/xtask/src/commands/release/latest_json.rs @@ -145,7 +145,7 @@ fn dmg_arch(name: &str) -> Option<&str> { } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, reason = "unwrap is idiomatic in tests")] mod tests { use super::*; From 5f6a43c64eba32921942c710373103abd45ff2c2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:09 +0800 Subject: [PATCH 02/17] refactor(gui): fail fast on an invalid embedded version CARGO_PKG_VERSION is cargo-provided and always valid semver; falling back to 0.0.0 would silently make every release look like an upgrade. Panic with a reasoned expect instead of hiding the impossible case. --- crates/openlogi-gui/src/platform/updater.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-gui/src/platform/updater.rs b/crates/openlogi-gui/src/platform/updater.rs index 6a1f0375..f451f2f2 100644 --- a/crates/openlogi-gui/src/platform/updater.rs +++ b/crates/openlogi-gui/src/platform/updater.rs @@ -56,8 +56,11 @@ pub fn new_entity(cx: &mut App) -> Entity { .os(std::env::consts::OS) .arch(release_arch()) .format(release_format()); - let version = - Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0)); + #[expect( + clippy::expect_used, + reason = "CARGO_PKG_VERSION is cargo-provided and always valid semver" + )] + let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("valid embedded version"); let mut config = EngineConfig::new(version).verification(Verification::Strict); if let Some(key) = minisign_public_key() { config = config.minisign_public_key(key); From 0a367ad0fa3ab08103f15b00f9670c0c342f881f Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:32 +0800 Subject: [PATCH 03/17] refactor(agent,core): resolve the LaunchAgents path via core paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the etcetera-backed home_dir from openlogi-core and use it for the launchd plist location instead of reading $HOME by hand — one home resolution policy for the whole workspace. --- crates/openlogi-agent/src/launch_agent.rs | 6 +++--- crates/openlogi-core/src/paths.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-agent/src/launch_agent.rs b/crates/openlogi-agent/src/launch_agent.rs index 41981bf1..6836297b 100644 --- a/crates/openlogi-agent/src/launch_agent.rs +++ b/crates/openlogi-agent/src/launch_agent.rs @@ -130,9 +130,9 @@ fn remove_legacy() { #[cfg(target_os = "macos")] fn plist_path(label: &str) -> io::Result { - let home = std::env::var_os("HOME") - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME not set"))?; - Ok(PathBuf::from(home) + let home = + openlogi_core::paths::home_dir().map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + Ok(home .join("Library") .join("LaunchAgents") .join(format!("{label}.plist"))) diff --git a/crates/openlogi-core/src/paths.rs b/crates/openlogi-core/src/paths.rs index dfeab113..4c871460 100644 --- a/crates/openlogi-core/src/paths.rs +++ b/crates/openlogi-core/src/paths.rs @@ -36,6 +36,14 @@ fn xdg() -> Result { Xdg::new().map_err(|_| PathsError::HomeNotFound) } +/// The current user's home directory. +/// +/// The plain home, not an XDG base — for callers placing files under +/// OS-native locations (e.g. macOS `~/Library/LaunchAgents`). +pub fn home_dir() -> Result { + Ok(xdg()?.home_dir().to_path_buf()) +} + /// The raw XDG config home directory (without the `openlogi` subdirectory). /// /// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`. From 5e51f3c642abd28cfa488baed3f94d4d81184a1c Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:45:45 +0800 Subject: [PATCH 04/17] fix(hidpp): stop dropping events that carry unknown wire values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver and feature listeners run in a void callback with no error channel, so every unrecognised byte used to degenerate into a silent return — losing whole events over an identity-only field: - Unifying/Bolt 0x41 device-connection: an unknown kind nibble dropped the event entirely; on the Unifying path arrival notifications are the only device source, so the device never appeared at all. Kind now folds to Unknown via num_enum(default) (identity-only by policy). - Pairing-info register reads no longer fail the whole read over the kind nibble either, matching the listener policy. - Bolt 0x54 pairing status: an unknown error code turned a failure into a session timeout; PairingError now carries the raw code in a num_enum(catch_all) Other variant. Same treatment for the 0x4e passkey press type. - Bolt 0x4d passkey: trim the NUL padding before decoding. - 0x1d4b WirelessDeviceStatus broadcast: the (re)connection signal was dropped if any of its three bytes was unrecognised; all fields now decode infallibly with catch_all Other variants. - smartshift_enhanced: an unknown wheel mode silently reported Ratchet; it is now Hidpp20Error::UnsupportedResponse, matching 0x2110 and hires_wheel (the pinned fallback test asserts the error instead). openlogi-hid's discovery kind mapping is simplified to the now- infallible From. --- crates/openlogi-hid/src/pairing.rs | 11 +--- .../src/feature/smartshift_enhanced/mod.rs | 11 ++-- .../src/feature/wireless_device_status/mod.rs | 34 ++++++----- crates/openlogi-hidpp/src/receiver/bolt.rs | 61 +++++++++---------- .../openlogi-hidpp/src/receiver/unifying.rs | 25 ++++---- 5 files changed, 69 insertions(+), 73 deletions(-) diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs index fa561f04..7e392c08 100644 --- a/crates/openlogi-hid/src/pairing.rs +++ b/crates/openlogi-hid/src/pairing.rs @@ -452,19 +452,10 @@ impl PartialDevice { self.name.clone()?, ); self.emitted = true; - // Clippy 1.96 falsely claims this `TryFrom` is infallible (its - // `From::from` suggestion does not compile): `BoltDeviceKind` derives - // only `TryFromPrimitive`, and unlisted nibbles must fall back to - // `Unknown`. - #[allow( - clippy::unnecessary_fallible_conversions, - reason = "false positive: BoltDeviceKind has no infallible From" - )] - let kind = BoltDeviceKind::try_from(kind & 0x0f).unwrap_or(BoltDeviceKind::Unknown); Some(DiscoveredDevice { address, authentication, - kind, + kind: BoltDeviceKind::from(kind & 0x0f), name, }) } diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs index 6d3aaa12..ae795b8e 100644 --- a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -126,7 +126,8 @@ impl SmartShiftEnhancedFeature { impl SmartShiftEnhancedStatus { fn from_payload(payload: [u8; 16]) -> Result { Ok(Self { - wheel_mode: WheelMode::try_from(payload[0]).unwrap_or(WheelMode::Ratchet), + wheel_mode: WheelMode::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, auto_disengage: payload[1], current_tunable_torque: payload[2], }) @@ -135,7 +136,7 @@ impl SmartShiftEnhancedStatus { #[cfg(test)] mod tests { - use super::{SmartShiftEnhancedStatus, WheelMode}; + use super::{Hidpp20Error, SmartShiftEnhancedStatus, WheelMode}; #[test] fn parses_status() { @@ -152,12 +153,12 @@ mod tests { } #[test] - fn falls_back_to_ratchet_for_unknown_wheel_mode() { + fn unknown_wheel_mode_is_an_unsupported_response() { let mut payload = [0; 16]; payload[0] = 9; - let status = SmartShiftEnhancedStatus::from_payload(payload).unwrap(); + let err = SmartShiftEnhancedStatus::from_payload(payload).unwrap_err(); - assert_eq!(status.wheel_mode, WheelMode::Ratchet); + assert!(matches!(err, Hidpp20Error::UnsupportedResponse)); } } diff --git a/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs b/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs index e94a229f..2fac1732 100755 --- a/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs +++ b/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive}; use crate::{ channel::{HidppChannel, MessageListenerGuard}, @@ -41,19 +41,14 @@ impl CreatableFeature for WirelessDeviceStatusFeature { return; } - let (Ok(status), Ok(request), Ok(reason)) = ( - WirelessDeviceStatus::try_from(payload[0]), - WirelessDeviceStatusRequest::try_from(payload[1]), - WirelessDeviceStatusReason::try_from(payload[2]), - ) else { - return; - }; - + // This broadcast is the device's (re)connection signal; an + // unrecognised field value must not swallow it, so every + // field decodes infallibly and carries unknown raw bytes. emitter.emit(WirelessDeviceStatusEvent::StatusBroadcast( WirelessDeviceStatusBroadcast { - status, - request, - reason, + status: WirelessDeviceStatus::from(payload[0]), + request: WirelessDeviceStatusRequest::from(payload[1]), + reason: WirelessDeviceStatusReason::from(payload[2]), }, )); } @@ -104,7 +99,7 @@ pub struct WirelessDeviceStatusBroadcast { /// Represents a device status as reported in /// [`WirelessDeviceStatusBroadcast::status`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -113,11 +108,14 @@ pub enum WirelessDeviceStatus { Unknown = 0x00, /// Device is reconnecting. Reconnection = 0x01, + /// A status value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents a request as reported in /// [`WirelessDeviceStatusBroadcast::request`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -126,11 +124,14 @@ pub enum WirelessDeviceStatusRequest { NoRequest = 0x00, /// Host software must reconfigure the device. SoftwareReconfigurationNeeded = 0x01, + /// A request value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents a broadcast reason as reported in /// [`WirelessDeviceStatusBroadcast::reason`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -139,4 +140,7 @@ pub enum WirelessDeviceStatusReason { Unknown = 0x00, /// Broadcast was caused by the device power switch. PowerSwitchActivated = 0x01, + /// A reason value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } diff --git a/crates/openlogi-hidpp/src/receiver/bolt.rs b/crates/openlogi-hidpp/src/receiver/bolt.rs index a836a1e3..e1fece6e 100755 --- a/crates/openlogi-hidpp/src/receiver/bolt.rs +++ b/crates/openlogi-hidpp/src/receiver/bolt.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use derive_builder::Builder; use futures::{FutureExt, pin_mut, select}; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use super::{RECEIVER_DEVICE_INDEX, ReceiverError}; use crate::{ @@ -127,13 +127,11 @@ impl Receiver { match header.sub_id { // Device connection 0x41 => { - let Ok(kind) = DeviceKind::try_from(payload[1] & 0x0f) else { - return; - }; - + // Kind is identity-only; an unrecognised nibble folds + // to `Unknown` instead of dropping the event. emitter.emit(Event::DeviceConnection(DeviceConnection { index: header.device_index, - kind, + kind: DeviceKind::from(payload[1] & 0x0f), encrypted: payload[1] & (1 << 5) != 0, online: payload[1] & (1 << 6) == 0, wpid: u16::from_le_bytes(payload[2..=3].try_into().unwrap()), @@ -144,13 +142,9 @@ impl Receiver { match payload[2] { // Device data 0 => { - let Ok(kind) = DeviceKind::try_from(payload[4] & 0x0f) else { - return; - }; - emitter.emit(Event::DeviceDiscoveryDeviceDetails { counter: payload[0] as u16 + payload[1] as u16 * 256, - kind, + kind: DeviceKind::from(payload[4] & 0x0f), wpid: u16::from_le_bytes(payload[5..=6].try_into().unwrap()), address: payload[7..=12].try_into().unwrap(), authentication: payload[15], @@ -181,15 +175,10 @@ impl Receiver { // payload[0] contains some kind of information about the status. I don't // know how to map that though. - let error = if payload[1] == 0x00 { - None - } else { - let Ok(parsed) = PairingError::try_from(payload[1]) else { - return; - }; - - Some(parsed) - }; + // An unrecognised error code still means "pairing + // failed" — dropping it here would turn the failure + // into a session timeout. Carry the raw code instead. + let error = (payload[1] != 0x00).then(|| PairingError::from(payload[1])); emitter.emit(Event::PairingStatus { device_address: payload[2..=7].try_into().unwrap(), @@ -203,7 +192,10 @@ impl Receiver { } // Passkey request 0x4d => { - let Ok(passkey) = str::from_utf8(&payload[1..=6]) else { + // 6 bytes, NUL-padded when the passkey is shorter. + let digits = &payload[1..=6]; + let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len()); + let Ok(passkey) = str::from_utf8(&digits[..len]) else { return; }; @@ -214,13 +206,9 @@ impl Receiver { } // Passkey pressed 0x4e => { - let Ok(press_type) = PairingPasskeyPressType::try_from(payload[0]) else { - return; - }; - emitter.emit(Event::PairingPasskeyPressed { device_address: payload[1..=6].try_into().unwrap(), - press_type, + press_type: PairingPasskeyPressType::from(payload[0]), }); } _ => (), @@ -384,8 +372,9 @@ impl Receiver { Ok(DevicePairingInformation { wpid: u16::from_le_bytes(response[2..=3].try_into().unwrap()), - kind: DeviceKind::try_from(response[1] & 0x0f) - .map_err(|_| Hidpp10Error::UnsupportedResponse)?, + // Kind is identity-only: an unrecognised nibble folds to + // `Unknown` instead of failing the whole pairing-info read. + kind: DeviceKind::from(response[1] & 0x0f), encrypted: response[1] & (1 << 5) != 0, online: response[1] & (1 << 6) == 0, unit_id: response[4..=7].try_into().unwrap(), @@ -550,12 +539,14 @@ pub struct DevicePairingInformation { } /// Represents the kind of a device paired to a Bolt receiver. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] pub enum DeviceKind { - /// Unknown device kind. + /// Unknown device kind — also the fold target for values this crate + /// does not model (kind is identity-only and must never drop an event). + #[num_enum(default)] Unknown = 0x00, /// Keyboard device. Keyboard = 0x01, @@ -727,7 +718,7 @@ pub struct DeviceConnection { /// Represents an error during device pairing. /// /// This is reported by the [`Event::PairingStatus`] event. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, TryFromPrimitive, IntoPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -736,13 +727,16 @@ pub enum PairingError { DeviceTimeout = 0x01, /// Pairing failed. Failed = 0x02, + /// An error code this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents the type of a single passkey press. /// /// This is reported by the [`Event::PairingPasskeyPressed`] event, which also /// includes some further information about the context of these values. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, TryFromPrimitive, IntoPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -753,6 +747,9 @@ pub enum PairingPasskeyPressType { Keypress = 0x01, /// Passkey entry was submitted. Submit = 0x04, + /// A press type this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } #[cfg(test)] diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 42611a7e..bfcd8d43 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -10,12 +10,12 @@ use std::sync::Arc; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use crate::{ channel::{HidppChannel, MessageListenerGuard}, event::EventEmitter, - protocol::v10::{self, Hidpp10Error}, + protocol::v10, receiver::{RECEIVER_DEVICE_INDEX, ReceiverError}, }; @@ -97,13 +97,13 @@ impl Receiver { return; } - let Ok(kind) = DeviceKind::try_from(payload[1] & 0x0f) else { - return; - }; - + // Kind is identity-only; an unrecognised nibble folds to + // `Unknown` — dropping the event would hide the device + // entirely (arrival notifications are the only device + // source on this path). emitter.emit(Event::DeviceConnection(DeviceConnection { index: header.device_index, - kind, + kind: DeviceKind::from(payload[1] & 0x0f), encrypted: payload[1] & (1 << 4) != 0, online: payload[1] & (1 << 6) == 0, wpid: u16::from_le_bytes(payload[2..=3].try_into().unwrap()), @@ -191,8 +191,9 @@ impl Receiver { Ok(DevicePairingInformation { wpid: u16::from_le_bytes(response[2..=3].try_into().unwrap()), - kind: DeviceKind::try_from(response[1] & 0x0f) - .map_err(|_| Hidpp10Error::UnsupportedResponse)?, + // Kind is identity-only: an unrecognised nibble folds to + // `Unknown` instead of failing the whole pairing-info read. + kind: DeviceKind::from(response[1] & 0x0f), encrypted: response[1] & (1 << 4) != 0, online: response[1] & (1 << 6) == 0, unit_id: response[4..=7].try_into().unwrap(), @@ -239,12 +240,14 @@ pub struct DevicePairingInformation { /// The encoding matches Bolt for values 1–4; from 5 onwards Unifying uses a /// shifted table (Remote=5, Trackball=6, Touchpad=7) while Bolt reserves those /// values and places them at 7–9. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] pub enum DeviceKind { - /// Unknown device kind. + /// Unknown device kind — also the fold target for values this crate + /// does not model (kind is identity-only and must never drop an event). + #[num_enum(default)] Unknown = 0x00, /// Keyboard device. Keyboard = 0x01, From a2874a4280aeee870265d14a3128fac5b45772d9 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:49:13 +0800 Subject: [PATCH 05/17] fix(hid,agent): validate the pairing passkey and DPI at their boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corrupt Bolt passkey notification used to flow through from_utf8_lossy and parse().unwrap_or(0), rendering an all-Left click sequence that can never authenticate — with no diagnostic anywhere. The 0x4d decoder now NUL-trims and validates ASCII digits once, producing the digits and their numeric value together; a malformed payload fails the pairing session explicitly (surfaced over IPC as the generic transport-failure message, so the wire format is unchanged). The DPI write path clamped an out-of-range u32 to u16::MAX and wrote that to the device. The awaitable path now rejects it as the same OutOfRange feature error the device itself would return; the fire-and-forget path logs and skips the write. --- crates/openlogi-agent-core/src/hardware.rs | 21 +++++-- crates/openlogi-agent-core/src/ipc.rs | 5 ++ crates/openlogi-hid/src/pairing.rs | 20 +++--- .../openlogi-hid/src/pairing/notification.rs | 61 +++++++++++++++++-- crates/openlogi-hid/src/pairing/tests.rs | 2 +- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 1687625b..f5cbdcc6 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -17,8 +17,8 @@ use std::time::Duration; use openlogi_core::config::Lighting; use openlogi_hid::{ - CaptureChannel, DeviceRoute, DpiInfo, HidppOperation, SharedChannel, SmartShiftMode, - SmartShiftStatus, WriteError, + CaptureChannel, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation, SharedChannel, + SmartShiftMode, SmartShiftStatus, WriteError, }; use tracing::{debug, warn}; @@ -228,9 +228,12 @@ pub fn write_dpi_in_background( return; } }; - // All device-supported DPI values fit in HID++'s u16 wire field. The - // saturating fallback exists only for type-system exhaustiveness. - let dpi_u16 = u16::try_from(dpi).unwrap_or(u16::MAX); + // All device-supported DPI values fit in HID++'s u16 wire field; a + // larger value is a caller bug and must not be clamped onto the device. + let Ok(dpi_u16) = u16::try_from(dpi) else { + warn!(dpi, "DPI exceeds the HID++ u16 wire field; write skipped"); + return; + }; let result = rt.block_on(async { tokio::time::timeout(WRITE_BUDGET, async { match &shared { @@ -376,7 +379,13 @@ pub async fn apply_dpi( route: &DeviceRoute, dpi: u32, ) -> Result<(), WriteError> { - let dpi = u16::try_from(dpi).unwrap_or(u16::MAX); + // Reject a DPI beyond the HID++ u16 wire field the same way the device + // itself would reject an out-of-range argument. + let dpi = u16::try_from(dpi).map_err(|_| WriteError::HidppFeature { + operation: HidppOperation::WriteDpi, + feature_hex: 0x2201, + kind: HidppFeatureErrorKind::OutOfRange, + })?; let shared = reusable_channel(Some(capture), route); timed(HidppOperation::WriteDpi, async { match &shared { diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 37e79e7c..ffbe33b5 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -162,6 +162,11 @@ impl From for PairingFailure { PairingError::Timeout => Self::Timeout, PairingError::Device(code) => Self::Device { code }, PairingError::Cancelled => Self::Cancelled, + // Carried as the generic transport-failure message so the wire + // format stays unchanged (PairingFailure variants are append-only). + PairingError::MalformedNotification(what) => Self::Hid { + message: format!("malformed pairing notification ({what})"), + }, } } } diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs index 7e392c08..36293969 100644 --- a/crates/openlogi-hid/src/pairing.rs +++ b/crates/openlogi-hid/src/pairing.rs @@ -153,9 +153,8 @@ pub enum PasskeyMethod { }, } -/// Renders a Bolt passkey as a 10-bit MSB-first left/right click sequence. -fn passkey_to_clicks(passkey: &str) -> Vec { - let value: u32 = passkey.trim().parse().unwrap_or(0); +/// Renders a Bolt passkey value as a 10-bit MSB-first left/right click sequence. +fn passkey_to_clicks(value: u32) -> Vec { (0..10) .rev() .map(|bit| { @@ -216,6 +215,10 @@ pub enum PairingError { /// Pairing flow was cancelled by the caller. #[error("pairing was cancelled")] Cancelled, + /// A receiver notification failed to decode; authentication cannot + /// proceed safely, so the flow fails instead of presenting bogus data. + #[error("malformed pairing notification ({0})")] + MalformedNotification(&'static str), } impl From for PairingError { @@ -392,16 +395,19 @@ async fn drive( let _ = events.send(PairingEvent::DeviceFound(device)); } } - Notification::Passkey(passkey) => { + Notification::Passkey { digits, value } => { let method = match pairing_auth { - Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(passkey), + Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(digits), _ => PasskeyMethod::Pointer { - clicks: passkey_to_clicks(&passkey), - passkey, + clicks: passkey_to_clicks(value), + passkey: digits, }, }; let _ = events.send(PairingEvent::Passkey(method)); } + Notification::MalformedPasskey => { + return Err(PairingError::MalformedNotification("passkey digits")); + } Notification::PairingSucceeded { slot } => { let _ = events.send(PairingEvent::Paired { slot }); return Ok(()); diff --git a/crates/openlogi-hid/src/pairing/notification.rs b/crates/openlogi-hid/src/pairing/notification.rs index e60cfbab..c87c0766 100644 --- a/crates/openlogi-hid/src/pairing/notification.rs +++ b/crates/openlogi-hid/src/pairing/notification.rs @@ -27,8 +27,12 @@ pub(super) enum Notification { PairingSucceeded { slot: u8 }, /// Bolt pairing/discovery failed with a receiver error code. PairingError(u8), - /// Bolt passkey to present to the user (6 ASCII digits). - Passkey(String), + /// Bolt passkey to present to the user: the digits shown by the device + /// (validated ASCII digits) plus their numeric value for click rendering. + Passkey { digits: String, value: u32 }, + /// A passkey notification whose payload was not ASCII digits; pairing + /// must fail rather than present a bogus authentication sequence. + MalformedPasskey, /// A device linked to the receiver (`slot` = its device index). Connected { slot: u8, established: bool }, /// Unifying pairing lock changed state; `error` is non-zero on failure. @@ -105,8 +109,20 @@ pub(super) fn parse_notification( } } id::PASSKEY_REQUEST => { - let passkey = String::from_utf8_lossy(&p[1..7]).into_owned(); - Some(Notification::Passkey(passkey)) + // 6 bytes, NUL-padded when the passkey is shorter. + let digits = &p[1..7]; + let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len()); + let digits = &digits[..len]; + if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) { + return Some(Notification::MalformedPasskey); + } + let value = digits + .iter() + .fold(0u32, |acc, &b| acc * 10 + u32::from(b - b'0')); + Some(Notification::Passkey { + digits: String::from_utf8_lossy(digits).into_owned(), + value, + }) } id::UNIFYING_LOCK => Some(Notification::UnifyingLock { open: p[0] & 0x01 != 0, @@ -225,7 +241,42 @@ mod tests { p[1..7].copy_from_slice(b"123456"); assert_eq!( parse_notification(id::PASSKEY_REQUEST, 0xff, p), - Some(Notification::Passkey("123456".to_string())) + Some(Notification::Passkey { + digits: "123456".to_string(), + value: 123_456, + }) + ); + } + + #[test] + fn parses_nul_padded_passkey() { + let mut p = [0u8; 17]; + p[1..4].copy_from_slice(b"042"); + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::Passkey { + digits: "042".to_string(), + value: 42, + }) + ); + } + + #[test] + fn non_digit_passkey_is_malformed() { + let mut p = [0u8; 17]; + p[1..7].copy_from_slice(b"12x456"); + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::MalformedPasskey) + ); + } + + #[test] + fn empty_passkey_is_malformed() { + let p = [0u8; 17]; + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::MalformedPasskey) ); } diff --git a/crates/openlogi-hid/src/pairing/tests.rs b/crates/openlogi-hid/src/pairing/tests.rs index c9bdd603..3b758eef 100644 --- a/crates/openlogi-hid/src/pairing/tests.rs +++ b/crates/openlogi-hid/src/pairing/tests.rs @@ -4,7 +4,7 @@ use super::*; fn passkey_clicks_are_msb_first_10_bits() { // 0b00_0000_0101 = 5 -> eight lefts then right, left, right. assert_eq!( - passkey_to_clicks("5"), + passkey_to_clicks(5), vec![ Click::Left, Click::Left, From 2ea0f171afa7208718554eee751841f75d05015c Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:01:24 +0800 Subject: [PATCH 06/17] fix(core,gui,agent,cli): validate the lighting color once as a typed Rgb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lighting color was a raw String parsed independently in two places with divergent silent fallbacks: the agent's parse_hex fell back to black (lights off) while the GUI's fell back to white and didn't strip a '#' prefix. A hand-edited config degraded differently on each side of the IPC with no diagnostic. openlogi-core now owns a validated Rgb newtype (exactly 6 hex digits) that serializes as the same hex string the field used to hold, so TOML files and the bincode wire format are unchanged — the pinned wire bytes in wire_format.rs prove it. Config load folds an unparseable value to white with the same documented per-field tolerance as the brightness clamp (failing the load would discard the whole user config). Every consumer — agent write path, GUI swatches and keyboard glow, CLI diag — now uses the typed components; both hand-rolled hex parsers are gone. --- crates/openlogi-agent-core/src/hardware.rs | 14 +-- .../openlogi-agent-core/tests/wire_format.rs | 4 +- crates/openlogi-cli/src/cmd/diag/lighting.rs | 38 +++--- crates/openlogi-core/src/color.rs | 114 ++++++++++++++++++ crates/openlogi-core/src/config.rs | 22 +++- crates/openlogi-core/src/config/settings.rs | 29 ++++- crates/openlogi-core/src/lib.rs | 1 + crates/openlogi-gui/src/app/home.rs | 2 +- .../src/components/lighting_panel.rs | 32 ++--- 9 files changed, 204 insertions(+), 52 deletions(-) create mode 100644 crates/openlogi-core/src/color.rs diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index f5cbdcc6..c270e350 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -345,23 +345,13 @@ pub fn set_lighting_in_background(target: Option, lighting: &Lighti }); } -/// Parse `"RRGGBB"` (optionally `#`-prefixed) into an `(r, g, b)` triple. -fn parse_hex(hex: &str) -> (u8, u8, u8) { - let v = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0); - ( - u8::try_from((v >> 16) & 0xff).unwrap_or(0), - u8::try_from((v >> 8) & 0xff).unwrap_or(0), - u8::try_from(v & 0xff).unwrap_or(0), - ) -} - -/// Resolve a [`Lighting`] config to an `(r, g, b)` triple: the configured hex +/// Resolve a [`Lighting`] config to an `(r, g, b)` triple: the configured /// colour scaled by brightness, or black when lighting is off. fn lighting_rgb(lighting: &Lighting) -> (u8, u8, u8) { if !lighting.enabled { return (0, 0, 0); } - let (r, g, b) = parse_hex(&lighting.color); + let (r, g, b) = lighting.color.components(); let scale = |c: u8| u8::try_from(u16::from(c) * u16::from(lighting.brightness) / 100).unwrap_or(c); (scale(r), scale(g), scale(b)) diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index e0c159b7..ed964300 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -255,10 +255,12 @@ fn device_settings_payloads() { }); assert_wire(&smartshift, "0001103c"); + // `Rgb` serializes as the same hex string the field used to hold raw, so + // the pinned bytes are identical to the pre-newtype encoding. assert_wire( &Lighting { enabled: true, - color: "8000ff".into(), + color: "8000ff".parse().expect("valid hex"), brightness: 80, }, "010638303030666650", diff --git a/crates/openlogi-cli/src/cmd/diag/lighting.rs b/crates/openlogi-cli/src/cmd/diag/lighting.rs index 7283a717..a6cde68a 100644 --- a/crates/openlogi-cli/src/cmd/diag/lighting.rs +++ b/crates/openlogi-cli/src/cmd/diag/lighting.rs @@ -6,6 +6,7 @@ use anyhow::{Result, anyhow}; use clap::{Args, ValueEnum}; +use openlogi_core::color::Rgb; use openlogi_hid::{DeviceRoute, LightingMethod}; #[derive(Debug, Clone, Copy, ValueEnum)] @@ -44,15 +45,8 @@ pub struct LightingArgs { } pub async fn run(args: LightingArgs) -> Result<()> { - let hex = args.color.trim_start_matches('#'); - if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(anyhow!("color must be exactly 6 hex digits, e.g. ff0000")); - } - let rgb = u32::from_str_radix(hex, 16) - .map_err(|_| anyhow!("color must be 6 hex digits, e.g. ff0000"))?; - let r = ((rgb >> 16) & 0xff) as u8; - let g = ((rgb >> 8) & 0xff) as u8; - let b = (rgb & 0xff) as u8; + let color: Rgb = args.color.trim_start_matches('#').parse()?; + let (r, g, b) = color.components(); let device_query = args.device; let needle = device_query.as_deref().map(str::to_lowercase); @@ -99,8 +93,14 @@ pub async fn run(args: LightingArgs) -> Result<()> { } #[cfg(test)] -#[allow(clippy::unwrap_used, reason = "expect/unwrap are idiomatic in tests")] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + reason = "expect/unwrap are idiomatic in tests" +)] mod color_validation_tests { + use openlogi_core::color::RgbParseError; + use super::{LightingArgs, Method, run}; fn args(color: &str) -> LightingArgs { @@ -118,21 +118,25 @@ mod color_validation_tests { async fn rejects_malformed_colors_before_touching_hardware() { for bad in ["zzz", "ff000", "ff00001", "gg0000", ""] { let err = run(args(bad)).await.unwrap_err(); - assert_eq!( - err.to_string(), - "color must be exactly 6 hex digits, e.g. ff0000" + assert!( + err.downcast_ref::().is_some(), + "{bad:?} should fail Rgb parsing, got: {err}" ); } } #[tokio::test] async fn hash_prefix_is_stripped_before_validation() { - // `#zzzzzz` still fails, but with the same message — proving the `#` - // is stripped rather than counted toward the 6-digit length. + // `#zzzzzz` still fails, and the rejected input the error reports is + // `zzzzzz` — proving the `#` is stripped rather than counted toward + // the 6-digit length. let err = run(args("#zzzzzz")).await.unwrap_err(); + let parse = err + .downcast_ref::() + .expect("Rgb parse error"); assert_eq!( - err.to_string(), - "color must be exactly 6 hex digits, e.g. ff0000" + parse.to_string(), + r#"invalid RGB color "zzzzzz": expected 6 hex digits ("RRGGBB", no '#')"# ); } } diff --git a/crates/openlogi-core/src/color.rs b/crates/openlogi-core/src/color.rs new file mode 100644 index 00000000..014b5b0f --- /dev/null +++ b/crates/openlogi-core/src/color.rs @@ -0,0 +1,114 @@ +//! A validated RGB color for the lighting config. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// An RGB color, parsed once at the boundary from the config/UI hex form +/// `"RRGGBB"` (exactly 6 hex digits, no leading `#`). +/// +/// Serializes as that hex string, so the type is drop-in TOML- and +/// wire-compatible with the raw `String` field it replaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Rgb { + r: u8, + g: u8, + b: u8, +} + +impl Rgb { + /// White — the lighting default. + pub const WHITE: Self = Self::new(0xff, 0xff, 0xff); + + /// A color from its red/green/blue components. + #[must_use] + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// The `(r, g, b)` components. + #[must_use] + pub const fn components(self) -> (u8, u8, u8) { + (self.r, self.g, self.b) + } + + /// The color packed as `0xRRGGBB` (the form GPUI's `rgb()` takes). + #[must_use] + pub const fn packed(self) -> u32 { + (self.r as u32) << 16 | (self.g as u32) << 8 | self.b as u32 + } +} + +/// A color string that is not exactly 6 hex digits. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid RGB color {input:?}: expected 6 hex digits (\"RRGGBB\", no '#')")] +pub struct RgbParseError { + /// The rejected input. + input: String, +} + +impl FromStr for Rgb { + type Err = RgbParseError; + + fn from_str(s: &str) -> Result { + let packed = (s.len() == 6) + .then(|| u32::from_str_radix(s, 16).ok()) + .flatten() + .ok_or_else(|| RgbParseError { input: s.into() })?; + Ok(Self::new( + (packed >> 16 & 0xff) as u8, + (packed >> 8 & 0xff) as u8, + (packed & 0xff) as u8, + )) + } +} + +impl TryFrom for Rgb { + type Error = RgbParseError; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +impl From for String { + fn from(color: Rgb) -> Self { + color.to_string() + } +} + +impl fmt::Display for Rgb { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "unwrap is idiomatic in tests")] +mod tests { + use super::Rgb; + + #[test] + fn parses_and_round_trips_hex() { + let color: Rgb = "8000ff".parse().unwrap(); + assert_eq!(color, Rgb::new(0x80, 0x00, 0xff)); + assert_eq!(color.packed(), 0x0080_00ff); + assert_eq!(color.to_string(), "8000ff"); + } + + #[test] + fn accepts_uppercase_but_prints_lowercase() { + let color: Rgb = "FF3B30".parse().unwrap(); + assert_eq!(color.to_string(), "ff3b30"); + } + + #[test] + fn rejects_wrong_length_prefix_and_non_hex() { + for bad in ["fff", "ff00aa0", "#ff00aa", "red", ""] { + assert!(bad.parse::().is_err(), "{bad:?} should not parse"); + } + } +} diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index ee16ecec..4e7bb7af 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -571,7 +571,7 @@ mod tests { "g513", Lighting { enabled: true, - color: "00aabb".to_string(), + color: "00aabb".parse().expect("valid hex"), brightness: 75, }, ); @@ -580,13 +580,31 @@ mod tests { restored.lighting("g513"), Some(Lighting { enabled: true, - color: "00aabb".to_string(), + color: "00aabb".parse().expect("valid hex"), brightness: 75, }) ); assert_eq!(restored.lighting("absent"), None); } + #[test] + fn unparseable_lighting_color_falls_back_to_white() { + let cfg: Config = toml::from_str( + r#" + schema_version = 3 + [devices.g513.lighting] + enabled = true + color = "red" + brightness = 50 + "#, + ) + .expect("config with a bad color still loads"); + assert_eq!( + cfg.lighting("g513").map(|l| l.color), + Some(crate::color::Rgb::WHITE) + ); + } + #[test] fn dpi_roundtrips_per_device() { let mut cfg = Config::default(); diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs index 6912fa30..828c2876 100644 --- a/crates/openlogi-core/src/config/settings.rs +++ b/crates/openlogi-core/src/config/settings.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::binding::ButtonId; +use crate::color::Rgb; /// Light/dark appearance preference. `System` follows the OS appearance (the /// historical behaviour); `Light` / `Dark` force a mode regardless of the OS. @@ -166,9 +167,15 @@ const fn default_thumbwheel_sensitivity() -> i32 { pub struct Lighting { #[serde(default = "default_lighting_enabled")] pub enabled: bool, - /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). - #[serde(default = "default_lighting_color")] - pub color: String, + /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value + /// that does not parse falls back to white on load — the same per-field + /// tolerance as `brightness`, because failing the whole load would + /// discard the user's entire config (see the `load_or_default` callers). + #[serde( + default = "default_lighting_color", + deserialize_with = "deserialize_lighting_color" + )] + pub color: Rgb, /// Brightness percent, clamped to 0–100 on load. #[serde( default = "default_lighting_brightness", @@ -191,8 +198,8 @@ fn default_lighting_enabled() -> bool { true } -fn default_lighting_color() -> String { - "ffffff".to_string() +fn default_lighting_color() -> Rgb { + Rgb::WHITE } fn default_lighting_brightness() -> u8 { @@ -209,6 +216,18 @@ where Ok(u8::deserialize(deserializer)?.min(100)) } +/// Fall back to white when the configured color does not parse, mirroring +/// the `brightness` clamp above: a hand-edited value degrades predictably +/// instead of failing the whole config load. +fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(String::deserialize(deserializer)? + .parse() + .unwrap_or(Rgb::WHITE)) +} + /// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/openlogi-core/src/lib.rs b/crates/openlogi-core/src/lib.rs index fd01bb1a..f147a704 100644 --- a/crates/openlogi-core/src/lib.rs +++ b/crates/openlogi-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod binding; pub mod brand; +pub mod color; pub mod config; pub mod device; pub mod diagnostics; diff --git a/crates/openlogi-gui/src/app/home.rs b/crates/openlogi-gui/src/app/home.rs index 74e5ad26..22933e8e 100644 --- a/crates/openlogi-gui/src/app/home.rs +++ b/crates/openlogi-gui/src/app/home.rs @@ -117,7 +117,7 @@ pub(crate) fn keyboard_glow( .lighting_for(&record.config_key) .filter(|l| l.enabled)?; let geom = record.asset.as_ref()?.glow.clone()?; - let [_, r, g, b] = crate::components::lighting_panel::parse_hex(&lighting.color).to_be_bytes(); + let (r, g, b) = lighting.color.components(); let color = gpui::Rgba { r: f32::from(r) / 255., g: f32::from(g) / 255., diff --git a/crates/openlogi-gui/src/components/lighting_panel.rs b/crates/openlogi-gui/src/components/lighting_panel.rs index 6c2e011a..6f35c664 100644 --- a/crates/openlogi-gui/src/components/lighting_panel.rs +++ b/crates/openlogi-gui/src/components/lighting_panel.rs @@ -14,6 +14,7 @@ use gpui_component::{ slider::{Slider, SliderEvent, SliderState}, v_flex, }; +use openlogi_core::color::Rgb; use openlogi_core::config::Lighting; use crate::state::AppState; @@ -21,10 +22,18 @@ use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle}; const SWATCH: f32 = 28.; -/// Preset colors as 6-hex `"RRGGBB"`. Deliberately small — covering the common -/// keyboard accent colors. -const PALETTE: &[&str] = &[ - "ff3b30", "ff9500", "ffcc00", "34c759", "00c7be", "007aff", "5856d6", "af52de", "ffffff", +/// Preset colors. Deliberately small — covering the common keyboard accent +/// colors. +const PALETTE: &[Rgb] = &[ + Rgb::new(0xff, 0x3b, 0x30), + Rgb::new(0xff, 0x95, 0x00), + Rgb::new(0xff, 0xcc, 0x00), + Rgb::new(0x34, 0xc7, 0x59), + Rgb::new(0x00, 0xc7, 0xbe), + Rgb::new(0x00, 0x7a, 0xff), + Rgb::new(0x58, 0x56, 0xd6), + Rgb::new(0xaf, 0x52, 0xde), + Rgb::WHITE, ]; pub struct LightingPanel { @@ -96,7 +105,7 @@ impl Render for LightingPanel { let swatches: Vec = PALETTE .iter() .enumerate() - .map(|(idx, hex)| swatch(idx, hex, &lighting, pal)) + .map(|(idx, &color)| swatch(idx, color, &lighting, pal)) .collect(); v_flex() @@ -137,8 +146,8 @@ impl Render for LightingPanel { } /// One color swatch. Clicking it turns lighting on and sets that color. -fn swatch(idx: usize, hex: &'static str, current: &Lighting, pal: Palette) -> AnyElement { - let selected = current.enabled && current.color.eq_ignore_ascii_case(hex); +fn swatch(idx: usize, color: Rgb, current: &Lighting, pal: Palette) -> AnyElement { + let selected = current.enabled && current.color == color; div() .id(("light-swatch", idx)) .size(px(SWATCH)) @@ -149,13 +158,13 @@ fn swatch(idx: usize, hex: &'static str, current: &Lighting, pal: Palette) -> An } else { pal.border }) - .bg(rgb(parse_hex(hex))) + .bg(rgb(color.packed())) .cursor_pointer() .on_click(move |_event, _window, cx| { cx.update_global::(|state, _| { let mut next = state.lighting(); next.enabled = true; - next.color = hex.to_string(); + next.color = color; state.commit_lighting(next); }); cx.refresh_windows(); @@ -197,8 +206,3 @@ fn toggle(current: &Lighting, pal: Palette) -> AnyElement { fn clamp_brightness(raw: f32) -> u8 { raw.clamp(0., 100.).round() as u8 } - -/// Parse `"RRGGBB"` to a `0xRRGGBB` int for `rgb()`. Falls back to white. -pub(crate) fn parse_hex(hex: &str) -> u32 { - u32::from_str_radix(hex, 16).unwrap_or(0x00ff_ffff) -} From ecc543b62f6aeef218d1b0c12d82620133696a8e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:03:51 +0800 Subject: [PATCH 07/17] refactor(core): persist the config through atomic-write-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled temp-file writer used a fixed .toml.tmp name (a concurrent-save collision surface) and never fsynced the directory, so the rename could vanish on a crash. atomic-write-file — already the asset cache's writer — covers both; the 0600 mode on every save is preserved. --- Cargo.lock | 1 + crates/openlogi-core/Cargo.toml | 1 + crates/openlogi-core/src/config.rs | 42 ++++++++++++------------------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6749e87a..4b797e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5057,6 +5057,7 @@ dependencies = [ name = "openlogi-core" version = "0.6.19" dependencies = [ + "atomic-write-file", "etcetera", "serde", "tempfile", diff --git a/crates/openlogi-core/Cargo.toml b/crates/openlogi-core/Cargo.toml index 2d713197..abaee606 100644 --- a/crates/openlogi-core/Cargo.toml +++ b/crates/openlogi-core/Cargo.toml @@ -16,6 +16,7 @@ toml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } etcetera = "0.11.0" +atomic-write-file = "0.3.0" [dev-dependencies] tempfile = "3" diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 4e7bb7af..3e3e90d9 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -12,6 +12,7 @@ use std::{ path::{Path, PathBuf}, }; +use atomic_write_file::AtomicWriteFile; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -511,33 +512,24 @@ impl Config { } } +/// Write `bytes` to `path` atomically via a randomized temp file + rename, +/// with the directory fsync the old hand-rolled writer lacked. fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { - let tmp = path.with_extension("toml.tmp"); + #[cfg_attr( + not(unix), + expect(unused_mut, reason = "only the unix path mutates the options") + )] + let mut options = AtomicWriteFile::options(); + #[cfg(unix)] { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - let mut f = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&tmp)?; - io::Write::write_all(&mut f, bytes)?; - f.sync_all()?; - } - #[cfg(not(unix))] - { - let mut f = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp)?; - io::Write::write_all(&mut f, bytes)?; - f.sync_all()?; - } - } - fs::rename(&tmp, path) + use atomic_write_file::unix::OpenOptionsExt as _; + use std::os::unix::fs::OpenOptionsExt as _; + // Force 0600 on every save, matching the previous writer. + options.preserve_mode(false).mode(0o600); + } + let mut file = options.open(path)?; + io::Write::write_all(&mut file, bytes)?; + file.commit() } #[cfg(test)] From 420b4e71953d69e2287c9434b95d7e11770cab94 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:21:31 +0800 Subject: [PATCH 08/17] refactor(gui,cli): rename semantics-carrying mod.rs modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asset/mod.rs, windows/mod.rs, and cmd/diag/mod.rs all carry real logic (the asset resolver, the window registry, the diag device-selection policy) — per the module-layout rule they are foo.rs files with their children in the sibling directory. Pure renames. --- crates/openlogi-cli/src/cmd/{diag/mod.rs => diag.rs} | 0 crates/openlogi-gui/src/{asset/mod.rs => asset.rs} | 0 crates/openlogi-gui/src/{windows/mod.rs => windows.rs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename crates/openlogi-cli/src/cmd/{diag/mod.rs => diag.rs} (100%) rename crates/openlogi-gui/src/{asset/mod.rs => asset.rs} (100%) rename crates/openlogi-gui/src/{windows/mod.rs => windows.rs} (100%) diff --git a/crates/openlogi-cli/src/cmd/diag/mod.rs b/crates/openlogi-cli/src/cmd/diag.rs similarity index 100% rename from crates/openlogi-cli/src/cmd/diag/mod.rs rename to crates/openlogi-cli/src/cmd/diag.rs diff --git a/crates/openlogi-gui/src/asset/mod.rs b/crates/openlogi-gui/src/asset.rs similarity index 100% rename from crates/openlogi-gui/src/asset/mod.rs rename to crates/openlogi-gui/src/asset.rs diff --git a/crates/openlogi-gui/src/windows/mod.rs b/crates/openlogi-gui/src/windows.rs similarity index 100% rename from crates/openlogi-gui/src/windows/mod.rs rename to crates/openlogi-gui/src/windows.rs From d8dcd65aab089ba72385664f0f8c178a49ed6444 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:26:33 +0800 Subject: [PATCH 09/17] refactor(gui): extract the pacing module and drop tiny indirections The ipc_client's inline pacing module (with its own tests) becomes a file; the duplicated file_url helper collapses to one copy; two one-line single-caller wrappers in app.rs are inlined; the hotspot geometry sorts with total_cmp instead of NaN-tolerant unwrap_or. --- crates/openlogi-gui/src/app/detail.rs | 17 +- crates/openlogi-gui/src/app/widgets.rs | 6 +- crates/openlogi-gui/src/app_menu.rs | 2 +- crates/openlogi-gui/src/ipc_client.rs | 170 +----------------- crates/openlogi-gui/src/ipc_client/pacing.rs | 167 +++++++++++++++++ .../openlogi-gui/src/mouse_model/geometry.rs | 10 +- 6 files changed, 179 insertions(+), 193 deletions(-) create mode 100644 crates/openlogi-gui/src/ipc_client/pacing.rs diff --git a/crates/openlogi-gui/src/app/detail.rs b/crates/openlogi-gui/src/app/detail.rs index ea888cac..4b439435 100644 --- a/crates/openlogi-gui/src/app/detail.rs +++ b/crates/openlogi-gui/src/app/detail.rs @@ -14,13 +14,13 @@ use gpui_component::{ v_flex, }; use openlogi_core::device::DeviceKind; -use url::Url; use super::widgets::{ add_device_button, back_button, battery_summary, kind_label, panel_card, panel_card_fill, route_label, sidebar_action, status_badge, }; use super::{AppView, DetailTab}; +use crate::app_menu::file_url; use crate::components::dpi_panel::DpiPanel; use crate::components::lighting_panel::LightingPanel; use crate::components::smartshift_panel::SmartShiftPanel; @@ -177,7 +177,12 @@ fn pointer_tab( pal, smartshift_panel.clone().into_any_element(), ))) - .child(pointer_grid_card_natural(scrolling_card(pal, cx))), + .child( + div() + .min_w(px(332.)) + .flex_1() + .child(scrolling_card(pal, cx)), + ), ) } @@ -187,10 +192,6 @@ fn pointer_grid_card(card: impl IntoElement) -> impl IntoElement { div().min_w(px(332.)).flex_1().h_full().child(card) } -fn pointer_grid_card_natural(card: impl IntoElement) -> impl IntoElement { - div().min_w(px(332.)).flex_1().child(card) -} - /// Scrolling card: a per-device "invert scroll direction" toggle (#126). Pure /// config — no hardware read — so it is a plain switch row rather than an /// `Entity` panel like DPI / SmartShift. @@ -442,7 +443,3 @@ fn device_description_list(record: DeviceRecord) -> impl IntoElement { .bordered(false) .children(items) } - -fn file_url(path: &std::path::Path) -> Option { - Url::from_file_path(path).ok().map(Into::into) -} diff --git a/crates/openlogi-gui/src/app/widgets.rs b/crates/openlogi-gui/src/app/widgets.rs index 8944ffe0..75dbd81a 100644 --- a/crates/openlogi-gui/src/app/widgets.rs +++ b/crates/openlogi-gui/src/app/widgets.rs @@ -190,7 +190,7 @@ pub(super) fn battery_summary(battery: &BatteryInfo, pal: Palette) -> impl IntoE .child( div() .h_full() - .w(relative_percent(battery.percentage)) + .w(relative(f32::from(battery.percentage.clamp(1, 100)) / 100.)) .rounded_full() .bg(rgb(battery_color(battery.percentage))), ), @@ -205,10 +205,6 @@ fn battery_color(percentage: u8) -> u32 { } } -fn relative_percent(value: u8) -> gpui::DefiniteLength { - relative(f32::from(value.clamp(1, 100)) / 100.) -} - pub(super) fn sidebar_action( id: &'static str, icon: IconName, diff --git a/crates/openlogi-gui/src/app_menu.rs b/crates/openlogi-gui/src/app_menu.rs index 3346238f..7530edb7 100644 --- a/crates/openlogi-gui/src/app_menu.rs +++ b/crates/openlogi-gui/src/app_menu.rs @@ -232,6 +232,6 @@ fn device_menu_items(cx: &App) -> Vec { items } -fn file_url(path: &std::path::Path) -> Option { +pub(crate) fn file_url(path: &std::path::Path) -> Option { Url::from_file_path(path).ok().map(Into::into) } diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index e956cbe2..35c6386b 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -264,175 +264,7 @@ fn ticker(first_in: Option, period: Duration) -> tokio::time::Interval /// /// Pure bookkeeping — the caller maps [`Cadence`] switches onto its timer — /// so the transitions are unit-testable. -mod pacing { - use std::time::{Duration, Instant}; - - /// Which poll period the loop should run on. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Cadence { - /// `STARTUP_POLL_PERIOD` — converging on a fresh agent. - Fast, - /// The configured steady `poll_period`. - Steady, - } - - pub struct Pacing { - steady_period: Duration, - fast_cap: Duration, - mode: Cadence, - /// When the current fast phase began (valid while `mode == Fast`). - fast_since: Instant, - /// The fast phase expired without readiness. Cleared by readiness, a - /// disconnect, or the first delivery after an outage — each starts a - /// genuinely new episode that deserves a fresh fast phase. - capped: bool, - /// Whether the previous tick delivered a snapshot, so the first - /// delivery after an outage is recognizable. - was_delivering: bool, - } - - impl Pacing { - pub fn new(steady_period: Duration, fast_cap: Duration, now: Instant) -> Self { - Self { - steady_period, - fast_cap, - mode: Cadence::Fast, - fast_since: now, - capped: false, - was_delivering: false, - } - } - - pub fn steady_period(&self) -> Duration { - self.steady_period - } - - /// A snapshot was delivered. Ready → steady; not ready → fast until - /// the cap, then steady. - pub fn on_delivered(&mut self, ready: bool, now: Instant) -> Option { - if !self.was_delivering { - // First delivery after an outage: a just-(re)started agent - // deserves a fresh fast phase regardless of how the outage - // episode ended. - self.capped = false; - self.fast_since = now; - } - self.was_delivering = true; - if ready { - self.capped = false; - return self.switch(Cadence::Steady, now); - } - if self.capped || self.expired(now) { - self.capped = true; - return self.switch(Cadence::Steady, now); - } - self.switch(Cadence::Fast, now) - } - - /// No agent reachable this tick (and no live connection to lose). - pub fn on_unreachable(&mut self, now: Instant) -> Option { - self.was_delivering = false; - if self.capped || self.expired(now) { - self.capped = true; - return self.switch(Cadence::Steady, now); - } - None - } - - /// A live connection dropped — re-converge fast, fresh phase. - pub fn on_disconnect(&mut self, now: Instant) -> Option { - self.was_delivering = false; - self.capped = false; - self.switch(Cadence::Fast, now) - } - - /// The agent speaks a newer protocol: only a GUI relaunch resolves - /// it, so fast polling buys nothing. - pub fn on_newer_agent(&mut self, now: Instant) -> Option { - self.was_delivering = false; - self.capped = true; - self.switch(Cadence::Steady, now) - } - - fn expired(&self, now: Instant) -> bool { - self.mode == Cadence::Fast && now.duration_since(self.fast_since) >= self.fast_cap - } - - fn switch(&mut self, to: Cadence, now: Instant) -> Option { - if self.mode == to { - return None; - } - if to == Cadence::Fast { - self.fast_since = now; - } - self.mode = to; - Some(to) - } - } - - #[cfg(test)] - mod tests { - use super::{Cadence, Pacing}; - use std::time::{Duration, Instant}; - - const STEADY: Duration = Duration::from_secs(2); - const CAP: Duration = Duration::from_secs(15); - - fn pacing(now: Instant) -> Pacing { - Pacing::new(STEADY, CAP, now) - } - - #[test] - fn readiness_settles_to_steady_and_disconnect_rearms_fast() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_delivered(false, t0), None); // already fast - assert_eq!(p.on_delivered(true, t0), Some(Cadence::Steady)); - assert_eq!(p.on_delivered(true, t0 + STEADY), None); - assert_eq!(p.on_disconnect(t0 + STEADY * 2), Some(Cadence::Fast)); - } - - #[test] - fn never_ready_falls_back_to_steady_after_the_cap() { - let t0 = Instant::now(); - let mut p = pacing(t0); - // The first delivery opens the fast phase; the cap counts from it. - assert_eq!(p.on_delivered(false, t0), None); - assert_eq!(p.on_delivered(false, t0 + CAP / 2), None); - assert_eq!(p.on_delivered(false, t0 + CAP), Some(Cadence::Steady)); - // Capped: further not-ready deliveries stay steady. - assert_eq!(p.on_delivered(false, t0 + CAP + STEADY), None); - // …but readiness still lands (and stays steady). - assert_eq!(p.on_delivered(true, t0 + CAP + STEADY * 2), None); - } - - #[test] - fn unreachable_episode_caps_and_a_new_agent_gets_a_fresh_fast_phase() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_unreachable(t0 + Duration::from_secs(1)), None); - assert_eq!(p.on_unreachable(t0 + CAP), Some(Cadence::Steady)); - // An agent finally comes up, still scanning: fresh fast phase - // despite the cap from the outage episode. - assert_eq!( - p.on_delivered(false, t0 + CAP + STEADY), - Some(Cadence::Fast) - ); - assert_eq!( - p.on_delivered(true, t0 + CAP + STEADY * 2), - Some(Cadence::Steady) - ); - } - - #[test] - fn newer_agent_goes_steady_immediately() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_newer_agent(t0), Some(Cadence::Steady)); - assert_eq!(p.on_unreachable(t0 + STEADY), None); // stays steady - } - } -} +mod pacing; /// Long-poll the agent's pairing event stream on a dedicated connection, pushing /// each [`PairingUpdate`] to the GUI. Runs for the client's lifetime; when no diff --git a/crates/openlogi-gui/src/ipc_client/pacing.rs b/crates/openlogi-gui/src/ipc_client/pacing.rs new file mode 100644 index 00000000..887e0915 --- /dev/null +++ b/crates/openlogi-gui/src/ipc_client/pacing.rs @@ -0,0 +1,167 @@ +use std::time::{Duration, Instant}; + +/// Which poll period the loop should run on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cadence { + /// `STARTUP_POLL_PERIOD` — converging on a fresh agent. + Fast, + /// The configured steady `poll_period`. + Steady, +} + +pub struct Pacing { + steady_period: Duration, + fast_cap: Duration, + mode: Cadence, + /// When the current fast phase began (valid while `mode == Fast`). + fast_since: Instant, + /// The fast phase expired without readiness. Cleared by readiness, a + /// disconnect, or the first delivery after an outage — each starts a + /// genuinely new episode that deserves a fresh fast phase. + capped: bool, + /// Whether the previous tick delivered a snapshot, so the first + /// delivery after an outage is recognizable. + was_delivering: bool, +} + +impl Pacing { + pub fn new(steady_period: Duration, fast_cap: Duration, now: Instant) -> Self { + Self { + steady_period, + fast_cap, + mode: Cadence::Fast, + fast_since: now, + capped: false, + was_delivering: false, + } + } + + pub fn steady_period(&self) -> Duration { + self.steady_period + } + + /// A snapshot was delivered. Ready → steady; not ready → fast until + /// the cap, then steady. + pub fn on_delivered(&mut self, ready: bool, now: Instant) -> Option { + if !self.was_delivering { + // First delivery after an outage: a just-(re)started agent + // deserves a fresh fast phase regardless of how the outage + // episode ended. + self.capped = false; + self.fast_since = now; + } + self.was_delivering = true; + if ready { + self.capped = false; + return self.switch(Cadence::Steady, now); + } + if self.capped || self.expired(now) { + self.capped = true; + return self.switch(Cadence::Steady, now); + } + self.switch(Cadence::Fast, now) + } + + /// No agent reachable this tick (and no live connection to lose). + pub fn on_unreachable(&mut self, now: Instant) -> Option { + self.was_delivering = false; + if self.capped || self.expired(now) { + self.capped = true; + return self.switch(Cadence::Steady, now); + } + None + } + + /// A live connection dropped — re-converge fast, fresh phase. + pub fn on_disconnect(&mut self, now: Instant) -> Option { + self.was_delivering = false; + self.capped = false; + self.switch(Cadence::Fast, now) + } + + /// The agent speaks a newer protocol: only a GUI relaunch resolves + /// it, so fast polling buys nothing. + pub fn on_newer_agent(&mut self, now: Instant) -> Option { + self.was_delivering = false; + self.capped = true; + self.switch(Cadence::Steady, now) + } + + fn expired(&self, now: Instant) -> bool { + self.mode == Cadence::Fast && now.duration_since(self.fast_since) >= self.fast_cap + } + + fn switch(&mut self, to: Cadence, now: Instant) -> Option { + if self.mode == to { + return None; + } + if to == Cadence::Fast { + self.fast_since = now; + } + self.mode = to; + Some(to) + } +} + +#[cfg(test)] +mod tests { + use super::{Cadence, Pacing}; + use std::time::{Duration, Instant}; + + const STEADY: Duration = Duration::from_secs(2); + const CAP: Duration = Duration::from_secs(15); + + fn pacing(now: Instant) -> Pacing { + Pacing::new(STEADY, CAP, now) + } + + #[test] + fn readiness_settles_to_steady_and_disconnect_rearms_fast() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_delivered(false, t0), None); // already fast + assert_eq!(p.on_delivered(true, t0), Some(Cadence::Steady)); + assert_eq!(p.on_delivered(true, t0 + STEADY), None); + assert_eq!(p.on_disconnect(t0 + STEADY * 2), Some(Cadence::Fast)); + } + + #[test] + fn never_ready_falls_back_to_steady_after_the_cap() { + let t0 = Instant::now(); + let mut p = pacing(t0); + // The first delivery opens the fast phase; the cap counts from it. + assert_eq!(p.on_delivered(false, t0), None); + assert_eq!(p.on_delivered(false, t0 + CAP / 2), None); + assert_eq!(p.on_delivered(false, t0 + CAP), Some(Cadence::Steady)); + // Capped: further not-ready deliveries stay steady. + assert_eq!(p.on_delivered(false, t0 + CAP + STEADY), None); + // …but readiness still lands (and stays steady). + assert_eq!(p.on_delivered(true, t0 + CAP + STEADY * 2), None); + } + + #[test] + fn unreachable_episode_caps_and_a_new_agent_gets_a_fresh_fast_phase() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_unreachable(t0 + Duration::from_secs(1)), None); + assert_eq!(p.on_unreachable(t0 + CAP), Some(Cadence::Steady)); + // An agent finally comes up, still scanning: fresh fast phase + // despite the cap from the outage episode. + assert_eq!( + p.on_delivered(false, t0 + CAP + STEADY), + Some(Cadence::Fast) + ); + assert_eq!( + p.on_delivered(true, t0 + CAP + STEADY * 2), + Some(Cadence::Steady) + ); + } + + #[test] + fn newer_agent_goes_steady_immediately() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_newer_agent(t0), Some(Cadence::Steady)); + assert_eq!(p.on_unreachable(t0 + STEADY), None); // stays steady + } +} diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index ec709edc..7ad9b4ed 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -156,13 +156,7 @@ pub fn labels_from_hotspots(hotspots: &[Hotspot], mouse_h: f32) -> Vec