diff --git a/Cargo.lock b/Cargo.lock index 6749e87a..5683c9ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5034,6 +5034,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 2.0.18", "tracing", "ureq", @@ -5057,6 +5058,7 @@ dependencies = [ name = "openlogi-core" version = "0.6.19" dependencies = [ + "atomic-write-file", "etcetera", "serde", "tempfile", @@ -5095,6 +5097,7 @@ dependencies = [ "serde_json", "sys-locale", "tarpc", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 416f15a1..c270e350 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 { @@ -342,35 +345,23 @@ 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)) } -// --------------------------------------------------------------------------- // 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( @@ -378,7 +369,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-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-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-assets/Cargo.toml b/crates/openlogi-assets/Cargo.toml index 27719650..11b7b770 100644 --- a/crates/openlogi-assets/Cargo.toml +++ b/crates/openlogi-assets/Cargo.toml @@ -22,3 +22,6 @@ atomic-write-file = "0.3.0" [lints] workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/openlogi-assets/src/http.rs b/crates/openlogi-assets/src/http.rs index b6f5bc7c..9b5c6a99 100644 --- a/crates/openlogi-assets/src/http.rs +++ b/crates/openlogi-assets/src/http.rs @@ -297,27 +297,23 @@ mod tests { #[test] #[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")] fn write_replace_overwrites_in_place() { - let dir = std::env::temp_dir().join(format!("openlogi-http-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let dst = dir.join("a.png"); + let dir = tempfile::tempdir().expect("create temp dir"); + let dst = dir.path().join("a.png"); write_replace(&dst, b"one").expect("first write"); write_replace(&dst, b"two").expect("replace"); assert_eq!(std::fs::read(&dst).expect("read back"), b"two"); - let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] #[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")] fn write_replace_replaces_a_planted_symlink_instead_of_following_it() { - let dir = - std::env::temp_dir().join(format!("openlogi-http-symlink-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - let victim = dir.join("victim.txt"); + let dir = tempfile::tempdir().expect("create temp dir"); + let victim = dir.path().join("victim.txt"); std::fs::write(&victim, b"untouched").expect("seed victim"); - let dst = dir.join("b.png"); + let dst = dir.path().join("b.png"); std::os::unix::fs::symlink(&victim, &dst).expect("plant symlink"); write_replace(&dst, b"payload").expect("write through planted link"); @@ -328,7 +324,6 @@ mod tests { let meta = std::fs::symlink_metadata(&dst).expect("stat dst"); assert!(meta.file_type().is_file()); assert_eq!(std::fs::read(&dst).expect("read dst"), b"payload"); - let _ = std::fs::remove_dir_all(&dir); } #[test] 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-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/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/binding.rs b/crates/openlogi-core/src/binding.rs index 0752e58a..97ddea09 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -8,19 +8,32 @@ use std::collections::BTreeMap; use std::fmt; -use std::time::Instant; use serde::{Deserialize, Serialize}; +mod swipe; + +pub use swipe::{ + GESTURE_HOLD_FOR_SWIPE, GESTURE_SWIPE_DEADZONE, GESTURE_SWIPE_THRESHOLD, SwipeAccumulator, + detect_swipe, +}; + /// One of the user-rebindable hotspots on a Logi mouse. The order matches the /// physical layout from front to side; [`ButtonId::ALL`] is consumed by the /// default-binding generator and the popover trigger list. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ButtonId { + /// The primary button. Rebindable in the config schema, but the OS hook + /// never suppresses it — see [`ButtonId::is_os_hook_button`]. LeftClick, + /// The secondary button. Like [`ButtonId::LeftClick`], it always passes + /// through the OS hook. RightClick, + /// The wheel click — one of the three buttons the OS hook remaps. MiddleClick, + /// The thumb-side "back" button (mouse button 4), remapped by the OS hook. Back, + /// The thumb-side "forward" button (mouse button 5), remapped by the OS hook. Forward, /// The "ModeShift" button under the wheel — typically used for SmartShift / /// DPI cycle. Named `DpiToggle` for historical reasons. @@ -41,6 +54,9 @@ pub enum ButtonId { } impl ButtonId { + /// Every rebindable button in declaration (physical front-to-side) order — + /// the iteration source for default-binding seeding and the popover + /// trigger list. pub const ALL: [ButtonId; 10] = [ ButtonId::LeftClick, ButtonId::RightClick, @@ -102,14 +118,23 @@ impl fmt::Display for ButtonId { /// Variant identifiers are TOML-stable: renames are migration events. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum GestureDirection { + /// Hold + swipe up (negative raw-XY `dy`). Up, + /// Hold + swipe down (positive raw-XY `dy`). Down, + /// Hold + swipe left (negative raw-XY `dx`). Left, + /// Hold + swipe right (positive raw-XY `dx`). Right, + /// A press-and-release that never committed a swipe — the gesture + /// button's plain-click slot. Click, } impl GestureDirection { + /// All five direction slots, swipes first and [`Click`](Self::Click) last. + /// Iterated to seed or complete a full gesture map — see + /// [`Binding::fill_gesture_defaults`] and [`default_binding_for`]. pub const ALL: [GestureDirection; 5] = [ GestureDirection::Up, GestureDirection::Down, @@ -118,6 +143,7 @@ impl GestureDirection { GestureDirection::Click, ]; + /// Human-readable label for popovers and tooltips. #[must_use] pub fn label(self) -> &'static str { match self { @@ -148,150 +174,6 @@ impl fmt::Display for GestureDirection { } } -/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to -/// a direction. Tuned to match Logitech Options+'s responsiveness. -pub const GESTURE_SWIPE_THRESHOLD: i32 = 50; -/// Maximum cross-axis travel allowed at the threshold, so only a reasonably -/// straight swipe commits. Grows with the dominant axis (`max(deadzone, 35%)`). -pub const GESTURE_SWIPE_DEADZONE: i32 = 40; -/// Minimum time a gesture button must be held before its travel can commit to a -/// swipe. Distinguishes a deliberate hold-and-swipe from a quick click whose -/// cursor happened to be moving. Shared by both gesture paths (the HID++ thumb -/// pad and the OS-hook Middle/Back/Forward). -pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160); - -/// Classify the *running* raw-XY travel of a held gesture button into a -/// directional swipe, the instant it commits — or `None` while it's still too -/// short or too diagonal. -/// -/// The dominant axis must pass [`GESTURE_SWIPE_THRESHOLD`] while the cross axis -/// stays within `max(`[`GESTURE_SWIPE_DEADZONE`]`, 35% of dominant)`. Callers -/// fire the bound action the moment this returns `Some` — mid-swipe, like -/// Options+ — rather than waiting for the button release; a press that never -/// commits a direction is treated as [`GestureDirection::Click`] on release. -/// -/// Coordinates follow the device's raw-XY convention (`+x` = right, `+y` = -/// down), so an upward swipe (negative `dy`) maps to [`GestureDirection::Up`]. -#[must_use] -pub fn detect_swipe(dx: i32, dy: i32) -> Option { - // Saturating throughout: a [`SwipeAccumulator`] hold that never commits (a - // sustained diagonal) keeps summing travel, so `dx`/`dy` can reach the i32 - // bounds. `i32::MIN.abs()` would panic and a plain `dominant * 35` would - // overflow — and a panic in the input-hook callback is exactly the freeze - // hazard we must never hit. The clamp is inert in the normal range. - let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs()); - let dominant = abs_x.max(abs_y); - if dominant < GESTURE_SWIPE_THRESHOLD { - return None; - } - let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100); - if abs_x > abs_y { - if abs_y > cross_limit { - return None; - } - Some(if dx > 0 { - GestureDirection::Right - } else { - GestureDirection::Left - }) - } else { - if abs_x > cross_limit { - return None; - } - Some(if dy > 0 { - GestureDirection::Down - } else { - GestureDirection::Up - }) - } -} - -/// The mid-swipe state machine shared by both gesture-capture paths: the HID++ -/// dedicated gesture button (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook -/// Middle/Back/Forward buttons (`openlogi-agent-core`'s CGEventTap). A gesture -/// button's hold accumulates travel; the instant the dominant axis commits a -/// direction — after the button has been held [`GESTURE_HOLD_FOR_SWIPE`], so a -/// quick click whose cursor drifted doesn't count — [`Self::accumulate`] returns -/// that direction exactly once, like Logitech Options+. A hold that never -/// commits is a plain click, reported by [`Self::end`]. -/// -/// The two paths differ only in *what identifies the held control* (a -/// [`ButtonId`] for the OS hook, a diverted CID for the HID++ gesture control), so each owns -/// that and embeds this for the shared travel logic. Keeping the logic in one -/// place is deliberate: the two copies it replaced had already drifted apart -/// (one resolved a swipe only on release), which mis-fired the click. -#[derive(Debug, Default)] -pub struct SwipeAccumulator { - /// When the current hold began, or `None` when not holding. Gates a - /// deliberate swipe against a quick click whose cursor happened to move. - held_since: Option, - /// Accumulated raw-XY travel since the hold began (saturating, so an - /// arbitrarily long hold can never overflow). - dx: i32, - dy: i32, - /// Set once a direction has committed this hold, so it fires exactly once - /// and the release isn't then also read as a click. - fired: bool, -} - -impl SwipeAccumulator { - /// Begin a fresh hold, resetting the travel accumulator and commit state. - pub fn begin(&mut self) { - self.held_since = Some(Instant::now()); - self.dx = 0; - self.dy = 0; - self.fired = false; - } - - /// Whether a hold is in progress (between [`Self::begin`] and [`Self::end`]), - /// so callers can do rising/falling-edge detection without a second flag. - #[must_use] - pub fn is_holding(&self) -> bool { - self.held_since.is_some() - } - - /// Feed a pointer-move / raw-XY delta into the current hold. Returns - /// `Some(direction)` exactly once per hold — the instant travel commits, and - /// only after the hold passes [`GESTURE_HOLD_FOR_SWIPE`] — and `None` while - /// still too short, already committed, or not holding. - pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option { - if self.fired || self.held_since.is_none() { - return None; - } - self.dx = self.dx.saturating_add(dx); - self.dy = self.dy.saturating_add(dy); - let held_long_enough = self - .held_since - .is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE); - if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) { - self.fired = true; - return Some(dir); - } - None - } - - /// End the current hold. Returns `true` when an in-progress hold ended - /// without committing a swipe — the caller should fire the plain `Click` - /// action — and `false` when a swipe already fired mid-motion, or when there - /// was no hold to end (a stray release reports no click). - pub fn end(&mut self) -> bool { - let was_click = self.held_since.is_some() && !self.fired; - self.held_since = None; - was_click - } - - /// Test-only seam: backdate the current hold so its [`GESTURE_HOLD_FOR_SWIPE`] - /// gate is already satisfied, letting a test exercise a committed swipe - /// without sleeping. Real code never calls this — [`Self::begin`] records the - /// true start instant. A no-op when not currently holding. - #[doc(hidden)] - pub fn backdate_hold_for_test(&mut self) { - if self.held_since.is_some() { - self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2); - } - } -} - /// Grouping for popover section headers. /// /// Used by [`Action::category`] and rendered as a small muted label above @@ -509,9 +391,13 @@ pub struct KeyCombo { } impl KeyCombo { + /// Bit for the ⌘ Command modifier in [`Self::modifiers`]. pub const MOD_CMD: u8 = 1 << 0; + /// Bit for the ⇧ Shift modifier in [`Self::modifiers`]. pub const MOD_SHIFT: u8 = 1 << 1; + /// Bit for the ⌃ Control modifier in [`Self::modifiers`]. pub const MOD_CTRL: u8 = 1 << 2; + /// Bit for the ⌥ Option/Alt modifier in [`Self::modifiers`]. pub const MOD_OPTION: u8 = 1 << 3; /// Build the human-readable label from the modifier bitmask + key code. @@ -1065,194 +951,6 @@ mod tests { assert!(Action::catalog().contains(&Action::CaptureRegion)); } - // ── Gesture classification ──────────────────────────────────────────────── - - #[test] - fn detect_swipe_below_threshold_keeps_accumulating() { - // Too little travel to commit — caller keeps summing raw-XY. - assert_eq!(detect_swipe(40, 5), None); - assert_eq!(detect_swipe(0, 0), None); - } - - #[test] - fn detect_swipe_commits_clean_direction() { - assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right)); - assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left)); - assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down)); - assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up)); - } - - #[test] - fn detect_swipe_rejects_diagonal() { - // Past the threshold but too diagonal (cross axis beyond the band). - assert_eq!(detect_swipe(60, 60), None); - assert_eq!(detect_swipe(-60, -60), None); - } - - #[test] - fn detect_swipe_threshold_and_cross_band_boundaries() { - // The threshold bound is inclusive (`< THRESHOLD` rejects), so exactly at - // it commits and one below does not. - assert_eq!( - detect_swipe(GESTURE_SWIPE_THRESHOLD, 0), - Some(GestureDirection::Right) - ); - assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None); - - // The cross-axis band is max(deadzone, 35% of dominant). For a large - // dominant the 35% term wins (200 → 70): 69 commits, 71 is too diagonal. - assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right)); - assert_eq!(detect_swipe(200, 71), None); - // For a small dominant the 40-unit floor wins (100 → max(40, 35) = 40). - assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right)); - assert_eq!(detect_swipe(100, 41), None); - } - - #[test] - fn detect_swipe_does_not_panic_on_extreme_values() { - // Saturated accumulator travel can reach the i32 bounds. `i32::MIN.abs()` - // panics and `dominant * 35` overflows — both must be clamped, not crash. - assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right)); - assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left)); - assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down)); - assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up)); - // A diagonal at the extremes is still rejected, without panicking. - assert_eq!(detect_swipe(i32::MIN, i32::MIN), None); - } - - // ── SwipeAccumulator (the shared mid-swipe state machine) ───────────────── - - #[test] - fn accumulator_commits_a_direction_once_after_the_hold_gate() { - let mut acc = SwipeAccumulator::default(); - acc.begin(); - acc.backdate_hold_for_test(); - // A clear rightward swipe commits exactly once, mid-motion. - assert_eq!( - acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some(GestureDirection::Right) - ); - // Further travel in the same hold must not re-fire. - assert_eq!(acc.accumulate(50, 0), None); - } - - #[test] - fn accumulator_does_not_commit_before_the_hold_gate() { - let mut acc = SwipeAccumulator::default(); - acc.begin(); // held_since = now, so the gate is not yet satisfied - // A big delta arriving immediately (a quick click whose cursor drifted) - // must not commit. - assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None); - // Once held long enough, the next delta commits. - acc.backdate_hold_for_test(); - assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some()); - } - - #[test] - fn accumulator_end_reports_click_only_when_no_swipe_fired() { - // A hold with only tiny drift never commits → end() is a click. - let mut acc = SwipeAccumulator::default(); - acc.begin(); - acc.backdate_hold_for_test(); - assert_eq!(acc.accumulate(2, -1), None); - assert!(acc.end(), "a hold that never swiped is a click"); - - // A hold that committed a swipe → end() is not a click. - acc.begin(); - acc.backdate_hold_for_test(); - assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some()); - assert!(!acc.end(), "a committed swipe must not also click"); - } - - #[test] - fn accumulator_ignores_motion_when_not_holding() { - let mut acc = SwipeAccumulator::default(); - assert!(!acc.is_holding()); - // Travel outside a hold is dropped, never committing a stray swipe. - assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None); - } - - #[test] - fn accumulator_sums_sub_threshold_deltas_until_they_commit() { - // The whole reason for an accumulator (vs. detect_swipe on one delta): - // several deltas each too small to commit on their own must sum across - // the hold until the running total crosses the threshold, then commit. - let mut acc = SwipeAccumulator::default(); - acc.begin(); - acc.backdate_hold_for_test(); - // Just under half the threshold: one or two steps never reach it, three do. - let step = GESTURE_SWIPE_THRESHOLD / 2 - 1; - assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold"); - assert_eq!(acc.accumulate(step, 0), None, "two steps still under"); - assert_eq!( - acc.accumulate(step, 0), - Some(GestureDirection::Right), - "the running sum finally crosses the threshold" - ); - } - - #[test] - fn accumulator_saturates_instead_of_overflowing() { - // The doc promises an arbitrarily long hold can't overflow. A perfect - // diagonal never commits, so travel keeps summing; feed deltas that would - // overflow both an i32 sum and a naive cross-band multiply — both must - // saturate, not panic (debug builds panic on overflow). - let mut acc = SwipeAccumulator::default(); - acc.begin(); - acc.backdate_hold_for_test(); - assert_eq!( - acc.accumulate(i32::MAX, i32::MAX), - None, - "a diagonal never commits" - ); - assert_eq!( - acc.accumulate(i32::MAX, i32::MAX), - None, - "the saturating sum must not panic" - ); - // A clean axis on a fresh hold still commits with a saturated magnitude. - acc.begin(); - acc.backdate_hold_for_test(); - assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right)); - } - - #[test] - fn accumulator_begin_recovers_a_stale_hold() { - // A missed release (e.g. focus loss between press and release) can leave - // a dangling hold that already fired with travel in some direction. A - // fresh begin() must wipe both the `fired` latch and the travel, so the - // next press isn't poisoned by the old one. - let mut acc = SwipeAccumulator::default(); - acc.begin(); - acc.backdate_hold_for_test(); - // Stale hold commits LEFT (negative dx) and latches `fired`. - assert_eq!( - acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0), - Some(GestureDirection::Left) - ); - // No end() — a dropped release, then a fresh press. - acc.begin(); - acc.backdate_hold_for_test(); - // Had `fired` leaked this would be None; had the negative travel leaked it - // would commit Left. Committing Right proves begin() reset both. - assert_eq!( - acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), - Some(GestureDirection::Right) - ); - } - - #[test] - fn accumulator_end_without_a_hold_is_not_a_click() { - // end() in isolation (no begin) must not claim a click — there was no - // hold — so a stray release can't be read as a press. - let mut acc = SwipeAccumulator::default(); - assert!(!acc.end(), "a release with no hold is not a click"); - // A redundant second release after a real hold already ended is inert too. - acc.begin(); - assert!(acc.end(), "the held release is a click"); - assert!(!acc.end(), "the redundant second release is not a click"); - } - // ── TOML roundtrip ──────────────────────────────────────────────────────── /// Serialize then deserialize `action` through TOML, using a wrapper diff --git a/crates/openlogi-core/src/binding/swipe.rs b/crates/openlogi-core/src/binding/swipe.rs new file mode 100644 index 00000000..43fe1eaa --- /dev/null +++ b/crates/openlogi-core/src/binding/swipe.rs @@ -0,0 +1,345 @@ +//! The swipe-gesture runtime machinery: travel thresholds, the +//! [`detect_swipe`] classifier, and the [`SwipeAccumulator`] state machine +//! shared by both gesture-capture paths. This is input processing, distinct +//! from the `Action` vocabulary the parent [`binding`](super) module defines. + +use std::time::Instant; + +use super::GestureDirection; + +/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to +/// a direction. Tuned to match Logitech Options+'s responsiveness. +pub const GESTURE_SWIPE_THRESHOLD: i32 = 50; +/// Maximum cross-axis travel allowed at the threshold, so only a reasonably +/// straight swipe commits. Grows with the dominant axis (`max(deadzone, 35%)`). +pub const GESTURE_SWIPE_DEADZONE: i32 = 40; +/// Minimum time a gesture button must be held before its travel can commit to a +/// swipe. Distinguishes a deliberate hold-and-swipe from a quick click whose +/// cursor happened to be moving. Shared by both gesture paths (the HID++ thumb +/// pad and the OS-hook Middle/Back/Forward). +pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160); + +/// Classify the *running* raw-XY travel of a held gesture button into a +/// directional swipe, the instant it commits — or `None` while it's still too +/// short or too diagonal. +/// +/// The dominant axis must pass [`GESTURE_SWIPE_THRESHOLD`] while the cross axis +/// stays within `max(`[`GESTURE_SWIPE_DEADZONE`]`, 35% of dominant)`. Callers +/// fire the bound action the moment this returns `Some` — mid-swipe, like +/// Options+ — rather than waiting for the button release; a press that never +/// commits a direction is treated as [`GestureDirection::Click`] on release. +/// +/// Coordinates follow the device's raw-XY convention (`+x` = right, `+y` = +/// down), so an upward swipe (negative `dy`) maps to [`GestureDirection::Up`]. +#[must_use] +pub fn detect_swipe(dx: i32, dy: i32) -> Option { + // Saturating throughout: a [`SwipeAccumulator`] hold that never commits (a + // sustained diagonal) keeps summing travel, so `dx`/`dy` can reach the i32 + // bounds. `i32::MIN.abs()` would panic and a plain `dominant * 35` would + // overflow — and a panic in the input-hook callback is exactly the freeze + // hazard we must never hit. The clamp is inert in the normal range. + let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs()); + let dominant = abs_x.max(abs_y); + if dominant < GESTURE_SWIPE_THRESHOLD { + return None; + } + let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100); + if abs_x > abs_y { + if abs_y > cross_limit { + return None; + } + Some(if dx > 0 { + GestureDirection::Right + } else { + GestureDirection::Left + }) + } else { + if abs_x > cross_limit { + return None; + } + Some(if dy > 0 { + GestureDirection::Down + } else { + GestureDirection::Up + }) + } +} + +/// The mid-swipe state machine shared by both gesture-capture paths: the HID++ +/// dedicated gesture button (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook +/// Middle/Back/Forward buttons (`openlogi-agent-core`'s CGEventTap). A gesture +/// button's hold accumulates travel; the instant the dominant axis commits a +/// direction — after the button has been held [`GESTURE_HOLD_FOR_SWIPE`], so a +/// quick click whose cursor drifted doesn't count — [`Self::accumulate`] returns +/// that direction exactly once, like Logitech Options+. A hold that never +/// commits is a plain click, reported by [`Self::end`]. +/// +/// The two paths differ only in *what identifies the held control* (a +/// [`ButtonId`](super::ButtonId) for the OS hook, a diverted CID for the HID++ gesture control), so each owns +/// that and embeds this for the shared travel logic. Keeping the logic in one +/// place is deliberate: the two copies it replaced had already drifted apart +/// (one resolved a swipe only on release), which mis-fired the click. +#[derive(Debug, Default)] +pub struct SwipeAccumulator { + /// When the current hold began, or `None` when not holding. Gates a + /// deliberate swipe against a quick click whose cursor happened to move. + held_since: Option, + /// Accumulated raw-XY travel since the hold began (saturating, so an + /// arbitrarily long hold can never overflow). + dx: i32, + dy: i32, + /// Set once a direction has committed this hold, so it fires exactly once + /// and the release isn't then also read as a click. + fired: bool, +} + +impl SwipeAccumulator { + /// Begin a fresh hold, resetting the travel accumulator and commit state. + pub fn begin(&mut self) { + self.held_since = Some(Instant::now()); + self.dx = 0; + self.dy = 0; + self.fired = false; + } + + /// Whether a hold is in progress (between [`Self::begin`] and [`Self::end`]), + /// so callers can do rising/falling-edge detection without a second flag. + #[must_use] + pub fn is_holding(&self) -> bool { + self.held_since.is_some() + } + + /// Feed a pointer-move / raw-XY delta into the current hold. Returns + /// `Some(direction)` exactly once per hold — the instant travel commits, and + /// only after the hold passes [`GESTURE_HOLD_FOR_SWIPE`] — and `None` while + /// still too short, already committed, or not holding. + pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option { + if self.fired || self.held_since.is_none() { + return None; + } + self.dx = self.dx.saturating_add(dx); + self.dy = self.dy.saturating_add(dy); + let held_long_enough = self + .held_since + .is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE); + if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) { + self.fired = true; + return Some(dir); + } + None + } + + /// End the current hold. Returns `true` when an in-progress hold ended + /// without committing a swipe — the caller should fire the plain `Click` + /// action — and `false` when a swipe already fired mid-motion, or when there + /// was no hold to end (a stray release reports no click). + pub fn end(&mut self) -> bool { + let was_click = self.held_since.is_some() && !self.fired; + self.held_since = None; + was_click + } + + /// Test-only seam: backdate the current hold so its [`GESTURE_HOLD_FOR_SWIPE`] + /// gate is already satisfied, letting a test exercise a committed swipe + /// without sleeping. Real code never calls this — [`Self::begin`] records the + /// true start instant. A no-op when not currently holding. + #[doc(hidden)] + pub fn backdate_hold_for_test(&mut self) { + if self.held_since.is_some() { + self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Gesture classification ──────────────────────────────────────────────── + + #[test] + fn detect_swipe_below_threshold_keeps_accumulating() { + // Too little travel to commit — caller keeps summing raw-XY. + assert_eq!(detect_swipe(40, 5), None); + assert_eq!(detect_swipe(0, 0), None); + } + + #[test] + fn detect_swipe_commits_clean_direction() { + assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right)); + assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left)); + assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down)); + assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up)); + } + + #[test] + fn detect_swipe_rejects_diagonal() { + // Past the threshold but too diagonal (cross axis beyond the band). + assert_eq!(detect_swipe(60, 60), None); + assert_eq!(detect_swipe(-60, -60), None); + } + + #[test] + fn detect_swipe_threshold_and_cross_band_boundaries() { + // The threshold bound is inclusive (`< THRESHOLD` rejects), so exactly at + // it commits and one below does not. + assert_eq!( + detect_swipe(GESTURE_SWIPE_THRESHOLD, 0), + Some(GestureDirection::Right) + ); + assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None); + + // The cross-axis band is max(deadzone, 35% of dominant). For a large + // dominant the 35% term wins (200 → 70): 69 commits, 71 is too diagonal. + assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right)); + assert_eq!(detect_swipe(200, 71), None); + // For a small dominant the 40-unit floor wins (100 → max(40, 35) = 40). + assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right)); + assert_eq!(detect_swipe(100, 41), None); + } + + #[test] + fn detect_swipe_does_not_panic_on_extreme_values() { + // Saturated accumulator travel can reach the i32 bounds. `i32::MIN.abs()` + // panics and `dominant * 35` overflows — both must be clamped, not crash. + assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right)); + assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left)); + assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down)); + assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up)); + // A diagonal at the extremes is still rejected, without panicking. + assert_eq!(detect_swipe(i32::MIN, i32::MIN), None); + } + + // ── SwipeAccumulator (the shared mid-swipe state machine) ───────────────── + + #[test] + fn accumulator_commits_a_direction_once_after_the_hold_gate() { + let mut acc = SwipeAccumulator::default(); + acc.begin(); + acc.backdate_hold_for_test(); + // A clear rightward swipe commits exactly once, mid-motion. + assert_eq!( + acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), + Some(GestureDirection::Right) + ); + // Further travel in the same hold must not re-fire. + assert_eq!(acc.accumulate(50, 0), None); + } + + #[test] + fn accumulator_does_not_commit_before_the_hold_gate() { + let mut acc = SwipeAccumulator::default(); + acc.begin(); // held_since = now, so the gate is not yet satisfied + // A big delta arriving immediately (a quick click whose cursor drifted) + // must not commit. + assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None); + // Once held long enough, the next delta commits. + acc.backdate_hold_for_test(); + assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some()); + } + + #[test] + fn accumulator_end_reports_click_only_when_no_swipe_fired() { + // A hold with only tiny drift never commits → end() is a click. + let mut acc = SwipeAccumulator::default(); + acc.begin(); + acc.backdate_hold_for_test(); + assert_eq!(acc.accumulate(2, -1), None); + assert!(acc.end(), "a hold that never swiped is a click"); + + // A hold that committed a swipe → end() is not a click. + acc.begin(); + acc.backdate_hold_for_test(); + assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some()); + assert!(!acc.end(), "a committed swipe must not also click"); + } + + #[test] + fn accumulator_ignores_motion_when_not_holding() { + let mut acc = SwipeAccumulator::default(); + assert!(!acc.is_holding()); + // Travel outside a hold is dropped, never committing a stray swipe. + assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None); + } + + #[test] + fn accumulator_sums_sub_threshold_deltas_until_they_commit() { + // The whole reason for an accumulator (vs. detect_swipe on one delta): + // several deltas each too small to commit on their own must sum across + // the hold until the running total crosses the threshold, then commit. + let mut acc = SwipeAccumulator::default(); + acc.begin(); + acc.backdate_hold_for_test(); + // Just under half the threshold: one or two steps never reach it, three do. + let step = GESTURE_SWIPE_THRESHOLD / 2 - 1; + assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold"); + assert_eq!(acc.accumulate(step, 0), None, "two steps still under"); + assert_eq!( + acc.accumulate(step, 0), + Some(GestureDirection::Right), + "the running sum finally crosses the threshold" + ); + } + + #[test] + fn accumulator_saturates_instead_of_overflowing() { + // The doc promises an arbitrarily long hold can't overflow. A perfect + // diagonal never commits, so travel keeps summing; feed deltas that would + // overflow both an i32 sum and a naive cross-band multiply — both must + // saturate, not panic (debug builds panic on overflow). + let mut acc = SwipeAccumulator::default(); + acc.begin(); + acc.backdate_hold_for_test(); + assert_eq!( + acc.accumulate(i32::MAX, i32::MAX), + None, + "a diagonal never commits" + ); + assert_eq!( + acc.accumulate(i32::MAX, i32::MAX), + None, + "the saturating sum must not panic" + ); + // A clean axis on a fresh hold still commits with a saturated magnitude. + acc.begin(); + acc.backdate_hold_for_test(); + assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right)); + } + + #[test] + fn accumulator_begin_recovers_a_stale_hold() { + // A missed release (e.g. focus loss between press and release) can leave + // a dangling hold that already fired with travel in some direction. A + // fresh begin() must wipe both the `fired` latch and the travel, so the + // next press isn't poisoned by the old one. + let mut acc = SwipeAccumulator::default(); + acc.begin(); + acc.backdate_hold_for_test(); + // Stale hold commits LEFT (negative dx) and latches `fired`. + assert_eq!( + acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0), + Some(GestureDirection::Left) + ); + // No end() — a dropped release, then a fresh press. + acc.begin(); + acc.backdate_hold_for_test(); + // Had `fired` leaked this would be None; had the negative travel leaked it + // would commit Left. Committing Right proves begin() reset both. + assert_eq!( + acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0), + Some(GestureDirection::Right) + ); + } + + #[test] + fn accumulator_end_without_a_hold_is_not_a_click() { + // end() in isolation (no begin) must not claim a click — there was no + // hold — so a stray release can't be read as a press. + let mut acc = SwipeAccumulator::default(); + assert!(!acc.end(), "a release with no hold is not a click"); + // A redundant second release after a real hold already ended is inert too. + acc.begin(); + assert!(acc.end(), "the held release is a click"); + assert!(!acc.end(), "the redundant second release is not a click"); + } +} 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..7f9bcb48 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; @@ -46,6 +47,9 @@ pub const SCHEMA_VERSION: u32 = 3; /// Top-level config document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { + /// Schema version the file was written with. Compared against + /// [`SCHEMA_VERSION`] on load: older layouts migrate, newer ones are + /// rejected loudly rather than silently losing settings. pub schema_version: u32, /// Non-device-scoped preferences (autostart, tray, language, …). #[serde(default, skip_serializing_if = "AppSettings::is_default")] @@ -55,6 +59,9 @@ pub struct Config { /// first paired device. `None` means "fall back to the first device". #[serde(default, skip_serializing_if = "Option::is_none")] pub selected_device: Option, + /// Per-device state, keyed by the stable physical-device identifier + /// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share + /// an entry. #[serde(default)] pub devices: BTreeMap, } @@ -70,32 +77,56 @@ impl Default for Config { } } +/// Failure loading or persisting `config.toml`. The file-scoped variants +/// carry the offending path so callers can surface an actionable message. #[derive(Debug, Error)] pub enum ConfigError { + /// The platform config directory could not be resolved (no home + /// directory for the current user). #[error("could not resolve config path")] Path(#[from] PathsError), + /// Reading the config file from disk failed. #[error("could not read config at {path}")] Read { + /// The config file the read targeted. path: PathBuf, + /// The underlying I/O error. #[source] source: io::Error, }, + /// The file was read but is not valid TOML for this schema. #[error("could not parse config at {path}")] Parse { + /// The config file that failed to parse. path: PathBuf, + /// The underlying TOML deserialization error. #[source] source: toml::de::Error, }, + /// Writing the updated config back to disk failed. #[error("could not write config at {path}")] Write { + /// The config file the write targeted. path: PathBuf, + /// The underlying I/O error. #[source] source: io::Error, }, + /// The in-memory config could not be serialized to TOML — a bug in the + /// config types rather than user error, since [`Config`] always + /// serializes cleanly. #[error("could not serialize config")] Serialize(#[from] toml::ser::Error), + /// The file declares a `schema_version` newer than this build + /// understands; failing loudly avoids silently dropping settings a newer + /// build wrote. #[error("config at {path} has unsupported schema_version {found}")] - UnsupportedSchemaVersion { path: PathBuf, found: u32 }, + UnsupportedSchemaVersion { + /// The config file carrying the unsupported version. + path: PathBuf, + /// The `schema_version` the file declared. + found: u32, + }, } #[allow( @@ -511,33 +542,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)] @@ -571,7 +593,7 @@ mod tests { "g513", Lighting { enabled: true, - color: "00aabb".to_string(), + color: "00aabb".parse().expect("valid hex"), brightness: 75, }, ); @@ -580,13 +602,59 @@ 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 hash_prefixed_lighting_color_migrates_to_canonical_hex() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r##" + schema_version = 3 + [devices.g513.lighting] + enabled = true + color = "#ff0000" + brightness = 50 + "##, + ) + .expect("write config"); + + let cfg = Config::load_from_path(&path).expect("load hash-prefixed color"); + assert_eq!( + cfg.lighting("g513").map(|lighting| lighting.color), + Some(crate::color::Rgb::new(0xff, 0x00, 0x00)) + ); + + cfg.save_to_path(&path).expect("save canonical color"); + let saved = fs::read_to_string(path).expect("read saved config"); + assert!(saved.contains("color = \"ff0000\"")); + assert!(!saved.contains("color = \"#")); + } + #[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..3bca99b9 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. @@ -164,11 +165,19 @@ const fn default_thumbwheel_sensitivity() -> i32 { /// `openlogi-agent-core/tests/wire_format.rs`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Lighting { + /// Master on/off for the device's lighting. The color and brightness + /// persist while disabled, so re-enabling restores the previous look. #[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 +200,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,11 +218,30 @@ where Ok(u8::deserialize(deserializer)?.min(100)) } +/// Accept the optional `#` prefix supported by older releases, then fall back +/// to white when the configured color does not parse, mirroring the `brightness` +/// clamp above instead of failing the whole config load. +fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let color = String::deserialize(deserializer)?; + Ok(color + .strip_prefix('#') + .unwrap_or(color.as_str()) + .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")] pub enum WheelMode { + /// Free-spin — the wheel rotates without détentes. Free, + /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware + /// auto-releases into free-spin past the configured + /// [`auto_disengage`](SmartShift::auto_disengage) speed. Ratchet, } @@ -262,6 +290,7 @@ where /// `PROTOCOL_VERSION` bump. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct SmartShift { + /// The persisted wheel mode, re-applied to device RAM on reconnect. pub mode: WheelMode, /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..e9eec80d 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -20,17 +20,30 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DeviceKind { + /// Mice — the family OpenLogi's binding/DPI panels primarily target. Mouse, + /// Keyboards, including lighting-capable ones. Keyboard, + /// Standalone numeric keypads. Numpad, + /// Presentation remotes (slide clickers). Presenter, + /// Remote controls; the registry's `"remotecontrol"` string also folds here. Remote, + /// Trackballs — treated like mice for presumed capabilities. Trackball, + /// External touchpads; the registry's `"trackpad"` string also folds here. Touchpad, + /// Pen/graphics tablets. Tablet, + /// Game controllers, mirrored from the Bolt pairing vocabulary. Gamepad, + /// Joysticks, mirrored from the Bolt pairing vocabulary. Joystick, + /// Audio headsets paired through a receiver. Headset, + /// Not classified by any source — also the "no asset opinion" value + /// [`DeviceKind::from_registry_type`] returns for unmodelled strings. Unknown, } @@ -135,10 +148,15 @@ impl Capabilities { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BatteryLevel { + /// Almost depleted — the firmware's most urgent bucket. Critical, + /// Running low; worth surfacing a charge hint. Low, + /// Comfortable middle range, no user action needed. Good, + /// At or near full charge. Full, + /// The firmware did not report a level, or reported one we don't model. Unknown, } @@ -147,26 +165,44 @@ pub enum BatteryLevel { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BatteryStatus { + /// Running on battery. Discharging, + /// Charging at the normal rate. Charging, + /// Charging at reduced current (e.g. from a weak power source). ChargingSlow, + /// Charge complete while still connected to power. Full, + /// The device reported a charging fault. Error, + /// A status value this build doesn't model (future protocol additions). Unknown, } +/// Battery snapshot for one paired device, as last polled over HID++. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BatteryInfo { + /// Reported charge percentage (`0..=100`). pub percentage: u8, + /// Coarse bucket for UI that doesn't want the raw percentage. pub level: BatteryLevel, + /// Charging state at poll time. pub status: BatteryStatus, } +/// Identity of an enumerated receiver — no paired-device state (that lives +/// in [`DeviceInventory::paired`]). For a direct (Bluetooth/wired) device, +/// a synthetic entry mirroring the device's own HID identity fills this role. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReceiverInfo { + /// Product string from the HID enumeration (e.g. `"Logi Bolt Receiver"`). pub name: String, + /// USB vendor ID (`0x046d` for Logitech). pub vendor_id: u16, + /// USB product ID distinguishing the receiver model. pub product_id: u16, + /// Platform-reported serial, when one is exposed. Deliberately excluded + /// from diagnostics (see [`crate::diagnostics::ReceiverDiag`]). pub unique_id: Option, } @@ -181,13 +217,23 @@ pub struct ReceiverInfo { /// to format `extended_model_id` + `model_ids[N]` to match. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceModelInfo { + /// Number of firmware entities (main firmware, bootloader, …) the + /// device reports. pub entity_count: u8, /// HID++ DeviceInformation serial number, when the device supports the /// optional serial-number function. pub serial_number: Option, + /// Per-unit ID bytes — unique to the physical unit, unlike the + /// model-level fields around it. pub unit_id: [u8; 4], + /// Which transports the firmware supports; defines the slot order of + /// [`Self::model_ids`]. pub transports: DeviceTransports, + /// Per-transport PIDs ordered to match [`Self::transports`] (USB, eQuad, + /// BTLE, Bluetooth); slots for disabled transports stay `0`. pub model_ids: [u16; 3], + /// Extra model byte prefixed to a PID to form the asset registry's + /// `modelId` — see [`Self::config_key`]. pub extended_model_id: u8, } @@ -215,21 +261,37 @@ impl DeviceModelInfo { )] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceTransports { + /// Wired USB. pub usb: bool, + /// Logitech eQuad — the Unifying/Bolt receiver RF protocol. pub equad: bool, + /// Bluetooth Low Energy. pub btle: bool, + /// Classic Bluetooth. pub bluetooth: bool, } +/// One device in the agent's inventory snapshot: a receiver pairing slot, +/// or a direct (Bluetooth/wired) attachment under its synthetic +/// [`ReceiverInfo`]. Embedded in [`DeviceInventory`], so its field order is +/// IPC wire format — see that type's contract. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PairedDevice { /// Receiver-assigned slot (1..=6 for Bolt). pub slot: u8, + /// Firmware codename (e.g. `"MX Master 3S"`), when reported. pub codename: Option, /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2. pub wpid: Option, + /// Best-guess classification. Identity only — panel gating uses + /// [`Self::capabilities`] instead, so a misread kind can't hide panels + /// (issue #127). pub kind: DeviceKind, + /// Whether the device was reachable at enumeration time; offline devices + /// keep their slot with reduced detail. pub online: bool, + /// Last battery reading, `None` when offline or the device doesn't + /// report battery. pub battery: Option, /// Output of HID++ feature 0x0003 — populated for online devices that /// expose the feature. Drives asset-registry lookups in the GUI. @@ -250,7 +312,11 @@ pub struct PairedDevice { /// bump (guarded by `openlogi-agent-core/tests/wire_format.rs`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceInventory { + /// The receiver's identity — synthetic (mirroring the device itself) + /// for a direct Bluetooth/wired attachment. pub receiver: ReceiverInfo, + /// The devices reached through this receiver; a direct attachment + /// carries exactly one entry. pub paired: Vec, } diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs index fd54a09b..43589079 100644 --- a/crates/openlogi-core/src/diagnostics.rs +++ b/crates/openlogi-core/src/diagnostics.rs @@ -22,10 +22,15 @@ pub enum AssetSource { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConnectionKind { + /// Paired through a Logi Bolt receiver. BoltReceiver, + /// Paired through a legacy Unifying receiver. UnifyingReceiver, + /// Connected directly over Bluetooth — no receiver involved. BluetoothDirect, + /// Connected over a USB cable. Wired, + /// The route could not be classified from the announced transports. Unknown, } @@ -33,7 +38,10 @@ pub enum ConnectionKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "state", content = "depot")] pub enum RenderState { + /// A curated render resolved; carries the depot name (e.g. + /// `"mx_master_3s"`). Resolved(String), + /// No depot matched — the UI draws the synthetic silhouette instead. Silhouette, } @@ -53,20 +61,28 @@ pub enum InventoryState { /// A receiver, by model only — never its `unique_id`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReceiverDiag { + /// Receiver product string — model-level, carries no per-unit identity. pub name: String, + /// USB vendor ID (`0x046d` for Logitech). pub vendor_id: u16, + /// USB product ID distinguishing the receiver model. pub product_id: u16, } /// One paired device, model-level only. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceDiag { + /// The name the GUI shows for this device. pub display_name: String, + /// Classified device kind — an identity guess, not a capability claim. pub kind: DeviceKind, /// Firmware codename (e.g. `"MX Master 3S"`), when known. pub codename: Option, + /// How the device reaches the host. pub connection: ConnectionKind, + /// Whether the device was reachable when the report was generated. pub online: bool, + /// Battery snapshot, `None` when offline or unreported. pub battery: Option, /// Measured HID++ capabilities, or `None` if never probed since the agent started. pub capabilities: Option, @@ -74,66 +90,99 @@ pub struct DeviceDiag { pub dpi: Option, /// Model identifier (e.g. `"2b35a"`) — a per-model key, not user-identifying. pub config_key: String, + /// Wireless PID from the receiver's pairing table, when paired via a + /// receiver. pub wpid: Option, /// Per-transport PID array from HID++ DeviceInformation (0x0003). pub model_ids: Option<[u16; 3]>, + /// Extended-model byte pairing with [`Self::model_ids`] to form the + /// registry `modelId`. pub extended_model_id: Option, + /// Transports announced by the firmware, when the device was probed. pub transports: Option, + /// Whether a curated render resolved, or the silhouette fallback drew. pub render: RenderState, + /// Receiver slot, or `0xFF` for direct connections (rendered as + /// "direct"). pub slot: u8, } /// App, agent, and host environment. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppInfo { + /// Version of the GUI process writing the report. pub gui_version: String, /// `"debug"` or `"release"`. pub build_profile: String, /// `None` when the agent is unreachable (not yet connected / restarting). pub agent_version: Option, + /// IPC protocol version compiled into the GUI. pub protocol_gui: u32, + /// IPC protocol version the agent reported, `None` when unreachable. + /// A mismatch with [`Self::protocol_gui`] is flagged in the rendered + /// report. pub protocol_agent: Option, /// Enumeration health behind the device section, `None` when the agent /// status is unavailable. pub inventory: Option, /// Raw `std::env::consts::OS` (`"macos"` / `"linux"` / `"windows"`). pub os: String, + /// OS version string, when the platform exposes one. pub os_version: Option, + /// Host CPU architecture (e.g. `"arm64"`). pub arch: String, + /// OS-reported locale, `None` when detection failed. pub system_locale: Option, /// Explicit UI-language override, or `None` for "follow system". pub ui_language: Option, + /// Input-monitoring/Accessibility permission state — macOS gates the + /// input hook on it. pub accessibility_granted: bool, /// `None` when the agent status is unavailable. pub hook_installed: Option, + /// Launch-at-login setting, `None` when unknown. pub launch_at_login: Option, + /// Menu-bar/tray icon setting, `None` when unknown. pub show_in_menu_bar: Option, + /// Automatic update-check setting, `None` when unknown. pub check_for_updates: Option, + /// Thumbwheel sensitivity setting, `None` when unknown. pub thumbwheel_sensitivity: Option, + /// `schema_version` of the loaded `config.toml`, when one loaded. pub config_schema_version: Option, + /// Number of device entries in the config, when known. pub configured_device_count: Option, + /// `true` for an installed app bundle, `false` for a source/dev build. pub running_from_bundle: bool, } /// Asset-cache state behind device renders. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetInfo { + /// Which tier the render resolver is serving from. pub source: AssetSource, + /// Whether a registry `index.json` parsed successfully. pub index_loaded: bool, /// Number of device models in the loaded index, when known. pub index_entries: Option, + /// Whether the per-user asset cache directory exists. pub user_cache_present: bool, /// Cache directory with the home prefix redacted to `~`. pub cache_path: String, + /// Whether the assets shipped inside the app bundle were found. pub bundle_present: bool, } /// The whole report. Render with [`Self::to_markdown`] for the clipboard. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiagnosticsReport { + /// App, agent, and host environment section. pub app: AppInfo, + /// Asset-cache state behind device renders. pub assets: AssetInfo, + /// Model-level receiver list (may be empty for direct-only setups). pub receivers: Vec, + /// Per-device detail, one entry per enumerated device. pub devices: Vec, } diff --git a/crates/openlogi-core/src/lib.rs b/crates/openlogi-core/src/lib.rs index fd01bb1a..64695944 100644 --- a/crates/openlogi-core/src/lib.rs +++ b/crates/openlogi-core/src/lib.rs @@ -4,8 +4,11 @@ //! the user config file. It must never depend on `hidpp`, `async-hid`, or any //! platform-specific event/window API — those live in sibling crates. +#![deny(missing_docs)] + pub mod binding; pub mod brand; +pub mod color; pub mod config; pub mod device; pub mod diagnostics; diff --git a/crates/openlogi-core/src/paths.rs b/crates/openlogi-core/src/paths.rs index dfeab113..05c2b3e0 100644 --- a/crates/openlogi-core/src/paths.rs +++ b/crates/openlogi-core/src/paths.rs @@ -26,8 +26,11 @@ use thiserror::Error; /// Subdirectory created under each XDG base directory. const APP_DIR: &str = "openlogi"; +/// Failure resolving the per-user base directories. #[derive(Debug, Error)] pub enum PathsError { + /// No home directory could be determined for the current user, so none + /// of the XDG bases resolve. #[error("could not resolve a home directory for the current user")] HomeNotFound, } @@ -36,6 +39,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`. diff --git a/crates/openlogi-core/src/single_instance.rs b/crates/openlogi-core/src/single_instance.rs index c61e0d4c..c6afd962 100644 --- a/crates/openlogi-core/src/single_instance.rs +++ b/crates/openlogi-core/src/single_instance.rs @@ -32,21 +32,36 @@ pub struct InstanceGuard { _handle: File, } +/// Failure acquiring the single-instance lock. +/// [`InstanceError::AlreadyRunning`] is the expected "another copy is open" +/// signal; every other variant indicates filesystem trouble. #[derive(Debug, Error)] pub enum InstanceError { + /// The lock file's directory could not be resolved (no home directory). #[error("could not resolve lock path")] Path(#[from] PathsError), + /// Creating or opening the lock file failed. #[error("could not open lock file at {path}")] Open { + /// The lock file being opened. path: PathBuf, + /// The underlying I/O error. #[source] source: io::Error, }, + /// Another process of the same role already holds the lock — surface it + /// politely and exit with a non-error status. #[error("another instance already holds the lock at {path}")] - AlreadyRunning { path: PathBuf }, + AlreadyRunning { + /// The contested lock file. + path: PathBuf, + }, + /// The lock syscall itself failed, as opposed to the lock being held. #[error("lock attempt at {path} failed")] LockFailed { + /// The lock file the attempt targeted. path: PathBuf, + /// The underlying I/O error. #[source] source: io::Error, }, diff --git a/crates/openlogi-gui/Cargo.toml b/crates/openlogi-gui/Cargo.toml index f4ee8769..5f341711 100644 --- a/crates/openlogi-gui/Cargo.toml +++ b/crates/openlogi-gui/Cargo.toml @@ -68,6 +68,9 @@ expect_used = "warn" missing_errors_doc = "allow" doc_markdown = "allow" +[dev-dependencies] +tempfile = "3.27.0" + [package.metadata.bundle] name = "OpenLogi" identifier = "org.openlogi.openlogi" 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/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/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/asset/mod.rs b/crates/openlogi-gui/src/asset.rs similarity index 95% rename from crates/openlogi-gui/src/asset/mod.rs rename to crates/openlogi-gui/src/asset.rs index 633468c6..f015a3a9 100644 --- a/crates/openlogi-gui/src/asset/mod.rs +++ b/crates/openlogi-gui/src/asset.rs @@ -508,9 +508,9 @@ mod tests { /// MX Vertical and the older mice render. #[test] fn resolves_old_schema_depot_on_disk() { - let root = std::env::temp_dir().join(format!("openlogi-asset-test-{}", std::process::id())); + let root = tempfile::tempdir().expect("create temp dir"); let depot = "mx_vertical"; - let dir = root.join(depot); + let dir = root.path().join(depot); std::fs::create_dir_all(&dir).expect("create depot dir"); std::fs::write( dir.join("metadata.json"), @@ -525,8 +525,8 @@ mod tests { std::fs::write(dir.join("front.png"), png_header(100, 200)).expect("write front.png"); let resolver = AssetResolver { - read_roots: vec![root.clone()], - write_root: root.clone(), + read_roots: vec![root.path().to_path_buf()], + write_root: root.path().to_path_buf(), has_bundle: false, index: None, }; @@ -539,10 +539,9 @@ mod tests { files: Vec::new(), }; - let result = resolver.load_files(depot, &entry, &bare_model()); - std::fs::remove_dir_all(&root).ok(); - - let asset = result.expect("old-schema depot should resolve"); + let asset = resolver + .load_files(depot, &entry, &bare_model()) + .expect("old-schema depot should resolve"); assert_eq!( asset.image_path.file_name().expect("image has a file name"), "front.png" @@ -553,24 +552,23 @@ mod tests { #[test] fn cleanup_removes_only_legacy_glow_pngs() { - let root = - std::env::temp_dir().join(format!("openlogi-glow-cleanup-{}", std::process::id())); - let depot = root.join("g513"); + let root = tempfile::tempdir().expect("create temp dir"); + let depot = root.path().join("g513"); std::fs::create_dir_all(&depot).expect("create depot dir"); std::fs::write(depot.join("glow-ff9500.png"), b"x").expect("write glow png"); std::fs::write(depot.join("glow-af52de.png.tmp"), b"x").expect("write glow tmp"); std::fs::write(depot.join("front.png"), b"x").expect("write front render"); std::fs::write(depot.join("metadata.json"), b"{}").expect("write metadata"); - cleanup_glow_pngs_in(&root); - - let kept = depot.join("front.png").exists() && depot.join("metadata.json").exists(); - let swept = - !depot.join("glow-ff9500.png").exists() && !depot.join("glow-af52de.png.tmp").exists(); - // Clean up before asserting so a failing assert doesn't leave the temp dir behind. - std::fs::remove_dir_all(&root).ok(); + cleanup_glow_pngs_in(root.path()); - assert!(swept, "legacy glow files must be deleted"); - assert!(kept, "real assets must be left untouched"); + assert!( + !depot.join("glow-ff9500.png").exists() && !depot.join("glow-af52de.png.tmp").exists(), + "legacy glow files must be deleted" + ); + assert!( + depot.join("front.png").exists() && depot.join("metadata.json").exists(), + "real assets must be left untouched" + ); } } diff --git a/crates/openlogi-gui/src/asset/sync.rs b/crates/openlogi-gui/src/asset/sync.rs index 907b98cc..3ee1cf24 100644 --- a/crates/openlogi-gui/src/asset/sync.rs +++ b/crates/openlogi-gui/src/asset/sync.rs @@ -10,11 +10,13 @@ use std::fs; use std::path::Path; +use std::time::Duration; use anyhow::{Context as _, Result}; +use backon::{BackoffBuilder, ExponentialBuilder}; use openlogi_assets::http; use openlogi_assets::{BUTTONS_RENDER_FILES, DepotManifest, DeviceEntry, FetchOutcome}; -use openlogi_core::device::DeviceModelInfo; +use openlogi_core::device::{DeviceInventory, DeviceModelInfo}; use tracing::{debug, info, warn}; /// Default origin for asset fetches. Overridable via `OPENLOGI_ASSETS` @@ -193,3 +195,104 @@ fn pick_variant_filename( .resource_for_variant(base_model_id, ext, resource_key) .map(str::to_string) } + +/// Result of one background asset-sync run, reported back to the select +/// loop: whether the run succeeded, and which model keys it covered (folded +/// into the synced set on success so the same device doesn't re-sync every +/// snapshot). +pub(crate) struct SyncOutcome { + pub(crate) ok: bool, + pub(crate) keys: Vec, +} + +/// Session-stable identity for a synced model: the HID++ model ids plus the +/// extended-model byte (the colour-variant selector) and the codename the +/// depot match falls back on. Models that collapse to one key would resolve +/// to the same depot files anyway. +pub(crate) fn model_key((model, codename): &(DeviceModelInfo, Option)) -> String { + format!( + "{:02x}:{:04x}:{:04x}:{:04x}:{}", + model.extended_model_id, + model.model_ids[0], + model.model_ids[1], + model.model_ids[2], + codename.as_deref().unwrap_or_default() + ) +} + +/// A manual asset action requested from the Settings → Assets tab, pushed to +/// the main event loop via [`AssetControl`]. +pub enum AssetCommand { + /// Force-fetch assets for the connected devices now, bypassing the + /// automatic download policy. + Refresh, + /// Delete the per-user cache, then re-fetch. + ClearCache, +} + +/// Global handle the Settings window uses to push [`AssetCommand`]s into the +/// main loop, mirroring how the Add Device window drives pairing. +pub struct AssetControl(pub tokio::sync::mpsc::UnboundedSender); + +impl gpui::Global for AssetControl {} + +/// Minimum gap before re-attempting a failed sync, doubling with each +/// consecutive attempt and capped at a minute. The first attempt is +/// immediate (`last_sync_at` is `None`); after that a permanently-down host +/// is polled ever more slowly (1s, 2s, 4s … 60s) instead of on every tick, +/// while a recovered host still self-heals on the next attempt. +pub(crate) fn sync_retry_delay(attempts: u32) -> Duration { + ExponentialBuilder::default() + .without_max_times() + .build() + .nth(attempts.saturating_sub(1).min(6) as usize) + .unwrap_or(Duration::from_mins(1)) +} + +/// Refresh the asset cache: the shared index always, plus the depots for +/// `models`. Returns `true` when the sync completed and `false` when it +/// failed and should be retried. Runs on a dedicated background thread — +/// the HTTP layer's blocking retries are fine here. (Whether sync runs at +/// all is the caller's gate: the automatic path checks `should_run` once at +/// startup plus the auto-download setting; the Settings → Assets manual +/// actions always fetch, even in a release build that would otherwise serve +/// only bundled art.) +pub(crate) fn run_asset_sync(models: &[(DeviceModelInfo, Option)]) -> bool { + let server = std::env::var("OPENLOGI_ASSETS").unwrap_or_else(|_| DEFAULT_BASE.to_string()); + match sync(&server, models) { + Ok(()) => true, + Err(e) => { + warn!(error = ?e, "asset sync failed — will retry with backoff"); + false + } + } +} + +/// Flatten every paired device's HID++ model snapshot — that's what the +/// asset sync feeds into the registry lookup. +pub(crate) fn collect_models( + inventories: &[DeviceInventory], +) -> Vec<(DeviceModelInfo, Option)> { + inventories + .iter() + .flat_map(|inv| inv.paired.iter()) + .filter_map(|p| p.model_info.clone().map(|m| (m, p.codename.clone()))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::sync_retry_delay; + use std::time::Duration; + + #[test] + fn retry_delay_doubles_then_caps() { + assert_eq!(sync_retry_delay(1), Duration::from_secs(1)); + assert_eq!(sync_retry_delay(2), Duration::from_secs(2)); + assert_eq!(sync_retry_delay(3), Duration::from_secs(4)); + assert_eq!(sync_retry_delay(5), Duration::from_secs(16)); + // Caps at 60s and never overflows the shift for large attempt counts. + assert_eq!(sync_retry_delay(7), Duration::from_mins(1)); + assert_eq!(sync_retry_delay(u32::MAX), Duration::from_mins(1)); + } +} 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) -} 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/main.rs b/crates/openlogi-gui/src/main.rs index cdfe73dc..6c4485dc 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -51,10 +51,9 @@ mod windows; rust_i18n::i18n!("locales", fallback = "en"); use std::collections::HashSet; -use std::time::{Duration, Instant}; +use std::time::Instant; use anyhow::Result; -use backon::{BackoffBuilder, ExponentialBuilder}; use gpui::{ AppContext, BorrowAppContext as _, Bounds, SharedString, Size, Styled, TitlebarOptions, WindowBounds, WindowOptions, px, @@ -62,11 +61,15 @@ use gpui::{ use gpui_component::{ActiveTheme, Root}; use openlogi_core::brand::DeeplinkCommand; use openlogi_core::config::Config; -use openlogi_core::device::{DeviceInventory, DeviceModelInfo}; +use openlogi_core::device::DeviceInventory; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use crate::app::AppView; +use crate::asset::sync::{ + AssetCommand, AssetControl, SyncOutcome, collect_models, model_key, run_asset_sync, + sync_retry_delay, +}; use crate::state::AppState; fn dispatch_gui_command(command: DeeplinkCommand, cx: &mut gpui::App) { @@ -477,79 +480,6 @@ fn main() -> Result<()> { Ok(()) } -/// Result of one background asset-sync run, reported back to the select -/// loop: whether the run succeeded, and which model keys it covered (folded -/// into the synced set on success so the same device doesn't re-sync every -/// snapshot). -struct SyncOutcome { - ok: bool, - keys: Vec, -} - -/// Session-stable identity for a synced model: the HID++ model ids plus the -/// extended-model byte (the colour-variant selector) and the codename the -/// depot match falls back on. Models that collapse to one key would resolve -/// to the same depot files anyway. -fn model_key((model, codename): &(DeviceModelInfo, Option)) -> String { - format!( - "{:02x}:{:04x}:{:04x}:{:04x}:{}", - model.extended_model_id, - model.model_ids[0], - model.model_ids[1], - model.model_ids[2], - codename.as_deref().unwrap_or_default() - ) -} - -/// A manual asset action requested from the Settings → Assets tab, pushed to -/// the main event loop via [`AssetControl`]. -pub enum AssetCommand { - /// Force-fetch assets for the connected devices now, bypassing the - /// automatic download policy. - Refresh, - /// Delete the per-user cache, then re-fetch. - ClearCache, -} - -/// Global handle the Settings window uses to push [`AssetCommand`]s into the -/// main loop, mirroring how the Add Device window drives pairing. -pub struct AssetControl(pub tokio::sync::mpsc::UnboundedSender); - -impl gpui::Global for AssetControl {} - -/// Minimum gap before re-attempting a failed sync, doubling with each -/// consecutive attempt and capped at a minute. The first attempt is -/// immediate (`last_sync_at` is `None`); after that a permanently-down host -/// is polled ever more slowly (1s, 2s, 4s … 60s) instead of on every tick, -/// while a recovered host still self-heals on the next attempt. -fn sync_retry_delay(attempts: u32) -> Duration { - ExponentialBuilder::default() - .without_max_times() - .build() - .nth(attempts.saturating_sub(1).min(6) as usize) - .unwrap_or(Duration::from_mins(1)) -} - -/// Refresh the asset cache: the shared index always, plus the depots for -/// `models`. Returns `true` when the sync completed and `false` when it -/// failed and should be retried. Runs on a dedicated background thread — -/// the HTTP layer's blocking retries are fine here. (Whether sync runs at -/// all is the caller's gate: the automatic path checks `should_run` once at -/// startup plus the auto-download setting; the Settings → Assets manual -/// actions always fetch, even in a release build that would otherwise serve -/// only bundled art.) -fn run_asset_sync(models: &[(DeviceModelInfo, Option)]) -> bool { - let server = - std::env::var("OPENLOGI_ASSETS").unwrap_or_else(|_| asset::sync::DEFAULT_BASE.to_string()); - match asset::sync::sync(&server, models) { - Ok(()) => true, - Err(e) => { - warn!(error = ?e, "asset sync failed — will retry with backoff"); - false - } - } -} - fn main_window_options(cx: &mut gpui::App) -> WindowOptions { let bounds = Bounds::centered(None, Size::new(px(1100.), px(750.)), cx); WindowOptions { @@ -615,30 +545,3 @@ fn init_tracing() { ) .init(); } - -/// Flatten every paired device's HID++ model snapshot — that's what the -/// asset sync feeds into the registry lookup. -fn collect_models(inventories: &[DeviceInventory]) -> Vec<(DeviceModelInfo, Option)> { - inventories - .iter() - .flat_map(|inv| inv.paired.iter()) - .filter_map(|p| p.model_info.clone().map(|m| (m, p.codename.clone()))) - .collect() -} - -#[cfg(test)] -mod tests { - use super::sync_retry_delay; - use std::time::Duration; - - #[test] - fn retry_delay_doubles_then_caps() { - assert_eq!(sync_retry_delay(1), Duration::from_secs(1)); - assert_eq!(sync_retry_delay(2), Duration::from_secs(2)); - assert_eq!(sync_retry_delay(3), Duration::from_secs(4)); - assert_eq!(sync_retry_delay(5), Duration::from_secs(16)); - // Caps at 60s and never overflows the shift for large attempt counts. - assert_eq!(sync_retry_delay(7), Duration::from_mins(1)); - assert_eq!(sync_retry_delay(u32::MAX), Duration::from_mins(1)); - } -} 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