#![expect(
unsafe_code,
reason = "the wake pipe and the Wayland toplevel listener call libc directly"
)]
use std::collections::HashMap;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{
Arc, Condvar, LazyLock, Mutex, MutexGuard,
atomic::{AtomicBool, Ordering},
mpsc,
};
use std::thread;
use std::time::{Duration, Instant};
use evdev::uinput::VirtualDevice;
use evdev::{
AbsoluteAxisCode, AttributeSetRef, Device, EventSummary, KeyCode, PropType, RelativeAxisCode,
};
use thiserror::Error;
use tracing::{debug, error, warn};
use x11rb::connection::Connection as _;
use x11rb::properties::WmClass;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, Window,
};
use x11rb::rust_connection::RustConnection;
use crate::{
ButtonId, CursorPosition, EventDisposition, ForegroundApp, HookBackend, HookError, HookEvent,
LOGITECH_VENDOR_ID, MouseEvent,
};
const OPENLOGI_DEVICE_PREFIX: &str = "OpenLogi ";
const VIRTUAL_DEVICE_NAME: &str = "OpenLogi virtual mouse";
const HIRES_UNITS_PER_TICK: f32 = 120.0;
pub(crate) struct HookInner {
stop: Arc<AtomicBool>,
stop_pipes: Vec<OwnedFd>,
threads: Vec<thread::JoinHandle<()>>,
}
pub(crate) struct Backend;
impl HookBackend for Backend {
type Running = HookInner;
fn start(
cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
let devices = find_mouse_devices();
if devices.is_empty() {
return Err(HookError::NoDeviceFound);
}
let stop = Arc::new(AtomicBool::new(false));
let cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let mut threads: Vec<thread::JoinHandle<()>> = Vec::with_capacity(devices.len());
let mut stop_pipes: Vec<OwnedFd> = Vec::with_capacity(devices.len());
let result = (|| -> io::Result<()> {
for (path, device) in devices {
let virtual_device = build_virtual_device(&device)?;
let (rx, tx) = create_pipe()?;
let stop_clone = Arc::clone(&stop);
let cb_clone = Arc::clone(&cb);
let handle = thread::Builder::new()
.name(format!("openlogi-hook:{}", path.display()))
.spawn(move || {
device_thread(path, device, virtual_device, cb_clone, stop_clone, rx);
})?;
threads.push(handle);
stop_pipes.push(tx);
}
Ok(())
})();
if let Err(e) = result {
shutdown(&stop, &stop_pipes, threads);
return Err(HookError::Linux(e));
}
Ok(HookInner {
stop,
stop_pipes,
threads,
})
}
fn stop(inner: HookInner) {
shutdown(&inner.stop, &inner.stop_pipes, inner.threads);
}
fn frontmost_app() -> Option<ForegroundApp> {
FRONTMOST.frontmost_app()
}
fn cursor_position() -> Option<CursorPosition> {
let source = X11Source::connect()?;
let reply = source.conn.query_pointer(source.root).ok()?.reply().ok()?;
Some(CursorPosition {
x: f64::from(reply.root_x),
y: f64::from(reply.root_y),
})
}
}
fn shutdown(stop: &AtomicBool, pipes: &[OwnedFd], threads: Vec<thread::JoinHandle<()>>) {
stop.store(true, Ordering::Relaxed);
for fd in pipes {
signal_pipe(fd);
}
for handle in threads {
if let Err(e) = handle.join() {
error!("hook thread panicked on shutdown: {e:?}");
}
}
}
fn signal_pipe(fd: &OwnedFd) {
loop {
let ret = unsafe { libc::write(fd.as_raw_fd(), [0u8].as_ptr().cast(), 1) };
if ret >= 0 {
return;
}
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
continue;
}
error!("failed to signal hook thread pipe ({err}): hook thread may not wake");
return;
}
}
fn create_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds = [0i32; 2];
if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) })
}
fn find_mouse_devices() -> Vec<(std::path::PathBuf, Device)> {
evdev::enumerate()
.filter(|(path, d)| {
let ours = u32::from(d.input_id().vendor()) == LOGITECH_VENDOR_ID;
if !ours
&& d.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT))
{
debug!(
"not hooking {} ({}): not a Logitech device",
path.display(),
d.name().unwrap_or("unnamed"),
);
}
ours
})
.filter(|(path, d)| {
let vendor = u32::from(d.input_id().vendor());
let hookable = is_hookable_mouse(
d.name(),
d.supported_keys(),
d.supported_relative_axes(),
d.supported_absolute_axes(),
d.properties(),
vendor,
);
if !hookable
&& d.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT))
{
debug!(
"not hooking {} ({}): has mouse buttons but is not a managed Logitech relative-pointer mouse",
path.display(),
d.name().unwrap_or("unnamed"),
);
}
hookable
})
.collect()
}
fn is_hookable_mouse(
name: Option<&str>,
keys: Option<&AttributeSetRef<KeyCode>>,
rel_axes: Option<&AttributeSetRef<RelativeAxisCode>>,
abs_axes: Option<&AttributeSetRef<AbsoluteAxisCode>>,
props: &AttributeSetRef<PropType>,
vendor_id: u32,
) -> bool {
if name.is_some_and(|n| n.starts_with(OPENLOGI_DEVICE_PREFIX)) {
return false;
}
if vendor_id != LOGITECH_VENDOR_ID {
return false;
}
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;
}
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<evdev::uinput::VirtualDevice> {
let builder = VirtualDevice::builder()?.name(VIRTUAL_DEVICE_NAME);
let builder = if let Some(keys) = device.supported_keys() {
builder.with_keys(keys)?
} else {
builder
};
let builder = if let Some(axes) = device.supported_relative_axes() {
builder.with_relative_axes(axes)?
} else {
builder
};
builder.build()
}
fn wait_readable(device_fd: i32, stop_fd: i32) -> bool {
const ERR_FLAGS: libc::c_short = libc::POLLERR | libc::POLLHUP | libc::POLLNVAL;
let mut fds = [
libc::pollfd {
fd: device_fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: stop_fd,
events: libc::POLLIN,
revents: 0,
},
];
loop {
let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
if ret < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
continue; }
error!("poll() failed: {err}");
return false;
}
if fds[0].revents & ERR_FLAGS != 0 {
warn!("hooked device closed or errored; stopping its thread");
return false;
}
if fds[1].revents & ERR_FLAGS != 0 {
return false; }
if fds[1].revents & libc::POLLIN != 0 {
return false; }
if fds[0].revents & libc::POLLIN != 0 {
return true; }
}
}
fn scroll(delta_x: f64, delta_y: f64) -> MouseEvent {
MouseEvent::Scroll {
delta: crate::ScrollDelta::wheel_ticks(delta_x, delta_y),
from_trackpad: false,
device: None,
}
}
fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option<MouseEvent> {
match event.destructure() {
EventSummary::Key(_, key, value) => {
let id = key_to_button(key)?;
Some(MouseEvent::Button {
id,
pressed: value != 0,
device: None,
})
}
EventSummary::RelativeAxis(_, axis, value) => match axis {
RelativeAxisCode::REL_X => Some(MouseEvent::Moved {
delta_x: value,
delta_y: 0,
}),
RelativeAxisCode::REL_Y => Some(MouseEvent::Moved {
delta_x: 0,
delta_y: value,
}),
_ => {
let v = f64::from(value);
if hires_scroll {
match axis {
RelativeAxisCode::REL_WHEEL_HI_RES => {
Some(scroll(0.0, v / f64::from(HIRES_UNITS_PER_TICK)))
}
RelativeAxisCode::REL_HWHEEL_HI_RES => {
Some(scroll(v / f64::from(HIRES_UNITS_PER_TICK), 0.0))
}
_ => None,
}
} else {
match axis {
RelativeAxisCode::REL_WHEEL => Some(scroll(0.0, v)),
RelativeAxisCode::REL_HWHEEL => Some(scroll(v, 0.0)),
_ => None,
}
}
}
},
_ => None,
}
}
fn key_to_button(key: KeyCode) -> Option<ButtonId> {
match key {
KeyCode::BTN_LEFT => Some(ButtonId::LeftClick),
KeyCode::BTN_RIGHT => Some(ButtonId::RightClick),
KeyCode::BTN_MIDDLE => Some(ButtonId::MiddleClick),
KeyCode::BTN_BACK | KeyCode::BTN_SIDE => Some(ButtonId::Back),
KeyCode::BTN_FORWARD | KeyCode::BTN_EXTRA => Some(ButtonId::Forward),
KeyCode::BTN_TASK => Some(ButtonId::DpiToggle),
_ => None,
}
}
#[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,
mut virtual_device: VirtualDevice,
cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync>,
stop: Arc<AtomicBool>,
stop_rx: OwnedFd,
) {
if let Err(e) = device.grab() {
warn!(
"failed to grab {} exclusively: {e} — skipping (left un-hooked)",
path.display()
);
return;
}
let hires_scroll = device
.supported_relative_axes()
.is_some_and(|axes| axes.contains(RelativeAxisCode::REL_WHEEL_HI_RES));
let device_fd = device.as_raw_fd();
let stop_fd = stop_rx.as_raw_fd();
let mut pending: Vec<evdev::InputEvent> = Vec::new();
debug!("hook started on {}", path.display());
'read: loop {
if stop.load(Ordering::Relaxed) {
break;
}
if !wait_readable(device_fd, stop_fd) {
break;
}
let events = match device.fetch_events() {
Ok(iter) => iter,
Err(e) => {
error!("read error on {}: {e}", path.display());
break;
}
};
for event in events {
if let EventSummary::Synchronization(..) = event.destructure() {
if !pending.is_empty() {
if let Err(e) = virtual_device.emit(&pending) {
error!(
"uinput emit failed on {}: {e} — stopping hook for this device",
path.display()
);
break 'read;
}
pending.clear();
}
} else {
let disposition = match translate(&event, hires_scroll) {
Some(me) => cb(HookEvent::Mouse(me)),
None if hires_scroll
&& matches!(
event.destructure(),
EventSummary::RelativeAxis(
_,
RelativeAxisCode::REL_WHEEL | RelativeAxisCode::REL_HWHEEL,
_
)
) =>
{
EventDisposition::Suppress
}
None => EventDisposition::PassThrough,
};
match disposition {
EventDisposition::PassThrough => pending.push(event),
EventDisposition::Suppress => {}
}
}
}
}
debug!("hook stopped on {}", path.display());
}
mod gnome_shell;
mod wlr_foreign_toplevel;
trait FrontmostSource: Send {
fn frontmost_app_id(&mut self) -> Option<String>;
fn observe(self: Box<Self>, stop: StopToken, publish: PublishAppId)
-> Box<dyn FrontmostSource>;
fn name(&self) -> &'static str;
}
type PublishAppId = Arc<dyn Fn(Option<String>) + Send + Sync>;
pub(super) const RECONNECT_DELAY: Duration = Duration::from_secs(2);
struct StopState {
requested: AtomicBool,
wait_lock: Mutex<()>,
changed: Condvar,
}
impl StopState {
fn new() -> Self {
Self {
requested: AtomicBool::new(false),
wait_lock: Mutex::new(()),
changed: Condvar::new(),
}
}
fn request(&self) {
if self.requested.swap(true, Ordering::AcqRel) {
return;
}
let _guard = lock_unpoisoned(&self.wait_lock);
self.changed.notify_all();
}
fn is_requested(&self) -> bool {
self.requested.load(Ordering::Acquire)
}
fn wait(&self) {
let guard = lock_unpoisoned(&self.wait_lock);
drop(
self.changed
.wait_while(guard, |()| !self.is_requested())
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
}
fn wait_timeout(&self, timeout: Duration) -> bool {
if self.is_requested() {
return true;
}
let guard = lock_unpoisoned(&self.wait_lock);
let (_guard, _) = self
.changed
.wait_timeout_while(guard, timeout, |()| !self.is_requested())
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.is_requested()
}
}
struct StopControl {
state: Arc<StopState>,
wake: OwnedFd,
}
impl StopControl {
fn request(&self) {
self.state.request();
signal_pipe(&self.wake);
}
}
pub(super) struct StopToken {
state: Arc<StopState>,
wake: OwnedFd,
}
impl StopToken {
pub(super) fn is_requested(&self) -> bool {
self.state.is_requested()
}
pub(super) fn wait(&self) {
self.state.wait();
}
pub(super) fn wait_timeout(&self, timeout: Duration) -> bool {
self.state.wait_timeout(timeout)
}
pub(super) fn wake_fd(&self) -> RawFd {
self.wake.as_raw_fd()
}
fn state(&self) -> Arc<StopState> {
Arc::clone(&self.state)
}
}
fn stop_pair() -> io::Result<(StopControl, StopToken)> {
let (read, write) = create_pipe()?;
let state = Arc::new(StopState::new());
Ok((
StopControl {
state: Arc::clone(&state),
wake: write,
},
StopToken { state, wake: read },
))
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum PollResult {
SourceReady,
StopRequested,
DeadlineReached,
Error,
}
pub(super) fn poll_source_or_stop(
source_fd: Option<RawFd>,
stop_fd: RawFd,
deadline: Option<Instant>,
) -> PollResult {
let mut fds = [
libc::pollfd {
fd: stop_fd,
events: libc::POLLIN | libc::POLLERR | libc::POLLHUP,
revents: 0,
},
libc::pollfd {
fd: source_fd.unwrap_or(-1),
events: libc::POLLIN | libc::POLLERR | libc::POLLHUP,
revents: 0,
},
];
loop {
let timeout = deadline.map_or(-1, |deadline| {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
0
} else {
i32::try_from(remaining.as_millis().max(1).min(i32::MAX as u128))
.unwrap_or(i32::MAX)
}
});
let result = unsafe { libc::poll(fds.as_mut_ptr(), 2, timeout) };
if result > 0 {
if fds[0].revents != 0 {
return PollResult::StopRequested;
}
if fds[1].revents != 0 {
return PollResult::SourceReady;
}
continue;
}
if result == 0 {
return PollResult::DeadlineReached;
}
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
debug!("frontmost: native event poll failed: {error}");
return PollResult::Error;
}
}
}
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct X11Source {
conn: RustConnection,
root: Window,
net_active_window: Atom,
}
impl X11Source {
fn connect() -> Option<Self> {
let (conn, screen_num) = RustConnection::connect(None)
.map_err(|e| debug!("X11 not available, frontmost will return None: {e}"))
.ok()?;
let root = conn.setup().roots[screen_num].root;
let net_active_window = conn
.intern_atom(false, b"_NET_ACTIVE_WINDOW")
.ok()?
.reply()
.ok()?
.atom;
Some(Self {
conn,
root,
net_active_window,
})
}
fn subscribe(&self) -> bool {
self.set_event_mask(EventMask::PROPERTY_CHANGE)
.map_err(|e| debug!("frontmost: failed to subscribe to X11 root changes: {e}"))
.is_ok()
}
fn unsubscribe(&self) {
if let Err(error) = self.set_event_mask(EventMask::NO_EVENT) {
debug!("frontmost: failed to unsubscribe from X11 root changes: {error}");
}
}
fn set_event_mask(&self, event_mask: EventMask) -> Result<(), String> {
let cookie = self
.conn
.change_window_attributes(
self.root,
&ChangeWindowAttributesAux::new().event_mask(event_mask),
)
.map_err(|error| error.to_string())?;
cookie.check().map_err(|error| error.to_string())?;
self.conn.flush().map_err(|error| error.to_string())
}
fn run_until_stopped_or_disconnected(
&mut self,
stop: &StopToken,
publish: &PublishAppId,
) -> bool {
if !self.subscribe() {
return false;
}
publish(self.frontmost_app_id());
loop {
match poll_source_or_stop(Some(self.conn.stream().as_raw_fd()), stop.wake_fd(), None) {
PollResult::StopRequested => {
self.unsubscribe();
return true;
}
PollResult::SourceReady => {}
PollResult::DeadlineReached => continue,
PollResult::Error => return false,
}
let mut active_window_changed = false;
loop {
match self.conn.poll_for_event() {
Ok(Some(Event::PropertyNotify(event)))
if event.window == self.root && event.atom == self.net_active_window =>
{
active_window_changed = true;
}
Ok(Some(_)) => {}
Ok(None) => break,
Err(error) => {
debug!("frontmost: X11 connection lost: {error}");
return false;
}
}
}
if active_window_changed {
publish(self.frontmost_app_id());
}
}
}
}
impl FrontmostSource for X11Source {
fn frontmost_app_id(&mut self) -> Option<String> {
let window: Window = self
.conn
.get_property(
false,
self.root,
self.net_active_window,
AtomEnum::WINDOW,
0,
1,
)
.ok()?
.reply()
.ok()?
.value32()?
.next()?;
if window == 0 {
return None;
}
let wm = WmClass::get(&self.conn, window)
.ok()?
.reply_unchecked()
.ok()??;
std::str::from_utf8(wm.class())
.ok()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
fn observe(
mut self: Box<Self>,
stop: StopToken,
publish: PublishAppId,
) -> Box<dyn FrontmostSource> {
loop {
if self.run_until_stopped_or_disconnected(&stop, &publish) {
return self;
}
publish(None);
if stop.wait_timeout(RECONNECT_DELAY) {
return self;
}
if let Some(reconnected) = Self::connect() {
debug!("frontmost: X11 connection restored");
*self = reconnected;
}
}
}
fn name(&self) -> &'static str {
"x11"
}
}
struct NullSource;
impl FrontmostSource for NullSource {
fn frontmost_app_id(&mut self) -> Option<String> {
None
}
fn observe(
self: Box<Self>,
stop: StopToken,
publish: PublishAppId,
) -> Box<dyn FrontmostSource> {
publish(None);
stop.wait();
self
}
fn name(&self) -> &'static str {
"null"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionKind {
X11,
Wayland,
Unknown,
}
fn detect_session_kind() -> SessionKind {
if let Ok(kind) = std::env::var("XDG_SESSION_TYPE") {
match kind.as_str() {
"wayland" => return SessionKind::Wayland,
"x11" => return SessionKind::X11,
_ => {}
}
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
SessionKind::Wayland
} else if std::env::var_os("DISPLAY").is_some() {
SessionKind::X11
} else {
SessionKind::Unknown
}
}
type Candidate = fn() -> Option<Box<dyn FrontmostSource>>;
fn x11_candidate() -> Option<Box<dyn FrontmostSource>> {
X11Source::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
}
fn wayland_candidates() -> Vec<Candidate> {
vec![wlr_foreign_toplevel::candidate, gnome_shell::candidate]
}
fn detect_frontmost_source() -> Box<dyn FrontmostSource> {
let session = detect_session_kind();
debug!("frontmost: session kind = {session:?}");
let mut candidates: Vec<Candidate> = match session {
SessionKind::Wayland => wayland_candidates(),
SessionKind::X11 | SessionKind::Unknown => Vec::new(),
};
candidates.push(x11_candidate);
for candidate in candidates {
if let Some(source) = candidate() {
debug!("frontmost: using '{}' backend", source.name());
if session == SessionKind::Wayland && source.name() == "x11" {
debug!(
"frontmost: on Wayland but using the X11/XWayland backend; \
native Wayland windows will report None. Install the OpenLogi \
GNOME Shell extension (GNOME) or use a wlroots compositor."
);
}
return source;
}
}
debug!("frontmost: no usable backend; frontmost_app_id will return None");
Box::new(NullSource)
}
struct DeliveryState {
active: bool,
version: Option<u64>,
}
struct Subscriber {
callback: Box<dyn Fn(Option<ForegroundApp>) + Send + Sync>,
delivery: Mutex<DeliveryState>,
}
impl Subscriber {
fn new(callback: impl Fn(Option<ForegroundApp>) + Send + Sync + 'static) -> Self {
Self {
callback: Box::new(callback),
delivery: Mutex::new(DeliveryState {
active: true,
version: None,
}),
}
}
fn deliver(&self, version: u64, app: Option<ForegroundApp>) {
let mut delivery = lock_unpoisoned(&self.delivery);
if !delivery.active || delivery.version.is_some_and(|seen| seen >= version) {
return;
}
delivery.version = Some(version);
if catch_unwind(AssertUnwindSafe(|| (self.callback)(app))).is_err() {
error!("foreground-application callback panicked");
}
}
fn deactivate(&self) {
let mut delivery = lock_unpoisoned(&self.delivery);
delivery.active = false;
}
}
struct PublicationState {
active: bool,
initialized: bool,
version: u64,
current: Option<ForegroundApp>,
subscribers: HashMap<u64, Arc<Subscriber>>,
}
struct Publication {
state: Mutex<PublicationState>,
}
enum ActiveSnapshot {
Idle,
Active(Option<ForegroundApp>),
}
impl Publication {
fn new() -> Self {
Self {
state: Mutex::new(PublicationState {
active: false,
initialized: false,
version: 0,
current: None,
subscribers: HashMap::new(),
}),
}
}
fn begin(&self) {
let mut state = lock_unpoisoned(&self.state);
state.active = true;
state.initialized = false;
state.current = None;
}
fn finish(&self) {
let mut state = lock_unpoisoned(&self.state);
state.active = false;
state.initialized = false;
state.current = None;
}
fn subscribe(
&self,
id: u64,
subscriber: &Arc<Subscriber>,
) -> Option<(u64, Option<ForegroundApp>)> {
let mut state = lock_unpoisoned(&self.state);
state.subscribers.insert(id, Arc::clone(subscriber));
state
.initialized
.then(|| (state.version, state.current.clone()))
}
fn unsubscribe(&self, id: u64) {
let subscriber = lock_unpoisoned(&self.state).subscribers.remove(&id);
if let Some(subscriber) = subscriber {
subscriber.deactivate();
}
}
fn is_empty(&self) -> bool {
lock_unpoisoned(&self.state).subscribers.is_empty()
}
fn publish(&self, app_id: Option<String>) {
let app = app_id.map(ForegroundApp::unnamed);
let (version, subscribers) = {
let mut state = lock_unpoisoned(&self.state);
if state.initialized && state.current == app {
return;
}
state.initialized = true;
state.version += 1;
state.current.clone_from(&app);
(
state.version,
state.subscribers.values().cloned().collect::<Vec<_>>(),
)
};
for subscriber in subscribers {
subscriber.deliver(version, app.clone());
}
}
fn active_snapshot(&self) -> ActiveSnapshot {
let state = lock_unpoisoned(&self.state);
if state.active {
ActiveSnapshot::Active(state.current.clone())
} else {
ActiveSnapshot::Idle
}
}
}
struct ObserverWorker {
stop: StopControl,
thread: thread::JoinHandle<Box<dyn FrontmostSource>>,
}
type ObserverWorkerStart = (Box<dyn FrontmostSource>, StopToken, PublishAppId);
#[derive(Debug, Error)]
pub(crate) enum ForegroundApplicationObserverError {
#[error("could not create the Linux foreground observer stop pipe: {0}")]
StopPipe(io::Error),
#[error("could not spawn the Linux foreground observer thread: {0}")]
ThreadSpawn(io::Error),
#[error("the Linux foreground observer thread stopped during startup")]
WorkerStoppedDuringStartup,
}
struct FrontmostRuntime {
source: Option<Box<dyn FrontmostSource>>,
worker: Option<ObserverWorker>,
stopping: bool,
next_subscriber_id: u64,
}
struct FrontmostController {
runtime: Mutex<FrontmostRuntime>,
worker_stopped: Condvar,
publication: Arc<Publication>,
}
impl FrontmostController {
fn new(source: Box<dyn FrontmostSource>) -> Arc<Self> {
Arc::new(Self {
runtime: Mutex::new(FrontmostRuntime {
source: Some(source),
worker: None,
stopping: false,
next_subscriber_id: 0,
}),
worker_stopped: Condvar::new(),
publication: Arc::new(Publication::new()),
})
}
fn frontmost_app(&self) -> Option<ForegroundApp> {
match self.publication.active_snapshot() {
ActiveSnapshot::Active(app) => return app,
ActiveSnapshot::Idle => {}
}
let mut runtime = lock_unpoisoned(&self.runtime);
while runtime.stopping {
runtime = self
.worker_stopped
.wait(runtime)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
match self.publication.active_snapshot() {
ActiveSnapshot::Active(app) => app,
ActiveSnapshot::Idle => runtime
.source
.as_mut()
.and_then(|source| source.frontmost_app_id())
.map(ForegroundApp::unnamed),
}
}
fn watch(
self: &Arc<Self>,
callback: impl Fn(Option<ForegroundApp>) + Send + Sync + 'static,
) -> Result<ForegroundApplicationObserver, ForegroundApplicationObserverError> {
let subscriber = Arc::new(Subscriber::new(callback));
let mut runtime = lock_unpoisoned(&self.runtime);
while runtime.stopping {
runtime = self
.worker_stopped
.wait(runtime)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
let id = runtime.next_subscriber_id;
runtime.next_subscriber_id += 1;
let initial = if runtime.worker.is_none() {
let (stop, stop_token) =
stop_pair().map_err(ForegroundApplicationObserverError::StopPipe)?;
let (start_tx, start_rx) = mpsc::sync_channel::<ObserverWorkerStart>(0);
let thread = thread::Builder::new()
.name("openlogi-frontmost".into())
.spawn(move || match start_rx.recv() {
Ok((source, stop_token, publish)) => source.observe(stop_token, publish),
Err(_) => Box::new(NullSource),
})
.map_err(ForegroundApplicationObserverError::ThreadSpawn)?;
self.publication.begin();
let initial = self.publication.subscribe(id, &subscriber);
let source = runtime
.source
.take()
.unwrap_or_else(|| Box::new(NullSource));
let publication = Arc::clone(&self.publication);
let publish: PublishAppId = Arc::new(move |app| publication.publish(app));
if let Err(error) = start_tx.send((source, stop_token, publish)) {
let (source, _, _) = error.0;
runtime.source = Some(source);
self.publication.unsubscribe(id);
self.publication.finish();
drop(runtime);
let _ = thread.join();
return Err(ForegroundApplicationObserverError::WorkerStoppedDuringStartup);
}
runtime.worker = Some(ObserverWorker { stop, thread });
initial
} else {
self.publication.subscribe(id, &subscriber)
};
drop(runtime);
if let Some((version, app)) = initial {
subscriber.deliver(version, app);
}
Ok(ForegroundApplicationObserver {
controller: Arc::clone(self),
subscriber_id: id,
})
}
fn check_health(&self) -> Result<(), &'static str> {
let runtime = lock_unpoisoned(&self.runtime);
if runtime
.worker
.as_ref()
.is_some_and(|worker| worker.thread.is_finished())
{
Err("Linux foreground observer worker stopped")
} else {
Ok(())
}
}
fn remove_observer(&self, id: u64) {
self.publication.unsubscribe(id);
let worker = {
let mut runtime = lock_unpoisoned(&self.runtime);
while runtime.stopping {
runtime = self
.worker_stopped
.wait(runtime)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
if !self.publication.is_empty() {
return;
}
let Some(worker) = runtime.worker.take() else {
return;
};
runtime.stopping = true;
worker
};
worker.stop.request();
let source = worker.thread.join().unwrap_or_else(|panic| {
error!("foreground-application worker panicked on shutdown: {panic:?}");
detect_frontmost_source()
});
let mut runtime = lock_unpoisoned(&self.runtime);
runtime.source = Some(source);
self.publication.finish();
runtime.stopping = false;
self.worker_stopped.notify_all();
}
}
static FRONTMOST: LazyLock<Arc<FrontmostController>> =
LazyLock::new(|| FrontmostController::new(detect_frontmost_source()));
#[must_use]
pub(crate) struct ForegroundApplicationObserver {
controller: Arc<FrontmostController>,
subscriber_id: u64,
}
impl ForegroundApplicationObserver {
pub(crate) fn check_health(&self) -> Result<(), &'static str> {
self.controller.check_health()
}
}
impl Drop for ForegroundApplicationObserver {
fn drop(&mut self) {
self.controller.remove_observer(self.subscriber_id);
}
}
pub(crate) fn watch_frontmost_application_activations(
callback: impl Fn(Option<ForegroundApp>) + Send + Sync + 'static,
) -> Result<ForegroundApplicationObserver, ForegroundApplicationObserverError> {
FRONTMOST.watch(callback)
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::sync::mpsc;
use evdev::{EventType, InputEvent, KeyCode, RelativeAxisCode};
use super::*;
struct FakeFrontmostSource {
stopped: Arc<AtomicBool>,
}
impl FrontmostSource for FakeFrontmostSource {
fn frontmost_app_id(&mut self) -> Option<String> {
Some("org.example.App".into())
}
fn observe(
self: Box<Self>,
stop: StopToken,
publish: PublishAppId,
) -> Box<dyn FrontmostSource> {
publish(Some("org.example.App".into()));
stop.wait();
self.stopped.store(true, Ordering::Release);
self
}
fn name(&self) -> &'static str {
"fake"
}
}
#[test]
fn foreground_publication_suppresses_duplicates_and_contains_panics() {
let publication = Publication::new();
publication.begin();
let panic_once = Arc::new(AtomicBool::new(true));
let callback_panic_once = Arc::clone(&panic_once);
let panicking = Arc::new(Subscriber::new(move |_| {
assert!(
!callback_panic_once.swap(false, Ordering::AcqRel),
"contained callback panic"
);
}));
assert!(publication.subscribe(1, &panicking).is_none());
let received = Arc::new(Mutex::new(Vec::new()));
let callback_received = Arc::clone(&received);
let recording_subscriber = Arc::new(Subscriber::new(move |app| {
lock_unpoisoned(&callback_received).push(app.map(|app| app.id));
}));
assert!(publication.subscribe(2, &recording_subscriber).is_none());
publication.publish(Some("one".into()));
publication.publish(Some("one".into()));
publication.publish(Some("two".into()));
publication.publish(None);
assert_eq!(
*lock_unpoisoned(&received),
vec![Some("one".into()), Some("two".into()), None]
);
assert!(!panic_once.load(Ordering::Acquire));
}
#[test]
fn foreground_observer_drop_stops_joins_and_returns_source() {
let stopped = Arc::new(AtomicBool::new(false));
let controller = FrontmostController::new(Box::new(FakeFrontmostSource {
stopped: Arc::clone(&stopped),
}));
let (tx, rx) = mpsc::channel();
let observer = controller
.watch(move |app| {
tx.send(app).expect("test receiver remains alive");
})
.expect("observer starts");
let initial = rx
.recv_timeout(Duration::from_secs(1))
.expect("observer publishes its initial snapshot");
assert_eq!(
initial.map(|app| app.id).as_deref(),
Some("org.example.App")
);
drop(observer);
assert!(stopped.load(Ordering::Acquire));
assert_eq!(
controller.frontmost_app().map(|app| app.id).as_deref(),
Some("org.example.App")
);
}
#[test]
fn unsupported_foreground_source_publishes_none_and_stops_cleanly() {
let controller = FrontmostController::new(Box::new(NullSource));
let (tx, rx) = mpsc::channel();
let observer = controller
.watch(move |app| {
tx.send(app).expect("test receiver remains alive");
})
.expect("observer starts");
assert_eq!(
rx.recv_timeout(Duration::from_secs(1))
.expect("null source publishes an initial snapshot"),
None
);
drop(observer);
assert_eq!(controller.frontmost_app(), None);
}
#[test]
fn foreground_unsubscribe_waits_for_inflight_callback() {
let publication = Arc::new(Publication::new());
publication.begin();
let release = Arc::new((Mutex::new(false), Condvar::new()));
let callback_release = Arc::clone(&release);
let (entered_tx, entered_rx) = mpsc::channel();
let subscriber = Arc::new(Subscriber::new(move |_| {
entered_tx.send(()).expect("test receiver remains alive");
let (lock, changed) = &*callback_release;
let guard = lock_unpoisoned(lock);
drop(
changed
.wait_while(guard, |released| !*released)
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
}));
assert!(publication.subscribe(1, &subscriber).is_none());
let publisher = Arc::clone(&publication);
let publish_thread = thread::spawn(move || publisher.publish(Some("one".into())));
entered_rx
.recv_timeout(Duration::from_secs(1))
.expect("callback starts");
let unsubscriber = Arc::clone(&publication);
let (unsubscribed_tx, unsubscribed_rx) = mpsc::channel();
let unsubscribe_thread = thread::spawn(move || {
unsubscriber.unsubscribe(1);
unsubscribed_tx
.send(())
.expect("test receiver remains alive");
});
assert!(
unsubscribed_rx
.recv_timeout(Duration::from_millis(25))
.is_err(),
"unsubscribe returned while the callback was in flight"
);
let (lock, changed) = &*release;
*lock_unpoisoned(lock) = true;
changed.notify_all();
unsubscribed_rx
.recv_timeout(Duration::from_secs(1))
.expect("unsubscribe returns after callback completion");
publish_thread.join().expect("publisher thread exits");
unsubscribe_thread.join().expect("unsubscribe thread exits");
}
#[test]
fn key_to_button_maps_standard_mouse_buttons() {
let cases = [
(KeyCode::BTN_LEFT, ButtonId::LeftClick),
(KeyCode::BTN_RIGHT, ButtonId::RightClick),
(KeyCode::BTN_MIDDLE, ButtonId::MiddleClick),
(KeyCode::BTN_BACK, ButtonId::Back),
(KeyCode::BTN_SIDE, ButtonId::Back),
(KeyCode::BTN_FORWARD, ButtonId::Forward),
(KeyCode::BTN_EXTRA, ButtonId::Forward),
(KeyCode::BTN_TASK, ButtonId::DpiToggle),
];
for (key, expected) in cases {
assert_eq!(
key_to_button(key),
Some(expected),
"key_to_button({key:?}) should be {expected:?}"
);
}
}
#[test]
fn key_to_button_returns_none_for_non_mouse_keys() {
assert_eq!(key_to_button(KeyCode::KEY_A), None);
assert_eq!(key_to_button(KeyCode::KEY_LEFTSHIFT), None);
}
#[test]
fn translate_btn_left_down_returns_button_pressed() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: true,
device: None,
})
);
}
#[test]
fn translate_btn_left_up_returns_button_released() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 0);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: false,
device: None,
})
);
}
#[test]
fn translate_btn_back_returns_back() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::BTN_BACK.0, 1);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Back,
pressed: true,
device: None,
})
);
}
#[test]
fn translate_btn_side_returns_back() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::BTN_SIDE.0, 1);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Back,
pressed: true,
device: None,
})
);
}
#[test]
fn translate_btn_forward_returns_forward() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::BTN_FORWARD.0, 1);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Forward,
pressed: true,
device: None,
})
);
}
#[test]
fn translate_rel_x_returns_horizontal_move() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_X.0, 7);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Moved {
delta_x: 7,
delta_y: 0
})
);
}
#[test]
fn translate_rel_y_returns_vertical_move() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_Y.0, -4);
assert_matches!(
translate(&event, false),
Some(MouseEvent::Moved {
delta_x: 0,
delta_y: -4
})
);
}
#[test]
fn translate_rel_wheel_returns_scroll_y() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 3);
let result = translate(&event, false);
assert!(
matches!(result, Some(MouseEvent::Scroll { delta, .. })
if delta == crate::ScrollDelta::wheel_ticks(0.0, 3.0)),
"expected a three-tick vertical scroll, got {result:?}"
);
}
#[test]
fn translate_rel_hwheel_returns_scroll_x() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_HWHEEL.0, -2);
let result = translate(&event, false);
assert!(
matches!(result, Some(MouseEvent::Scroll { delta, .. })
if delta == crate::ScrollDelta::wheel_ticks(-2.0, 0.0)),
"expected a negative two-tick horizontal scroll, got {result:?}"
);
}
#[test]
fn translate_hires_wheel_returns_fractional_scroll_y() {
let event = InputEvent::new(
EventType::RELATIVE.0,
RelativeAxisCode::REL_WHEEL_HI_RES.0,
60,
);
let result = translate(&event, true);
assert!(
matches!(result, Some(MouseEvent::Scroll { delta, .. })
if delta == crate::ScrollDelta::wheel_ticks(0.0, 0.5)),
"expected a half-tick vertical scroll, got {result:?}"
);
}
#[test]
fn translate_hires_hwheel_returns_fractional_scroll_x() {
let event = InputEvent::new(
EventType::RELATIVE.0,
RelativeAxisCode::REL_HWHEEL_HI_RES.0,
-120,
);
let result = translate(&event, true);
assert!(
matches!(result, Some(MouseEvent::Scroll { delta, .. })
if delta == crate::ScrollDelta::wheel_ticks(-1.0, 0.0)),
"expected a negative one-tick horizontal scroll, got {result:?}"
);
}
#[test]
fn translate_low_res_wheel_skipped_when_hires_active() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 1);
assert!(translate(&event, true).is_none());
}
#[test]
fn translate_low_res_hwheel_skipped_when_hires_active() {
let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_HWHEEL.0, 1);
assert!(translate(&event, true).is_none());
}
#[test]
fn translate_non_mouse_key_returns_none() {
let event = InputEvent::new(EventType::KEY.0, KeyCode::KEY_A.0, 1);
assert!(translate(&event, false).is_none());
}
#[test]
fn translate_sync_event_returns_none() {
let event = InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0);
assert!(translate(&event, false).is_none());
}
use evdev::{AbsoluteAxisCode, AttributeSet, PropType};
struct Caps {
name: Option<&'static str>,
keys: Option<AttributeSet<KeyCode>>,
rel: Option<AttributeSet<RelativeAxisCode>>,
abs: Option<AttributeSet<AbsoluteAxisCode>>,
props: AttributeSet<PropType>,
vendor_id: u32,
}
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(),
vendor_id: LOGITECH_VENDOR_ID,
}
}
fn is_hookable(&self) -> bool {
is_hookable_mouse(
self.name,
self.keys.as_deref(),
self.rel.as_deref(),
self.abs.as_deref(),
&self.props,
self.vendor_id,
)
}
}
#[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 non_logitech_mouse_is_not_hookable() {
let mut caps = Caps::mouse();
caps.name = Some("Apple SPI Trackpad");
caps.vendor_id = 0x05ac;
assert!(
!caps.is_hookable(),
"foreign vendors must never be exclusively grabbed"
);
caps.name = Some("Generic USB Mouse");
caps.vendor_id = 0x1234;
assert!(!caps.is_hookable());
}
#[test]
fn touchpad_is_not_hookable() {
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() {
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() {
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() {
let mut caps = Caps::mouse();
caps.abs = Some([AbsoluteAxisCode::ABS_MISC].into_iter().collect());
assert!(caps.is_hookable());
}
}