1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DeviceKind {
36 Keyboard,
38 Mouse,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct InputDevice {
45 pub name: String,
47 pub handlers: Vec<String>,
49 pub sysfs: String,
51 pub key_bits: Vec<u64>,
53}
54
55const ALPHA_KEYS: &[u32] = &[
60 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 30, 31, 32, 33, 34, 35, 36, 37, 38, 44, 45, 46, 47, 48, 49, 50, ];
64
65pub fn parse_bitmap(value: &str) -> Vec<u64> {
71 let mut words: Vec<u64> = value
72 .split_whitespace()
73 .filter_map(|w| u64::from_str_radix(w, 16).ok())
74 .collect();
75 words.reverse();
76 words
77}
78
79fn bit_set(bits: &[u64], bit: u32) -> bool {
81 let word = (bit / 64) as usize;
82 let offset = bit % 64;
83 bits.get(word).is_some_and(|w| (w >> offset) & 1 == 1)
84}
85
86pub fn has_alpha_block(key_bits: &[u64]) -> bool {
88 ALPHA_KEYS.iter().all(|&k| bit_set(key_bits, k))
89}
90
91pub fn parse_input_devices(content: &str) -> Vec<InputDevice> {
97 let mut out = Vec::new();
98 let mut cur = InputDevice::default();
99
100 let flush = |cur: &mut InputDevice, out: &mut Vec<InputDevice>| {
101 if !cur.name.is_empty() {
102 out.push(std::mem::take(cur));
103 } else {
104 *cur = InputDevice::default();
105 }
106 };
107
108 for line in content.lines() {
109 let line = line.trim_end();
110 if line.is_empty() {
111 flush(&mut cur, &mut out);
112 continue;
113 }
114 if let Some(rest) = line.strip_prefix("N: Name=") {
115 cur.name = rest.trim().trim_matches('"').to_string();
116 } else if let Some(rest) = line.strip_prefix("H: Handlers=") {
117 cur.handlers = rest.split_whitespace().map(|s| s.to_string()).collect();
118 } else if let Some(rest) = line.strip_prefix("S: Sysfs=") {
119 cur.sysfs = rest.trim().to_string();
120 } else if let Some(rest) = line.strip_prefix("B: KEY=") {
121 cur.key_bits = parse_bitmap(rest);
122 }
123 }
124 flush(&mut cur, &mut out);
125 out
126}
127
128pub fn classify_device<F>(dev: &InputDevice, model_name: F) -> Option<DeviceKind>
138where
139 F: Fn(&str) -> Option<String>,
140{
141 let has_kbd = dev.handlers.iter().any(|h| h == "kbd") && has_alpha_block(&dev.key_bits);
142 let has_ptr = dev.handlers.iter().any(|h| h.starts_with("mouse"));
143
144 match (has_kbd, has_ptr) {
145 (true, false) => Some(DeviceKind::Keyboard),
146 (false, true) => Some(DeviceKind::Mouse),
147 (false, false) => None,
148 (true, true) => {
151 let model = model_name(&dev.sysfs)?.to_lowercase();
152 if model.contains("keyboard") {
153 Some(DeviceKind::Keyboard)
154 } else if model.contains("mouse") || model.contains("trackball") {
155 Some(DeviceKind::Mouse)
156 } else {
157 None
158 }
159 }
160 }
161}
162
163pub fn classify_input_devices_with<F>(
169 devices: &[InputDevice],
170 model_name: F,
171) -> (Vec<String>, Vec<String>)
172where
173 F: Fn(&str) -> Option<String>,
174{
175 let mut keyboards: Vec<String> = Vec::new();
176 let mut mice: Vec<String> = Vec::new();
177
178 for dev in devices {
179 let target = match classify_device(dev, &model_name) {
180 Some(DeviceKind::Keyboard) => &mut keyboards,
181 Some(DeviceKind::Mouse) => &mut mice,
182 None => continue,
183 };
184 if !target.contains(&dev.name) {
185 target.push(dev.name.clone());
186 }
187 }
188 (keyboards, mice)
189}
190
191#[cfg(target_os = "linux")]
197fn hidpp_model_name(sysfs: &str) -> Option<String> {
198 use std::path::Path;
199 let hid_dir = Path::new("/sys")
200 .join(sysfs.trim_start_matches('/'))
201 .parent()?
202 .parent()?
203 .to_path_buf();
204 for entry in std::fs::read_dir(hid_dir.join("power_supply"))
205 .ok()?
206 .flatten()
207 {
208 if let Ok(model) = std::fs::read_to_string(entry.path().join("model_name")) {
209 let model = model.trim();
210 if !model.is_empty() {
211 return Some(model.to_string());
212 }
213 }
214 }
215 None
216}
217
218pub const HID_PAGE_GENERIC_DESKTOP: i64 = 1;
225pub const HID_USAGE_KEYBOARD: i64 = 6;
227pub const HID_USAGE_MOUSE: i64 = 2;
229
230pub fn classify_hid_interfaces(interfaces: &[(String, i64, i64)]) -> (Vec<String>, Vec<String>) {
248 let mut keyboards: Vec<String> = Vec::new();
249 let mut mice: Vec<String> = Vec::new();
250
251 for (name, page, usage) in interfaces {
252 if *page != HID_PAGE_GENERIC_DESKTOP {
253 continue;
254 }
255 let name = name.trim();
256 if name.is_empty() {
257 continue;
258 }
259 let target = match *usage {
260 HID_USAGE_KEYBOARD => &mut keyboards,
261 HID_USAGE_MOUSE => &mut mice,
262 _ => continue,
263 };
264 if !target.iter().any(|n| n == name) {
268 target.push(name.to_string());
269 }
270 }
271 (keyboards, mice)
272}
273
274pub fn detect_input_devices() -> (Vec<String>, Vec<String>) {
281 #[cfg(target_os = "linux")]
282 {
283 let Ok(content) = std::fs::read_to_string("/proc/bus/input/devices") else {
284 return (Vec::new(), Vec::new());
285 };
286 let devices = parse_input_devices(&content);
287 classify_input_devices_with(&devices, hidpp_model_name)
288 }
289 #[cfg(target_os = "macos")]
290 {
291 classify_hid_interfaces(&crate::macos_ffi::get_hid_interfaces())
292 }
293 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
294 {
295 (Vec::new(), Vec::new())
296 }
297}
298
299#[cfg(test)]
300mod macos_tests {
301 use super::*;
302
303 fn fixture() -> Vec<(String, i64, i64)> {
311 [
312 ("Apple Internal Keyboard / Trackpad", 65280, 3),
314 ("Apple Internal Keyboard / Trackpad", 65280, 13),
315 ("Keyboard Backlight", 65280, 15),
316 ("BTM", 65280, 72),
317 ("ktobias's Magic Keyboard", 65280, 75),
318 ("USB Receiver", 65280, 1),
319 ("Headset", 12, 1),
321 ("Logitech BRIO", 12, 1),
322 ("Apple Internal Keyboard / Trackpad", 1, 6), ("Apple Internal Keyboard / Trackpad", 1, 2), ("ktobias's Magic Keyboard", 1, 6),
326 ("USB Receiver", 1, 2),
327 ("USB Receiver", 1, 6),
328 ]
329 .into_iter()
330 .map(|(n, p, u)| (n.to_string(), p, u))
331 .collect()
332 }
333
334 #[test]
335 fn test_classify_hid_interfaces_matches_the_real_machine() {
336 let (keyboards, mice) = classify_hid_interfaces(&fixture());
337 assert_eq!(
338 keyboards,
339 vec![
340 "Apple Internal Keyboard / Trackpad",
341 "ktobias's Magic Keyboard",
342 "USB Receiver",
343 ]
344 );
345 assert_eq!(
346 mice,
347 vec!["Apple Internal Keyboard / Trackpad", "USB Receiver"]
348 );
349 }
350
351 #[test]
358 fn test_composite_device_is_listed_under_both_roles() {
359 let (keyboards, mice) = classify_hid_interfaces(&fixture());
360 let composite = "Apple Internal Keyboard / Trackpad";
361 assert!(keyboards.iter().any(|n| n == composite));
362 assert!(mice.iter().any(|n| n == composite));
363 }
364
365 #[test]
374 fn test_vendor_page_devices_are_not_reported() {
375 let (keyboards, mice) = classify_hid_interfaces(&fixture());
376 for list in [&keyboards, &mice] {
377 assert!(!list.iter().any(|n| n == "Keyboard Backlight"));
378 assert!(!list.iter().any(|n| n == "BTM"));
379 assert!(!list.iter().any(|n| n == "Headset"));
380 assert!(!list.iter().any(|n| n == "Logitech BRIO"));
381 }
382 }
383
384 #[test]
395 fn test_page_filter_rejects_a_vendor_usage_that_collides_with_keyboard() {
396 let interfaces = vec![
397 ("Vendor Widget".to_string(), 65280, HID_USAGE_KEYBOARD),
398 ("Vendor Widget".to_string(), 65280, HID_USAGE_MOUSE),
399 (
400 "Real Keyboard".to_string(),
401 HID_PAGE_GENERIC_DESKTOP,
402 HID_USAGE_KEYBOARD,
403 ),
404 ];
405 let (keyboards, mice) = classify_hid_interfaces(&interfaces);
406 assert_eq!(keyboards, vec!["Real Keyboard"]);
407 assert!(mice.is_empty());
408 }
409
410 #[test]
411 fn test_names_are_deduplicated_and_blanks_dropped() {
412 let interfaces = vec![
413 ("Dup".to_string(), 1, 6),
414 ("Dup".to_string(), 1, 6),
415 (" ".to_string(), 1, 6),
416 ("Trimmed ".to_string(), 1, 2),
417 ("Trimmed".to_string(), 1, 2),
418 ];
419 let (keyboards, mice) = classify_hid_interfaces(&interfaces);
420 assert_eq!(keyboards, vec!["Dup"]);
421 assert_eq!(mice, vec!["Trimmed"]);
422 }
423
424 #[test]
428 fn test_other_generic_desktop_usages_are_not_input_devices() {
429 let interfaces = vec![
430 ("Joystick".to_string(), 1, 4),
431 ("Gamepad".to_string(), 1, 5),
432 ("Pointer".to_string(), 1, 1),
433 ("Keypad".to_string(), 1, 7),
434 ];
435 let (keyboards, mice) = classify_hid_interfaces(&interfaces);
436 assert!(keyboards.is_empty());
437 assert!(mice.is_empty());
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 const FIXTURE: &str = r#"I: Bus=0019 Vendor=0000 Product=0001 Version=0000
450N: Name="Power Button"
451P: Phys=PNP0C0C/button/input0
452S: Sysfs=/devices/platform/PNP0C0C:00/input/input1
453U: Uniq=
454H: Handlers=kbd event1
455B: PROP=0
456B: EV=3
457B: KEY=8000 10000000000000 0
458
459I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
460N: Name="AT Translated Set 2 keyboard"
461P: Phys=isa0060/serio0/input0
462S: Sysfs=/devices/platform/i8042/serio0/input/input3
463U: Uniq=
464H: Handlers=sysrq kbd leds event3
465B: PROP=0
466B: EV=120013
467B: KEY=2000000000000000 0 40000 0 0 0 0 11100f02902007 f780307cfb10f001 feffffdfffcfffff fffffffffffffffe
468
469I: Bus=0011 Vendor=0002 Product=0001 Version=0000
470N: Name="PS/2 Generic Mouse"
471P: Phys=isa0060/serio1/input0
472S: Sysfs=/devices/platform/i8042/serio1/input/input5
473U: Uniq=
474H: Handlers=mouse0 event4
475B: PROP=0
476B: EV=7
477B: KEY=70000 0 0 0 0
478
479I: Bus=0018 Vendor=06cb Product=cf06 Version=0100
480N: Name="VEN_06CB:00 06CB:CF06 Touchpad"
481P: Phys=i2c-VEN_06CB:00
482S: Sysfs=/devices/pci0000:00/0000:00:15.0/i2c_designware.0/i2c-1/i2c-VEN_06CB:00/0018:06CB:CF06.0001/input/input8
483U: Uniq=
484H: Handlers=mouse2 event6
485B: PROP=5
486B: EV=1b
487B: KEY=e520 10000 0 0 0 0
488
489I: Bus=0003 Vendor=046d Product=408a Version=0111
490N: Name="Logitech MX Keys"
491P: Phys=usb-0000:00:14.0-2/input2:1
492S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:408A.000F/input/input53
493U: Uniq=
494H: Handlers=sysrq kbd leds mouse6 event26
495B: PROP=0
496B: EV=12001f
497B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
498
499I: Bus=0003 Vendor=046d Product=4082 Version=0111
500N: Name="Logitech MX Master 3"
501P: Phys=usb-0000:00:14.0-2/input2:2
502S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:4082.0011/input/input51
503U: Uniq=
504H: Handlers=sysrq kbd leds mouse5 event25
505B: PROP=0
506B: EV=12001f
507B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad9415fff ffbeffdfffefffff fffffffffffffffe
508"#;
509
510 fn fixture_model_name(sysfs: &str) -> Option<String> {
512 if sysfs.contains("408A") {
513 Some("MX Keys Wireless Keyboard".to_string())
514 } else if sysfs.contains("4082") {
515 Some("Wireless Mouse MX Master 3".to_string())
516 } else {
517 None
518 }
519 }
520
521 #[test]
522 fn test_parse_bitmap_is_low_word_first() {
523 assert_eq!(parse_bitmap("2 1"), vec![1, 2]);
525 assert_eq!(parse_bitmap("ff"), vec![0xff]);
526 assert_eq!(parse_bitmap(""), Vec::<u64>::new());
527 assert_eq!(parse_bitmap("zz 3"), vec![3]);
529 }
530
531 #[test]
532 fn test_bit_set_across_word_boundary() {
533 let bits = parse_bitmap("1 8000000000000000");
534 assert!(bit_set(&bits, 63), "bit 63 is the top of the low word");
535 assert!(bit_set(&bits, 64), "bit 64 is the bottom of the high word");
536 assert!(!bit_set(&bits, 62));
537 assert!(!bit_set(&bits, 65));
538 assert!(!bit_set(&bits, 4096));
540 }
541
542 #[test]
543 fn test_has_alpha_block() {
544 let devices = parse_input_devices(FIXTURE);
545 let by_name = |n: &str| devices.iter().find(|d| d.name == n).unwrap().clone();
546 assert!(has_alpha_block(
547 &by_name("AT Translated Set 2 keyboard").key_bits
548 ));
549 assert!(
550 !has_alpha_block(&by_name("Power Button").key_bits),
551 "a power button registers `kbd` but offers no text entry"
552 );
553 assert!(!has_alpha_block(&by_name("PS/2 Generic Mouse").key_bits));
554 }
555
556 #[test]
557 fn test_parse_input_devices_fields() {
558 let devices = parse_input_devices(FIXTURE);
559 assert_eq!(
560 devices.len(),
561 6,
562 "one record per blank-line-separated block"
563 );
564 let kb = devices
565 .iter()
566 .find(|d| d.name == "AT Translated Set 2 keyboard")
567 .unwrap();
568 assert_eq!(kb.handlers, vec!["sysrq", "kbd", "leds", "event3"]);
569 assert_eq!(kb.sysfs, "/devices/platform/i8042/serio0/input/input3");
570 assert_eq!(*kb.key_bits.first().unwrap(), 0xfffffffffffffffe);
571 }
572
573 #[test]
574 fn test_classify_unambiguous_devices() {
575 let devices = parse_input_devices(FIXTURE);
576 let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
577
578 assert_eq!(
579 classify_device(find("AT Translated Set 2 keyboard"), fixture_model_name),
580 Some(DeviceKind::Keyboard)
581 );
582 assert_eq!(
583 classify_device(find("PS/2 Generic Mouse"), fixture_model_name),
584 Some(DeviceKind::Mouse)
585 );
586 assert_eq!(
587 classify_device(find("VEN_06CB:00 06CB:CF06 Touchpad"), fixture_model_name),
588 Some(DeviceKind::Mouse),
589 "a touchpad is a pointing device"
590 );
591 assert_eq!(
592 classify_device(find("Power Button"), fixture_model_name),
593 None,
594 "`kbd` handler without the alphabet block is neither"
595 );
596 }
597
598 #[test]
599 fn test_merged_hidpp_endpoints_use_the_model_name_tiebreak() {
600 let devices = parse_input_devices(FIXTURE);
601 let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
602
603 let keys = find("Logitech MX Keys");
606 let master = find("Logitech MX Master 3");
607 assert_eq!(keys.key_bits.len(), master.key_bits.len());
608 assert!(has_alpha_block(&keys.key_bits) && has_alpha_block(&master.key_bits));
609
610 assert_eq!(
611 classify_device(keys, fixture_model_name),
612 Some(DeviceKind::Keyboard)
613 );
614 assert_eq!(
615 classify_device(master, fixture_model_name),
616 Some(DeviceKind::Mouse)
617 );
618 }
619
620 #[test]
621 fn test_ambiguous_device_without_model_name_is_omitted() {
622 let devices = parse_input_devices(FIXTURE);
623 let keys = devices
624 .iter()
625 .find(|d| d.name == "Logitech MX Keys")
626 .unwrap();
627 assert_eq!(classify_device(keys, |_| None), None);
630 }
631
632 #[test]
633 fn test_classify_input_devices_with_end_to_end() {
634 let devices = parse_input_devices(FIXTURE);
635 let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
636 assert_eq!(
637 keyboards,
638 vec!["AT Translated Set 2 keyboard", "Logitech MX Keys"]
639 );
640 assert_eq!(
641 mice,
642 vec![
643 "PS/2 Generic Mouse",
644 "VEN_06CB:00 06CB:CF06 Touchpad",
645 "Logitech MX Master 3"
646 ]
647 );
648 }
649
650 #[test]
651 fn test_duplicate_names_are_collapsed() {
652 let doubled = format!("{}\n{}", FIXTURE, FIXTURE);
654 let devices = parse_input_devices(&doubled);
655 let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
656 assert_eq!(keyboards.len(), 2, "names must not repeat");
657 assert_eq!(mice.len(), 3);
658 }
659
660 #[test]
661 fn test_empty_and_malformed_input() {
662 assert!(parse_input_devices("").is_empty());
663 assert!(parse_input_devices("garbage\nlines\nwith no records").is_empty());
664 assert!(parse_input_devices("H: Handlers=kbd event0\nB: EV=3\n").is_empty());
666 let (k, m) = classify_input_devices_with(&[], fixture_model_name);
667 assert!(k.is_empty() && m.is_empty());
668 }
669}