2026-04-22 00:56:03 -03:00
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
2026-04-13 23:11:35 -03:00
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode};
|
2026-04-20 12:12:29 -03:00
|
|
|
use serde_json::Value;
|
2026-04-16 12:58:05 -03:00
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
|
|
|
pub struct DeviceCatalog {
|
|
|
|
|
pub cameras: Vec<String>,
|
|
|
|
|
pub microphones: Vec<String>,
|
|
|
|
|
pub speakers: Vec<String>,
|
2026-04-16 12:58:05 -03:00
|
|
|
pub keyboards: Vec<String>,
|
|
|
|
|
pub mice: Vec<String>,
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-16 12:58:05 -03:00
|
|
|
self.cameras.is_empty()
|
|
|
|
|
&& self.microphones.is_empty()
|
|
|
|
|
&& self.speakers.is_empty()
|
|
|
|
|
&& self.keyboards.is_empty()
|
|
|
|
|
&& self.mice.is_empty()
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
|
|
|
|
|
let cameras = discover_camera_devices(override_dir);
|
2026-04-20 12:12:29 -03:00
|
|
|
let microphones = discover_microphone_devices();
|
|
|
|
|
let speakers = discover_speaker_devices();
|
2026-04-16 12:58:05 -03:00
|
|
|
let keyboards = discover_input_devices(InputDeviceKind::Keyboard);
|
|
|
|
|
let mice = discover_input_devices(InputDeviceKind::Mouse);
|
2026-04-13 23:11:35 -03:00
|
|
|
Self {
|
|
|
|
|
cameras,
|
|
|
|
|
microphones,
|
|
|
|
|
speakers,
|
2026-04-16 12:58:05 -03:00
|
|
|
keyboards,
|
|
|
|
|
mice,
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:12:29 -03:00
|
|
|
fn discover_microphone_devices() -> Vec<String> {
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:56:03 -03:00
|
|
|
fn dedupe_camera_devices(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
|
|
|
|
let mut by_physical_device: BTreeMap<String, String> = BTreeMap::new();
|
|
|
|
|
for name in names {
|
|
|
|
|
let key = camera_physical_key(&name);
|
|
|
|
|
match by_physical_device.get(&key) {
|
|
|
|
|
Some(existing) if camera_device_rank(existing) <= camera_device_rank(&name) => {}
|
|
|
|
|
_ => {
|
|
|
|
|
by_physical_device.insert(key, name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
by_physical_device.into_values().collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn camera_physical_key(name: &str) -> String {
|
|
|
|
|
let trimmed = name.trim();
|
|
|
|
|
trimmed
|
|
|
|
|
.strip_prefix("usb-")
|
|
|
|
|
.unwrap_or(trimmed)
|
|
|
|
|
.split("-video-index")
|
|
|
|
|
.next()
|
|
|
|
|
.unwrap_or(trimmed)
|
|
|
|
|
.to_ascii_lowercase()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn camera_device_rank(name: &str) -> u8 {
|
|
|
|
|
if name.contains("-video-index0") {
|
|
|
|
|
0
|
|
|
|
|
} else if name.contains("-video-index") {
|
|
|
|
|
1
|
|
|
|
|
} else {
|
|
|
|
|
2
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:12:29 -03:00
|
|
|
fn discover_speaker_devices() -> Vec<String> {
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-22 00:56:03 -03:00
|
|
|
dedupe_camera_devices(set)
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn discover_pactl_devices(kind: &str) -> Vec<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 12:12:29 -03:00
|
|
|
fn discover_pipewire_audio_nodes(media_class: &str) -> Vec<String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
let Ok(Value::Array(objects)) = serde_json::from_slice::<Value>(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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum InputDeviceKind {
|
|
|
|
|
Keyboard,
|
|
|
|
|
Mouse,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn discover_input_devices(kind: InputDeviceKind) -> Vec<String> {
|
|
|
|
|
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<InputDeviceKind> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[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()));
|
2026-04-14 23:03:18 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
devices,
|
|
|
|
|
vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()]
|
|
|
|
|
);
|
2026-04-13 23:11:35 -03:00
|
|
|
let _ = std::fs::remove_dir_all(tmp);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:56:03 -03:00
|
|
|
#[test]
|
|
|
|
|
fn camera_discovery_prefers_one_endpoint_per_physical_webcam() {
|
|
|
|
|
let devices = dedupe_camera_devices([
|
|
|
|
|
"usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index1".to_string(),
|
|
|
|
|
"usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(),
|
|
|
|
|
"usb-Logitech_C920-video-index0".to_string(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
devices,
|
|
|
|
|
vec![
|
|
|
|
|
"usb-Azurewave_USB2.0_HD_UVC_WebCam_0x0001-video-index0".to_string(),
|
|
|
|
|
"usb-Logitech_C920-video-index0".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:11:35 -03:00
|
|
|
#[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");
|
2026-04-14 23:03:18 -03:00
|
|
|
let catalog =
|
|
|
|
|
DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string()));
|
2026-04-13 23:11:35 -03:00
|
|
|
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());
|
|
|
|
|
}
|
2026-04-20 12:12:29 -03:00
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|