diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index b5e98729..6ce40c45 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -125,13 +125,20 @@ async fn run(config: Config) { // LaunchAgent, before `config` moves into the orchestrator. launch_agent::reconcile(config.app_settings.launch_at_login); + // Read the hook kill-switch before `config` moves into the orchestrator. + // Startup-only on purpose (like `show_in_menu_bar`): flipping it requires + // an agent restart, which the config docs state. + let capture_mouse_events = config.app_settings.capture_mouse_events; + // The agent owns the CGEventTap, so it must be the binary the user authorizes // for Accessibility. Fire the prompt at startup when we're not yet trusted so // openlogi-agent appears (named correctly) in System Settings even on a // launchd start with no GUI. macOS only shows the dialog when we're not // already in the list, so this doesn't nag on every login. The GUI's grant // button drives the same prompt over IPC (`request_accessibility_prompt`). - if !Hook::has_accessibility() { + // With the hook disabled the agent needs no Accessibility at all, so the + // opt-out also silences the permission prompt. + if capture_mouse_events && !Hook::has_accessibility() { Hook::prompt_accessibility(); } @@ -220,14 +227,21 @@ async fn run(config: Config) { hook_installed.store(false, Ordering::Relaxed); } if granted && hook.is_none() { - info!("accessibility granted — installing OS mouse hook"); - hook = hook_runtime::start( - shared.hook_maps.clone(), - shared.dpi_cycle.clone(), - shared.capture_channel.clone(), - Arc::clone(&event_monitor), - ); - hook_installed.store(hook.is_some(), Ordering::Relaxed); + if capture_mouse_events { + info!("accessibility granted — installing OS mouse hook"); + hook = hook_runtime::start( + shared.hook_maps.clone(), + shared.dpi_cycle.clone(), + shared.capture_channel.clone(), + Arc::clone(&event_monitor), + ); + hook_installed.store(hook.is_some(), Ordering::Relaxed); + } else { + info!( + "OS mouse hook disabled by app_settings.capture_mouse_events — \ + button remapping is off" + ); + } } } else => break, diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs index 1192c1d0..cc50d019 100644 --- a/crates/openlogi-core/src/config/settings.rs +++ b/crates/openlogi-core/src/config/settings.rs @@ -89,6 +89,21 @@ pub struct AppSettings { /// while a window is open). Ignored on Linux. #[serde(default = "default_true")] pub show_in_menu_bar: bool, + /// Whether the agent installs the OS-level mouse hook (CGEventTap / + /// exclusive `evdev` grab / `WH_MOUSE_LL`) that intercepts mouse events + /// for button remapping. `true` (default) keeps remapping active; + /// `false` is an escape hatch that leaves every input device untouched + /// (on Linux: no exclusive grabs at all; on macOS the agent also skips + /// the startup Accessibility prompt). HID++-side features — DPI, + /// SmartShift, the gesture button, the thumb wheel — are unaffected. + /// Takes effect on agent restart. + #[serde(default = "default_true")] + pub capture_mouse_events: bool, + /// Whether the GUI automatically downloads device images from + /// `assets.openlogi.org` when a device appears. `true` (default) keeps + /// the current behavior; `false` makes no asset network requests at all + /// (the app falls back to bundled art and the synthetic silhouette). A + /// manual "Refresh assets" in Settings still fetches on demand regardless. /// Whether the GUI automatically downloads device images from the selected /// source when a device appears. `true` (default) keeps the current behavior; /// `false` makes no asset network requests at all (the app falls back to @@ -160,6 +175,7 @@ impl Default for AppSettings { auto_install_updates: false, update_prompt_seen: false, show_in_menu_bar: true, + capture_mouse_events: true, auto_download_assets: true, asset_source: AssetSourcePreference::Automatic, language: None, @@ -172,8 +188,10 @@ impl Default for AppSettings { } } -/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar -/// icon is on out of the box and configs predating the field keep that behavior. +/// serde default for the on-by-default [`AppSettings`] toggles +/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`], +/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the +/// out-of-the-box behavior. fn default_true() -> bool { true } diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index 3e42e79a..959155a8 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -1,10 +1,17 @@ //! Linux `evdev` + `uinput` implementation of the OS-level mouse hook. //! -//! Each physical mouse found under `/dev/input/` is grabbed exclusively; -//! a paired `uinput` virtual device re-injects events the callback marks +//! Each physical mouse — a relative pointer with buttons — found under +//! `/dev/input/` is grabbed exclusively; a paired `uinput` virtual device +//! re-injects events the callback marks //! [`crate::EventDisposition::PassThrough`]. Events marked //! [`crate::EventDisposition::Suppress`] are consumed and never reach the desktop. //! +//! Touch-driven devices (touchpads, touchscreens) and pointing sticks are +//! never grabbed, even though they advertise mouse buttons: the virtual +//! device mirrors only keys and relative axes, so a grab would swallow their +//! multitouch `EV_ABS` stream (and the input properties libinput keys +//! behavior on) with no way to re-inject it — killing the built-in pointer. +//! //! # Permissions //! //! The process needs read access to `/dev/input/eventN` (typically the `input` @@ -21,7 +28,9 @@ use std::sync::{ use std::thread; use evdev::uinput::VirtualDevice; -use evdev::{Device, EventSummary, KeyCode, RelativeAxisCode}; +use evdev::{ + AbsoluteAxisCode, AttributeSetRef, Device, EventSummary, KeyCode, PropType, RelativeAxisCode, +}; use tracing::{debug, error, warn}; use x11rb::connection::Connection as _; use x11rb::properties::WmClass; @@ -30,8 +39,14 @@ use x11rb::rust_connection::RustConnection; use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; -/// Name stamped on every uinput pass-through device; used to skip those -/// devices during enumeration so we don't hook our own virtual mice. +/// Prefix carried by every uinput device OpenLogi creates — the hook's +/// pass-through mice ([`VIRTUAL_DEVICE_NAME`]) and openlogi-inject's +/// "OpenLogi action injector" (which also advertises mouse buttons). +/// Enumeration refuses anything with this prefix so the hook can never grab +/// one of our own virtual devices. +const OPENLOGI_DEVICE_PREFIX: &str = "OpenLogi "; + +/// Name stamped on every uinput pass-through device. const VIRTUAL_DEVICE_NAME: &str = "OpenLogi virtual mouse"; /// Hi-res scroll resolution: 120 units per standard wheel tick, matching the @@ -135,14 +150,72 @@ fn create_pipe() -> io::Result<(OwnedFd, OwnedFd)> { fn find_mouse_devices() -> Vec<(std::path::PathBuf, Device)> { evdev::enumerate() - .filter(|(_, d)| d.name().unwrap_or("") != VIRTUAL_DEVICE_NAME) - .filter(|(_, d)| { - d.supported_keys() - .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)) + .filter(|(path, d)| { + let hookable = is_hookable_mouse( + d.name(), + d.supported_keys(), + d.supported_relative_axes(), + d.supported_absolute_axes(), + d.properties(), + ); + if !hookable + && d.supported_keys() + .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)) + { + debug!( + "not hooking {} ({}): has mouse buttons but is not a plain relative-pointer mouse", + path.display(), + d.name().unwrap_or("unnamed"), + ); + } + hookable }) .collect() } +/// Decide whether an input device is a physical mouse the hook may grab. +/// +/// Grabbing is only correct for devices whose full event stream the paired +/// virtual device can re-inject, i.e. relative pointers. Touch-driven +/// devices (touchpads, touchscreens) speak multitouch `EV_ABS`, which +/// [`build_virtual_device`] does not mirror — grabbing one swallows its +/// events and kills the pointer. Pointing sticks are relative pointers but +/// are excluded too: libinput derives their on-button scrolling from the +/// `POINTING_STICK` input property, which a re-injected uinput stream loses, +/// and built-in sticks are never OpenLogi's target hardware. +fn is_hookable_mouse( + name: Option<&str>, + keys: Option<&AttributeSetRef>, + rel_axes: Option<&AttributeSetRef>, + abs_axes: Option<&AttributeSetRef>, + props: &AttributeSetRef, +) -> bool { + // Never hook one of our own uinput devices (an unnamed device is fine — + // ours are always named). + if name.is_some_and(|n| n.starts_with(OPENLOGI_DEVICE_PREFIX)) { + return false; + } + // A mouse clicks and moves relatively; nothing else qualifies. This alone + // rejects pure-ABS touchpads, keyboards with stray button bits, and + // wheel-only devices like the action injector. + let clicks = keys.is_some_and(|k| k.contains(KeyCode::BTN_LEFT)); + let moves = rel_axes.is_some_and(|r| { + r.contains(RelativeAxisCode::REL_X) && r.contains(RelativeAxisCode::REL_Y) + }); + if !clicks || !moves { + return false; + } + // Combo devices that qualify as relative pointers but also expose a touch + // surface must stay un-grabbed: their touch stream cannot be re-injected. + let touches = keys + .is_some_and(|k| k.contains(KeyCode::BTN_TOUCH) || k.contains(KeyCode::BTN_TOOL_FINGER)) + || abs_axes.is_some_and(|a| a.contains(AbsoluteAxisCode::ABS_MT_POSITION_X)) + || props.contains(PropType::BUTTONPAD) + || props.contains(PropType::SEMI_MT) + || props.contains(PropType::DIRECT); + !touches && !props.contains(PropType::POINTING_STICK) +} + fn build_virtual_device(device: &Device) -> io::Result { let builder = VirtualDevice::builder()?.name(VIRTUAL_DEVICE_NAME); @@ -662,4 +735,189 @@ mod tests { let event = InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0); assert!(translate(&event, false).is_none()); } + + // ── is_hookable_mouse ──────────────────────────────────────────────────── + + use evdev::{AbsoluteAxisCode, AttributeSet, PropType}; + + /// Capability profile fed to [`is_hookable_mouse`]. [`Caps::mouse`] models a + /// plain relative mouse; tests mutate it toward the device they model. + struct Caps { + name: Option<&'static str>, + keys: Option>, + rel: Option>, + abs: Option>, + props: AttributeSet, + } + + impl Caps { + fn mouse() -> Self { + Self { + name: Some("Logitech MX Master 3S"), + keys: Some( + [KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE] + .into_iter() + .collect(), + ), + rel: Some( + [ + RelativeAxisCode::REL_X, + RelativeAxisCode::REL_Y, + RelativeAxisCode::REL_WHEEL, + ] + .into_iter() + .collect(), + ), + abs: None, + props: AttributeSet::new(), + } + } + + fn is_hookable(&self) -> bool { + is_hookable_mouse( + self.name, + self.keys.as_deref(), + self.rel.as_deref(), + self.abs.as_deref(), + &self.props, + ) + } + } + + #[test] + fn plain_mouse_is_hookable() { + assert!(Caps::mouse().is_hookable()); + } + + #[test] + fn unnamed_mouse_is_hookable() { + let mut caps = Caps::mouse(); + caps.name = None; + assert!( + caps.is_hookable(), + "a missing name must not exclude a device" + ); + } + + #[test] + fn touchpad_is_not_hookable() { + // A libinput touchpad: clicks via BTN_LEFT but moves via multitouch + // EV_ABS, no relative axes — the device class the old BTN_LEFT-only + // filter wrongly grabbed, killing the built-in touchpad. + let mut caps = Caps::mouse(); + caps.name = Some("ELAN0670:00 04F3:3150 Touchpad"); + caps.keys = Some( + [ + KeyCode::BTN_LEFT, + KeyCode::BTN_TOUCH, + KeyCode::BTN_TOOL_FINGER, + ] + .into_iter() + .collect(), + ); + caps.rel = None; + caps.abs = Some([AbsoluteAxisCode::ABS_MT_POSITION_X].into_iter().collect()); + caps.props = [PropType::POINTER, PropType::BUTTONPAD] + .into_iter() + .collect(); + assert!(!caps.is_hookable()); + } + + #[test] + fn touch_keys_exclude_a_relative_pointer() { + for touch_key in [KeyCode::BTN_TOUCH, KeyCode::BTN_TOOL_FINGER] { + let mut caps = Caps::mouse(); + caps.keys = Some( + [KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, touch_key] + .into_iter() + .collect(), + ); + assert!( + !caps.is_hookable(), + "{touch_key:?} should mark the device as touch-driven" + ); + } + } + + #[test] + fn multitouch_abs_axis_excludes_a_relative_pointer() { + let mut caps = Caps::mouse(); + caps.abs = Some([AbsoluteAxisCode::ABS_MT_POSITION_X].into_iter().collect()); + assert!(!caps.is_hookable()); + } + + #[test] + fn touch_props_exclude_a_relative_pointer() { + for prop in [PropType::BUTTONPAD, PropType::SEMI_MT, PropType::DIRECT] { + let mut caps = Caps::mouse(); + caps.props = [prop].into_iter().collect(); + assert!( + !caps.is_hookable(), + "{prop:?} should mark the device as touch-driven" + ); + } + } + + #[test] + fn pointing_stick_is_not_hookable() { + let mut caps = Caps::mouse(); + caps.name = Some("TPPS/2 Elan TrackPoint"); + caps.props = [PropType::POINTER, PropType::POINTING_STICK] + .into_iter() + .collect(); + assert!(!caps.is_hookable()); + } + + #[test] + fn device_without_relative_motion_is_not_hookable() { + // Buttons but no REL_X/REL_Y: keyboards with stray button bits and + // wheel-only virtual devices (the action injector's shape). + let mut caps = Caps::mouse(); + caps.rel = None; + assert!(!caps.is_hookable()); + + let mut caps = Caps::mouse(); + caps.rel = Some([RelativeAxisCode::REL_WHEEL].into_iter().collect()); + assert!(!caps.is_hookable()); + } + + #[test] + fn device_without_buttons_is_not_hookable() { + let mut caps = Caps::mouse(); + caps.keys = Some( + [KeyCode::KEY_A, KeyCode::KEY_LEFTSHIFT] + .into_iter() + .collect(), + ); + assert!(!caps.is_hookable()); + } + + #[test] + fn own_virtual_devices_are_not_hookable() { + // Both uinput devices OpenLogi creates carry the prefix; even with + // fully mouse-like capabilities they must never be grabbed. + for name in ["OpenLogi virtual mouse", "OpenLogi action injector"] { + let mut caps = Caps::mouse(); + caps.name = Some(name); + assert!(!caps.is_hookable(), "{name:?} must be excluded by prefix"); + } + } + + #[test] + fn virtual_device_name_carries_exclusion_prefix() { + assert!( + VIRTUAL_DEVICE_NAME.starts_with(OPENLOGI_DEVICE_PREFIX), + "renaming the virtual device away from the prefix would let the \ + hook grab its own pass-through mice" + ); + } + + #[test] + fn stray_abs_axis_does_not_exclude_a_mouse() { + // Some mice expose odd ABS codes (e.g. ABS_MISC for tilt); only the + // multitouch position axis marks a touch surface. + let mut caps = Caps::mouse(); + caps.abs = Some([AbsoluteAxisCode::ABS_MISC].into_iter().collect()); + assert!(caps.is_hookable()); + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 555d169d..6afa4162 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -36,10 +36,14 @@ MX Master 4): The app-wide `[app_settings]` block holds `launch_at_login`, `check_for_updates`, and `auto_install_updates` (all off by default); `show_in_menu_bar` (macOS menu bar / Windows tray, ignored on Linux; on by -default); `auto_download_assets` (on by default); `language` (absent = follow -the system locale); `thumbwheel_sensitivity` (default `14`); and the -`appearance` (default `"system"`), `theme_light`, `theme_dark`, and `ui_radius` -presentation settings. The theme and radius overrides are absent by default. +default); `capture_mouse_events` (on by default; set to `false` to keep the +agent from installing the OS-level mouse hook at all — button remapping stops +working, but no input device is grabbed or intercepted; DPI, SmartShift, and +the other HID++-side features keep working; takes effect on agent restart); +`auto_download_assets` (on by default); `language` (absent = follow the system +locale); `thumbwheel_sensitivity` (default `14`); and the `appearance` (default +`"system"`), `theme_light`, `theme_dark`, and `ui_radius` presentation +settings. The theme and radius overrides are absent by default. ```toml schema_version = 2