From 2de995b5a957d44ff15698e5591231c7a5c9e5cb Mon Sep 17 00:00:00 2001 From: Tinnci Date: Sun, 5 Jul 2026 14:08:02 +0800 Subject: [PATCH] fix linux hook device selection --- .../openlogi-agent-core/src/orchestrator.rs | 72 ++++++++++++++++- crates/openlogi-agent/src/main.rs | 79 ++++++++++++++++--- crates/openlogi-hook/src/linux.rs | 17 ++-- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 019d08bb..37b5ce04 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::{Arc, RwLock}; use openlogi_core::config::Config; -use openlogi_core::device::{Capabilities, DeviceInventory}; +use openlogi_core::device::{Capabilities, DeviceInventory, DeviceKind}; use openlogi_hid::{CaptureChannel, DeviceRoute}; use tracing::warn; @@ -39,6 +39,7 @@ struct AgentDevice { serial: Option, unit_id: [u8; 4], capabilities: Option, + kind: DeviceKind, /// Live link state from the inventory snapshot. An offline→online /// transition is a reconnect — the device may have power-cycled, so its /// volatile settings need re-applying (#189). @@ -308,6 +309,22 @@ impl Orchestrator { } } + /// Whether there is a current online pointing device for the OS mouse hook + /// to manage. Without this guard the Linux backend enumerates system input + /// devices before HID++ discovery has found anything, which can grab a + /// laptop's built-in touchpad/TrackPoint for no useful OpenLogi target. + #[must_use] + pub fn should_install_os_hook(&self) -> bool { + self.devices.iter().any(|dev| { + dev.online + && dev.route.is_some() + && matches!(dev.kind, DeviceKind::Mouse | DeviceKind::Trackball) + && dev + .capabilities + .is_none_or(|capabilities| capabilities.buttons || capabilities.pointer) + }) + } + /// Record that enumeration has never worked and has stopped being treated /// as "still starting" (persistent initial failure, or the watcher died). /// Downgrades only [`InventoryState::Pending`]: once a snapshot exists the @@ -377,6 +394,7 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec { serial: model.serial_number.clone(), unit_id: model.unit_id, capabilities: paired.capabilities, + kind: paired.kind, online: paired.online, }); } @@ -457,6 +475,7 @@ fn write_value(lock: &RwLock, value: T, name: &str) { mod tests { use super::{AgentDevice, InventoryHealth, Orchestrator, reapply_targets}; use openlogi_core::config::Config; + use openlogi_core::device::{Capabilities, DeviceKind}; use openlogi_hid::DeviceRoute; fn dev(key: &str, slot: u8, online: bool) -> AgentDevice { @@ -471,6 +490,7 @@ mod tests { serial: None, unit_id: [0; 4], capabilities: None, + kind: DeviceKind::Mouse, online, } } @@ -552,4 +572,54 @@ mod tests { orch.mark_inventory_unavailable(); assert_eq!(orch.inventory_health(), InventoryHealth::Ready); } + + #[test] + fn os_hook_waits_for_online_pointing_device() { + let mut orch = Orchestrator::new(Config::default()); + assert!( + !orch.should_install_os_hook(), + "empty/pending inventory must not install the OS hook" + ); + + orch.devices = vec![dev("mouse", 1, true)]; + assert!( + orch.should_install_os_hook(), + "online mouse with unknown capabilities should be hookable" + ); + + orch.devices = vec![dev("mouse", 1, false)]; + assert!( + !orch.should_install_os_hook(), + "offline mouse should not keep the OS hook installed" + ); + + orch.devices = vec![AgentDevice { + kind: DeviceKind::Keyboard, + capabilities: Some(Capabilities { + buttons: false, + pointer: false, + lighting: true, + scroll_inversion: false, + }), + ..dev("keyboard", 1, true) + }]; + assert!( + !orch.should_install_os_hook(), + "keyboard-only inventory should not install a mouse hook" + ); + + orch.devices = vec![AgentDevice { + capabilities: Some(Capabilities { + buttons: false, + pointer: false, + lighting: false, + scroll_inversion: false, + }), + ..dev("presenter", 1, true) + }]; + assert!( + !orch.should_install_os_hook(), + "pointing device without button or pointer capability should not install the hook" + ); + } } diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 7acfc861..f5830c62 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -176,6 +176,7 @@ async fn run(config: Config) { // if it's revoked (the tap self-disables on revoke regardless; dropping the // handle stops its thread). let mut hook: Option = None; + let mut accessibility_granted = Hook::has_accessibility(); info!("openlogi-agent started"); // Set once the inventory channel closes (the watcher thread died), so the @@ -186,9 +187,25 @@ async fn run(config: Config) { event = inventory_rx.recv(), if inventory_open => match event { Some(watchers::inventory::InventoryEvent::Snapshot(inventories)) => { orchestrator.lock().await.refresh_inventory(&inventories); + reconcile_hook( + &mut hook, + &hook_installed, + &orchestrator, + &shared, + accessibility_granted, + ) + .await; } Some(watchers::inventory::InventoryEvent::Unavailable) => { orchestrator.lock().await.mark_inventory_unavailable(); + reconcile_hook( + &mut hook, + &hook_installed, + &orchestrator, + &shared, + accessibility_granted, + ) + .await; } Some(watchers::inventory::InventoryEvent::SystemWake) => { // Devices likely power-cycled during the sleep; the next @@ -200,6 +217,14 @@ async fn run(config: Config) { None => { warn!("inventory watcher channel closed — marking enumeration unavailable"); orchestrator.lock().await.mark_inventory_unavailable(); + reconcile_hook( + &mut hook, + &hook_installed, + &orchestrator, + &shared, + accessibility_granted, + ) + .await; inventory_open = false; } }, @@ -207,25 +232,53 @@ async fn run(config: Config) { orchestrator.lock().await.set_current_app(bundle); } Some(granted) = accessibility_rx.recv() => { - if !granted { - hook = None; - 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(), - ); - hook_installed.store(hook.is_some(), Ordering::Relaxed); - } + accessibility_granted = granted; + reconcile_hook( + &mut hook, + &hook_installed, + &orchestrator, + &shared, + accessibility_granted, + ) + .await; } else => break, } } } +async fn reconcile_hook( + hook: &mut Option, + hook_installed: &AtomicBool, + orchestrator: &Mutex, + shared: &openlogi_agent_core::orchestrator::SharedRuntime, + accessibility_granted: bool, +) { + let should_install = if accessibility_granted { + orchestrator.lock().await.should_install_os_hook() + } else { + false + }; + + if !should_install { + if hook.take().is_some() { + info!("stopping OS mouse hook — no hookable device or accessibility revoked"); + } + hook_installed.store(false, Ordering::Relaxed); + return; + } + + if hook.is_none() { + info!("hookable device available — installing OS mouse hook"); + *hook = hook_runtime::start( + shared.hook_maps.clone(), + shared.dpi_cycle.clone(), + shared.capture_channel.clone(), + ); + hook_installed.store(hook.is_some(), Ordering::Relaxed); + } +} + fn init_tracing() { tracing_subscriber::fmt() .with_writer(std::io::stderr) diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index 3581d4b0..2e6f8ba9 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -34,6 +34,9 @@ use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; /// devices during enumeration so we don't hook our own virtual mice. const VIRTUAL_DEVICE_NAME: &str = "OpenLogi virtual mouse"; +/// Linux input vendor id assigned to Logitech. +const LOGITECH_VENDOR_ID: u16 = 0x046d; + /// Hi-res scroll resolution: 120 units per standard wheel tick, matching the /// Linux kernel's `REL_WHEEL_HI_RES` convention and Windows HID semantics. const HIRES_UNITS_PER_TICK: f32 = 120.0; @@ -135,14 +138,18 @@ 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(|(_, d)| is_hook_candidate(d)) .collect() } +fn is_hook_candidate(device: &Device) -> bool { + device.name().unwrap_or("") != VIRTUAL_DEVICE_NAME + && device.input_id().vendor() == LOGITECH_VENDOR_ID + && device + .supported_keys() + .is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT)) +} + fn build_virtual_device(device: &Device) -> io::Result { let builder = VirtualDevice::builder()?.name(VIRTUAL_DEVICE_NAME);