Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -39,6 +39,7 @@ struct AgentDevice {
serial: Option<String>,
unit_id: [u8; 4],
capabilities: Option<Capabilities>,
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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -377,6 +394,7 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
serial: model.serial_number.clone(),
unit_id: model.unit_id,
capabilities: paired.capabilities,
kind: paired.kind,
online: paired.online,
});
}
Expand Down Expand Up @@ -457,6 +475,7 @@ fn write_value<T>(lock: &RwLock<T>, 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 {
Expand All @@ -471,6 +490,7 @@ mod tests {
serial: None,
unit_id: [0; 4],
capabilities: None,
kind: DeviceKind::Mouse,
online,
}
}
Expand Down Expand Up @@ -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"
);
}
}
79 changes: 66 additions & 13 deletions crates/openlogi-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hook> = None;
let mut accessibility_granted = Hook::has_accessibility();

info!("openlogi-agent started");
// Set once the inventory channel closes (the watcher thread died), so the
Expand All @@ -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
Expand All @@ -200,32 +217,68 @@ 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;
}
},
Some(bundle) = app_rx.recv() => {
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>,
hook_installed: &AtomicBool,
orchestrator: &Mutex<Orchestrator>,
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)
Expand Down
17 changes: 12 additions & 5 deletions crates/openlogi-hook/src/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<evdev::uinput::VirtualDevice> {
let builder = VirtualDevice::builder()?.name(VIRTUAL_DEVICE_NAME);

Expand Down