use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::sync::{
Arc, LazyLock,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use evdev::uinput::VirtualDevice;
use evdev::{
AbsoluteAxisCode, AttributeSetRef, Device, EventSummary, KeyCode, PropType, RelativeAxisCode,
};
use tracing::{debug, error, warn};
use x11rb::connection::Connection as _;
use x11rb::properties::WmClass;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, Window};
use x11rb::rust_connection::RustConnection;
use crate::{ButtonId, EventDisposition, 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) 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,
})
}
pub(crate) fn stop(inner: HookInner) {
shutdown(&inner.stop, &inner.stop_pipes, inner.threads);
}
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: f32, delta_y: f32) -> MouseEvent {
MouseEvent::Scroll {
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,
}),
_ => {
#[expect(
clippy::cast_precision_loss,
reason = "scroll deltas fit comfortably in the f32 mantissa"
)]
let v = value as f32;
if hires_scroll {
match axis {
RelativeAxisCode::REL_WHEEL_HI_RES => {
Some(scroll(0.0, v / HIRES_UNITS_PER_TICK))
}
RelativeAxisCode::REL_HWHEEL_HI_RES => {
Some(scroll(v / 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 + Sync {
fn frontmost_bundle_id(&self) -> Option<String>;
fn name(&self) -> &'static str;
}
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,
})
}
}
impl FrontmostSource for X11Source {
fn frontmost_bundle_id(&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 name(&self) -> &'static str {
"x11"
}
}
struct NullSource;
impl FrontmostSource for NullSource {
fn frontmost_bundle_id(&self) -> Option<String> {
None
}
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_bundle_id will return None");
Box::new(NullSource)
}
static FRONTMOST_SOURCE: LazyLock<Box<dyn FrontmostSource>> =
LazyLock::new(detect_frontmost_source);
pub(crate) fn frontmost_bundle_id() -> Option<String> {
FRONTMOST_SOURCE.frontmost_bundle_id()
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use evdev::{EventType, InputEvent, KeyCode, RelativeAxisCode};
use super::*;
#[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_x, delta_y, .. })
if delta_x.abs() < f32::EPSILON && (delta_y - 3.0).abs() < f32::EPSILON),
"expected Scroll {{ delta_x: 0.0, delta_y: 3.0 }}, 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_x, delta_y, .. })
if (delta_x - -2.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON),
"expected Scroll {{ delta_x: -2.0, delta_y: 0.0 }}, 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_x, delta_y, .. })
if delta_x.abs() < f32::EPSILON && (delta_y - 0.5).abs() < f32::EPSILON),
"expected Scroll {{ delta_x: 0.0, delta_y: 0.5 }}, 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_x, delta_y, .. })
if (delta_x - -1.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON),
"expected Scroll {{ delta_x: -1.0, delta_y: 0.0 }}, 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());
}
}