1use std::collections::HashMap;
9use std::collections::HashSet;
10use std::ffi::CString;
11use std::io;
12use std::mem::size_of;
13use std::os::fd::AsRawFd;
14use std::os::fd::FromRawFd;
15use std::os::fd::OwnedFd;
16use std::os::fd::RawFd;
17use std::os::unix::ffi::OsStrExt;
18use std::path::Path;
19use std::path::PathBuf;
20
21use evdev::Device;
22use evdev::EventSummary;
23use evdev::InputEvent;
24use evdev::KeyCode;
25use kbd::key::Key;
26use kbd::key_state::KeyTransition;
27
28use crate::EvdevKeyCodeExt;
29use crate::forwarder::VIRTUAL_DEVICE_NAME;
30
31pub const INPUT_DIRECTORY: &str = "/dev/input";
36const HOTPLUG_BUFFER_SIZE: usize = 4096;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DeviceGrabMode {
41 Shared,
43 Exclusive,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub(crate) struct HotplugFsEvent {
49 pub(crate) mask: u32,
50 pub(crate) device_name: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub(crate) enum HotplugPathChange {
55 Added(PathBuf),
56 Removed(PathBuf),
57 Unchanged,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum DiscoveryOutcome {
62 Keyboard,
63 NotKeyboard,
64 Skip,
65}
66
67#[derive(Debug)]
68struct ManagedDevice {
69 path: PathBuf,
70 device: Device,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct DeviceKeyEvent {
79 pub device_fd: RawFd,
81 pub key: Key,
83 pub transition: KeyTransition,
85}
86
87#[derive(Debug)]
93pub struct PollResult {
94 pub key_events: Vec<DeviceKeyEvent>,
96 pub disconnected_devices: Vec<RawFd>,
99}
100
101#[derive(Debug)]
116pub struct DeviceManager {
117 input_dir: PathBuf,
118 grab_mode: DeviceGrabMode,
119 inotify_fd: Option<OwnedFd>,
120 devices: HashMap<RawFd, ManagedDevice>,
121 poll_fds: Vec<RawFd>,
122}
123
124impl Default for DeviceManager {
125 fn default() -> Self {
126 Self::new(Path::new(INPUT_DIRECTORY), DeviceGrabMode::Shared)
127 }
128}
129
130impl DeviceManager {
131 #[must_use]
140 pub fn new(input_dir: &Path, grab_mode: DeviceGrabMode) -> Self {
141 let mut manager = Self {
142 input_dir: input_dir.to_path_buf(),
143 grab_mode,
144 inotify_fd: initialize_inotify(input_dir).ok(),
145 devices: HashMap::new(),
146 poll_fds: Vec::new(),
147 };
148
149 manager.discover_existing_devices();
150 manager.rebuild_poll_fds();
151 manager
152 }
153
154 fn discover_existing_devices(&mut self) {
155 let discover_result =
156 discover_devices_in_dir_with(&self.input_dir, DiscoveryOutcome::probe);
157
158 if let Ok(paths) = discover_result {
159 for path in paths {
160 self.add_device_path(&path);
161 }
162 }
163 }
164
165 fn add_device_path(&mut self, path: &Path) {
166 if self.devices.values().any(|device| device.path == path) {
167 return;
168 }
169
170 let open_result = ManagedDevice::open(path, self.grab_mode);
171 let Some(device) = open_result.ok().flatten() else {
172 return;
173 };
174
175 let fd = device.device.as_raw_fd();
176 self.devices.insert(fd, device);
177 self.rebuild_poll_fds();
178 }
179
180 fn remove_device_fd(&mut self, fd: RawFd) -> bool {
181 if self.devices.remove(&fd).is_some() {
182 self.rebuild_poll_fds();
183 true
184 } else {
185 false
186 }
187 }
188
189 fn remove_device_path(&mut self, path: &Path) -> Option<RawFd> {
190 let fd = self
191 .devices
192 .iter()
193 .find_map(|(&fd, device)| (device.path == path).then_some(fd))?;
194
195 if self.remove_device_fd(fd) {
196 Some(fd)
197 } else {
198 None
199 }
200 }
201
202 fn rebuild_poll_fds(&mut self) {
203 self.poll_fds.clear();
204
205 if let Some(inotify_fd) = self.inotify_fd.as_ref() {
206 self.poll_fds.push(inotify_fd.as_raw_fd());
207 }
208
209 let mut device_fds: Vec<_> = self.devices.keys().copied().collect();
210 device_fds.sort_unstable();
211 self.poll_fds.extend(device_fds);
212 }
213
214 fn process_hotplug_events(&mut self, disconnected: &mut Vec<RawFd>) {
215 let Some(inotify_fd) = self.inotify_fd.as_ref().map(AsRawFd::as_raw_fd) else {
216 return;
217 };
218
219 let mut buffer = [0_u8; HOTPLUG_BUFFER_SIZE];
220 let mut known_paths: HashSet<PathBuf> = self
221 .devices
222 .values()
223 .map(|device| device.path.clone())
224 .collect();
225
226 loop {
227 let read_result = unsafe {
230 libc::read(
231 inotify_fd,
232 (&raw mut buffer).cast::<libc::c_void>(),
233 buffer.len(),
234 )
235 };
236
237 if read_result < 0 {
238 let error = io::Error::last_os_error();
239 if error.kind() == io::ErrorKind::Interrupted {
240 continue;
241 }
242 if error.kind() == io::ErrorKind::WouldBlock {
243 break;
244 }
245 break;
246 }
247
248 if read_result == 0 {
249 break;
250 }
251
252 let bytes_read = usize::try_from(read_result).unwrap_or(0);
253 for event in parse_hotplug_events(&buffer, bytes_read) {
254 match event.classify_change(&mut known_paths, &self.input_dir) {
255 HotplugPathChange::Added(path) => {
256 self.add_device_path(&path);
257 }
258 HotplugPathChange::Removed(path) => {
259 if let Some(fd) = self.remove_device_path(&path) {
260 disconnected.push(fd);
261 }
262 }
263 HotplugPathChange::Unchanged => {}
264 }
265 }
266 }
267 }
268
269 fn process_device_fd(
270 &mut self,
271 fd: RawFd,
272 revents: i16,
273 collected_events: &mut Vec<DeviceKeyEvent>,
274 disconnected: &mut Vec<RawFd>,
275 ) {
276 if (revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL)) != 0 {
277 if self.remove_device_fd(fd) {
278 disconnected.push(fd);
279 }
280 return;
281 }
282
283 if (revents & libc::POLLIN) == 0 {
284 return;
285 }
286
287 let Some(device) = self.devices.get_mut(&fd) else {
288 return;
289 };
290
291 match device.device.read_key_events() {
292 Ok(events) => {
293 for event in events {
294 collected_events.push(DeviceKeyEvent {
295 device_fd: fd,
296 key: event.key,
297 transition: event.transition,
298 });
299 }
300 }
301 Err(error) if should_drop_device(&error) => {
302 if self.remove_device_fd(fd) {
303 disconnected.push(fd);
304 }
305 }
306 Err(_) => {}
307 }
308 }
309
310 #[must_use]
315 pub fn poll_fds(&self) -> &[RawFd] {
316 &self.poll_fds
317 }
318
319 pub fn process_polled_events(&mut self, polled_fds: &[libc::pollfd]) -> PollResult {
324 let mut key_events = Vec::new();
325 let mut disconnected_devices = Vec::new();
326
327 let ready_fds: Vec<_> = polled_fds
328 .iter()
329 .filter(|pollfd| pollfd.revents != 0)
330 .map(|pollfd| (pollfd.fd, pollfd.revents))
331 .collect();
332
333 for (fd, revents) in ready_fds {
334 if self
335 .inotify_fd
336 .as_ref()
337 .is_some_and(|inotify_fd| inotify_fd.as_raw_fd() == fd)
338 {
339 self.process_hotplug_events(&mut disconnected_devices);
340 } else {
341 self.process_device_fd(fd, revents, &mut key_events, &mut disconnected_devices);
342 }
343 }
344
345 PollResult {
346 key_events,
347 disconnected_devices,
348 }
349 }
350}
351
352fn initialize_inotify(input_dir: &Path) -> io::Result<OwnedFd> {
353 let raw_fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
355 if raw_fd < 0 {
356 return Err(io::Error::last_os_error());
357 }
358
359 let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };
361 let path_cstr = CString::new(input_dir.as_os_str().as_bytes()).map_err(|_| {
362 io::Error::new(
363 io::ErrorKind::InvalidInput,
364 "input directory contained interior NUL byte",
365 )
366 })?;
367
368 let watch_result = unsafe {
371 libc::inotify_add_watch(
372 fd.as_raw_fd(),
373 path_cstr.as_ptr(),
374 libc::IN_CREATE
375 | libc::IN_DELETE
376 | libc::IN_MOVED_FROM
377 | libc::IN_MOVED_TO
378 | libc::IN_DELETE_SELF
379 | libc::IN_MOVE_SELF,
380 )
381 };
382
383 if watch_result < 0 {
384 return Err(io::Error::last_os_error());
385 }
386
387 Ok(fd)
388}
389
390impl DiscoveryOutcome {
391 fn probe(path: &Path) -> Self {
392 let Ok(device) = Device::open(path) else {
393 return Self::Skip;
394 };
395
396 if device.is_virtual_forwarder() {
397 return Self::Skip;
398 }
399
400 if device.is_keyboard() {
401 Self::Keyboard
402 } else {
403 Self::NotKeyboard
404 }
405 }
406}
407
408impl ManagedDevice {
409 fn open(path: &Path, grab_mode: DeviceGrabMode) -> io::Result<Option<Self>> {
410 let mut device = Device::open(path)?;
411
412 if !device.is_keyboard() {
413 return Ok(None);
414 }
415
416 if device.is_virtual_forwarder() {
417 return Ok(None);
418 }
419
420 if matches!(grab_mode, DeviceGrabMode::Exclusive) {
421 device.grab()?;
422 }
423
424 device.set_nonblocking(true)?;
425
426 Ok(Some(Self {
427 path: path.to_path_buf(),
428 device,
429 }))
430 }
431}
432
433trait DeviceExt {
434 fn is_virtual_forwarder(&self) -> bool;
439
440 fn is_keyboard(&self) -> bool;
442
443 fn read_key_events(&mut self) -> io::Result<Vec<ObservedKeyEvent>>;
445}
446
447impl DeviceExt for Device {
448 fn is_virtual_forwarder(&self) -> bool {
449 self.name().is_some_and(|name| name == VIRTUAL_DEVICE_NAME)
450 }
451
452 fn is_keyboard(&self) -> bool {
453 self.supported_keys().is_some_and(|supported_keys| {
454 supported_keys.contains(KeyCode::KEY_A)
455 && supported_keys.contains(KeyCode::KEY_Z)
456 && supported_keys.contains(KeyCode::KEY_ENTER)
457 })
458 }
459
460 fn read_key_events(&mut self) -> io::Result<Vec<ObservedKeyEvent>> {
461 let mut events = Vec::new();
462
463 for event in self.fetch_events()? {
464 if let Some(observed) = ObservedKeyEvent::from_input_event(event) {
465 events.push(observed);
466 }
467 }
468
469 Ok(events)
470 }
471}
472
473pub(crate) fn discover_devices_in_dir_with<F>(
474 input_dir: &Path,
475 mut classify: F,
476) -> io::Result<Vec<PathBuf>>
477where
478 F: FnMut(&Path) -> DiscoveryOutcome,
479{
480 let mut device_paths = Vec::new();
481
482 for entry_result in std::fs::read_dir(input_dir)? {
483 let Ok(entry) = entry_result else {
484 continue;
485 };
486
487 let path = entry.path();
488 let Some(name) = path.file_name().and_then(|candidate| candidate.to_str()) else {
489 continue;
490 };
491
492 if !name.starts_with("event") {
493 continue;
494 }
495
496 if matches!(classify(&path), DiscoveryOutcome::Keyboard) {
497 device_paths.push(path);
498 }
499 }
500
501 device_paths.sort_unstable();
502 Ok(device_paths)
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506struct ObservedKeyEvent {
507 key: Key,
508 transition: KeyTransition,
509}
510
511impl ObservedKeyEvent {
512 fn from_input_event(event: InputEvent) -> Option<Self> {
513 match event.destructure() {
514 EventSummary::Key(_, key_code, value) => {
515 let transition = key_transition(value)?;
516 let key = key_code.to_key();
517 if key == Key::UNIDENTIFIED {
518 return None;
519 }
520 Some(Self { key, transition })
521 }
522 _ => None,
523 }
524 }
525}
526
527fn key_transition(value: i32) -> Option<KeyTransition> {
528 match value {
529 1 => Some(KeyTransition::Press),
530 0 => Some(KeyTransition::Release),
531 2 => Some(KeyTransition::Repeat),
532 _ => None,
533 }
534}
535
536fn should_drop_device(error: &io::Error) -> bool {
537 error.raw_os_error() == Some(libc::ENODEV)
538 || error.kind() == io::ErrorKind::NotFound
539 || error.kind() == io::ErrorKind::UnexpectedEof
540}
541
542impl HotplugFsEvent {
543 pub fn classify_change(
544 &self,
545 known_paths: &mut HashSet<PathBuf>,
546 input_dir: &Path,
547 ) -> HotplugPathChange {
548 if !self.device_name.starts_with("event") {
549 return HotplugPathChange::Unchanged;
550 }
551
552 let path = input_dir.join(&self.device_name);
553
554 if self.mask & (libc::IN_CREATE | libc::IN_MOVED_TO) != 0
555 && known_paths.insert(path.clone())
556 {
557 return HotplugPathChange::Added(path);
558 }
559
560 if self.mask
561 & (libc::IN_DELETE | libc::IN_MOVED_FROM | libc::IN_DELETE_SELF | libc::IN_MOVE_SELF)
562 != 0
563 {
564 known_paths.remove(&path);
565 return HotplugPathChange::Removed(path);
566 }
567
568 HotplugPathChange::Unchanged
569 }
570}
571
572#[must_use]
573pub(crate) fn parse_hotplug_events(buffer: &[u8], bytes_read: usize) -> Vec<HotplugFsEvent> {
574 let mut events = Vec::new();
575 let mut offset = 0_usize;
576
577 while offset + size_of::<libc::inotify_event>() <= bytes_read {
578 #[allow(clippy::cast_ptr_alignment)]
579 let event_ptr = buffer[offset..].as_ptr().cast::<libc::inotify_event>();
580 let event = unsafe { std::ptr::read_unaligned(event_ptr) };
584
585 let name_start = offset + size_of::<libc::inotify_event>();
586 let Some(name_end) = name_start.checked_add(event.len as usize) else {
587 break;
588 };
589
590 let device_name = if event.len > 0 && name_end <= bytes_read {
591 parse_hotplug_name(&buffer[name_start..name_end])
592 } else {
593 String::new()
594 };
595
596 events.push(HotplugFsEvent {
597 mask: event.mask,
598 device_name,
599 });
600
601 let Some(next_offset) =
602 offset.checked_add(size_of::<libc::inotify_event>() + event.len as usize)
603 else {
604 break;
605 };
606
607 offset = next_offset;
608 }
609
610 events
611}
612
613fn parse_hotplug_name(name_bytes: &[u8]) -> String {
614 let name_end = name_bytes
615 .iter()
616 .position(|&byte| byte == 0)
617 .unwrap_or(name_bytes.len());
618
619 String::from_utf8_lossy(&name_bytes[..name_end]).into_owned()
620}
621
622#[cfg(test)]
623mod tests {
624 use std::path::Path;
625 use std::time::SystemTime;
626 use std::time::UNIX_EPOCH;
627
628 use evdev::EventType;
629 use evdev::InputEvent;
630 use evdev::KeyCode;
631 use kbd::key::Key;
632 use kbd::key_state::KeyTransition;
633
634 use super::DiscoveryOutcome;
635 use super::HotplugFsEvent;
636 use super::HotplugPathChange;
637 use super::ObservedKeyEvent;
638 use super::discover_devices_in_dir_with;
639 use super::parse_hotplug_events;
640 use crate::forwarder::VIRTUAL_DEVICE_NAME;
641
642 #[test]
643 fn discover_event_devices_ignores_non_event_entries_and_non_keyboards() {
644 let temp = unique_test_dir();
645
646 std::fs::create_dir_all(&temp).expect("temp dir should be created");
647 std::fs::File::create(temp.join("event0"))
648 .expect("event0 should be created for discovery test");
649 std::fs::File::create(temp.join("event1"))
650 .expect("event1 should be created for discovery test");
651 std::fs::File::create(temp.join("mouse0"))
652 .expect("mouse0 should be created for discovery test");
653
654 let keyboards = discover_devices_in_dir_with(&temp, |path| {
655 match path.file_name().and_then(|name| name.to_str()) {
656 Some("event0") => DiscoveryOutcome::Keyboard,
657 Some("event1") => DiscoveryOutcome::NotKeyboard,
658 _ => DiscoveryOutcome::Skip,
659 }
660 })
661 .expect("discovery should succeed for temp dir");
662
663 assert_eq!(keyboards, vec![temp.join("event0")]);
664
665 std::fs::remove_dir_all(temp).expect("temp dir should be removed");
666 }
667
668 #[test]
669 fn parse_hotplug_events_extracts_device_names() {
670 let mut buffer = Vec::new();
671
672 append_inotify_event(&mut buffer, libc::IN_CREATE, "event3");
673 append_inotify_event(&mut buffer, libc::IN_DELETE, "mouse0");
674
675 let events = parse_hotplug_events(&buffer, buffer.len());
676 assert_eq!(
677 events,
678 vec![
679 HotplugFsEvent {
680 mask: libc::IN_CREATE,
681 device_name: "event3".into(),
682 },
683 HotplugFsEvent {
684 mask: libc::IN_DELETE,
685 device_name: "mouse0".into(),
686 },
687 ]
688 );
689 }
690
691 #[test]
692 fn classify_hotplug_change_distinguishes_add_remove_and_ignore() {
693 let mut known_paths = std::collections::HashSet::new();
694 let input_dir = Path::new("/dev/input");
695
696 let add_event = HotplugFsEvent {
697 mask: libc::IN_CREATE,
698 device_name: "event7".into(),
699 };
700 let added = add_event.classify_change(&mut known_paths, input_dir);
701 assert_eq!(added, HotplugPathChange::Added(input_dir.join("event7")));
702
703 let remove_event = HotplugFsEvent {
704 mask: libc::IN_DELETE,
705 device_name: "event7".into(),
706 };
707 let removed = remove_event.classify_change(&mut known_paths, input_dir);
708 assert_eq!(
709 removed,
710 HotplugPathChange::Removed(input_dir.join("event7"))
711 );
712
713 let ignored = HotplugFsEvent {
714 mask: libc::IN_CREATE,
715 device_name: "js0".into(),
716 }
717 .classify_change(&mut known_paths, input_dir);
718 assert_eq!(ignored, HotplugPathChange::Unchanged);
719 }
720
721 #[test]
722 fn virtual_forwarder_name_is_detected() {
723 let is_forwarder = |name: &str| name == VIRTUAL_DEVICE_NAME;
724
725 assert!(is_forwarder(VIRTUAL_DEVICE_NAME));
726 assert!(!is_forwarder("AT Translated Set 2 keyboard"));
727 assert!(!is_forwarder("Logitech USB Keyboard"));
728 assert!(!is_forwarder(""));
729 }
730
731 #[test]
732 fn key_input_events_are_converted_to_domain_keys() {
733 let press = ObservedKeyEvent::from_input_event(InputEvent::new(
734 EventType::KEY.0,
735 KeyCode::KEY_C.0,
736 1,
737 ));
738 assert_eq!(
739 press,
740 Some(ObservedKeyEvent {
741 key: Key::C,
742 transition: KeyTransition::Press,
743 })
744 );
745
746 let release = ObservedKeyEvent::from_input_event(InputEvent::new(
747 EventType::KEY.0,
748 KeyCode::KEY_C.0,
749 0,
750 ));
751 assert_eq!(
752 release,
753 Some(ObservedKeyEvent {
754 key: Key::C,
755 transition: KeyTransition::Release,
756 })
757 );
758
759 let repeat = ObservedKeyEvent::from_input_event(InputEvent::new(
760 EventType::KEY.0,
761 KeyCode::KEY_C.0,
762 2,
763 ));
764 assert_eq!(
765 repeat,
766 Some(ObservedKeyEvent {
767 key: Key::C,
768 transition: KeyTransition::Repeat,
769 })
770 );
771
772 let ignored = ObservedKeyEvent::from_input_event(InputEvent::new(
773 EventType::KEY.0,
774 KeyCode::new(1023).0,
775 1,
776 ));
777 assert_eq!(ignored, None);
778 }
779
780 fn unique_test_dir() -> std::path::PathBuf {
781 let nanos = SystemTime::now()
782 .duration_since(UNIX_EPOCH)
783 .expect("system clock should be after unix epoch")
784 .as_nanos();
785
786 std::env::temp_dir().join(format!(
787 "kbd-discovery-test-{}-{}",
788 std::process::id(),
789 nanos
790 ))
791 }
792
793 fn append_inotify_event(buffer: &mut Vec<u8>, mask: u32, name: &str) {
794 let mut name_bytes = name.as_bytes().to_vec();
795 name_bytes.push(0);
796
797 let event = libc::inotify_event {
798 wd: 1,
799 mask,
800 cookie: 0,
801 len: u32::try_from(name_bytes.len()).expect("name should fit in u32"),
802 };
803
804 let event_bytes = unsafe {
806 std::slice::from_raw_parts(
807 (&raw const event).cast::<u8>(),
808 std::mem::size_of::<libc::inotify_event>(),
809 )
810 };
811
812 buffer.extend_from_slice(event_bytes);
813 buffer.extend_from_slice(&name_bytes);
814 }
815}