From f7d1c1b94edb97129d0234df73756b6025de1746 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:08 +0800 Subject: [PATCH 01/17] style: carry reasons on remaining lint suppressions, drop section banners Every allow/expect in the workspace now states its reason inline (the four stragglers had it in a loose comment or not at all), and the one remaining dashed section banner is reduced to plain prose. --- crates/openlogi-agent-core/src/hardware.rs | 2 -- crates/openlogi-hook/examples/print_events.rs | 5 ++++- crates/openlogi-hook/src/linux.rs | 12 ++++++++---- xtask/src/commands/release/latest_json.rs | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 416f15a1..1687625b 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -364,13 +364,11 @@ fn lighting_rgb(lighting: &Lighting) -> (u8, u8, u8) { (scale(r), scale(g), scale(b)) } -// --------------------------------------------------------------------------- // Async, awaitable variants used by the IPC server (the GUI routes "apply now" // / "read" device commands through the agent, which awaits and reports the // result). Writes reuse the capture session's open channel when it targets the // same device, exactly like the fire-and-forget `*_in_background` helpers, so // the daemon never opens a second channel to a device it already holds. -// --------------------------------------------------------------------------- /// Apply `dpi` to `route`, reusing the capture session's channel when possible. pub async fn apply_dpi( diff --git a/crates/openlogi-hook/examples/print_events.rs b/crates/openlogi-hook/examples/print_events.rs index 7221e443..931c1017 100644 --- a/crates/openlogi-hook/examples/print_events.rs +++ b/crates/openlogi-hook/examples/print_events.rs @@ -40,7 +40,10 @@ fn main() { // Block until Ctrl-C. let (tx, rx) = std::sync::mpsc::channel(); - #[allow(clippy::expect_used)] + #[expect( + clippy::expect_used, + reason = "example binary; aborting on a failed handler install is fine" + )] ctrlc::set_handler(move || { let _ = tx.send(()); }) diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index 3581d4b0..287b813e 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -244,8 +244,10 @@ fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option { - #[allow(clippy::cast_precision_loss)] - // scroll deltas fit comfortably in f32 mantissa + #[expect( + clippy::cast_precision_loss, + reason = "scroll deltas fit comfortably in the f32 mantissa" + )] let v = value as f32; if hires_scroll { match axis { @@ -286,8 +288,10 @@ fn key_to_button(key: KeyCode) -> Option { } } -// All params are owned: path/cb/stop/stop_rx are moved into the thread and must not be refs. -#[allow(clippy::needless_pass_by_value)] +#[expect( + clippy::needless_pass_by_value, + reason = "path/cb/stop/stop_rx are moved into the spawned thread and must not be refs" +)] fn device_thread( path: std::path::PathBuf, mut device: Device, diff --git a/xtask/src/commands/release/latest_json.rs b/xtask/src/commands/release/latest_json.rs index 31b67031..7433ba8a 100644 --- a/xtask/src/commands/release/latest_json.rs +++ b/xtask/src/commands/release/latest_json.rs @@ -145,7 +145,7 @@ fn dmg_arch(name: &str) -> Option<&str> { } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, reason = "unwrap is idiomatic in tests")] mod tests { use super::*; From dbc54d5fd7c4e50d22765c03f708b7206e2c4462 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:09 +0800 Subject: [PATCH 02/17] refactor(gui): fail fast on an invalid embedded version CARGO_PKG_VERSION is cargo-provided and always valid semver; falling back to 0.0.0 would silently make every release look like an upgrade. Panic with a reasoned expect instead of hiding the impossible case. --- crates/openlogi-gui/src/platform/updater.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-gui/src/platform/updater.rs b/crates/openlogi-gui/src/platform/updater.rs index 6a1f0375..f451f2f2 100644 --- a/crates/openlogi-gui/src/platform/updater.rs +++ b/crates/openlogi-gui/src/platform/updater.rs @@ -56,8 +56,11 @@ pub fn new_entity(cx: &mut App) -> Entity { .os(std::env::consts::OS) .arch(release_arch()) .format(release_format()); - let version = - Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or_else(|_| Version::new(0, 0, 0)); + #[expect( + clippy::expect_used, + reason = "CARGO_PKG_VERSION is cargo-provided and always valid semver" + )] + let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("valid embedded version"); let mut config = EngineConfig::new(version).verification(Verification::Strict); if let Some(key) = minisign_public_key() { config = config.minisign_public_key(key); From 92f8d4add434f6f68b2eb89af3314fd1620fd2d2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:34:32 +0800 Subject: [PATCH 03/17] refactor(agent,core): resolve the LaunchAgents path via core paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the etcetera-backed home_dir from openlogi-core and use it for the launchd plist location instead of reading $HOME by hand — one home resolution policy for the whole workspace. --- crates/openlogi-agent/src/launch_agent.rs | 6 +++--- crates/openlogi-core/src/paths.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-agent/src/launch_agent.rs b/crates/openlogi-agent/src/launch_agent.rs index 41981bf1..6836297b 100644 --- a/crates/openlogi-agent/src/launch_agent.rs +++ b/crates/openlogi-agent/src/launch_agent.rs @@ -130,9 +130,9 @@ fn remove_legacy() { #[cfg(target_os = "macos")] fn plist_path(label: &str) -> io::Result { - let home = std::env::var_os("HOME") - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "$HOME not set"))?; - Ok(PathBuf::from(home) + let home = + openlogi_core::paths::home_dir().map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?; + Ok(home .join("Library") .join("LaunchAgents") .join(format!("{label}.plist"))) diff --git a/crates/openlogi-core/src/paths.rs b/crates/openlogi-core/src/paths.rs index dfeab113..4c871460 100644 --- a/crates/openlogi-core/src/paths.rs +++ b/crates/openlogi-core/src/paths.rs @@ -36,6 +36,14 @@ fn xdg() -> Result { Xdg::new().map_err(|_| PathsError::HomeNotFound) } +/// The current user's home directory. +/// +/// The plain home, not an XDG base — for callers placing files under +/// OS-native locations (e.g. macOS `~/Library/LaunchAgents`). +pub fn home_dir() -> Result { + Ok(xdg()?.home_dir().to_path_buf()) +} + /// The raw XDG config home directory (without the `openlogi` subdirectory). /// /// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`. From 99791602a96b74888c9a6b11a5ca394fab111c5b Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:45:45 +0800 Subject: [PATCH 04/17] fix(hidpp): stop dropping events that carry unknown wire values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver and feature listeners run in a void callback with no error channel, so every unrecognised byte used to degenerate into a silent return — losing whole events over an identity-only field: - Unifying/Bolt 0x41 device-connection: an unknown kind nibble dropped the event entirely; on the Unifying path arrival notifications are the only device source, so the device never appeared at all. Kind now folds to Unknown via num_enum(default) (identity-only by policy). - Pairing-info register reads no longer fail the whole read over the kind nibble either, matching the listener policy. - Bolt 0x54 pairing status: an unknown error code turned a failure into a session timeout; PairingError now carries the raw code in a num_enum(catch_all) Other variant. Same treatment for the 0x4e passkey press type. - Bolt 0x4d passkey: trim the NUL padding before decoding. - 0x1d4b WirelessDeviceStatus broadcast: the (re)connection signal was dropped if any of its three bytes was unrecognised; all fields now decode infallibly with catch_all Other variants. - smartshift_enhanced: an unknown wheel mode silently reported Ratchet; it is now Hidpp20Error::UnsupportedResponse, matching 0x2110 and hires_wheel (the pinned fallback test asserts the error instead). openlogi-hid's discovery kind mapping is simplified to the now- infallible From. --- crates/openlogi-hid/src/pairing.rs | 2 +- .../src/feature/smartshift_enhanced/mod.rs | 11 ++-- .../src/feature/wireless_device_status/mod.rs | 34 ++++++----- crates/openlogi-hidpp/src/receiver/bolt.rs | 61 +++++++++---------- .../openlogi-hidpp/src/receiver/unifying.rs | 25 ++++---- 5 files changed, 69 insertions(+), 64 deletions(-) diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs index 93f2968b..7e392c08 100644 --- a/crates/openlogi-hid/src/pairing.rs +++ b/crates/openlogi-hid/src/pairing.rs @@ -455,7 +455,7 @@ impl PartialDevice { Some(DiscoveredDevice { address, authentication, - kind: BoltDeviceKind::try_from(kind & 0x0f).unwrap_or(BoltDeviceKind::Unknown), + kind: BoltDeviceKind::from(kind & 0x0f), name, }) } diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs index 6d3aaa12..ae795b8e 100644 --- a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -126,7 +126,8 @@ impl SmartShiftEnhancedFeature { impl SmartShiftEnhancedStatus { fn from_payload(payload: [u8; 16]) -> Result { Ok(Self { - wheel_mode: WheelMode::try_from(payload[0]).unwrap_or(WheelMode::Ratchet), + wheel_mode: WheelMode::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, auto_disengage: payload[1], current_tunable_torque: payload[2], }) @@ -135,7 +136,7 @@ impl SmartShiftEnhancedStatus { #[cfg(test)] mod tests { - use super::{SmartShiftEnhancedStatus, WheelMode}; + use super::{Hidpp20Error, SmartShiftEnhancedStatus, WheelMode}; #[test] fn parses_status() { @@ -152,12 +153,12 @@ mod tests { } #[test] - fn falls_back_to_ratchet_for_unknown_wheel_mode() { + fn unknown_wheel_mode_is_an_unsupported_response() { let mut payload = [0; 16]; payload[0] = 9; - let status = SmartShiftEnhancedStatus::from_payload(payload).unwrap(); + let err = SmartShiftEnhancedStatus::from_payload(payload).unwrap_err(); - assert_eq!(status.wheel_mode, WheelMode::Ratchet); + assert!(matches!(err, Hidpp20Error::UnsupportedResponse)); } } diff --git a/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs b/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs index e94a229f..2fac1732 100755 --- a/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs +++ b/crates/openlogi-hidpp/src/feature/wireless_device_status/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive}; use crate::{ channel::{HidppChannel, MessageListenerGuard}, @@ -41,19 +41,14 @@ impl CreatableFeature for WirelessDeviceStatusFeature { return; } - let (Ok(status), Ok(request), Ok(reason)) = ( - WirelessDeviceStatus::try_from(payload[0]), - WirelessDeviceStatusRequest::try_from(payload[1]), - WirelessDeviceStatusReason::try_from(payload[2]), - ) else { - return; - }; - + // This broadcast is the device's (re)connection signal; an + // unrecognised field value must not swallow it, so every + // field decodes infallibly and carries unknown raw bytes. emitter.emit(WirelessDeviceStatusEvent::StatusBroadcast( WirelessDeviceStatusBroadcast { - status, - request, - reason, + status: WirelessDeviceStatus::from(payload[0]), + request: WirelessDeviceStatusRequest::from(payload[1]), + reason: WirelessDeviceStatusReason::from(payload[2]), }, )); } @@ -104,7 +99,7 @@ pub struct WirelessDeviceStatusBroadcast { /// Represents a device status as reported in /// [`WirelessDeviceStatusBroadcast::status`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -113,11 +108,14 @@ pub enum WirelessDeviceStatus { Unknown = 0x00, /// Device is reconnecting. Reconnection = 0x01, + /// A status value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents a request as reported in /// [`WirelessDeviceStatusBroadcast::request`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -126,11 +124,14 @@ pub enum WirelessDeviceStatusRequest { NoRequest = 0x00, /// Host software must reconfigure the device. SoftwareReconfigurationNeeded = 0x01, + /// A request value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents a broadcast reason as reported in /// [`WirelessDeviceStatusBroadcast::reason`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -139,4 +140,7 @@ pub enum WirelessDeviceStatusReason { Unknown = 0x00, /// Broadcast was caused by the device power switch. PowerSwitchActivated = 0x01, + /// A reason value this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } diff --git a/crates/openlogi-hidpp/src/receiver/bolt.rs b/crates/openlogi-hidpp/src/receiver/bolt.rs index a836a1e3..e1fece6e 100755 --- a/crates/openlogi-hidpp/src/receiver/bolt.rs +++ b/crates/openlogi-hidpp/src/receiver/bolt.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use derive_builder::Builder; use futures::{FutureExt, pin_mut, select}; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use super::{RECEIVER_DEVICE_INDEX, ReceiverError}; use crate::{ @@ -127,13 +127,11 @@ impl Receiver { match header.sub_id { // Device connection 0x41 => { - let Ok(kind) = DeviceKind::try_from(payload[1] & 0x0f) else { - return; - }; - + // Kind is identity-only; an unrecognised nibble folds + // to `Unknown` instead of dropping the event. emitter.emit(Event::DeviceConnection(DeviceConnection { index: header.device_index, - kind, + kind: DeviceKind::from(payload[1] & 0x0f), encrypted: payload[1] & (1 << 5) != 0, online: payload[1] & (1 << 6) == 0, wpid: u16::from_le_bytes(payload[2..=3].try_into().unwrap()), @@ -144,13 +142,9 @@ impl Receiver { match payload[2] { // Device data 0 => { - let Ok(kind) = DeviceKind::try_from(payload[4] & 0x0f) else { - return; - }; - emitter.emit(Event::DeviceDiscoveryDeviceDetails { counter: payload[0] as u16 + payload[1] as u16 * 256, - kind, + kind: DeviceKind::from(payload[4] & 0x0f), wpid: u16::from_le_bytes(payload[5..=6].try_into().unwrap()), address: payload[7..=12].try_into().unwrap(), authentication: payload[15], @@ -181,15 +175,10 @@ impl Receiver { // payload[0] contains some kind of information about the status. I don't // know how to map that though. - let error = if payload[1] == 0x00 { - None - } else { - let Ok(parsed) = PairingError::try_from(payload[1]) else { - return; - }; - - Some(parsed) - }; + // An unrecognised error code still means "pairing + // failed" — dropping it here would turn the failure + // into a session timeout. Carry the raw code instead. + let error = (payload[1] != 0x00).then(|| PairingError::from(payload[1])); emitter.emit(Event::PairingStatus { device_address: payload[2..=7].try_into().unwrap(), @@ -203,7 +192,10 @@ impl Receiver { } // Passkey request 0x4d => { - let Ok(passkey) = str::from_utf8(&payload[1..=6]) else { + // 6 bytes, NUL-padded when the passkey is shorter. + let digits = &payload[1..=6]; + let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len()); + let Ok(passkey) = str::from_utf8(&digits[..len]) else { return; }; @@ -214,13 +206,9 @@ impl Receiver { } // Passkey pressed 0x4e => { - let Ok(press_type) = PairingPasskeyPressType::try_from(payload[0]) else { - return; - }; - emitter.emit(Event::PairingPasskeyPressed { device_address: payload[1..=6].try_into().unwrap(), - press_type, + press_type: PairingPasskeyPressType::from(payload[0]), }); } _ => (), @@ -384,8 +372,9 @@ impl Receiver { Ok(DevicePairingInformation { wpid: u16::from_le_bytes(response[2..=3].try_into().unwrap()), - kind: DeviceKind::try_from(response[1] & 0x0f) - .map_err(|_| Hidpp10Error::UnsupportedResponse)?, + // Kind is identity-only: an unrecognised nibble folds to + // `Unknown` instead of failing the whole pairing-info read. + kind: DeviceKind::from(response[1] & 0x0f), encrypted: response[1] & (1 << 5) != 0, online: response[1] & (1 << 6) == 0, unit_id: response[4..=7].try_into().unwrap(), @@ -550,12 +539,14 @@ pub struct DevicePairingInformation { } /// Represents the kind of a device paired to a Bolt receiver. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] pub enum DeviceKind { - /// Unknown device kind. + /// Unknown device kind — also the fold target for values this crate + /// does not model (kind is identity-only and must never drop an event). + #[num_enum(default)] Unknown = 0x00, /// Keyboard device. Keyboard = 0x01, @@ -727,7 +718,7 @@ pub struct DeviceConnection { /// Represents an error during device pairing. /// /// This is reported by the [`Event::PairingStatus`] event. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, TryFromPrimitive, IntoPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -736,13 +727,16 @@ pub enum PairingError { DeviceTimeout = 0x01, /// Pairing failed. Failed = 0x02, + /// An error code this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Represents the type of a single passkey press. /// /// This is reported by the [`Event::PairingPasskeyPressed`] event, which also /// includes some further information about the context of these values. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, TryFromPrimitive, IntoPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, FromPrimitive, IntoPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -753,6 +747,9 @@ pub enum PairingPasskeyPressType { Keypress = 0x01, /// Passkey entry was submitted. Submit = 0x04, + /// A press type this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } #[cfg(test)] diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 42611a7e..bfcd8d43 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -10,12 +10,12 @@ use std::sync::Arc; -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use crate::{ channel::{HidppChannel, MessageListenerGuard}, event::EventEmitter, - protocol::v10::{self, Hidpp10Error}, + protocol::v10, receiver::{RECEIVER_DEVICE_INDEX, ReceiverError}, }; @@ -97,13 +97,13 @@ impl Receiver { return; } - let Ok(kind) = DeviceKind::try_from(payload[1] & 0x0f) else { - return; - }; - + // Kind is identity-only; an unrecognised nibble folds to + // `Unknown` — dropping the event would hide the device + // entirely (arrival notifications are the only device + // source on this path). emitter.emit(Event::DeviceConnection(DeviceConnection { index: header.device_index, - kind, + kind: DeviceKind::from(payload[1] & 0x0f), encrypted: payload[1] & (1 << 4) != 0, online: payload[1] & (1 << 6) == 0, wpid: u16::from_le_bytes(payload[2..=3].try_into().unwrap()), @@ -191,8 +191,9 @@ impl Receiver { Ok(DevicePairingInformation { wpid: u16::from_le_bytes(response[2..=3].try_into().unwrap()), - kind: DeviceKind::try_from(response[1] & 0x0f) - .map_err(|_| Hidpp10Error::UnsupportedResponse)?, + // Kind is identity-only: an unrecognised nibble folds to + // `Unknown` instead of failing the whole pairing-info read. + kind: DeviceKind::from(response[1] & 0x0f), encrypted: response[1] & (1 << 4) != 0, online: response[1] & (1 << 6) == 0, unit_id: response[4..=7].try_into().unwrap(), @@ -239,12 +240,14 @@ pub struct DevicePairingInformation { /// The encoding matches Bolt for values 1–4; from 5 onwards Unifying uses a /// shifted table (Remote=5, Trackball=6, Touchpad=7) while Bolt reserves those /// values and places them at 7–9. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] pub enum DeviceKind { - /// Unknown device kind. + /// Unknown device kind — also the fold target for values this crate + /// does not model (kind is identity-only and must never drop an event). + #[num_enum(default)] Unknown = 0x00, /// Keyboard device. Keyboard = 0x01, From 7070ab08b96daa9e52f10b4175bf9124cc3ba7e3 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 09:49:13 +0800 Subject: [PATCH 05/17] fix(hid,agent): validate the pairing passkey and DPI at their boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corrupt Bolt passkey notification used to flow through from_utf8_lossy and parse().unwrap_or(0), rendering an all-Left click sequence that can never authenticate — with no diagnostic anywhere. The 0x4d decoder now NUL-trims and validates ASCII digits once, producing the digits and their numeric value together; a malformed payload fails the pairing session explicitly (surfaced over IPC as the generic transport-failure message, so the wire format is unchanged). The DPI write path clamped an out-of-range u32 to u16::MAX and wrote that to the device. The awaitable path now rejects it as the same OutOfRange feature error the device itself would return; the fire-and-forget path logs and skips the write. --- crates/openlogi-agent-core/src/hardware.rs | 21 +++++-- crates/openlogi-agent-core/src/ipc.rs | 5 ++ crates/openlogi-hid/src/pairing.rs | 20 +++--- .../openlogi-hid/src/pairing/notification.rs | 61 +++++++++++++++++-- crates/openlogi-hid/src/pairing/tests.rs | 2 +- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index 1687625b..f5cbdcc6 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -17,8 +17,8 @@ use std::time::Duration; use openlogi_core::config::Lighting; use openlogi_hid::{ - CaptureChannel, DeviceRoute, DpiInfo, HidppOperation, SharedChannel, SmartShiftMode, - SmartShiftStatus, WriteError, + CaptureChannel, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation, SharedChannel, + SmartShiftMode, SmartShiftStatus, WriteError, }; use tracing::{debug, warn}; @@ -228,9 +228,12 @@ pub fn write_dpi_in_background( return; } }; - // All device-supported DPI values fit in HID++'s u16 wire field. The - // saturating fallback exists only for type-system exhaustiveness. - let dpi_u16 = u16::try_from(dpi).unwrap_or(u16::MAX); + // All device-supported DPI values fit in HID++'s u16 wire field; a + // larger value is a caller bug and must not be clamped onto the device. + let Ok(dpi_u16) = u16::try_from(dpi) else { + warn!(dpi, "DPI exceeds the HID++ u16 wire field; write skipped"); + return; + }; let result = rt.block_on(async { tokio::time::timeout(WRITE_BUDGET, async { match &shared { @@ -376,7 +379,13 @@ pub async fn apply_dpi( route: &DeviceRoute, dpi: u32, ) -> Result<(), WriteError> { - let dpi = u16::try_from(dpi).unwrap_or(u16::MAX); + // Reject a DPI beyond the HID++ u16 wire field the same way the device + // itself would reject an out-of-range argument. + let dpi = u16::try_from(dpi).map_err(|_| WriteError::HidppFeature { + operation: HidppOperation::WriteDpi, + feature_hex: 0x2201, + kind: HidppFeatureErrorKind::OutOfRange, + })?; let shared = reusable_channel(Some(capture), route); timed(HidppOperation::WriteDpi, async { match &shared { diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 37e79e7c..ffbe33b5 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -162,6 +162,11 @@ impl From for PairingFailure { PairingError::Timeout => Self::Timeout, PairingError::Device(code) => Self::Device { code }, PairingError::Cancelled => Self::Cancelled, + // Carried as the generic transport-failure message so the wire + // format stays unchanged (PairingFailure variants are append-only). + PairingError::MalformedNotification(what) => Self::Hid { + message: format!("malformed pairing notification ({what})"), + }, } } } diff --git a/crates/openlogi-hid/src/pairing.rs b/crates/openlogi-hid/src/pairing.rs index 7e392c08..36293969 100644 --- a/crates/openlogi-hid/src/pairing.rs +++ b/crates/openlogi-hid/src/pairing.rs @@ -153,9 +153,8 @@ pub enum PasskeyMethod { }, } -/// Renders a Bolt passkey as a 10-bit MSB-first left/right click sequence. -fn passkey_to_clicks(passkey: &str) -> Vec { - let value: u32 = passkey.trim().parse().unwrap_or(0); +/// Renders a Bolt passkey value as a 10-bit MSB-first left/right click sequence. +fn passkey_to_clicks(value: u32) -> Vec { (0..10) .rev() .map(|bit| { @@ -216,6 +215,10 @@ pub enum PairingError { /// Pairing flow was cancelled by the caller. #[error("pairing was cancelled")] Cancelled, + /// A receiver notification failed to decode; authentication cannot + /// proceed safely, so the flow fails instead of presenting bogus data. + #[error("malformed pairing notification ({0})")] + MalformedNotification(&'static str), } impl From for PairingError { @@ -392,16 +395,19 @@ async fn drive( let _ = events.send(PairingEvent::DeviceFound(device)); } } - Notification::Passkey(passkey) => { + Notification::Passkey { digits, value } => { let method = match pairing_auth { - Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(passkey), + Some(auth) if auth & 0x01 != 0 => PasskeyMethod::Keyboard(digits), _ => PasskeyMethod::Pointer { - clicks: passkey_to_clicks(&passkey), - passkey, + clicks: passkey_to_clicks(value), + passkey: digits, }, }; let _ = events.send(PairingEvent::Passkey(method)); } + Notification::MalformedPasskey => { + return Err(PairingError::MalformedNotification("passkey digits")); + } Notification::PairingSucceeded { slot } => { let _ = events.send(PairingEvent::Paired { slot }); return Ok(()); diff --git a/crates/openlogi-hid/src/pairing/notification.rs b/crates/openlogi-hid/src/pairing/notification.rs index e60cfbab..c87c0766 100644 --- a/crates/openlogi-hid/src/pairing/notification.rs +++ b/crates/openlogi-hid/src/pairing/notification.rs @@ -27,8 +27,12 @@ pub(super) enum Notification { PairingSucceeded { slot: u8 }, /// Bolt pairing/discovery failed with a receiver error code. PairingError(u8), - /// Bolt passkey to present to the user (6 ASCII digits). - Passkey(String), + /// Bolt passkey to present to the user: the digits shown by the device + /// (validated ASCII digits) plus their numeric value for click rendering. + Passkey { digits: String, value: u32 }, + /// A passkey notification whose payload was not ASCII digits; pairing + /// must fail rather than present a bogus authentication sequence. + MalformedPasskey, /// A device linked to the receiver (`slot` = its device index). Connected { slot: u8, established: bool }, /// Unifying pairing lock changed state; `error` is non-zero on failure. @@ -105,8 +109,20 @@ pub(super) fn parse_notification( } } id::PASSKEY_REQUEST => { - let passkey = String::from_utf8_lossy(&p[1..7]).into_owned(); - Some(Notification::Passkey(passkey)) + // 6 bytes, NUL-padded when the passkey is shorter. + let digits = &p[1..7]; + let len = digits.iter().position(|&b| b == 0).unwrap_or(digits.len()); + let digits = &digits[..len]; + if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) { + return Some(Notification::MalformedPasskey); + } + let value = digits + .iter() + .fold(0u32, |acc, &b| acc * 10 + u32::from(b - b'0')); + Some(Notification::Passkey { + digits: String::from_utf8_lossy(digits).into_owned(), + value, + }) } id::UNIFYING_LOCK => Some(Notification::UnifyingLock { open: p[0] & 0x01 != 0, @@ -225,7 +241,42 @@ mod tests { p[1..7].copy_from_slice(b"123456"); assert_eq!( parse_notification(id::PASSKEY_REQUEST, 0xff, p), - Some(Notification::Passkey("123456".to_string())) + Some(Notification::Passkey { + digits: "123456".to_string(), + value: 123_456, + }) + ); + } + + #[test] + fn parses_nul_padded_passkey() { + let mut p = [0u8; 17]; + p[1..4].copy_from_slice(b"042"); + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::Passkey { + digits: "042".to_string(), + value: 42, + }) + ); + } + + #[test] + fn non_digit_passkey_is_malformed() { + let mut p = [0u8; 17]; + p[1..7].copy_from_slice(b"12x456"); + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::MalformedPasskey) + ); + } + + #[test] + fn empty_passkey_is_malformed() { + let p = [0u8; 17]; + assert_eq!( + parse_notification(id::PASSKEY_REQUEST, 0xff, p), + Some(Notification::MalformedPasskey) ); } diff --git a/crates/openlogi-hid/src/pairing/tests.rs b/crates/openlogi-hid/src/pairing/tests.rs index c9bdd603..3b758eef 100644 --- a/crates/openlogi-hid/src/pairing/tests.rs +++ b/crates/openlogi-hid/src/pairing/tests.rs @@ -4,7 +4,7 @@ use super::*; fn passkey_clicks_are_msb_first_10_bits() { // 0b00_0000_0101 = 5 -> eight lefts then right, left, right. assert_eq!( - passkey_to_clicks("5"), + passkey_to_clicks(5), vec![ Click::Left, Click::Left, From f80d558d6dfec6a36b3da39165f0a9a2c84af077 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:01:24 +0800 Subject: [PATCH 06/17] fix(core,gui,agent,cli): validate the lighting color once as a typed Rgb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lighting color was a raw String parsed independently in two places with divergent silent fallbacks: the agent's parse_hex fell back to black (lights off) while the GUI's fell back to white and didn't strip a '#' prefix. A hand-edited config degraded differently on each side of the IPC with no diagnostic. openlogi-core now owns a validated Rgb newtype (exactly 6 hex digits) that serializes as the same hex string the field used to hold, so TOML files and the bincode wire format are unchanged — the pinned wire bytes in wire_format.rs prove it. Config load folds an unparseable value to white with the same documented per-field tolerance as the brightness clamp (failing the load would discard the whole user config). Every consumer — agent write path, GUI swatches and keyboard glow, CLI diag — now uses the typed components; both hand-rolled hex parsers are gone. --- crates/openlogi-agent-core/src/hardware.rs | 14 +-- .../openlogi-agent-core/tests/wire_format.rs | 4 +- crates/openlogi-cli/src/cmd/diag/lighting.rs | 12 +- crates/openlogi-core/src/color.rs | 114 ++++++++++++++++++ crates/openlogi-core/src/config.rs | 48 ++++++-- crates/openlogi-core/src/lib.rs | 1 + crates/openlogi-gui/src/app.rs | 2 +- .../src/components/lighting_panel.rs | 32 ++--- 8 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 crates/openlogi-core/src/color.rs diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs index f5cbdcc6..c270e350 100644 --- a/crates/openlogi-agent-core/src/hardware.rs +++ b/crates/openlogi-agent-core/src/hardware.rs @@ -345,23 +345,13 @@ pub fn set_lighting_in_background(target: Option, lighting: &Lighti }); } -/// Parse `"RRGGBB"` (optionally `#`-prefixed) into an `(r, g, b)` triple. -fn parse_hex(hex: &str) -> (u8, u8, u8) { - let v = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0); - ( - u8::try_from((v >> 16) & 0xff).unwrap_or(0), - u8::try_from((v >> 8) & 0xff).unwrap_or(0), - u8::try_from(v & 0xff).unwrap_or(0), - ) -} - -/// Resolve a [`Lighting`] config to an `(r, g, b)` triple: the configured hex +/// Resolve a [`Lighting`] config to an `(r, g, b)` triple: the configured /// colour scaled by brightness, or black when lighting is off. fn lighting_rgb(lighting: &Lighting) -> (u8, u8, u8) { if !lighting.enabled { return (0, 0, 0); } - let (r, g, b) = parse_hex(&lighting.color); + let (r, g, b) = lighting.color.components(); let scale = |c: u8| u8::try_from(u16::from(c) * u16::from(lighting.brightness) / 100).unwrap_or(c); (scale(r), scale(g), scale(b)) diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index e0c159b7..ed964300 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -255,10 +255,12 @@ fn device_settings_payloads() { }); assert_wire(&smartshift, "0001103c"); + // `Rgb` serializes as the same hex string the field used to hold raw, so + // the pinned bytes are identical to the pre-newtype encoding. assert_wire( &Lighting { enabled: true, - color: "8000ff".into(), + color: "8000ff".parse().expect("valid hex"), brightness: 80, }, "010638303030666650", diff --git a/crates/openlogi-cli/src/cmd/diag/lighting.rs b/crates/openlogi-cli/src/cmd/diag/lighting.rs index 3ae708e8..8a22fab8 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); 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 a00def6d..1a296cba 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for}; +use crate::color::Rgb; use crate::device::{Capabilities, DeviceKind, DeviceModelInfo}; use crate::paths::{self, PathsError}; @@ -221,9 +222,15 @@ const fn default_thumbwheel_sensitivity() -> i32 { pub struct Lighting { #[serde(default = "default_lighting_enabled")] pub enabled: bool, - /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). - #[serde(default = "default_lighting_color")] - pub color: String, + /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value + /// that does not parse falls back to white on load — the same per-field + /// tolerance as `brightness`, because failing the whole load would + /// discard the user's entire config (see the `load_or_default` callers). + #[serde( + default = "default_lighting_color", + deserialize_with = "deserialize_lighting_color" + )] + pub color: Rgb, /// Brightness percent, clamped to 0–100 on load. #[serde( default = "default_lighting_brightness", @@ -246,8 +253,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 { @@ -264,6 +271,18 @@ where Ok(u8::deserialize(deserializer)?.min(100)) } +/// Fall back to white when the configured color does not parse, mirroring +/// the `brightness` clamp above: a hand-edited value degrades predictably +/// instead of failing the whole config load. +fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(String::deserialize(deserializer)? + .parse() + .unwrap_or(Rgb::WHITE)) +} + /// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1066,7 +1085,7 @@ mod tests { "g513", Lighting { enabled: true, - color: "00aabb".to_string(), + color: "00aabb".parse().expect("valid hex"), brightness: 75, }, ); @@ -1075,13 +1094,28 @@ 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(Rgb::WHITE)); + } + #[test] fn dpi_roundtrips_per_device() { let mut cfg = Config::default(); diff --git a/crates/openlogi-core/src/lib.rs b/crates/openlogi-core/src/lib.rs index fd01bb1a..f147a704 100644 --- a/crates/openlogi-core/src/lib.rs +++ b/crates/openlogi-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod binding; pub mod brand; +pub mod color; pub mod config; pub mod device; pub mod diagnostics; diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 9b455f7c..2a666225 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -638,7 +638,7 @@ pub(crate) fn keyboard_glow( .lighting_for(&record.config_key) .filter(|l| l.enabled)?; let geom = record.asset.as_ref()?.glow.clone()?; - let [_, r, g, b] = crate::components::lighting_panel::parse_hex(&lighting.color).to_be_bytes(); + let (r, g, b) = lighting.color.components(); let color = gpui::Rgba { r: f32::from(r) / 255., g: f32::from(g) / 255., diff --git a/crates/openlogi-gui/src/components/lighting_panel.rs b/crates/openlogi-gui/src/components/lighting_panel.rs index 6c2e011a..6f35c664 100644 --- a/crates/openlogi-gui/src/components/lighting_panel.rs +++ b/crates/openlogi-gui/src/components/lighting_panel.rs @@ -14,6 +14,7 @@ use gpui_component::{ slider::{Slider, SliderEvent, SliderState}, v_flex, }; +use openlogi_core::color::Rgb; use openlogi_core::config::Lighting; use crate::state::AppState; @@ -21,10 +22,18 @@ use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle}; const SWATCH: f32 = 28.; -/// Preset colors as 6-hex `"RRGGBB"`. Deliberately small — covering the common -/// keyboard accent colors. -const PALETTE: &[&str] = &[ - "ff3b30", "ff9500", "ffcc00", "34c759", "00c7be", "007aff", "5856d6", "af52de", "ffffff", +/// Preset colors. Deliberately small — covering the common keyboard accent +/// colors. +const PALETTE: &[Rgb] = &[ + Rgb::new(0xff, 0x3b, 0x30), + Rgb::new(0xff, 0x95, 0x00), + Rgb::new(0xff, 0xcc, 0x00), + Rgb::new(0x34, 0xc7, 0x59), + Rgb::new(0x00, 0xc7, 0xbe), + Rgb::new(0x00, 0x7a, 0xff), + Rgb::new(0x58, 0x56, 0xd6), + Rgb::new(0xaf, 0x52, 0xde), + Rgb::WHITE, ]; pub struct LightingPanel { @@ -96,7 +105,7 @@ impl Render for LightingPanel { let swatches: Vec = PALETTE .iter() .enumerate() - .map(|(idx, hex)| swatch(idx, hex, &lighting, pal)) + .map(|(idx, &color)| swatch(idx, color, &lighting, pal)) .collect(); v_flex() @@ -137,8 +146,8 @@ impl Render for LightingPanel { } /// One color swatch. Clicking it turns lighting on and sets that color. -fn swatch(idx: usize, hex: &'static str, current: &Lighting, pal: Palette) -> AnyElement { - let selected = current.enabled && current.color.eq_ignore_ascii_case(hex); +fn swatch(idx: usize, color: Rgb, current: &Lighting, pal: Palette) -> AnyElement { + let selected = current.enabled && current.color == color; div() .id(("light-swatch", idx)) .size(px(SWATCH)) @@ -149,13 +158,13 @@ fn swatch(idx: usize, hex: &'static str, current: &Lighting, pal: Palette) -> An } else { pal.border }) - .bg(rgb(parse_hex(hex))) + .bg(rgb(color.packed())) .cursor_pointer() .on_click(move |_event, _window, cx| { cx.update_global::(|state, _| { let mut next = state.lighting(); next.enabled = true; - next.color = hex.to_string(); + next.color = color; state.commit_lighting(next); }); cx.refresh_windows(); @@ -197,8 +206,3 @@ fn toggle(current: &Lighting, pal: Palette) -> AnyElement { fn clamp_brightness(raw: f32) -> u8 { raw.clamp(0., 100.).round() as u8 } - -/// Parse `"RRGGBB"` to a `0xRRGGBB` int for `rgb()`. Falls back to white. -pub(crate) fn parse_hex(hex: &str) -> u32 { - u32::from_str_radix(hex, 16).unwrap_or(0x00ff_ffff) -} From 75c3589cb93476726acfe53bfb2fc1f2e61a2077 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:03:51 +0800 Subject: [PATCH 07/17] refactor(core): persist the config through atomic-write-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled temp-file writer used a fixed .toml.tmp name (a concurrent-save collision surface) and never fsynced the directory, so the rename could vanish on a crash. atomic-write-file — already the asset cache's writer — covers both; the 0600 mode on every save is preserved. --- Cargo.lock | 1 + crates/openlogi-core/Cargo.toml | 1 + crates/openlogi-core/src/config.rs | 42 ++++++++++++------------------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b1b68fc..75caa7ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4906,6 +4906,7 @@ dependencies = [ name = "openlogi-core" version = "0.6.19" dependencies = [ + "atomic-write-file", "etcetera", "fs4", "serde", diff --git a/crates/openlogi-core/Cargo.toml b/crates/openlogi-core/Cargo.toml index c8e1e8c2..80fde736 100644 --- a/crates/openlogi-core/Cargo.toml +++ b/crates/openlogi-core/Cargo.toml @@ -17,6 +17,7 @@ thiserror = { workspace = true } tracing = { workspace = true } fs4 = { version = "1.1.0", features = ["sync"] } etcetera = "0.11.0" +atomic-write-file = "0.3.0" [dev-dependencies] tempfile = "3" diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 1a296cba..b07d2794 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; @@ -1027,33 +1028,24 @@ impl Config { } } +/// Write `bytes` to `path` atomically via a randomized temp file + rename, +/// with the directory fsync the old hand-rolled writer lacked. fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { - let tmp = path.with_extension("toml.tmp"); + #[cfg_attr( + not(unix), + expect(unused_mut, reason = "only the unix path mutates the options") + )] + let mut options = AtomicWriteFile::options(); + #[cfg(unix)] { - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - let mut f = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&tmp)?; - io::Write::write_all(&mut f, bytes)?; - f.sync_all()?; - } - #[cfg(not(unix))] - { - let mut f = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp)?; - io::Write::write_all(&mut f, bytes)?; - f.sync_all()?; - } - } - fs::rename(&tmp, path) + use atomic_write_file::unix::OpenOptionsExt as _; + use std::os::unix::fs::OpenOptionsExt as _; + // Force 0600 on every save, matching the previous writer. + options.preserve_mode(false).mode(0o600); + } + let mut file = options.open(path)?; + io::Write::write_all(&mut file, bytes)?; + file.commit() } #[cfg(test)] From 1fc9364771253becd48edd1345bd9241bc585598 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:10:54 +0800 Subject: [PATCH 08/17] refactor(inject): move the platform backends into per-platform files inject.rs simulated three whole backends as inline cfg-gated modules (~1700 lines in one file). Each moves verbatim to inject/{macos,linux, windows}.rs, matching the hook crate's layout; shared code and the public API are unchanged. --- crates/openlogi-inject/src/inject.rs | 1015 +----------------- crates/openlogi-inject/src/inject/linux.rs | 372 +++++++ crates/openlogi-inject/src/inject/macos.rs | 438 ++++++++ crates/openlogi-inject/src/inject/windows.rs | 197 ++++ 4 files changed, 1010 insertions(+), 1012 deletions(-) create mode 100644 crates/openlogi-inject/src/inject/linux.rs create mode 100644 crates/openlogi-inject/src/inject/macos.rs create mode 100644 crates/openlogi-inject/src/inject/windows.rs diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 48f8c603..c3c78930 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -420,823 +420,11 @@ const VK_TAB: u16 = 0x30; /// value is arbitrary but distinctive ("OLGI"); real events carry `0` here. pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749; -/// Platform helpers for synthesising OS-level input events on macOS. #[cfg(target_os = "macos")] -mod macos { - use core_graphics::event::{ - CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField, - ScrollEventUnit, - }; - use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; - use core_graphics::geometry::CGPoint; +mod macos; - use openlogi_core::binding::Action; - - // NX_KEYTYPE_* constants from . - pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0; - pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1; - pub(super) const NX_KEYTYPE_MUTE: i32 = 7; - pub(super) const NX_KEYTYPE_PLAY: i32 = 16; - pub(super) const NX_KEYTYPE_NEXT: i32 = 17; - pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18; - - /// Post a mouse-down + mouse-up pair for `button` at the cursor's current - /// location. - /// - /// Posted at the HID tap location, so OpenLogi's own event tap sees the - /// synthetic click too: a `LeftClick`/`RightClick` flows straight through - /// (the tap never owns the primary buttons), and a `MiddleClick` is left - /// alone unless the user has *also* remapped the middle button. - pub(super) fn post_click(button: CGMouseButton) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed for click"); - return; - }; - // A fresh event reports the current pointer location; mouse events need - // an explicit position or they land at (0, 0). - let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); - let (down, up) = match button { - CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp), - CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp), - CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp), - }; - for (kind, phase) in [(down, "down"), (up, "up")] { - if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) { - tag_synthetic(&ev); - ev.post(CGEventTapLocation::HID); - } else { - tracing::warn!(phase, "CGEvent::new_mouse_event failed"); - } - } - } - - /// Post a down + up pair for an "extra" mouse button by its raw button - /// number (3 = back / "button 4", 4 = forward / "button 5"). These are the - /// native events browsers and most apps interpret as back/forward. - /// - /// `CGMouseButton` only names Left/Right/Center, so we create an - /// `OtherMouse` event and override `MOUSE_EVENT_BUTTON_NUMBER` to address - /// buttons ≥ 3. Tagged via [`tag_synthetic`] so OpenLogi's own event tap - /// ignores it instead of re-translating it into a Back/Forward press. - pub(super) fn post_other_button(button_number: i64) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed for extra mouse button"); - return; - }; - let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); - for (kind, phase) in [ - (CGEventType::OtherMouseDown, "down"), - (CGEventType::OtherMouseUp, "up"), - ] { - if let Ok(ev) = - CGEvent::new_mouse_event(src.clone(), kind, location, CGMouseButton::Center) - { - ev.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, button_number); - tag_synthetic(&ev); - ev.post(CGEventTapLocation::HID); - } else { - tracing::warn!(phase, "CGEvent::new_mouse_event failed for extra button"); - } - } - } - - /// Stamp [`SYNTHETIC_EVENT_USER_DATA`](super::SYNTHETIC_EVENT_USER_DATA) - /// into the event's source user-data so OpenLogi's own event tap recognises - /// and skips its own injections instead of treating them as fresh input - /// (e.g. re-translating a synthesized button 4/5 into a Back/Forward press, - /// or misreading a remapped click as a new gesture hold). - fn tag_synthetic(ev: &CGEvent) { - ev.set_integer_value_field( - EventField::EVENT_SOURCE_USER_DATA, - super::SYNTHETIC_EVENT_USER_DATA, - ); - } - - /// Post a key-down + key-up pair for `vk` with `flags` set. - pub(super) fn post_key(vk: u16, flags: CGEventFlags) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed"); - return; - }; - let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { - tracing::warn!("CGEvent::new_keyboard_event(down) failed"); - return; - }; - down.set_flags(flags); - down.post(CGEventTapLocation::HID); - let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { - tracing::warn!("CGEvent::new_keyboard_event(up) failed"); - return; - }; - up.set_flags(flags); - up.post(CGEventTapLocation::HID); - } - - /// Post a media/system key event (play/pause, track navigation, volume). - /// - /// Runs on the hook/gesture dispatch threads, which have no run loop to - /// drain autorelease pools, and both `NSEvent` creation and the `CGEvent` - /// getter autorelease temporaries — so the exchange sits inside an - /// explicit `autoreleasepool`, same as the hook's `frontmost_bundle_id`. - pub(super) fn post_media_key(nx_key: i32) { - use objc2::rc::autoreleasepool; - use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType}; - use objc2_core_graphics::{CGEvent, CGEventTapLocation}; - use objc2_foundation::NSPoint; - - const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8; - const NX_KEY_DOWN: i32 = 0x0A; - const NX_KEY_UP: i32 = 0x0B; - - autoreleasepool(|_| { - for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] { - // data1 layout for subtype 8: high word is NX_KEYTYPE_*, next byte - // is key state (0x0A down, 0x0B up), low bit is repeat (0 here). - let data1 = ((nx_key << 16) | (state << 8)) as isize; - let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( - NSEventType::SystemDefined, - NSPoint::new(0.0, 0.0), - NSEventModifierFlags::empty(), - 0.0, - 0, - None, - NX_SUBTYPE_AUX_CONTROL_BUTTONS, - data1, - 0, - ) else { - tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed"); - return; - }; - let Some(cg_event) = ns_event.CGEvent() else { - tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed"); - return; - }; - CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event)); - } - }); - } - - /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants). - pub(super) fn post_scroll(action: &Action) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed for scroll"); - return; - }; - let (v, h): (i32, i32) = match action { - Action::ScrollUp => (3, 0), - Action::ScrollDown => (-3, 0), - Action::HorizontalScrollLeft => (0, -3), - Action::HorizontalScrollRight => (0, 3), - _ => return, - }; - let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else { - tracing::warn!("CGEvent::new_scroll_event failed"); - return; - }; - tag_synthetic(&ev); - ev.post(CGEventTapLocation::HID); - } - - /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit - /// the thumb wheel's ratchet-like increments better than pixels. - pub(super) fn post_horizontal_scroll(delta: i32) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed for thumbwheel scroll"); - return; - }; - let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else { - tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel"); - return; - }; - tag_synthetic(&ev); - ev.post(CGEventTapLocation::HID); - } - - pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop}; - pub(super) use symbolic_hotkey::{next_desktop, previous_desktop}; - - use app_services::symbol as app_services_symbol; - - /// Shared resolver for private ApplicationServices SPI used by the Dock and - /// symbolic-hotkey helpers. - #[allow( - unsafe_code, - reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI" - )] - mod app_services { - use std::ffi::{CStr, c_char, c_int, c_void}; - use std::sync::OnceLock; - - /// Resolve a symbol from ApplicationServices, caching the `dlopen` - /// handle for the process lifetime. Returns `None` if the framework or - /// symbol is unavailable on this macOS version. - pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> { - const RTLD_LAZY: c_int = 0x1; - const APP_SERVICES: &CStr = - c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; - static HANDLE: OnceLock = OnceLock::new(); - - // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and - // `symbol` are valid C strings. The handle is cached and - // intentionally never closed. - let sym = unsafe { - let handle = - *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize); - if handle == 0 { - return None; - } - dlsym(handle as *mut c_void, symbol.as_ptr()) - }; - (!sym.is_null()).then_some(sym) - } - - unsafe extern "C" { - fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; - fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; - } - } - - /// WindowServer window/space actions (Mission Control, App Exposé, Show - /// Desktop, Launchpad). - /// - /// These are driven by the Dock, and synthesising their keyboard shortcut is - /// unreliable — the WindowServer matcher needs the exact configured key - /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So - /// we post the action straight to the Dock via the private - /// `CoreDockSendNotification` SPI, which fires it regardless of the user's - /// Keyboard settings. - /// - /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI - /// needs is scoped here rather than spread across the platform helpers. - #[allow( - unsafe_code, - reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI" - )] - mod dock { - use std::ffi::{c_int, c_void}; - - use core_foundation::base::TCFType; - use core_foundation::string::CFString; - - use super::app_services_symbol; - - /// Show all windows across spaces (Mission Control). - pub(crate) fn mission_control() { - send("com.apple.expose.awake"); - } - - /// Show the front app's windows (App Exposé). - pub(crate) fn app_expose() { - send("com.apple.expose.front.awake"); - } - - /// Move all windows aside to reveal the desktop. - pub(crate) fn show_desktop() { - send("com.apple.showdesktop.awake"); - } - - /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad. - pub(crate) fn launchpad() { - send("com.apple.launchpad.toggle"); - } - - /// Post `notification` to the Dock. Logs and returns on any failure. - fn send(notification: &str) { - let Some(core_dock_send) = core_dock_send_notification() else { - tracing::warn!(notification, "CoreDockSendNotification unavailable"); - return; - }; - let name = CFString::new(notification); - // SAFETY: resolved AppServices symbol called with its documented - // signature; `name` is a live CFString for the call's duration. - let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) }; - if err != 0 { - tracing::warn!(notification, err, "CoreDockSendNotification failed"); - } - } - - type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int; - - /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching - /// the `dlopen` handle for the process lifetime. `None` if unavailable. - fn core_dock_send_notification() -> Option { - let sym = app_services_symbol(c"CoreDockSendNotification")?; - // SAFETY: the symbol, when present, has the documented signature. - Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) }) - } - } - - /// macOS Space switching actions. - /// - /// Use the system symbolic hotkey records for "Move left a space" (79) and - /// "Move right a space" (81). That respects the user's configured shortcut - /// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic - /// hotkey when the user has disabled it. - #[allow( - unsafe_code, - reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI" - )] - mod symbolic_hotkey { - use std::ffi::{c_int, c_uint, c_ushort, c_void}; - - use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation}; - use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; - - use super::app_services_symbol; - - const SPACE_LEFT: u32 = 79; - const SPACE_RIGHT: u32 = 81; - - /// Switch to the previous desktop / Space. - pub(crate) fn previous_desktop() { - post_symbolic_hotkey(SPACE_LEFT); - } - - /// Switch to the next desktop / Space. - pub(crate) fn next_desktop() { - post_symbolic_hotkey(SPACE_RIGHT); - } - - fn post_symbolic_hotkey(hotkey: u32) { - let Some(cgs) = cgs_hotkey_api() else { - tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable"); - return; - }; - - let mut key_equivalent = 0_u16; - let mut virtual_key = 0_u16; - let mut modifiers = 0_u32; - - // SAFETY: resolved AppServices symbols are called with their - // expected signatures and valid out-parameters. - let err = unsafe { - (cgs.get_value)( - hotkey, - &raw mut key_equivalent, - &raw mut virtual_key, - &raw mut modifiers, - ) - }; - if err != 0 { - tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed"); - return; - } - - // SAFETY: resolved AppServices symbol called with its expected - // signature. - let was_enabled = unsafe { (cgs.is_enabled)(hotkey) }; - if !was_enabled { - // SAFETY: resolved AppServices symbol called with its expected - // signature. - let err = unsafe { (cgs.set_enabled)(hotkey, true) }; - if err != 0 { - tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed"); - } - } - - post_key(virtual_key, modifiers); - - if !was_enabled { - // SAFETY: resolved AppServices symbol called with its expected - // signature. - let err = unsafe { (cgs.set_enabled)(hotkey, false) }; - if err != 0 { - tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed"); - } - } - } - - fn post_key(vk: u16, modifiers: u32) { - let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { - tracing::warn!("CGEventSource::new failed for symbolic hotkey"); - return; - }; - let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { - tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed"); - return; - }; - let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers)); - down.set_flags(flags); - down.post(CGEventTapLocation::Session); - - let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { - tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed"); - return; - }; - up.set_flags(flags); - up.post(CGEventTapLocation::Session); - } - - #[derive(Clone, Copy)] - struct CgsHotkeyApi { - get_value: CgsGetSymbolicHotKeyValueFn, - is_enabled: CgsIsSymbolicHotKeyEnabledFn, - set_enabled: CgsSetSymbolicHotKeyEnabledFn, - } - - type CgsGetSymbolicHotKeyValueFn = - unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int; - type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool; - type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int; - - fn cgs_hotkey_api() -> Option { - let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?; - let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?; - let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?; - - // SAFETY: the symbols, when present, have the private SPI - // signatures declared above. - Some(unsafe { - CgsHotkeyApi { - get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>( - get_value, - ), - is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>( - is_enabled, - ), - set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>( - set_enabled, - ), - } - }) - } - } -} - -/// Linux helpers for synthesising OS-level input events via a shared `uinput` -/// virtual device. -/// -/// The device is created lazily on first use. If `/dev/uinput` is inaccessible -/// (missing group membership or udev rule) every call logs a `warn` and returns -/// without panicking. #[cfg(target_os = "linux")] -mod linux { - use std::io; - use std::sync::{LazyLock, Mutex}; - - use evdev::uinput::VirtualDevice; - use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode}; - use zbus::blocking::Connection as DbusConn; - - const DEVICE_NAME: &str = "OpenLogi action injector"; - - static VIRTUAL_INPUT: LazyLock>> = LazyLock::new(|| { - build() - .map(Mutex::new) - .map_err(|e| tracing::warn!("failed to create uinput action device: {e}")) - .ok() - }); - - #[rustfmt::skip] - const KEY_CAPABILITIES: &[KeyCode] = &[ - // Letters - KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D, - KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H, - KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L, - KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P, - KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T, - KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X, - KeyCode::KEY_Y, KeyCode::KEY_Z, - // Digits - KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3, - KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7, - KeyCode::KEY_8, KeyCode::KEY_9, - // Punctuation / symbols - KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE, - KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON, - KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA, - KeyCode::KEY_DOT, KeyCode::KEY_SLASH, - // Navigation / editing - KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN, - KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN, - KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE, - KeyCode::KEY_ESC, KeyCode::KEY_SPACE, - // Modifiers (KEY_LEFTMETA used by the LockScreen Super+L fallback) - KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA, - // Function keys - KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4, - KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8, - KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12, - // System - KeyCode::KEY_SYSRQ, - // Multimedia - KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG, - KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE, - // Mouse buttons (injected as EV_KEY with BTN_* codes). The side pair - // must be registered here or the kernel silently drops their events. - KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE, - KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA, - ]; - - fn build() -> io::Result { - let mut keys = AttributeSet::::default(); - for &k in KEY_CAPABILITIES { - keys.insert(k); - } - - // Only scroll axes: the device never emits cursor movement, so leaving - // out REL_X/REL_Y keeps libinput from classifying it as a pointer — - // which can otherwise cause injected key/wheel events to be grabbed by - // pointer-grabbing X11 clients or routed oddly by some Wayland compositors. - let mut axes = AttributeSet::::default(); - for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] { - axes.insert(a); - } - - VirtualDevice::builder()? - .name(DEVICE_NAME) - .with_keys(&keys)? - .with_relative_axes(&axes)? - .build() - } - - fn emit(events: &[InputEvent]) { - if let Some(m) = &*VIRTUAL_INPUT { - if let Ok(mut guard) = m.lock() { - if let Err(e) = guard.emit(events) { - tracing::warn!("uinput action emit failed: {e}"); - } - } else { - tracing::warn!("uinput action device mutex poisoned"); - } - } else { - // Device creation failed at init; already logged once in LazyLock. - tracing::debug!("uinput action device unavailable — action skipped"); - } - } - - fn syn() -> InputEvent { - InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0) - } - - fn key_ev(code: KeyCode, value: i32) -> InputEvent { - InputEvent::new(EventType::KEY.0, code.0, value) - } - - fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent { - InputEvent::new(EventType::RELATIVE.0, axis.0, value) - } - - /// Inject modifier-down + key-down in one SYN frame, then key-up + - /// modifier-up in a second SYN frame. - /// - /// Two separate frames give the kernel distinct timestamps for press and - /// release, which matches what the kernel `uinput` docs show and avoids - /// toolkits treating a zero-duration event as invalid. - pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) { - // Down phase. - let mut down: Vec = Vec::with_capacity(mods.len() + 2); - for &m in mods { - down.push(key_ev(m, 1)); - } - down.push(key_ev(key, 1)); - down.push(syn()); - emit(&down); - - // Up phase. - let mut up: Vec = Vec::with_capacity(mods.len() + 2); - up.push(key_ev(key, 0)); - for &m in mods.iter().rev() { - up.push(key_ev(m, 0)); - } - up.push(syn()); - emit(&up); - } - - /// Inject a button-down in one SYN frame and button-up in a second. - pub(super) fn click(button: KeyCode) { - emit(&[key_ev(button, 1), syn()]); - emit(&[key_ev(button, 0), syn()]); - } - - /// Inject a single relative-axis delta followed by `SYN_REPORT`. - pub(super) fn scroll(axis: RelativeAxisCode, value: i32) { - emit(&[rel_ev(axis, value), syn()]); - } - - /// Force the virtual device to initialise (if it hasn't already) and return - /// its `/dev/input/eventN` node path. - /// - /// Uses `VirtualDevice::enumerate_dev_nodes()` which returns the correct - /// `/dev/input/eventN` path directly. Returns `None` if the device couldn't - /// be created or if the node hasn't appeared yet (udev typically creates it - /// within a few milliseconds of the `ioctl`). - pub(super) fn device_node() -> Option { - // Touch the LazyLock to force initialisation. - let _ = &*VIRTUAL_INPUT; - // Give udev a moment to create the /dev node. - std::thread::sleep(std::time::Duration::from_millis(150)); - if let Some(m) = &*VIRTUAL_INPUT - && let Ok(mut guard) = m.lock() - { - return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next(); - } - None - } - - /// Convert a [`KeyCombo`](openlogi_core::binding::KeyCombo) modifier bitmask - /// to the evdev keys to hold. - /// - /// macOS Cmd (`MOD_CMD`) and Ctrl (`MOD_CTRL`) both map to `KEY_LEFTCTRL`; - /// the bitwise-OR check deduplicates them so at most one Ctrl is pushed. - /// Order is canonical: Ctrl → Shift → Alt. - pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec { - use openlogi_core::binding::KeyCombo; - let mut mods = Vec::new(); - if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 { - mods.push(KeyCode::KEY_LEFTCTRL); - } - if modifiers & KeyCombo::MOD_SHIFT != 0 { - mods.push(KeyCode::KEY_LEFTSHIFT); - } - if modifiers & KeyCombo::MOD_OPTION != 0 { - mods.push(KeyCode::KEY_LEFTALT); - } - mods - } - - /// Map a macOS `kVK_*` virtual key code to the corresponding Linux `KeyCode`. - /// - /// Source: `HIToolbox/Events.h` (macOS side) and - /// `linux/input-event-codes.h` (Linux side). Only the codes the recorder UI - /// is likely to produce are mapped; unknown codes return `None`. - pub(super) fn macos_vk_to_linux(vk: u16) -> Option { - Some(match vk { - 0x00 => KeyCode::KEY_A, // kVK_ANSI_A - 0x01 => KeyCode::KEY_S, // kVK_ANSI_S - 0x02 => KeyCode::KEY_D, // kVK_ANSI_D - 0x03 => KeyCode::KEY_F, // kVK_ANSI_F - 0x04 => KeyCode::KEY_H, // kVK_ANSI_H - 0x05 => KeyCode::KEY_G, // kVK_ANSI_G - 0x06 => KeyCode::KEY_Z, // kVK_ANSI_Z - 0x07 => KeyCode::KEY_X, // kVK_ANSI_X - 0x08 => KeyCode::KEY_C, // kVK_ANSI_C - 0x09 => KeyCode::KEY_V, // kVK_ANSI_V - 0x0B => KeyCode::KEY_B, // kVK_ANSI_B - 0x0C => KeyCode::KEY_Q, // kVK_ANSI_Q - 0x0D => KeyCode::KEY_W, // kVK_ANSI_W - 0x0E => KeyCode::KEY_E, // kVK_ANSI_E - 0x0F => KeyCode::KEY_R, // kVK_ANSI_R - 0x10 => KeyCode::KEY_Y, // kVK_ANSI_Y - 0x11 => KeyCode::KEY_T, // kVK_ANSI_T - 0x12 => KeyCode::KEY_1, // kVK_ANSI_1 - 0x13 => KeyCode::KEY_2, // kVK_ANSI_2 - 0x14 => KeyCode::KEY_3, // kVK_ANSI_3 - 0x15 => KeyCode::KEY_4, // kVK_ANSI_4 - 0x16 => KeyCode::KEY_6, // kVK_ANSI_6 - 0x17 => KeyCode::KEY_5, // kVK_ANSI_5 - 0x18 => KeyCode::KEY_EQUAL, // kVK_ANSI_Equal - 0x19 => KeyCode::KEY_9, // kVK_ANSI_9 - 0x1A => KeyCode::KEY_7, // kVK_ANSI_7 - 0x1B => KeyCode::KEY_MINUS, // kVK_ANSI_Minus - 0x1C => KeyCode::KEY_8, // kVK_ANSI_8 - 0x1D => KeyCode::KEY_0, // kVK_ANSI_0 - 0x1E => KeyCode::KEY_RIGHTBRACE, // kVK_ANSI_RightBracket - 0x1F => KeyCode::KEY_O, // kVK_ANSI_O - 0x20 => KeyCode::KEY_U, // kVK_ANSI_U - 0x21 => KeyCode::KEY_LEFTBRACE, // kVK_ANSI_LeftBracket - 0x22 => KeyCode::KEY_I, // kVK_ANSI_I - 0x23 => KeyCode::KEY_P, // kVK_ANSI_P - 0x24 => KeyCode::KEY_ENTER, // kVK_Return - 0x25 => KeyCode::KEY_L, // kVK_ANSI_L - 0x26 => KeyCode::KEY_J, // kVK_ANSI_J - 0x27 => KeyCode::KEY_APOSTROPHE, // kVK_ANSI_Quote - 0x28 => KeyCode::KEY_K, // kVK_ANSI_K - 0x29 => KeyCode::KEY_SEMICOLON, // kVK_ANSI_Semicolon - 0x2A => KeyCode::KEY_BACKSLASH, // kVK_ANSI_Backslash - 0x2B => KeyCode::KEY_COMMA, // kVK_ANSI_Comma - 0x2C => KeyCode::KEY_SLASH, // kVK_ANSI_Slash - 0x2D => KeyCode::KEY_N, // kVK_ANSI_N - 0x2E => KeyCode::KEY_M, // kVK_ANSI_M - 0x2F => KeyCode::KEY_DOT, // kVK_ANSI_Period - 0x30 => KeyCode::KEY_TAB, // kVK_Tab - 0x31 => KeyCode::KEY_SPACE, // kVK_Space - 0x32 => KeyCode::KEY_GRAVE, // kVK_ANSI_Grave - 0x33 => KeyCode::KEY_BACKSPACE, // kVK_Delete (= Backspace on macOS) - 0x35 => KeyCode::KEY_ESC, // kVK_Escape - 0x60 => KeyCode::KEY_F5, // kVK_F5 - 0x61 => KeyCode::KEY_F6, // kVK_F6 - 0x62 => KeyCode::KEY_F7, // kVK_F7 - 0x63 => KeyCode::KEY_F3, // kVK_F3 - 0x64 => KeyCode::KEY_F8, // kVK_F8 - 0x65 => KeyCode::KEY_F9, // kVK_F9 - 0x67 => KeyCode::KEY_F11, // kVK_F11 - 0x6D => KeyCode::KEY_F10, // kVK_F10 - 0x6F => KeyCode::KEY_F12, // kVK_F12 - 0x76 => KeyCode::KEY_F4, // kVK_F4 - 0x78 => KeyCode::KEY_F2, // kVK_F2 - 0x7A => KeyCode::KEY_F1, // kVK_F1 - 0x73 => KeyCode::KEY_HOME, // kVK_Home - 0x77 => KeyCode::KEY_END, // kVK_End - 0x74 => KeyCode::KEY_PAGEUP, // kVK_PageUp - 0x79 => KeyCode::KEY_PAGEDOWN, // kVK_PageDown - 0x75 => KeyCode::KEY_DELETE, // kVK_ForwardDelete - 0x7B => KeyCode::KEY_LEFT, // kVK_LeftArrow - 0x7C => KeyCode::KEY_RIGHT, // kVK_RightArrow - 0x7D => KeyCode::KEY_DOWN, // kVK_DownArrow - 0x7E => KeyCode::KEY_UP, // kVK_UpArrow - _ => return None, - }) - } - - // ── D-Bus helpers ──────────────────────────────────────────────────────── - - static SESSION_BUS: LazyLock> = LazyLock::new(|| { - DbusConn::session() - .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}")) - .ok() - }); - - static SYSTEM_BUS: LazyLock> = LazyLock::new(|| { - DbusConn::system() - .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}")) - .ok() - }); - - /// Lock the screen via logind `LockSession($XDG_SESSION_ID)` on the system - /// bus, falling back to Super+L. - /// - /// Only the session identified by `$XDG_SESSION_ID` is locked; if the - /// variable is unset the D-Bus path is skipped entirely to avoid locking - /// all sessions on the machine. Super+L covers non-systemd systems and the - /// no-session-id case. - pub(super) fn lock_screen() { - if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) { - match conn.call_method( - Some("org.freedesktop.login1"), - "/org/freedesktop/login1", - Some("org.freedesktop.login1.Manager"), - "LockSession", - &(id.as_str(),), - ) { - Ok(_) => { - tracing::debug!("LockScreen via logind"); - return; - } - Err(e) => tracing::warn!("logind LockSession failed: {e}"), - } - } - // Super+L is the standard lock shortcut on GNOME and KDE. - tracing::debug!("LockScreen via Super+L key combo"); - press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L); - } - - /// Send `command` to the first MPRIS-capable media player on the session bus, - /// falling back to the corresponding XF86 multimedia key only if no MPRIS - /// player is found. When a player is found but the call fails, the fallback - /// is suppressed to avoid double-toggling (the player likely handles the - /// XF86 key too). - pub(super) fn mpris_command(command: &str) { - if try_mpris_command(command).is_none() { - let fallback = match command { - "PlayPause" => KeyCode::KEY_PLAYPAUSE, - "Next" => KeyCode::KEY_NEXTSONG, - "Previous" => KeyCode::KEY_PREVIOUSSONG, - _ => return, - }; - press_key(&[], fallback); - } - } - - fn try_mpris_command(command: &str) -> Option<()> { - let conn = SESSION_BUS.as_ref()?; - let reply = conn - .call_method( - Some("org.freedesktop.DBus"), - "/org/freedesktop/DBus", - Some("org.freedesktop.DBus"), - "ListNames", - &(), - ) - .ok()?; - let names = reply.body().deserialize::>().ok()?; - let Some(player) = names - .iter() - .find(|n| n.starts_with("org.mpris.MediaPlayer2.")) - else { - tracing::debug!("no MPRIS player found — {command} via XF86 key fallback"); - return None; - }; - match conn.call_method( - Some(player.as_str()), - "/org/mpris/MediaPlayer2", - Some("org.mpris.MediaPlayer2.Player"), - command, - &(), - ) { - Ok(_) => { - tracing::debug!("MPRIS {command} via {player}"); - Some(()) - } - Err(e) => { - // Player was identified — suppress XF86 fallback to avoid - // double-toggling if the player also handles multimedia keys. - tracing::warn!("MPRIS {command} on {player} failed: {e}"); - Some(()) - } - } - } -} +mod linux; /// Translate a macOS virtual key code (`kVK_*`, captured when a `CustomShortcut` /// was recorded on macOS) to the equivalent Windows virtual-key code, so a chord @@ -1352,204 +540,7 @@ fn mac_virtual_key_to_windows(key_code: u16) -> Option { } #[cfg(target_os = "windows")] -#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")] -mod windows { - use std::mem::size_of; - - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, - MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, - MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, - MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput, - }; - - use openlogi_core::binding::{Action, KeyCombo}; - - const WHEEL_DELTA: i32 = 120; - - pub(super) const VK_A: u16 = 0x41; - pub(super) const VK_C: u16 = 0x43; - pub(super) const VK_D: u16 = 0x44; - pub(super) const VK_F: u16 = 0x46; - pub(super) const VK_L: u16 = 0x4C; - pub(super) const VK_R: u16 = 0x52; - pub(super) const VK_S: u16 = 0x53; - pub(super) const VK_T: u16 = 0x54; - pub(super) const VK_V: u16 = 0x56; - pub(super) const VK_W: u16 = 0x57; - pub(super) const VK_X: u16 = 0x58; - pub(super) const VK_Y: u16 = 0x59; - pub(super) const VK_Z: u16 = 0x5A; - pub(super) const VK_TAB: u16 = 0x09; - pub(super) const VK_LEFT: u16 = 0x25; - pub(super) const VK_RIGHT: u16 = 0x27; - pub(super) const VK_SHIFT: u16 = 0x10; - pub(super) const VK_CONTROL: u16 = 0x11; - pub(super) const VK_MENU: u16 = 0x12; - pub(super) const VK_LWIN: u16 = 0x5B; - pub(super) const VK_BROWSER_BACK: u16 = 0xA6; - pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7; - pub(super) const VK_VOLUME_MUTE: u16 = 0xAD; - pub(super) const VK_VOLUME_DOWN: u16 = 0xAE; - pub(super) const VK_VOLUME_UP: u16 = 0xAF; - pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0; - pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1; - pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3; - - #[derive(Clone, Copy)] - pub(super) enum MouseButton { - Left, - Right, - Middle, - /// Extra button 4 ("back"). - Back, - /// Extra button 5 ("forward"). - Forward, - } - - // XBUTTON1/XBUTTON2 from WinUser.h — windows-sys puts them behind the - // Win32_UI_WindowsAndMessaging feature; not worth enabling for two - // integers (same treatment as the VK_* codes above). - const XBUTTON1: i32 = 1; - const XBUTTON2: i32 = 2; - - pub(super) fn post_click(button: MouseButton) { - let (down, up, data) = match button { - MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0), - MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0), - MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0), - // Extra buttons share the X flag pair; mouseData carries which one. - MouseButton::Back => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON1), - MouseButton::Forward => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON2), - }; - send_inputs(&[mouse_input(down, data), mouse_input(up, data)]); - } - - pub(super) fn post_key(vk: u16, modifiers: &[u16]) { - let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2); - for modifier in modifiers { - inputs.push(key_input(*modifier, false)); - } - inputs.push(key_input(vk, false)); - inputs.push(key_input(vk, true)); - for modifier in modifiers.iter().rev() { - inputs.push(key_input(*modifier, true)); - } - send_inputs(&inputs); - } - - pub(super) fn post_scroll(action: &Action) { - let (flags, data) = match action { - Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA), - Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA), - Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA), - Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA), - _ => return, - }; - send_inputs(&[mouse_input(flags, data)]); - } - - pub(super) fn post_horizontal_scroll(delta: i32) { - if delta == 0 { - return; - } - send_inputs(&[mouse_input( - MOUSEEVENTF_HWHEEL, - delta.saturating_mul(WHEEL_DELTA), - )]); - } - - pub(super) fn post_custom_shortcut(combo: &KeyCombo) { - if combo.key_code == 0 { - tracing::warn!( - chord = %combo.rendered_label(), - "CustomShortcut with no key code; press ignored" - ); - return; - } - let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else { - tracing::warn!( - key_code = combo.key_code, - chord = %combo.rendered_label(), - "CustomShortcut key has no Windows mapping yet; press ignored" - ); - return; - }; - - let mut modifiers = Vec::new(); - if combo.modifiers & KeyCombo::MOD_CMD != 0 { - modifiers.push(VK_CONTROL); - } - if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { - modifiers.push(VK_SHIFT); - } - if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) { - modifiers.push(VK_CONTROL); - } - if combo.modifiers & KeyCombo::MOD_OPTION != 0 { - modifiers.push(VK_MENU); - } - post_key(vk, &modifiers); - } - - fn send_inputs(inputs: &[INPUT]) { - let Ok(input_count) = u32::try_from(inputs.len()) else { - tracing::warn!( - requested = inputs.len(), - "too many SendInput events requested" - ); - return; - }; - let Ok(input_size) = i32::try_from(size_of::()) else { - tracing::warn!("INPUT size does not fit the Win32 SendInput contract"); - return; - }; - // SAFETY: inputs.as_ptr()/input_count describe a valid initialized INPUT slice; SendInput copies it and returns the count injected. - let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) }; - if sent != input_count { - tracing::warn!( - requested = inputs.len(), - sent, - "SendInput accepted fewer events than requested" - ); - } - } - - fn key_input(vk: u16, key_up: bool) -> INPUT { - let mut flags = 0; - if key_up { - flags |= KEYEVENTF_KEYUP; - } - INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: vk, - wScan: 0, - dwFlags: flags, - time: 0, - dwExtraInfo: 0, - }, - }, - } - } - - fn mouse_input(flags: u32, data: i32) -> INPUT { - INPUT { - r#type: INPUT_MOUSE, - Anonymous: INPUT_0 { - mi: MOUSEINPUT { - dx: 0, - dy: 0, - mouseData: u32::from_ne_bytes(data.to_ne_bytes()), - dwFlags: flags, - time: 0, - dwExtraInfo: 0, - }, - }, - } - } -} +mod windows; #[cfg(test)] #[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")] diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs new file mode 100644 index 00000000..add0719d --- /dev/null +++ b/crates/openlogi-inject/src/inject/linux.rs @@ -0,0 +1,372 @@ +//! Linux helpers for synthesising OS-level input events via a shared `uinput` +//! virtual device. +//! +//! The device is created lazily on first use. If `/dev/uinput` is inaccessible +//! (missing group membership or udev rule) every call logs a `warn` and returns +//! without panicking. + +use std::io; +use std::sync::{LazyLock, Mutex}; + +use evdev::uinput::VirtualDevice; +use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode}; +use zbus::blocking::Connection as DbusConn; + +const DEVICE_NAME: &str = "OpenLogi action injector"; + +static VIRTUAL_INPUT: LazyLock>> = LazyLock::new(|| { + build() + .map(Mutex::new) + .map_err(|e| tracing::warn!("failed to create uinput action device: {e}")) + .ok() +}); + +#[rustfmt::skip] +const KEY_CAPABILITIES: &[KeyCode] = &[ + // Letters + KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D, + KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H, + KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L, + KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P, + KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T, + KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X, + KeyCode::KEY_Y, KeyCode::KEY_Z, + // Digits + KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3, + KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7, + KeyCode::KEY_8, KeyCode::KEY_9, + // Punctuation / symbols + KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE, + KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON, + KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA, + KeyCode::KEY_DOT, KeyCode::KEY_SLASH, + // Navigation / editing + KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN, + KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN, + KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE, + KeyCode::KEY_ESC, KeyCode::KEY_SPACE, + // Modifiers (KEY_LEFTMETA used by the LockScreen Super+L fallback) + KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA, + // Function keys + KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4, + KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8, + KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12, + // System + KeyCode::KEY_SYSRQ, + // Multimedia + KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG, + KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE, + // Mouse buttons (injected as EV_KEY with BTN_* codes). The side pair + // must be registered here or the kernel silently drops their events. + KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE, + KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA, +]; + +fn build() -> io::Result { + let mut keys = AttributeSet::::default(); + for &k in KEY_CAPABILITIES { + keys.insert(k); + } + + // Only scroll axes: the device never emits cursor movement, so leaving + // out REL_X/REL_Y keeps libinput from classifying it as a pointer — + // which can otherwise cause injected key/wheel events to be grabbed by + // pointer-grabbing X11 clients or routed oddly by some Wayland compositors. + let mut axes = AttributeSet::::default(); + for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] { + axes.insert(a); + } + + VirtualDevice::builder()? + .name(DEVICE_NAME) + .with_keys(&keys)? + .with_relative_axes(&axes)? + .build() +} + +fn emit(events: &[InputEvent]) { + if let Some(m) = &*VIRTUAL_INPUT { + if let Ok(mut guard) = m.lock() { + if let Err(e) = guard.emit(events) { + tracing::warn!("uinput action emit failed: {e}"); + } + } else { + tracing::warn!("uinput action device mutex poisoned"); + } + } else { + // Device creation failed at init; already logged once in LazyLock. + tracing::debug!("uinput action device unavailable — action skipped"); + } +} + +fn syn() -> InputEvent { + InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0) +} + +fn key_ev(code: KeyCode, value: i32) -> InputEvent { + InputEvent::new(EventType::KEY.0, code.0, value) +} + +fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent { + InputEvent::new(EventType::RELATIVE.0, axis.0, value) +} + +/// Inject modifier-down + key-down in one SYN frame, then key-up + +/// modifier-up in a second SYN frame. +/// +/// Two separate frames give the kernel distinct timestamps for press and +/// release, which matches what the kernel `uinput` docs show and avoids +/// toolkits treating a zero-duration event as invalid. +pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) { + // Down phase. + let mut down: Vec = Vec::with_capacity(mods.len() + 2); + for &m in mods { + down.push(key_ev(m, 1)); + } + down.push(key_ev(key, 1)); + down.push(syn()); + emit(&down); + + // Up phase. + let mut up: Vec = Vec::with_capacity(mods.len() + 2); + up.push(key_ev(key, 0)); + for &m in mods.iter().rev() { + up.push(key_ev(m, 0)); + } + up.push(syn()); + emit(&up); +} + +/// Inject a button-down in one SYN frame and button-up in a second. +pub(super) fn click(button: KeyCode) { + emit(&[key_ev(button, 1), syn()]); + emit(&[key_ev(button, 0), syn()]); +} + +/// Inject a single relative-axis delta followed by `SYN_REPORT`. +pub(super) fn scroll(axis: RelativeAxisCode, value: i32) { + emit(&[rel_ev(axis, value), syn()]); +} + +/// Force the virtual device to initialise (if it hasn't already) and return +/// its `/dev/input/eventN` node path. +/// +/// Uses `VirtualDevice::enumerate_dev_nodes()` which returns the correct +/// `/dev/input/eventN` path directly. Returns `None` if the device couldn't +/// be created or if the node hasn't appeared yet (udev typically creates it +/// within a few milliseconds of the `ioctl`). +pub(super) fn device_node() -> Option { + // Touch the LazyLock to force initialisation. + let _ = &*VIRTUAL_INPUT; + // Give udev a moment to create the /dev node. + std::thread::sleep(std::time::Duration::from_millis(150)); + if let Some(m) = &*VIRTUAL_INPUT + && let Ok(mut guard) = m.lock() + { + return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next(); + } + None +} + +/// Convert a [`KeyCombo`](openlogi_core::binding::KeyCombo) modifier bitmask +/// to the evdev keys to hold. +/// +/// macOS Cmd (`MOD_CMD`) and Ctrl (`MOD_CTRL`) both map to `KEY_LEFTCTRL`; +/// the bitwise-OR check deduplicates them so at most one Ctrl is pushed. +/// Order is canonical: Ctrl → Shift → Alt. +pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec { + use openlogi_core::binding::KeyCombo; + let mut mods = Vec::new(); + if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 { + mods.push(KeyCode::KEY_LEFTCTRL); + } + if modifiers & KeyCombo::MOD_SHIFT != 0 { + mods.push(KeyCode::KEY_LEFTSHIFT); + } + if modifiers & KeyCombo::MOD_OPTION != 0 { + mods.push(KeyCode::KEY_LEFTALT); + } + mods +} + +/// Map a macOS `kVK_*` virtual key code to the corresponding Linux `KeyCode`. +/// +/// Source: `HIToolbox/Events.h` (macOS side) and +/// `linux/input-event-codes.h` (Linux side). Only the codes the recorder UI +/// is likely to produce are mapped; unknown codes return `None`. +pub(super) fn macos_vk_to_linux(vk: u16) -> Option { + Some(match vk { + 0x00 => KeyCode::KEY_A, // kVK_ANSI_A + 0x01 => KeyCode::KEY_S, // kVK_ANSI_S + 0x02 => KeyCode::KEY_D, // kVK_ANSI_D + 0x03 => KeyCode::KEY_F, // kVK_ANSI_F + 0x04 => KeyCode::KEY_H, // kVK_ANSI_H + 0x05 => KeyCode::KEY_G, // kVK_ANSI_G + 0x06 => KeyCode::KEY_Z, // kVK_ANSI_Z + 0x07 => KeyCode::KEY_X, // kVK_ANSI_X + 0x08 => KeyCode::KEY_C, // kVK_ANSI_C + 0x09 => KeyCode::KEY_V, // kVK_ANSI_V + 0x0B => KeyCode::KEY_B, // kVK_ANSI_B + 0x0C => KeyCode::KEY_Q, // kVK_ANSI_Q + 0x0D => KeyCode::KEY_W, // kVK_ANSI_W + 0x0E => KeyCode::KEY_E, // kVK_ANSI_E + 0x0F => KeyCode::KEY_R, // kVK_ANSI_R + 0x10 => KeyCode::KEY_Y, // kVK_ANSI_Y + 0x11 => KeyCode::KEY_T, // kVK_ANSI_T + 0x12 => KeyCode::KEY_1, // kVK_ANSI_1 + 0x13 => KeyCode::KEY_2, // kVK_ANSI_2 + 0x14 => KeyCode::KEY_3, // kVK_ANSI_3 + 0x15 => KeyCode::KEY_4, // kVK_ANSI_4 + 0x16 => KeyCode::KEY_6, // kVK_ANSI_6 + 0x17 => KeyCode::KEY_5, // kVK_ANSI_5 + 0x18 => KeyCode::KEY_EQUAL, // kVK_ANSI_Equal + 0x19 => KeyCode::KEY_9, // kVK_ANSI_9 + 0x1A => KeyCode::KEY_7, // kVK_ANSI_7 + 0x1B => KeyCode::KEY_MINUS, // kVK_ANSI_Minus + 0x1C => KeyCode::KEY_8, // kVK_ANSI_8 + 0x1D => KeyCode::KEY_0, // kVK_ANSI_0 + 0x1E => KeyCode::KEY_RIGHTBRACE, // kVK_ANSI_RightBracket + 0x1F => KeyCode::KEY_O, // kVK_ANSI_O + 0x20 => KeyCode::KEY_U, // kVK_ANSI_U + 0x21 => KeyCode::KEY_LEFTBRACE, // kVK_ANSI_LeftBracket + 0x22 => KeyCode::KEY_I, // kVK_ANSI_I + 0x23 => KeyCode::KEY_P, // kVK_ANSI_P + 0x24 => KeyCode::KEY_ENTER, // kVK_Return + 0x25 => KeyCode::KEY_L, // kVK_ANSI_L + 0x26 => KeyCode::KEY_J, // kVK_ANSI_J + 0x27 => KeyCode::KEY_APOSTROPHE, // kVK_ANSI_Quote + 0x28 => KeyCode::KEY_K, // kVK_ANSI_K + 0x29 => KeyCode::KEY_SEMICOLON, // kVK_ANSI_Semicolon + 0x2A => KeyCode::KEY_BACKSLASH, // kVK_ANSI_Backslash + 0x2B => KeyCode::KEY_COMMA, // kVK_ANSI_Comma + 0x2C => KeyCode::KEY_SLASH, // kVK_ANSI_Slash + 0x2D => KeyCode::KEY_N, // kVK_ANSI_N + 0x2E => KeyCode::KEY_M, // kVK_ANSI_M + 0x2F => KeyCode::KEY_DOT, // kVK_ANSI_Period + 0x30 => KeyCode::KEY_TAB, // kVK_Tab + 0x31 => KeyCode::KEY_SPACE, // kVK_Space + 0x32 => KeyCode::KEY_GRAVE, // kVK_ANSI_Grave + 0x33 => KeyCode::KEY_BACKSPACE, // kVK_Delete (= Backspace on macOS) + 0x35 => KeyCode::KEY_ESC, // kVK_Escape + 0x60 => KeyCode::KEY_F5, // kVK_F5 + 0x61 => KeyCode::KEY_F6, // kVK_F6 + 0x62 => KeyCode::KEY_F7, // kVK_F7 + 0x63 => KeyCode::KEY_F3, // kVK_F3 + 0x64 => KeyCode::KEY_F8, // kVK_F8 + 0x65 => KeyCode::KEY_F9, // kVK_F9 + 0x67 => KeyCode::KEY_F11, // kVK_F11 + 0x6D => KeyCode::KEY_F10, // kVK_F10 + 0x6F => KeyCode::KEY_F12, // kVK_F12 + 0x76 => KeyCode::KEY_F4, // kVK_F4 + 0x78 => KeyCode::KEY_F2, // kVK_F2 + 0x7A => KeyCode::KEY_F1, // kVK_F1 + 0x73 => KeyCode::KEY_HOME, // kVK_Home + 0x77 => KeyCode::KEY_END, // kVK_End + 0x74 => KeyCode::KEY_PAGEUP, // kVK_PageUp + 0x79 => KeyCode::KEY_PAGEDOWN, // kVK_PageDown + 0x75 => KeyCode::KEY_DELETE, // kVK_ForwardDelete + 0x7B => KeyCode::KEY_LEFT, // kVK_LeftArrow + 0x7C => KeyCode::KEY_RIGHT, // kVK_RightArrow + 0x7D => KeyCode::KEY_DOWN, // kVK_DownArrow + 0x7E => KeyCode::KEY_UP, // kVK_UpArrow + _ => return None, + }) +} + +// ── D-Bus helpers ──────────────────────────────────────────────────────── + +static SESSION_BUS: LazyLock> = LazyLock::new(|| { + DbusConn::session() + .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}")) + .ok() +}); + +static SYSTEM_BUS: LazyLock> = LazyLock::new(|| { + DbusConn::system() + .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}")) + .ok() +}); + +/// Lock the screen via logind `LockSession($XDG_SESSION_ID)` on the system +/// bus, falling back to Super+L. +/// +/// Only the session identified by `$XDG_SESSION_ID` is locked; if the +/// variable is unset the D-Bus path is skipped entirely to avoid locking +/// all sessions on the machine. Super+L covers non-systemd systems and the +/// no-session-id case. +pub(super) fn lock_screen() { + if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) { + match conn.call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + "LockSession", + &(id.as_str(),), + ) { + Ok(_) => { + tracing::debug!("LockScreen via logind"); + return; + } + Err(e) => tracing::warn!("logind LockSession failed: {e}"), + } + } + // Super+L is the standard lock shortcut on GNOME and KDE. + tracing::debug!("LockScreen via Super+L key combo"); + press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L); +} + +/// Send `command` to the first MPRIS-capable media player on the session bus, +/// falling back to the corresponding XF86 multimedia key only if no MPRIS +/// player is found. When a player is found but the call fails, the fallback +/// is suppressed to avoid double-toggling (the player likely handles the +/// XF86 key too). +pub(super) fn mpris_command(command: &str) { + if try_mpris_command(command).is_none() { + let fallback = match command { + "PlayPause" => KeyCode::KEY_PLAYPAUSE, + "Next" => KeyCode::KEY_NEXTSONG, + "Previous" => KeyCode::KEY_PREVIOUSSONG, + _ => return, + }; + press_key(&[], fallback); + } +} + +fn try_mpris_command(command: &str) -> Option<()> { + let conn = SESSION_BUS.as_ref()?; + let reply = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .ok()?; + let names = reply.body().deserialize::>().ok()?; + let Some(player) = names + .iter() + .find(|n| n.starts_with("org.mpris.MediaPlayer2.")) + else { + tracing::debug!("no MPRIS player found — {command} via XF86 key fallback"); + return None; + }; + match conn.call_method( + Some(player.as_str()), + "/org/mpris/MediaPlayer2", + Some("org.mpris.MediaPlayer2.Player"), + command, + &(), + ) { + Ok(_) => { + tracing::debug!("MPRIS {command} via {player}"); + Some(()) + } + Err(e) => { + // Player was identified — suppress XF86 fallback to avoid + // double-toggling if the player also handles multimedia keys. + tracing::warn!("MPRIS {command} on {player} failed: {e}"); + Some(()) + } + } +} diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs new file mode 100644 index 00000000..4a85d7d8 --- /dev/null +++ b/crates/openlogi-inject/src/inject/macos.rs @@ -0,0 +1,438 @@ +//! Platform helpers for synthesising OS-level input events on macOS. + +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField, + ScrollEventUnit, +}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use core_graphics::geometry::CGPoint; + +use openlogi_core::binding::Action; + +// NX_KEYTYPE_* constants from . +pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0; +pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1; +pub(super) const NX_KEYTYPE_MUTE: i32 = 7; +pub(super) const NX_KEYTYPE_PLAY: i32 = 16; +pub(super) const NX_KEYTYPE_NEXT: i32 = 17; +pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18; + +/// Post a mouse-down + mouse-up pair for `button` at the cursor's current +/// location. +/// +/// Posted at the HID tap location, so OpenLogi's own event tap sees the +/// synthetic click too: a `LeftClick`/`RightClick` flows straight through +/// (the tap never owns the primary buttons), and a `MiddleClick` is left +/// alone unless the user has *also* remapped the middle button. +pub(super) fn post_click(button: CGMouseButton) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for click"); + return; + }; + // A fresh event reports the current pointer location; mouse events need + // an explicit position or they land at (0, 0). + let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); + let (down, up) = match button { + CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp), + CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp), + CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp), + }; + for (kind, phase) in [(down, "down"), (up, "up")] { + if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) { + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); + } else { + tracing::warn!(phase, "CGEvent::new_mouse_event failed"); + } + } +} + +/// Post a down + up pair for an "extra" mouse button by its raw button +/// number (3 = back / "button 4", 4 = forward / "button 5"). These are the +/// native events browsers and most apps interpret as back/forward. +/// +/// `CGMouseButton` only names Left/Right/Center, so we create an +/// `OtherMouse` event and override `MOUSE_EVENT_BUTTON_NUMBER` to address +/// buttons ≥ 3. Tagged via [`tag_synthetic`] so OpenLogi's own event tap +/// ignores it instead of re-translating it into a Back/Forward press. +pub(super) fn post_other_button(button_number: i64) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for extra mouse button"); + return; + }; + let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); + for (kind, phase) in [ + (CGEventType::OtherMouseDown, "down"), + (CGEventType::OtherMouseUp, "up"), + ] { + if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, CGMouseButton::Center) + { + ev.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, button_number); + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); + } else { + tracing::warn!(phase, "CGEvent::new_mouse_event failed for extra button"); + } + } +} + +/// Stamp [`SYNTHETIC_EVENT_USER_DATA`](super::SYNTHETIC_EVENT_USER_DATA) +/// into the event's source user-data so OpenLogi's own event tap recognises +/// and skips its own injections instead of treating them as fresh input +/// (e.g. re-translating a synthesized button 4/5 into a Back/Forward press, +/// or misreading a remapped click as a new gesture hold). +fn tag_synthetic(ev: &CGEvent) { + ev.set_integer_value_field( + EventField::EVENT_SOURCE_USER_DATA, + super::SYNTHETIC_EVENT_USER_DATA, + ); +} + +/// Post a key-down + key-up pair for `vk` with `flags` set. +pub(super) fn post_key(vk: u16, flags: CGEventFlags) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed"); + return; + }; + let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { + tracing::warn!("CGEvent::new_keyboard_event(down) failed"); + return; + }; + down.set_flags(flags); + down.post(CGEventTapLocation::HID); + let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { + tracing::warn!("CGEvent::new_keyboard_event(up) failed"); + return; + }; + up.set_flags(flags); + up.post(CGEventTapLocation::HID); +} + +/// Post a media/system key event (play/pause, track navigation, volume). +/// +/// Runs on the hook/gesture dispatch threads, which have no run loop to +/// drain autorelease pools, and both `NSEvent` creation and the `CGEvent` +/// getter autorelease temporaries — so the exchange sits inside an +/// explicit `autoreleasepool`, same as the hook's `frontmost_bundle_id`. +pub(super) fn post_media_key(nx_key: i32) { + use objc2::rc::autoreleasepool; + use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType}; + use objc2_core_graphics::{CGEvent, CGEventTapLocation}; + use objc2_foundation::NSPoint; + + const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8; + const NX_KEY_DOWN: i32 = 0x0A; + const NX_KEY_UP: i32 = 0x0B; + + autoreleasepool(|_| { + for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] { + // data1 layout for subtype 8: high word is NX_KEYTYPE_*, next byte + // is key state (0x0A down, 0x0B up), low bit is repeat (0 here). + let data1 = ((nx_key << 16) | (state << 8)) as isize; + let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( + NSEventType::SystemDefined, + NSPoint::new(0.0, 0.0), + NSEventModifierFlags::empty(), + 0.0, + 0, + None, + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + data1, + 0, + ) else { + tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed"); + return; + }; + let Some(cg_event) = ns_event.CGEvent() else { + tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed"); + return; + }; + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event)); + } + }); +} + +/// Post a synthetic scroll event for `action` (one of the `Scroll*` variants). +pub(super) fn post_scroll(action: &Action) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for scroll"); + return; + }; + let (v, h): (i32, i32) = match action { + Action::ScrollUp => (3, 0), + Action::ScrollDown => (-3, 0), + Action::HorizontalScrollLeft => (0, -3), + Action::HorizontalScrollRight => (0, 3), + _ => return, + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed"); + return; + }; + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); +} + +/// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit +/// the thumb wheel's ratchet-like increments better than pixels. +pub(super) fn post_horizontal_scroll(delta: i32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for thumbwheel scroll"); + return; + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel"); + return; + }; + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); +} + +pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop}; +pub(super) use symbolic_hotkey::{next_desktop, previous_desktop}; + +use app_services::symbol as app_services_symbol; + +/// Shared resolver for private ApplicationServices SPI used by the Dock and +/// symbolic-hotkey helpers. +#[allow( + unsafe_code, + reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI" +)] +mod app_services { + use std::ffi::{CStr, c_char, c_int, c_void}; + use std::sync::OnceLock; + + /// Resolve a symbol from ApplicationServices, caching the `dlopen` + /// handle for the process lifetime. Returns `None` if the framework or + /// symbol is unavailable on this macOS version. + pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> { + const RTLD_LAZY: c_int = 0x1; + const APP_SERVICES: &CStr = + c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; + static HANDLE: OnceLock = OnceLock::new(); + + // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and + // `symbol` are valid C strings. The handle is cached and + // intentionally never closed. + let sym = unsafe { + let handle = *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize); + if handle == 0 { + return None; + } + dlsym(handle as *mut c_void, symbol.as_ptr()) + }; + (!sym.is_null()).then_some(sym) + } + + unsafe extern "C" { + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + } +} + +/// WindowServer window/space actions (Mission Control, App Exposé, Show +/// Desktop, Launchpad). +/// +/// These are driven by the Dock, and synthesising their keyboard shortcut is +/// unreliable — the WindowServer matcher needs the exact configured key +/// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So +/// we post the action straight to the Dock via the private +/// `CoreDockSendNotification` SPI, which fires it regardless of the user's +/// Keyboard settings. +/// +/// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI +/// needs is scoped here rather than spread across the platform helpers. +#[allow( + unsafe_code, + reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI" +)] +mod dock { + use std::ffi::{c_int, c_void}; + + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + + use super::app_services_symbol; + + /// Show all windows across spaces (Mission Control). + pub(crate) fn mission_control() { + send("com.apple.expose.awake"); + } + + /// Show the front app's windows (App Exposé). + pub(crate) fn app_expose() { + send("com.apple.expose.front.awake"); + } + + /// Move all windows aside to reveal the desktop. + pub(crate) fn show_desktop() { + send("com.apple.showdesktop.awake"); + } + + /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad. + pub(crate) fn launchpad() { + send("com.apple.launchpad.toggle"); + } + + /// Post `notification` to the Dock. Logs and returns on any failure. + fn send(notification: &str) { + let Some(core_dock_send) = core_dock_send_notification() else { + tracing::warn!(notification, "CoreDockSendNotification unavailable"); + return; + }; + let name = CFString::new(notification); + // SAFETY: resolved AppServices symbol called with its documented + // signature; `name` is a live CFString for the call's duration. + let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) }; + if err != 0 { + tracing::warn!(notification, err, "CoreDockSendNotification failed"); + } + } + + type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int; + + /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching + /// the `dlopen` handle for the process lifetime. `None` if unavailable. + fn core_dock_send_notification() -> Option { + let sym = app_services_symbol(c"CoreDockSendNotification")?; + // SAFETY: the symbol, when present, has the documented signature. + Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) }) + } +} + +/// macOS Space switching actions. +/// +/// Use the system symbolic hotkey records for "Move left a space" (79) and +/// "Move right a space" (81). That respects the user's configured shortcut +/// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic +/// hotkey when the user has disabled it. +#[allow( + unsafe_code, + reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI" +)] +mod symbolic_hotkey { + use std::ffi::{c_int, c_uint, c_ushort, c_void}; + + use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation}; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + + use super::app_services_symbol; + + const SPACE_LEFT: u32 = 79; + const SPACE_RIGHT: u32 = 81; + + /// Switch to the previous desktop / Space. + pub(crate) fn previous_desktop() { + post_symbolic_hotkey(SPACE_LEFT); + } + + /// Switch to the next desktop / Space. + pub(crate) fn next_desktop() { + post_symbolic_hotkey(SPACE_RIGHT); + } + + fn post_symbolic_hotkey(hotkey: u32) { + let Some(cgs) = cgs_hotkey_api() else { + tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable"); + return; + }; + + let mut key_equivalent = 0_u16; + let mut virtual_key = 0_u16; + let mut modifiers = 0_u32; + + // SAFETY: resolved AppServices symbols are called with their + // expected signatures and valid out-parameters. + let err = unsafe { + (cgs.get_value)( + hotkey, + &raw mut key_equivalent, + &raw mut virtual_key, + &raw mut modifiers, + ) + }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed"); + return; + } + + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let was_enabled = unsafe { (cgs.is_enabled)(hotkey) }; + if !was_enabled { + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let err = unsafe { (cgs.set_enabled)(hotkey, true) }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed"); + } + } + + post_key(virtual_key, modifiers); + + if !was_enabled { + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let err = unsafe { (cgs.set_enabled)(hotkey, false) }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed"); + } + } + } + + fn post_key(vk: u16, modifiers: u32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for symbolic hotkey"); + return; + }; + let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { + tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed"); + return; + }; + let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers)); + down.set_flags(flags); + down.post(CGEventTapLocation::Session); + + let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { + tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed"); + return; + }; + up.set_flags(flags); + up.post(CGEventTapLocation::Session); + } + + #[derive(Clone, Copy)] + struct CgsHotkeyApi { + get_value: CgsGetSymbolicHotKeyValueFn, + is_enabled: CgsIsSymbolicHotKeyEnabledFn, + set_enabled: CgsSetSymbolicHotKeyEnabledFn, + } + + type CgsGetSymbolicHotKeyValueFn = + unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int; + type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool; + type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int; + + fn cgs_hotkey_api() -> Option { + let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?; + let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?; + let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?; + + // SAFETY: the symbols, when present, have the private SPI + // signatures declared above. + Some(unsafe { + CgsHotkeyApi { + get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>( + get_value, + ), + is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>( + is_enabled, + ), + set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>( + set_enabled, + ), + } + }) + } +} diff --git a/crates/openlogi-inject/src/inject/windows.rs b/crates/openlogi-inject/src/inject/windows.rs new file mode 100644 index 00000000..377d295f --- /dev/null +++ b/crates/openlogi-inject/src/inject/windows.rs @@ -0,0 +1,197 @@ +#![allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")] + +use std::mem::size_of; + +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, MOUSEEVENTF_HWHEEL, + MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, + MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, + MOUSEEVENTF_XUP, MOUSEINPUT, SendInput, +}; + +use openlogi_core::binding::{Action, KeyCombo}; + +const WHEEL_DELTA: i32 = 120; + +pub(super) const VK_A: u16 = 0x41; +pub(super) const VK_C: u16 = 0x43; +pub(super) const VK_D: u16 = 0x44; +pub(super) const VK_F: u16 = 0x46; +pub(super) const VK_L: u16 = 0x4C; +pub(super) const VK_R: u16 = 0x52; +pub(super) const VK_S: u16 = 0x53; +pub(super) const VK_T: u16 = 0x54; +pub(super) const VK_V: u16 = 0x56; +pub(super) const VK_W: u16 = 0x57; +pub(super) const VK_X: u16 = 0x58; +pub(super) const VK_Y: u16 = 0x59; +pub(super) const VK_Z: u16 = 0x5A; +pub(super) const VK_TAB: u16 = 0x09; +pub(super) const VK_LEFT: u16 = 0x25; +pub(super) const VK_RIGHT: u16 = 0x27; +pub(super) const VK_SHIFT: u16 = 0x10; +pub(super) const VK_CONTROL: u16 = 0x11; +pub(super) const VK_MENU: u16 = 0x12; +pub(super) const VK_LWIN: u16 = 0x5B; +pub(super) const VK_BROWSER_BACK: u16 = 0xA6; +pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7; +pub(super) const VK_VOLUME_MUTE: u16 = 0xAD; +pub(super) const VK_VOLUME_DOWN: u16 = 0xAE; +pub(super) const VK_VOLUME_UP: u16 = 0xAF; +pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0; +pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1; +pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3; + +#[derive(Clone, Copy)] +pub(super) enum MouseButton { + Left, + Right, + Middle, + /// Extra button 4 ("back"). + Back, + /// Extra button 5 ("forward"). + Forward, +} + +// XBUTTON1/XBUTTON2 from WinUser.h — windows-sys puts them behind the +// Win32_UI_WindowsAndMessaging feature; not worth enabling for two +// integers (same treatment as the VK_* codes above). +const XBUTTON1: i32 = 1; +const XBUTTON2: i32 = 2; + +pub(super) fn post_click(button: MouseButton) { + let (down, up, data) = match button { + MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0), + MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0), + MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0), + // Extra buttons share the X flag pair; mouseData carries which one. + MouseButton::Back => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON1), + MouseButton::Forward => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON2), + }; + send_inputs(&[mouse_input(down, data), mouse_input(up, data)]); +} + +pub(super) fn post_key(vk: u16, modifiers: &[u16]) { + let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2); + for modifier in modifiers { + inputs.push(key_input(*modifier, false)); + } + inputs.push(key_input(vk, false)); + inputs.push(key_input(vk, true)); + for modifier in modifiers.iter().rev() { + inputs.push(key_input(*modifier, true)); + } + send_inputs(&inputs); +} + +pub(super) fn post_scroll(action: &Action) { + let (flags, data) = match action { + Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA), + Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA), + Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA), + Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA), + _ => return, + }; + send_inputs(&[mouse_input(flags, data)]); +} + +pub(super) fn post_horizontal_scroll(delta: i32) { + if delta == 0 { + return; + } + send_inputs(&[mouse_input( + MOUSEEVENTF_HWHEEL, + delta.saturating_mul(WHEEL_DELTA), + )]); +} + +pub(super) fn post_custom_shortcut(combo: &KeyCombo) { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "CustomShortcut with no key code; press ignored" + ); + return; + } + let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + chord = %combo.rendered_label(), + "CustomShortcut key has no Windows mapping yet; press ignored" + ); + return; + }; + + let mut modifiers = Vec::new(); + if combo.modifiers & KeyCombo::MOD_CMD != 0 { + modifiers.push(VK_CONTROL); + } + if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { + modifiers.push(VK_SHIFT); + } + if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) { + modifiers.push(VK_CONTROL); + } + if combo.modifiers & KeyCombo::MOD_OPTION != 0 { + modifiers.push(VK_MENU); + } + post_key(vk, &modifiers); +} + +fn send_inputs(inputs: &[INPUT]) { + let Ok(input_count) = u32::try_from(inputs.len()) else { + tracing::warn!( + requested = inputs.len(), + "too many SendInput events requested" + ); + return; + }; + let Ok(input_size) = i32::try_from(size_of::()) else { + tracing::warn!("INPUT size does not fit the Win32 SendInput contract"); + return; + }; + // SAFETY: inputs.as_ptr()/input_count describe a valid initialized INPUT slice; SendInput copies it and returns the count injected. + let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) }; + if sent != input_count { + tracing::warn!( + requested = inputs.len(), + sent, + "SendInput accepted fewer events than requested" + ); + } +} + +fn key_input(vk: u16, key_up: bool) -> INPUT { + let mut flags = 0; + if key_up { + flags |= KEYEVENTF_KEYUP; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} + +fn mouse_input(flags: u32, data: i32) -> INPUT { + INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: u32::from_ne_bytes(data.to_ne_bytes()), + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } +} From bb88de80ec69387375208346448aa5396ae41d3d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:21:31 +0800 Subject: [PATCH 09/17] refactor(gui,cli): rename semantics-carrying mod.rs modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asset/mod.rs, windows/mod.rs, and cmd/diag/mod.rs all carry real logic (the asset resolver, the window registry, the diag device-selection policy) — per the module-layout rule they are foo.rs files with their children in the sibling directory. Pure renames. --- crates/openlogi-cli/src/cmd/{diag/mod.rs => diag.rs} | 0 crates/openlogi-gui/src/{asset/mod.rs => asset.rs} | 0 crates/openlogi-gui/src/{windows/mod.rs => windows.rs} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename crates/openlogi-cli/src/cmd/{diag/mod.rs => diag.rs} (100%) rename crates/openlogi-gui/src/{asset/mod.rs => asset.rs} (100%) rename crates/openlogi-gui/src/{windows/mod.rs => windows.rs} (100%) diff --git a/crates/openlogi-cli/src/cmd/diag/mod.rs b/crates/openlogi-cli/src/cmd/diag.rs similarity index 100% rename from crates/openlogi-cli/src/cmd/diag/mod.rs rename to crates/openlogi-cli/src/cmd/diag.rs diff --git a/crates/openlogi-gui/src/asset/mod.rs b/crates/openlogi-gui/src/asset.rs similarity index 100% rename from crates/openlogi-gui/src/asset/mod.rs rename to crates/openlogi-gui/src/asset.rs diff --git a/crates/openlogi-gui/src/windows/mod.rs b/crates/openlogi-gui/src/windows.rs similarity index 100% rename from crates/openlogi-gui/src/windows/mod.rs rename to crates/openlogi-gui/src/windows.rs From 57ed1b3d49b392ac61b63ffeddef88185b9d196a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 10:26:33 +0800 Subject: [PATCH 10/17] refactor(gui): extract the pacing module and drop tiny indirections The ipc_client's inline pacing module (with its own tests) becomes a file; the duplicated file_url helper collapses to one copy; two one-line single-caller wrappers in app.rs are inlined; the hotspot geometry sorts with total_cmp instead of NaN-tolerant unwrap_or. --- crates/openlogi-gui/src/app.rs | 24 +-- crates/openlogi-gui/src/app_menu.rs | 2 +- crates/openlogi-gui/src/ipc_client.rs | 170 +----------------- crates/openlogi-gui/src/ipc_client/pacing.rs | 167 +++++++++++++++++ .../openlogi-gui/src/mouse_model/geometry.rs | 10 +- 5 files changed, 179 insertions(+), 194 deletions(-) create mode 100644 crates/openlogi-gui/src/ipc_client/pacing.rs diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 2a666225..cc089b7a 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -22,11 +22,10 @@ use openlogi_core::device::{ }; use openlogi_hid::DeviceRoute; use tracing::info; -use url::Url; use openlogi_agent_core::ipc::InventoryHealth; -use crate::app_menu::{CloseWindow, Minimize, Zoom}; +use crate::app_menu::{CloseWindow, Minimize, Zoom, file_url}; use crate::asset::{AssetResolver, GlowGeometry}; use crate::components::carousel::Carousel; use crate::components::dpi_panel::DpiPanel; @@ -997,7 +996,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)), + ), ) } @@ -1007,10 +1011,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. @@ -1366,7 +1366,7 @@ fn battery_summary(battery: &BatteryInfo, pal: Palette) -> impl IntoElement { .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))), ), @@ -1468,14 +1468,6 @@ fn battery_color(percentage: u8) -> u32 { } } -fn relative_percent(value: u8) -> gpui::DefiniteLength { - relative(f32::from(value.clamp(1, 100)) / 100.) -} - -fn file_url(path: &std::path::Path) -> Option { - Url::from_file_path(path).ok().map(Into::into) -} - /// Centered spinner over a muted one-line caption — the quiet "still working" /// body shared by the pre-connection frame and the scanning state, so the two /// loading phases render as one continuous frame with only the caption diff --git a/crates/openlogi-gui/src/app_menu.rs b/crates/openlogi-gui/src/app_menu.rs index 3346238f..7530edb7 100644 --- a/crates/openlogi-gui/src/app_menu.rs +++ b/crates/openlogi-gui/src/app_menu.rs @@ -232,6 +232,6 @@ fn device_menu_items(cx: &App) -> Vec { items } -fn file_url(path: &std::path::Path) -> Option { +pub(crate) fn file_url(path: &std::path::Path) -> Option { Url::from_file_path(path).ok().map(Into::into) } diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index e956cbe2..35c6386b 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -264,175 +264,7 @@ fn ticker(first_in: Option, period: Duration) -> tokio::time::Interval /// /// Pure bookkeeping — the caller maps [`Cadence`] switches onto its timer — /// so the transitions are unit-testable. -mod pacing { - use std::time::{Duration, Instant}; - - /// Which poll period the loop should run on. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum Cadence { - /// `STARTUP_POLL_PERIOD` — converging on a fresh agent. - Fast, - /// The configured steady `poll_period`. - Steady, - } - - pub struct Pacing { - steady_period: Duration, - fast_cap: Duration, - mode: Cadence, - /// When the current fast phase began (valid while `mode == Fast`). - fast_since: Instant, - /// The fast phase expired without readiness. Cleared by readiness, a - /// disconnect, or the first delivery after an outage — each starts a - /// genuinely new episode that deserves a fresh fast phase. - capped: bool, - /// Whether the previous tick delivered a snapshot, so the first - /// delivery after an outage is recognizable. - was_delivering: bool, - } - - impl Pacing { - pub fn new(steady_period: Duration, fast_cap: Duration, now: Instant) -> Self { - Self { - steady_period, - fast_cap, - mode: Cadence::Fast, - fast_since: now, - capped: false, - was_delivering: false, - } - } - - pub fn steady_period(&self) -> Duration { - self.steady_period - } - - /// A snapshot was delivered. Ready → steady; not ready → fast until - /// the cap, then steady. - pub fn on_delivered(&mut self, ready: bool, now: Instant) -> Option { - if !self.was_delivering { - // First delivery after an outage: a just-(re)started agent - // deserves a fresh fast phase regardless of how the outage - // episode ended. - self.capped = false; - self.fast_since = now; - } - self.was_delivering = true; - if ready { - self.capped = false; - return self.switch(Cadence::Steady, now); - } - if self.capped || self.expired(now) { - self.capped = true; - return self.switch(Cadence::Steady, now); - } - self.switch(Cadence::Fast, now) - } - - /// No agent reachable this tick (and no live connection to lose). - pub fn on_unreachable(&mut self, now: Instant) -> Option { - self.was_delivering = false; - if self.capped || self.expired(now) { - self.capped = true; - return self.switch(Cadence::Steady, now); - } - None - } - - /// A live connection dropped — re-converge fast, fresh phase. - pub fn on_disconnect(&mut self, now: Instant) -> Option { - self.was_delivering = false; - self.capped = false; - self.switch(Cadence::Fast, now) - } - - /// The agent speaks a newer protocol: only a GUI relaunch resolves - /// it, so fast polling buys nothing. - pub fn on_newer_agent(&mut self, now: Instant) -> Option { - self.was_delivering = false; - self.capped = true; - self.switch(Cadence::Steady, now) - } - - fn expired(&self, now: Instant) -> bool { - self.mode == Cadence::Fast && now.duration_since(self.fast_since) >= self.fast_cap - } - - fn switch(&mut self, to: Cadence, now: Instant) -> Option { - if self.mode == to { - return None; - } - if to == Cadence::Fast { - self.fast_since = now; - } - self.mode = to; - Some(to) - } - } - - #[cfg(test)] - mod tests { - use super::{Cadence, Pacing}; - use std::time::{Duration, Instant}; - - const STEADY: Duration = Duration::from_secs(2); - const CAP: Duration = Duration::from_secs(15); - - fn pacing(now: Instant) -> Pacing { - Pacing::new(STEADY, CAP, now) - } - - #[test] - fn readiness_settles_to_steady_and_disconnect_rearms_fast() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_delivered(false, t0), None); // already fast - assert_eq!(p.on_delivered(true, t0), Some(Cadence::Steady)); - assert_eq!(p.on_delivered(true, t0 + STEADY), None); - assert_eq!(p.on_disconnect(t0 + STEADY * 2), Some(Cadence::Fast)); - } - - #[test] - fn never_ready_falls_back_to_steady_after_the_cap() { - let t0 = Instant::now(); - let mut p = pacing(t0); - // The first delivery opens the fast phase; the cap counts from it. - assert_eq!(p.on_delivered(false, t0), None); - assert_eq!(p.on_delivered(false, t0 + CAP / 2), None); - assert_eq!(p.on_delivered(false, t0 + CAP), Some(Cadence::Steady)); - // Capped: further not-ready deliveries stay steady. - assert_eq!(p.on_delivered(false, t0 + CAP + STEADY), None); - // …but readiness still lands (and stays steady). - assert_eq!(p.on_delivered(true, t0 + CAP + STEADY * 2), None); - } - - #[test] - fn unreachable_episode_caps_and_a_new_agent_gets_a_fresh_fast_phase() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_unreachable(t0 + Duration::from_secs(1)), None); - assert_eq!(p.on_unreachable(t0 + CAP), Some(Cadence::Steady)); - // An agent finally comes up, still scanning: fresh fast phase - // despite the cap from the outage episode. - assert_eq!( - p.on_delivered(false, t0 + CAP + STEADY), - Some(Cadence::Fast) - ); - assert_eq!( - p.on_delivered(true, t0 + CAP + STEADY * 2), - Some(Cadence::Steady) - ); - } - - #[test] - fn newer_agent_goes_steady_immediately() { - let t0 = Instant::now(); - let mut p = pacing(t0); - assert_eq!(p.on_newer_agent(t0), Some(Cadence::Steady)); - assert_eq!(p.on_unreachable(t0 + STEADY), None); // stays steady - } - } -} +mod pacing; /// Long-poll the agent's pairing event stream on a dedicated connection, pushing /// each [`PairingUpdate`] to the GUI. Runs for the client's lifetime; when no diff --git a/crates/openlogi-gui/src/ipc_client/pacing.rs b/crates/openlogi-gui/src/ipc_client/pacing.rs new file mode 100644 index 00000000..887e0915 --- /dev/null +++ b/crates/openlogi-gui/src/ipc_client/pacing.rs @@ -0,0 +1,167 @@ +use std::time::{Duration, Instant}; + +/// Which poll period the loop should run on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cadence { + /// `STARTUP_POLL_PERIOD` — converging on a fresh agent. + Fast, + /// The configured steady `poll_period`. + Steady, +} + +pub struct Pacing { + steady_period: Duration, + fast_cap: Duration, + mode: Cadence, + /// When the current fast phase began (valid while `mode == Fast`). + fast_since: Instant, + /// The fast phase expired without readiness. Cleared by readiness, a + /// disconnect, or the first delivery after an outage — each starts a + /// genuinely new episode that deserves a fresh fast phase. + capped: bool, + /// Whether the previous tick delivered a snapshot, so the first + /// delivery after an outage is recognizable. + was_delivering: bool, +} + +impl Pacing { + pub fn new(steady_period: Duration, fast_cap: Duration, now: Instant) -> Self { + Self { + steady_period, + fast_cap, + mode: Cadence::Fast, + fast_since: now, + capped: false, + was_delivering: false, + } + } + + pub fn steady_period(&self) -> Duration { + self.steady_period + } + + /// A snapshot was delivered. Ready → steady; not ready → fast until + /// the cap, then steady. + pub fn on_delivered(&mut self, ready: bool, now: Instant) -> Option { + if !self.was_delivering { + // First delivery after an outage: a just-(re)started agent + // deserves a fresh fast phase regardless of how the outage + // episode ended. + self.capped = false; + self.fast_since = now; + } + self.was_delivering = true; + if ready { + self.capped = false; + return self.switch(Cadence::Steady, now); + } + if self.capped || self.expired(now) { + self.capped = true; + return self.switch(Cadence::Steady, now); + } + self.switch(Cadence::Fast, now) + } + + /// No agent reachable this tick (and no live connection to lose). + pub fn on_unreachable(&mut self, now: Instant) -> Option { + self.was_delivering = false; + if self.capped || self.expired(now) { + self.capped = true; + return self.switch(Cadence::Steady, now); + } + None + } + + /// A live connection dropped — re-converge fast, fresh phase. + pub fn on_disconnect(&mut self, now: Instant) -> Option { + self.was_delivering = false; + self.capped = false; + self.switch(Cadence::Fast, now) + } + + /// The agent speaks a newer protocol: only a GUI relaunch resolves + /// it, so fast polling buys nothing. + pub fn on_newer_agent(&mut self, now: Instant) -> Option { + self.was_delivering = false; + self.capped = true; + self.switch(Cadence::Steady, now) + } + + fn expired(&self, now: Instant) -> bool { + self.mode == Cadence::Fast && now.duration_since(self.fast_since) >= self.fast_cap + } + + fn switch(&mut self, to: Cadence, now: Instant) -> Option { + if self.mode == to { + return None; + } + if to == Cadence::Fast { + self.fast_since = now; + } + self.mode = to; + Some(to) + } +} + +#[cfg(test)] +mod tests { + use super::{Cadence, Pacing}; + use std::time::{Duration, Instant}; + + const STEADY: Duration = Duration::from_secs(2); + const CAP: Duration = Duration::from_secs(15); + + fn pacing(now: Instant) -> Pacing { + Pacing::new(STEADY, CAP, now) + } + + #[test] + fn readiness_settles_to_steady_and_disconnect_rearms_fast() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_delivered(false, t0), None); // already fast + assert_eq!(p.on_delivered(true, t0), Some(Cadence::Steady)); + assert_eq!(p.on_delivered(true, t0 + STEADY), None); + assert_eq!(p.on_disconnect(t0 + STEADY * 2), Some(Cadence::Fast)); + } + + #[test] + fn never_ready_falls_back_to_steady_after_the_cap() { + let t0 = Instant::now(); + let mut p = pacing(t0); + // The first delivery opens the fast phase; the cap counts from it. + assert_eq!(p.on_delivered(false, t0), None); + assert_eq!(p.on_delivered(false, t0 + CAP / 2), None); + assert_eq!(p.on_delivered(false, t0 + CAP), Some(Cadence::Steady)); + // Capped: further not-ready deliveries stay steady. + assert_eq!(p.on_delivered(false, t0 + CAP + STEADY), None); + // …but readiness still lands (and stays steady). + assert_eq!(p.on_delivered(true, t0 + CAP + STEADY * 2), None); + } + + #[test] + fn unreachable_episode_caps_and_a_new_agent_gets_a_fresh_fast_phase() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_unreachable(t0 + Duration::from_secs(1)), None); + assert_eq!(p.on_unreachable(t0 + CAP), Some(Cadence::Steady)); + // An agent finally comes up, still scanning: fresh fast phase + // despite the cap from the outage episode. + assert_eq!( + p.on_delivered(false, t0 + CAP + STEADY), + Some(Cadence::Fast) + ); + assert_eq!( + p.on_delivered(true, t0 + CAP + STEADY * 2), + Some(Cadence::Steady) + ); + } + + #[test] + fn newer_agent_goes_steady_immediately() { + let t0 = Instant::now(); + let mut p = pacing(t0); + assert_eq!(p.on_newer_agent(t0), Some(Cadence::Steady)); + assert_eq!(p.on_unreachable(t0 + STEADY), None); // stays steady + } +} diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs index ec709edc..7ad9b4ed 100644 --- a/crates/openlogi-gui/src/mouse_model/geometry.rs +++ b/crates/openlogi-gui/src/mouse_model/geometry.rs @@ -156,13 +156,7 @@ pub fn labels_from_hotspots(hotspots: &[Hotspot], mouse_h: f32) -> Vec