From def61ddc58037900f90462a2bfa6401e4941c882 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 22 Jan 2026 12:13:07 +0100 Subject: [PATCH] Replace inotify with notify crate for device hotplug detection This switches the device hotplug detection in the evdev listener from using inotify directly to using the notify crate, which abstracts filesystem watching across platforms. Benefits: - Cleaner dependency (notify is already used for status --follow) - The notify crate is better maintained and more widely used - Prepares codebase for future cross-platform support The notify crate uses inotify internally on Linux, so behavior is unchanged on the primary platform. --- Cargo.lock | 22 +---- Cargo.toml | 3 +- src/hotkey/evdev_listener.rs | 177 +++++++++++++++++++++++++---------- 3 files changed, 129 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3aeec73..80377e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,12 +734,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - [[package]] name = "getrandom" version = "0.2.16" @@ -950,19 +944,6 @@ dependencies = [ "libc", ] -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "futures-core", - "inotify-sys", - "libc", - "tokio", -] - [[package]] name = "inotify-sys" version = "0.1.5" @@ -1356,7 +1337,7 @@ dependencies = [ "crossbeam-channel", "filetime", "fsevent-sys", - "inotify 0.9.6", + "inotify", "kqueue", "libc", "log", @@ -2617,7 +2598,6 @@ dependencies = [ "directories", "evdev", "hound", - "inotify 0.10.2", "libc", "nix 0.29.0", "notify", diff --git a/Cargo.toml b/Cargo.toml index df16aa1..e220cc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ async-trait = "0.1" # Input handling (evdev for kernel-level key events) evdev = "0.12" libc = "0.2" -inotify = "0.10" # Watch /dev/input for device hotplug nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC # Audio capture @@ -64,7 +63,7 @@ parakeet-rs = { version = "0.2.9", optional = true } # CPU count for thread detection num_cpus = "1.16" -# File watching for status --follow +# File and device watching (status --follow, device hotplug) notify = "6" # Single instance check diff --git a/src/hotkey/evdev_listener.rs b/src/hotkey/evdev_listener.rs index fdb2ed4..2013a62 100644 --- a/src/hotkey/evdev_listener.rs +++ b/src/hotkey/evdev_listener.rs @@ -3,8 +3,9 @@ //! Uses the Linux evdev interface to detect key presses at the kernel level. //! This works on all Wayland compositors because it bypasses the display server. //! -//! Uses inotify to detect device changes (hotplug, screenlock, suspend/resume) -//! and automatically re-enumerates devices when needed. +//! Uses the notify crate to detect device changes (hotplug, screenlock, suspend/resume) +//! and automatically re-enumerates devices when needed. The notify crate provides +//! cross-platform filesystem watching (inotify on Linux, FSEvents on macOS). //! //! The user must be in the 'input' group to access /dev/input/* devices. @@ -12,10 +13,11 @@ use super::{HotkeyEvent, HotkeyListener}; use crate::config::HotkeyConfig; use crate::error::HotkeyError; use evdev::{Device, InputEventKind, Key}; -use inotify::{Inotify, WatchMask}; +use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{HashMap, HashSet}; use std::os::unix::io::AsRawFd; use std::path::PathBuf; +use std::sync::mpsc as std_mpsc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; @@ -119,35 +121,49 @@ impl HotkeyListener for EvdevListener { } } -/// Manages input devices with hotplug detection via inotify +/// Manages input devices with hotplug detection via notify crate struct DeviceManager { /// Map of device path to opened device devices: HashMap, - /// inotify instance watching /dev/input - inotify: Inotify, - /// Buffer for inotify events - inotify_buffer: [u8; 1024], + /// Filesystem watcher for /dev/input (kept alive to maintain watch) + #[allow(dead_code)] + watcher: RecommendedWatcher, + /// Receiver for filesystem events + fs_event_rx: std_mpsc::Receiver>, /// Last time we did a full validation last_validation: Instant, } impl DeviceManager { - /// Create a new device manager with inotify watcher + /// Create a new device manager with filesystem watcher fn new() -> Result { - let inotify = Inotify::init().map_err(|e| { - HotkeyError::DeviceAccess(format!("Failed to initialize inotify: {}", e)) + // Set up channel for filesystem events + let (tx, rx) = std_mpsc::channel(); + + // Create filesystem watcher using the notify crate + // On Linux this uses inotify internally, on macOS it uses FSEvents + let mut watcher = RecommendedWatcher::new( + move |res| { + let _ = tx.send(res); + }, + NotifyConfig::default(), + ) + .map_err(|e| { + HotkeyError::DeviceAccess(format!("Failed to initialize filesystem watcher: {}", e)) })?; // Watch /dev/input for device creation and deletion - inotify - .watches() - .add("/dev/input", WatchMask::CREATE | WatchMask::DELETE) + watcher + .watch( + std::path::Path::new("/dev/input"), + RecursiveMode::NonRecursive, + ) .map_err(|e| HotkeyError::DeviceAccess(format!("Failed to watch /dev/input: {}", e)))?; let mut manager = Self { devices: HashMap::new(), - inotify, - inotify_buffer: [0u8; 1024], + watcher, + fs_event_rx: rx, last_validation: Instant::now(), }; @@ -233,45 +249,51 @@ impl DeviceManager { } } - /// Check inotify for device changes (non-blocking) + /// Check for device changes (non-blocking) /// Returns true if devices changed fn check_for_device_changes(&mut self) -> bool { - // Set inotify to non-blocking for this check - let fd = self.inotify.as_raw_fd(); - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags != -1 { - libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } + let mut changed = false; - let events = match self.inotify.read_events(&mut self.inotify_buffer) { - Ok(events) => events, - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - return false; - } - Err(e) => { - tracing::warn!("inotify read error: {}", e); - return false; - } - }; + // Drain all pending filesystem events (non-blocking) + loop { + match self.fs_event_rx.try_recv() { + Ok(Ok(event)) => { + // Check if this is a create or remove event for an event* device + for path in event.paths { + let is_event_device = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + + if !is_event_device { + continue; + } - let mut changed = false; - for event in events { - if let Some(name) = event.name { - let name_str = name.to_string_lossy(); - if name_str.starts_with("event") { - let path = PathBuf::from("/dev/input").join(&*name_str); - - if event.mask.contains(inotify::EventMask::CREATE) { - tracing::debug!("Device created: {:?}", path); - changed = true; - } else if event.mask.contains(inotify::EventMask::DELETE) { - tracing::debug!("Device removed: {:?}", path); - self.devices.remove(&path); - changed = true; + match event.kind { + notify::EventKind::Create(_) => { + tracing::debug!("Device created: {:?}", path); + changed = true; + } + notify::EventKind::Remove(_) => { + tracing::debug!("Device removed: {:?}", path); + self.devices.remove(&path); + changed = true; + } + _ => {} + } } } + Ok(Err(e)) => { + tracing::warn!("Filesystem watch error: {}", e); + } + Err(std_mpsc::TryRecvError::Empty) => { + // No more events pending + break; + } + Err(std_mpsc::TryRecvError::Disconnected) => { + tracing::warn!("Filesystem watcher disconnected"); + break; + } } } @@ -417,7 +439,7 @@ fn evdev_listener_loop( Err(oneshot::error::TryRecvError::Empty) => {} } - // Check inotify for device changes + // Check for device changes (filesystem events) if manager.check_for_device_changes() { // Clear state when devices change active_modifiers.clear(); @@ -661,4 +683,59 @@ mod tests { fn test_parse_key_name_error() { assert!(parse_key_name("INVALID_KEY_NAME").is_err()); } + + #[test] + fn test_is_event_device_filter() { + // Test the device path filtering logic used in check_for_device_changes + let test_cases = [ + ("/dev/input/event0", true), + ("/dev/input/event123", true), + ("/dev/input/mouse0", false), + ("/dev/input/js0", false), + ("/dev/input/by-id/usb-keyboard", false), + ("/dev/input/eventfoo", true), // starts with event + ]; + + for (path_str, expected) in test_cases { + let path = PathBuf::from(path_str); + let is_event = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + assert_eq!( + is_event, expected, + "Path {} should be event device: {}", + path_str, expected + ); + } + } + + #[test] + fn test_notify_event_kind_matching() { + // Verify we correctly identify Create and Remove event kinds + // This tests the pattern matching used in check_for_device_changes + use notify::event::{CreateKind, RemoveKind}; + + let create_event = notify::EventKind::Create(CreateKind::File); + let remove_event = notify::EventKind::Remove(RemoveKind::File); + let modify_event = notify::EventKind::Modify(notify::event::ModifyKind::Any); + + // Test Create matching + let is_create = matches!(create_event, notify::EventKind::Create(_)); + assert!(is_create, "Create event should match Create pattern"); + + // Test Remove matching + let is_remove = matches!(remove_event, notify::EventKind::Remove(_)); + assert!(is_remove, "Remove event should match Remove pattern"); + + // Test that Modify does not match Create or Remove + let is_create_or_remove = matches!( + modify_event, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) + ); + assert!( + !is_create_or_remove, + "Modify event should not match Create or Remove" + ); + } }