use std::collections::BTreeSet; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use serde_json::Value; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DeviceCatalog { pub cameras: Vec, pub microphones: Vec, pub speakers: Vec, pub keyboards: Vec, pub mice: Vec, } impl DeviceCatalog { pub fn discover() -> Self { Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok()) } pub fn is_empty(&self) -> bool { self.cameras.is_empty() && self.microphones.is_empty() && self.speakers.is_empty() && self.keyboards.is_empty() && self.mice.is_empty() } fn discover_with_camera_override(override_dir: Option) -> Self { let cameras = discover_camera_devices(override_dir); let microphones = discover_microphone_devices(); let speakers = discover_speaker_devices(); let keyboards = discover_input_devices(InputDeviceKind::Keyboard); let mice = discover_input_devices(InputDeviceKind::Mouse); Self { cameras, microphones, speakers, keyboards, mice, } } } fn discover_microphone_devices() -> Vec { let mut set = BTreeSet::new(); for source in discover_pactl_devices("sources") { if !source.ends_with(".monitor") { set.insert(source); } } for source in discover_pipewire_audio_nodes("Audio/Source") { if !source.ends_with(".monitor") { set.insert(source); } } set.into_iter().collect() } fn discover_speaker_devices() -> Vec { let mut set = BTreeSet::new(); for sink in discover_pactl_devices("sinks") { set.insert(sink); } for sink in discover_pipewire_audio_nodes("Audio/Sink") { set.insert(sink); } set.into_iter().collect() } fn discover_camera_devices(override_dir: Option) -> Vec { let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string()); let Ok(iter) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut set = BTreeSet::new(); for entry in iter.flatten() { let path = entry.path(); if let Some(name) = path.file_name() { set.insert(name.to_string_lossy().to_string()); } } set.into_iter().collect() } fn discover_pactl_devices(kind: &str) -> Vec { let output = std::process::Command::new("pactl") .args(["list", "short", kind]) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } parse_pactl_short(&String::from_utf8_lossy(&output.stdout)) } pub fn parse_pactl_short(stdout: &str) -> Vec { let mut set = BTreeSet::new(); for line in stdout.lines() { let mut cols = line.split_whitespace(); let _id = cols.next(); if let Some(name) = cols.next() { set.insert(name.to_string()); } } set.into_iter().collect() } fn discover_pipewire_audio_nodes(media_class: &str) -> Vec { let output = std::process::Command::new("pw-dump").output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } parse_pipewire_audio_nodes(&output.stdout, media_class) } pub fn parse_pipewire_audio_nodes(stdout: &[u8], media_class: &str) -> Vec { let Ok(Value::Array(objects)) = serde_json::from_slice::(stdout) else { return Vec::new(); }; let mut set = BTreeSet::new(); for object in objects { let Some(props) = object .get("info") .and_then(|info| info.get("props")) .and_then(Value::as_object) else { continue; }; if props.get("media.class").and_then(Value::as_str) != Some(media_class) { continue; } let Some(name) = props .get("node.name") .or_else(|| props.get("node.nick")) .or_else(|| props.get("device.name")) .and_then(Value::as_str) else { continue; }; if !name.trim().is_empty() { set.insert(name.to_string()); } } set.into_iter().collect() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputDeviceKind { Keyboard, Mouse, } fn discover_input_devices(kind: InputDeviceKind) -> Vec { let Ok(iter) = std::fs::read_dir("/dev/input") else { return Vec::new(); }; let mut set = BTreeSet::new(); for entry in iter.flatten() { let path = entry.path(); if !path .file_name() .is_some_and(|name| name.to_string_lossy().starts_with("event")) { continue; } let Ok(device) = Device::open(&path) else { continue; }; if classify_input_device(&device) == Some(kind) { set.insert(path.to_string_lossy().to_string()); } } set.into_iter().collect() } fn classify_input_device(device: &Device) -> Option { let events = device.supported_events(); if events.contains(EventType::KEY) && device .supported_keys() .is_some_and(|keys| keys.contains(KeyCode::KEY_A) || keys.contains(KeyCode::KEY_ENTER)) { return Some(InputDeviceKind::Keyboard); } if events.contains(EventType::RELATIVE) && let (Some(rel), Some(keys)) = (device.supported_relative_axes(), device.supported_keys()) && rel.contains(RelativeAxisCode::REL_X) && rel.contains(RelativeAxisCode::REL_Y) && (keys.contains(KeyCode::BTN_LEFT) || keys.contains(KeyCode::BTN_RIGHT)) { return Some(InputDeviceKind::Mouse); } if events.contains(EventType::ABSOLUTE) && let (Some(abs), Some(keys)) = (device.supported_absolute_axes(), device.supported_keys()) && ((abs.contains(AbsoluteAxisCode::ABS_X) && abs.contains(AbsoluteAxisCode::ABS_Y)) || (abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_X) && abs.contains(AbsoluteAxisCode::ABS_MT_POSITION_Y))) && (keys.contains(KeyCode::BTN_TOUCH) || keys.contains(KeyCode::BTN_LEFT)) { return Some(InputDeviceKind::Mouse); } None } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn mk_temp_dir(prefix: &str) -> PathBuf { let mut path = std::env::temp_dir(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&path).expect("create temp dir"); path } #[test] fn parse_pactl_short_collects_second_column_and_sorts_unique() { let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n"; let parsed = parse_pactl_short(input); assert_eq!( parsed, vec![ "alsa_input.pci".to_string(), "alsa_input.usb.test".to_string(), ] ); } #[test] fn parse_pactl_short_ignores_blank_or_short_lines() { let input = "\nweird\n3\n4 sink.a\tmodule\n"; let parsed = parse_pactl_short(input); assert_eq!(parsed, vec!["sink.a".to_string()]); } #[test] fn camera_discovery_reads_entry_names_from_override_dir() { let tmp = mk_temp_dir("camera-discovery"); std::fs::write(tmp.join("usb-cam-a"), "").expect("write"); std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); assert_eq!( devices, vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()] ); let _ = std::fs::remove_dir_all(tmp); } #[test] fn camera_discovery_returns_empty_when_directory_missing() { let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); assert!(devices.is_empty()); } #[test] fn camera_discovery_default_path_is_stable_without_overrides() { let _ = discover_camera_devices(None); } #[test] fn discover_uses_override_and_tolerates_missing_pactl() { let tmp = mk_temp_dir("discover-override"); std::fs::write(tmp.join("cam"), "").expect("write"); let catalog = DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); assert_eq!(catalog.cameras, vec!["cam".to_string()]); let _ = std::fs::remove_dir_all(tmp); } #[test] fn discover_is_stable_with_process_environment_defaults() { let _ = DeviceCatalog::discover(); } #[test] fn catalog_empty_reflects_collections() { let mut catalog = DeviceCatalog::default(); assert!(catalog.is_empty()); catalog.speakers.push("sink-1".to_string()); assert!(!catalog.is_empty()); } #[test] fn parse_pipewire_audio_nodes_collects_named_audio_nodes() { let sample = br#" [ { "info": { "props": { "media.class": "Audio/Source", "node.name": "alsa_input.usb-TestMic-00.mono-fallback" } } }, { "info": { "props": { "media.class": "Audio/Source", "node.name": "bluez_input.80:C3:BA:76:26:AB" } } }, { "info": { "props": { "media.class": "Audio/Sink", "node.name": "alsa_output.pci-0000_00_1f.3.analog-stereo" } } } ] "#; assert_eq!( parse_pipewire_audio_nodes(sample, "Audio/Source"), vec![ "alsa_input.usb-TestMic-00.mono-fallback".to_string(), "bluez_input.80:C3:BA:76:26:AB".to_string(), ] ); } #[test] fn parse_pipewire_audio_nodes_ignores_invalid_payloads() { assert!(parse_pipewire_audio_nodes(b"not json", "Audio/Source").is_empty()); } }