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" + ); + } }