Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f282aa1
style: carry reasons on remaining lint suppressions, drop section ban…
AprilNEA Jul 13, 2026
5f6a43c
refactor(gui): fail fast on an invalid embedded version
AprilNEA Jul 13, 2026
0a367ad
refactor(agent,core): resolve the LaunchAgents path via core paths
AprilNEA Jul 13, 2026
5e51f3c
fix(hidpp): stop dropping events that carry unknown wire values
AprilNEA Jul 13, 2026
a2874a4
fix(hid,agent): validate the pairing passkey and DPI at their boundaries
AprilNEA Jul 13, 2026
2ea0f17
fix(core,gui,agent,cli): validate the lighting color once as a typed Rgb
AprilNEA Jul 13, 2026
ecc543b
refactor(core): persist the config through atomic-write-file
AprilNEA Jul 13, 2026
420b4e7
refactor(gui,cli): rename semantics-carrying mod.rs modules
AprilNEA Jul 13, 2026
d8dcd65
refactor(gui): extract the pacing module and drop tiny indirections
AprilNEA Jul 13, 2026
a67a414
test(gui): use tempfile for the asset-resolver test directories
AprilNEA Jul 13, 2026
c56a3e7
refactor(gui): move the Load/LazyDeviceData infrastructure to state/l…
AprilNEA Jul 13, 2026
a45f9a1
refactor(gui): move the asset-sync orchestration out of main.rs
AprilNEA Jul 13, 2026
cbeb0e5
refactor(core): move the swipe-gesture machinery to binding/swipe.rs
AprilNEA Jul 13, 2026
ec8abf8
test(assets): use tempfile for the cache test directories
AprilNEA Jul 13, 2026
16fe809
docs(core): document the remaining public items and deny missing_docs
AprilNEA Jul 13, 2026
a25aeac
fix(core): preserve hash-prefixed lighting colors
AprilNEA Jul 14, 2026
16e8d27
fix(hid): cancel failed Bolt pairing sessions
AprilNEA Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 17 additions & 20 deletions crates/openlogi-agent-core/src/hardware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -342,43 +345,37 @@ pub fn set_lighting_in_background(target: Option<DeviceRoute>, 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(
capture: &CaptureChannel,
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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/openlogi-agent-core/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ impl From<PairingError> 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})"),
},
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/openlogi-agent-core/tests/wire_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions crates/openlogi-agent/src/launch_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ fn remove_legacy() {

#[cfg(target_os = "macos")]
fn plist_path(label: &str) -> io::Result<PathBuf> {
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")))
Expand Down
3 changes: 3 additions & 0 deletions crates/openlogi-assets/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ atomic-write-file = "0.3.0"

[lints]
workspace = true

[dev-dependencies]
tempfile = "3.27.0"
15 changes: 5 additions & 10 deletions crates/openlogi-assets/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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]
Expand Down
38 changes: 21 additions & 17 deletions crates/openlogi-cli/src/cmd/diag/lighting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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::<RgbParseError>().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::<RgbParseError>()
.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 '#')"#
);
}
}
1 change: 1 addition & 0 deletions crates/openlogi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading