fix(relay): stabilize routing and media recovery

This commit is contained in:
Brad Stein 2026-04-20 18:41:48 -03:00
parent 48503c7c22
commit 3526bf7b6d
12 changed files with 405 additions and 93 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.28"
version = "0.11.29"
edition = "2024"
[dependencies]

View File

@ -47,10 +47,16 @@ pub struct InputAggregator {
quick_toggle_down: bool,
quick_toggle_debounce: Duration,
last_quick_toggle_at: Option<Instant>,
pending_release_started_at: Option<Instant>,
pending_release_timeout: Duration,
#[cfg(not(coverage))]
routing_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
routing_control_marker: u128,
last_routing_request_raw: Option<String>,
#[cfg(not(coverage))]
quick_toggle_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
last_quick_toggle_request_raw: Option<String>,
#[cfg(not(coverage))]
clipboard_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
@ -85,6 +91,9 @@ impl InputAggregator {
#[cfg(not(coverage))]
let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE");
#[cfg(not(coverage))]
let quick_toggle_control_path =
launcher_routing_path_from_env("LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL");
#[cfg(not(coverage))]
let clipboard_control_path =
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
Self {
@ -107,14 +116,21 @@ impl InputAggregator {
quick_toggle_down: false,
quick_toggle_debounce: quick_toggle_debounce_from_env(),
last_quick_toggle_at: None,
pending_release_started_at: None,
pending_release_timeout: pending_release_timeout_from_env(),
#[cfg(not(coverage))]
routing_control_marker: routing_control_path
last_routing_request_raw: routing_control_path
.as_deref()
.map(path_marker)
.unwrap_or_default(),
.and_then(read_launcher_control_snapshot),
#[cfg(not(coverage))]
routing_control_path,
#[cfg(not(coverage))]
last_quick_toggle_request_raw: quick_toggle_control_path
.as_deref()
.and_then(read_launcher_control_snapshot),
#[cfg(not(coverage))]
quick_toggle_control_path,
#[cfg(not(coverage))]
clipboard_control_marker: clipboard_control_path
.as_deref()
.map(path_marker)
@ -350,6 +366,7 @@ impl InputAggregator {
want_kill |= kbd.magic_kill();
}
self.poll_launcher_routing_request();
self.poll_launcher_quick_toggle_request();
self.poll_launcher_clipboard_request();
let quick_toggle_now = self.quick_toggle_active();
self.observe_quick_toggle(quick_toggle_now);
@ -381,6 +398,7 @@ impl InputAggregator {
}
self.pending_kill = true;
self.capture_pending_keys();
self.pending_release_started_at = Some(Instant::now());
}
if self.pending_release || self.pending_kill {
@ -394,25 +412,17 @@ impl InputAggregator {
.iter()
.all(|key| !self.keyboards.iter().any(|k| k.has_key(*key)))
};
if chord_released {
for k in &mut self.keyboards {
k.set_grab(false);
k.reset_state();
let timed_out = self.pending_release_timed_out();
if chord_released || timed_out {
if timed_out {
warn!(
"⌛ local release timed out waiting for key-up events; forcing the handoff"
);
}
for m in &mut self.mice {
m.set_grab(false);
m.reset_state();
}
self.released = true;
if !self.pending_kill {
focus_launcher_on_local_if_enabled();
}
self.publish_routing_state_if_changed();
self.finish_local_release(!self.pending_kill);
if self.pending_kill {
return Ok(());
}
self.pending_release = false;
self.pending_keys.clear();
}
}
@ -457,10 +467,17 @@ impl InputAggregator {
}
self.released = false;
self.pending_release = false;
self.pending_release_started_at = None;
self.pending_keys.clear();
self.last_keyboard_report = [0; 8];
}
fn begin_local_release(&mut self) {
if self.released && !self.pending_release {
#[cfg(not(coverage))]
self.publish_routing_state_if_changed();
return;
}
self.remote_capture_enabled.store(false, Ordering::Relaxed);
for k in &mut self.keyboards {
k.send_empty_report();
@ -471,10 +488,39 @@ impl InputAggregator {
m.set_send(false);
}
self.pending_release = true;
self.pending_release_started_at = Some(Instant::now());
self.last_keyboard_report = [0; 8];
self.capture_pending_keys();
}
fn finish_local_release(&mut self, focus_launcher: bool) {
for k in &mut self.keyboards {
k.set_grab(false);
k.reset_state();
}
for m in &mut self.mice {
m.set_grab(false);
m.reset_state();
}
self.released = true;
self.pending_release = false;
self.pending_release_started_at = None;
self.pending_keys.clear();
if focus_launcher {
#[cfg(not(coverage))]
focus_launcher_on_local_if_enabled();
}
#[cfg(not(coverage))]
self.publish_routing_state_if_changed();
}
fn pending_release_timed_out(&self) -> bool {
(self.pending_release || self.pending_kill)
&& self
.pending_release_started_at
.is_some_and(|started_at| started_at.elapsed() >= self.pending_release_timeout)
}
fn capture_pending_keys(&mut self) {
self.pending_keys.clear();
for k in &self.keyboards {
@ -560,27 +606,60 @@ impl InputAggregator {
let Some(path) = self.routing_control_path.as_deref() else {
return;
};
let marker = path_marker(path);
if marker <= self.routing_control_marker {
return;
}
self.routing_control_marker = marker;
let Some(remote_capture) = read_launcher_routing_request(path) else {
let Some(raw) = read_launcher_control_snapshot(path) else {
return;
};
if self.pending_release || self.pending_kill || remote_capture == !self.released {
if self.last_routing_request_raw.as_deref() == Some(raw.as_str()) {
return;
}
self.last_routing_request_raw = Some(raw.clone());
let Some(remote_capture) = parse_launcher_routing_request(&raw) else {
return;
};
if self.pending_kill {
return;
}
if remote_capture {
if !self.released && !self.pending_release {
return;
}
info!("🎛️ launcher requested remote input capture");
self.enable_remote_capture();
self.publish_routing_state_if_changed();
} else {
if self.released && !self.pending_release {
return;
}
info!("🎛️ launcher requested local input capture");
self.begin_local_release();
}
}
#[cfg(not(coverage))]
fn poll_launcher_quick_toggle_request(&mut self) {
let Some(path) = self.quick_toggle_control_path.as_deref() else {
return;
};
let Some(raw) = read_launcher_control_snapshot(path) else {
return;
};
if self.last_quick_toggle_request_raw.as_deref() == Some(raw.as_str()) {
return;
}
self.last_quick_toggle_request_raw = Some(raw.clone());
let next_key = raw
.split_ascii_whitespace()
.next()
.and_then(parse_quick_toggle_key);
self.quick_toggle_key = next_key;
self.quick_toggle_down = false;
self.last_quick_toggle_at = None;
match next_key {
Some(key) => info!("🎛️ launcher updated the live swap key to {:?}", key),
None => info!("🎛️ launcher disabled the live swap key"),
}
}
#[cfg(not(coverage))]
fn poll_launcher_clipboard_request(&mut self) {
let Some(path) = self.clipboard_control_path.as_deref() else {
@ -852,6 +931,14 @@ fn quick_toggle_debounce_from_env() -> Duration {
Duration::from_millis(millis.max(50))
}
fn pending_release_timeout_from_env() -> Duration {
let millis = std::env::var("LESAVKA_INPUT_RELEASE_TIMEOUT_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(750);
Duration::from_millis(millis.max(100))
}
#[cfg(not(coverage))]
fn focus_launcher_on_local_if_enabled() {
if std::env::var("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL")
@ -899,9 +986,20 @@ fn matches_selected_input_device(path: &std::path::Path, selected: Option<&str>)
}
#[cfg(not(coverage))]
fn read_launcher_routing_request(path: &Path) -> Option<bool> {
fn read_launcher_control_snapshot(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(not(coverage))]
fn parse_launcher_routing_request(raw: &str) -> Option<bool> {
match raw
.split_ascii_whitespace()
.next()?
.to_ascii_lowercase()
.as_str()
{
"remote" => Some(true),
"local" => Some(false),
_ => None,

View File

@ -17,12 +17,13 @@ use {
super::ui_runtime::{
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
capture_swap_key, copy_plain_text, copy_session_log, dock_all_displays_to_preview,
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
open_diagnostics_popout, open_popout_window, open_session_log_popout, path_marker,
present_popout_windows, read_input_routing_state, reap_exited_child, refresh_launcher_ui,
refresh_test_buttons, routing_name, selected_combo_value, selected_server_addr,
shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label,
update_test_action_result, write_input_routing_request,
dock_display_to_preview, input_control_path, input_state_path, input_toggle_control_path,
next_input_routing, open_diagnostics_popout, open_popout_window, open_session_log_popout,
path_marker, present_popout_windows, read_input_routing_state, reap_exited_child,
refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value,
selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process,
toggle_key_label, update_test_action_result, write_input_routing_request,
write_input_toggle_key_request,
},
crate::handshake::{HandshakeProbe, probe},
crate::output::display::enumerate_monitors,
@ -621,10 +622,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let clipboard_control_path = Rc::new(launcher_clipboard_control_path());
let input_control_path = Rc::new(input_control_path());
let input_state_path = Rc::new(input_state_path());
let input_toggle_control_path = Rc::new(input_toggle_control_path());
let _ = std::fs::remove_file(focus_signal_path.as_path());
let _ = std::fs::remove_file(clipboard_control_path.as_path());
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
{
let child_proc = Rc::clone(&child_proc);
@ -632,6 +635,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let clipboard_control_path = Rc::clone(&clipboard_control_path);
let input_control_path = Rc::clone(&input_control_path);
let input_state_path = Rc::clone(&input_state_path);
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
let tests = Rc::clone(&tests);
app.connect_shutdown(move |_| {
stop_child_process(&child_proc);
@ -640,6 +644,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let _ = std::fs::remove_file(clipboard_control_path.as_path());
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
});
}
@ -652,6 +657,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let focus_signal_path = Rc::clone(&focus_signal_path);
let input_control_path = Rc::clone(&input_control_path);
let input_state_path = Rc::clone(&input_state_path);
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
app.connect_activate(move |app| {
let (display_width, display_height) = largest_monitor_size();
@ -1175,6 +1181,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let speaker_combo = speaker_combo.clone();
let input_control_path = Rc::clone(&input_control_path);
let input_state_path = Rc::clone(&input_state_path);
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
let server_addr_fallback = Rc::clone(&server_addr);
let preview = preview.clone();
let power_tx = power_tx.clone();
@ -1245,10 +1252,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
}
let _ = std::fs::remove_file(input_control_path.as_path());
let _ = std::fs::remove_file(input_state_path.as_path());
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
let launch_state = state.borrow().clone();
let input_toggle_key = launch_state.swap_key.clone();
let input_control_path = input_control_path.as_ref().clone();
let input_state_path = input_state_path.as_ref().clone();
let input_toggle_control_path = input_toggle_control_path.as_ref().clone();
relay_request_in_flight.set(true);
widgets_handle.status_label.set_text(&format!(
"Connecting relay with {} as the swap key...",
@ -1267,6 +1276,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
&input_toggle_key,
input_control_path.as_path(),
input_state_path.as_path(),
input_toggle_control_path.as_path(),
)
.map_err(|err| err.to_string());
let _ = relay_tx.send(RelayMessage::Spawned(result));
@ -1739,6 +1749,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let widgets = widgets.clone();
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(move |_, key, _, _| {
if !state.borrow().swap_key_binding {
@ -1763,10 +1774,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
state.complete_swap_key_binding(swap_key.clone());
}
let status_message = if relay_live {
format!(
"Swap key set to {}. Disconnect and reconnect the relay to use it live.",
toggle_key_label(&swap_key)
)
match write_input_toggle_key_request(
input_toggle_control_path.as_path(),
&swap_key,
) {
Ok(()) => format!(
"Swap key set to {} and applied to the live relay.",
toggle_key_label(&swap_key)
),
Err(err) => format!(
"Swap key set to {}, but Lesavka could not push it live: {err}",
toggle_key_label(&swap_key)
),
}
} else {
format!(
"Swap key set to {}. The next relay launch will use it.",
@ -1792,8 +1812,6 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let server_addr_fallback = Rc::clone(&server_addr);
let last_focus_marker =
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
let last_state_marker =
Rc::new(RefCell::new(path_marker(input_state_path.as_path())));
let power_request_in_flight = Rc::clone(&power_request_in_flight);
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
let preview = preview.clone();
@ -1846,19 +1864,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
);
}
let next_state_marker = path_marker(input_state_path.as_path());
let mut last_state = last_state_marker.borrow_mut();
if next_state_marker > *last_state {
*last_state = next_state_marker;
if let Some(routing) = read_input_routing_state(input_state_path.as_path())
{
state.borrow_mut().set_routing(routing);
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
if matches!(routing, InputRouting::Remote) {
present_popout_windows(&popouts);
} else {
window.present();
}
if child_running
&& let Some(routing) = read_input_routing_state(input_state_path.as_path())
&& routing != state.borrow().routing
{
state.borrow_mut().set_routing(routing);
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
if matches!(routing, InputRouting::Remote) {
present_popout_windows(&popouts);
} else {
window.present();
}
}

View File

@ -197,7 +197,8 @@ pub fn build_launcher_view(
let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
staging_row.set_hexpand(true);
staging_row.set_vexpand(false);
staging_row.set_vexpand(true);
staging_row.set_homogeneous(true);
workspace.append(&staging_row);
let device_refresh_button = gtk::Button::with_label("Refresh Devices");
@ -208,8 +209,9 @@ pub fn build_launcher_view(
let (devices_panel, devices_body) =
build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref()));
devices_panel.set_hexpand(true);
devices_panel.set_vexpand(false);
devices_panel.set_vexpand(true);
devices_body.set_spacing(8);
devices_body.set_vexpand(true);
let control_group = build_subgroup("Control Inputs");
let control_stack = gtk::Box::new(gtk::Orientation::Vertical, 10);
@ -315,10 +317,11 @@ pub fn build_launcher_view(
devices_body.append(&media_group);
staging_row.append(&devices_panel);
let (preview_panel, preview_body) = build_panel("Webcam Test");
let (preview_panel, preview_body) = build_panel("Device Testing");
preview_panel.set_hexpand(true);
preview_panel.set_vexpand(false);
preview_body.set_spacing(6);
preview_panel.set_vexpand(true);
preview_body.set_spacing(8);
preview_body.set_vexpand(true);
let camera_preview = gtk::Picture::new();
camera_preview.set_can_shrink(false);
camera_preview.set_hexpand(true);
@ -343,7 +346,11 @@ pub fn build_launcher_view(
camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT);
camera_preview_frame.set_child(Some(&camera_preview));
camera_preview_shell.append(&camera_preview_frame);
preview_body.append(&camera_preview_shell);
let webcam_group = build_subgroup("Webcam Preview");
webcam_group.set_vexpand(true);
webcam_group.append(&camera_preview_shell);
webcam_group.append(&camera_status);
preview_body.append(&webcam_group);
let playback_group = build_subgroup("Mic Playback");
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 8);
@ -359,6 +366,7 @@ pub fn build_launcher_view(
playback_row.append(&audio_preview_heading);
playback_body.append(&playback_row);
playback_body.append(&audio_check_meter);
playback_body.append(&audio_check_detail);
playback_group.append(&playback_body);
preview_body.append(&playback_group);
staging_row.append(&preview_panel);
@ -1217,7 +1225,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
controls_grid.set_column_spacing(8);
controls_grid.set_row_spacing(8);
controls_grid.set_hexpand(true);
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 4);
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 7);
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 7);
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 7);
feed_row.set_hexpand(true);

View File

@ -7,6 +7,7 @@ use std::{
process::{Child, Command, Stdio},
rc::Rc,
sync::mpsc::Sender,
time::{SystemTime, UNIX_EPOCH},
};
use super::{
@ -22,8 +23,10 @@ use super::{
pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL";
pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL";
pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control";
pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control";
pub type RelayChild = Child;
@ -745,20 +748,49 @@ pub fn input_state_path() -> PathBuf {
.unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH))
}
pub fn input_toggle_control_path() -> PathBuf {
std::env::var(TOGGLE_KEY_CONTROL_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH))
}
pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> {
std::fs::write(path, format!("{}\n", routing_name(routing)))?;
std::fs::write(
path,
format!("{} {}\n", routing_name(routing), control_request_nonce()),
)?;
Ok(())
}
pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> {
std::fs::write(
path,
format!("{} {}\n", swap_key.trim(), control_request_nonce()),
)?;
Ok(())
}
pub fn read_input_routing_state(path: &Path) -> Option<InputRouting> {
let raw = std::fs::read_to_string(path).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
match raw
.split_ascii_whitespace()
.next()?
.to_ascii_lowercase()
.as_str()
{
"local" => Some(InputRouting::Local),
"remote" => Some(InputRouting::Remote),
_ => None,
}
}
fn control_request_nonce() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default()
}
pub fn routing_name(routing: InputRouting) -> &'static str {
match routing {
InputRouting::Local => "local",
@ -866,6 +898,7 @@ pub fn spawn_client_process(
input_toggle_key: &str,
input_control_path: &Path,
input_state_path: &Path,
input_toggle_control_path: &Path,
) -> Result<RelayChild> {
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
@ -884,6 +917,7 @@ pub fn spawn_client_process(
);
command.env(INPUT_CONTROL_ENV, input_control_path);
command.env(INPUT_STATE_ENV, input_state_path);
command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path);
command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1");
command.env("LESAVKA_CLIPBOARD_PASTE", "1");
for (key, value) in runtime_env_vars(state) {

View File

@ -143,18 +143,8 @@ fn pick_sink_element() -> Result<String> {
return Ok(sink);
}
let sinks = list_pw_sinks();
for (n, st) in &sinks {
if *st == "RUNNING" {
info!("🔈 using default RUNNING sink '{}'", n);
return Ok(pulsesink_device_element(n));
}
}
if let Some((n, _)) = sinks.iter().find(|(_, st)| *st == "RUNNING") {
warn!("🏃 picking first RUNNING sink '{}'", n);
return Ok(pulsesink_device_element(n));
}
if let Some((n, _)) = sinks.first() {
warn!("🎲 picking first sink '{}'", n);
if let Some((n, st)) = sinks.first() {
info!("🔈 using PipeWire sink '{}' ({st})", n);
return Ok(pulsesink_device_element(n));
}
warn!("🫣 no PipeWire sinks readable - falling back to autoaudiosink");
@ -194,15 +184,81 @@ fn pulsesink_device_element(device: &str) -> String {
}
fn list_pw_sinks() -> Vec<(String, String)> {
if let Ok(info) = std::process::Command::new("pactl")
let default_sink = std::process::Command::new("pactl")
.args(["info"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.ok()
.filter(|output| output.status.success())
.and_then(|output| parse_pactl_default_sink(&String::from_utf8_lossy(&output.stdout)));
if let Ok(output) = std::process::Command::new("pactl")
.args(["list", "short", "sinks"])
.output()
&& output.status.success()
{
if let Some(line) = info.lines().find(|l| l.starts_with("Default Sink:")) {
let def = line["Default Sink:".len()..].trim();
return vec![(def.to_string(), "UNKNOWN".to_string())];
}
return parse_pactl_short_sinks(
&String::from_utf8_lossy(&output.stdout),
default_sink.as_deref(),
);
}
default_sink
.map(|sink| vec![(sink, "DEFAULT".to_string())])
.unwrap_or_default()
}
fn parse_pactl_default_sink(stdout: &str) -> Option<String> {
stdout
.lines()
.find_map(|line| line.strip_prefix("Default Sink:"))
.map(str::trim)
.filter(|sink| !sink.is_empty())
.map(str::to_string)
}
fn parse_pactl_short_sinks(stdout: &str, default_sink: Option<&str>) -> Vec<(String, String)> {
let mut sinks = Vec::new();
for line in stdout.lines() {
let columns: Vec<_> = line.split_whitespace().collect();
if columns.len() < 2 {
continue;
}
let name = columns[1].to_string();
let state = columns
.last()
.copied()
.unwrap_or("UNKNOWN")
.to_ascii_uppercase();
sinks.push((name, state));
}
sinks.sort_by_key(|(name, state)| {
(
sink_state_rank(state),
if Some(name.as_str()) == default_sink {
0
} else {
1
},
name.clone(),
)
});
sinks.dedup_by(|left, right| left.0 == right.0);
if let Some(default_sink) = default_sink
&& sinks.iter().all(|(name, _)| name != default_sink)
{
sinks.insert(0, (default_sink.to_string(), "DEFAULT".to_string()));
}
sinks
}
fn sink_state_rank(state: &str) -> u8 {
match state {
"RUNNING" => 0,
"IDLE" => 1,
"SUSPENDED" => 2,
_ => 3,
}
Vec::new()
}

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.28"
version = "0.11.29"
edition = "2024"
build = "build.rs"

View File

@ -17,6 +17,6 @@ mod tests {
#[test]
fn banner_includes_version() {
assert_eq!(banner("0.11.28"), "lesavka-common CLI (v0.11.28)");
assert_eq!(banner("0.11.29"), "lesavka-common CLI (v0.11.29)");
}
}

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.28"
version = "0.11.29"
edition = "2024"
autobins = false

View File

@ -6,7 +6,7 @@ use anyhow::Context;
use futures_util::{Stream, StreamExt};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::os::unix::fs::FileTypeExt;
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration};
@ -118,7 +118,11 @@ impl Handler {
fn detected_capture_devices_from_symlinks() -> u32 {
["/dev/lesavka_l_eye", "/dev/lesavka_r_eye"]
.into_iter()
.filter(|path| Path::new(path).exists())
.filter(|path| {
std::fs::metadata(path)
.ok()
.is_some_and(|metadata| metadata.file_type().is_char_device())
})
.count() as u32
}

View File

@ -388,10 +388,29 @@ fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, cand
#[cfg(not(coverage))]
fn detect_uac_card_candidates() -> Vec<String> {
let Ok(cards) = fs::read_to_string("/proc/asound/cards") else {
return Vec::new();
};
let mut out = Vec::new();
let mut seen = BTreeSet::new();
let card_data = fs::read_to_string("/proc/asound/cards").ok();
let numeric_card_ids = card_data
.as_deref()
.map(parse_uac_numeric_card_ids)
.unwrap_or_default();
if let Some(cards) = card_data.as_deref() {
for candidate in parse_uac_named_card_candidates(cards) {
push_audio_candidate(&mut out, &mut seen, &candidate);
}
}
if let Ok(pcm) = fs::read_to_string("/proc/asound/pcm") {
for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) {
push_audio_candidate(&mut out, &mut seen, &candidate);
}
}
out
}
#[cfg(not(coverage))]
fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
cards
.lines()
.filter_map(|line| {
@ -411,6 +430,52 @@ fn detect_uac_card_candidates() -> Vec<String> {
.collect()
}
#[cfg(not(coverage))]
fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet<String> {
cards
.lines()
.filter_map(|line| {
let lower = line.to_ascii_lowercase();
if !(lower.contains("uac2")
|| lower.contains("gadget")
|| lower.contains("composite")
|| lower.contains("lesavka"))
{
return None;
}
line.split_whitespace()
.next()
.filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit()))
.map(|candidate| candidate.to_string())
})
.collect()
}
#[cfg(not(coverage))]
fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet<String>) -> Vec<String> {
pcm.lines()
.filter_map(|line| {
let (prefix, _) = line.split_once(':')?;
let (card_id, device_id) = prefix.split_once('-')?;
let normalized_card = card_id.trim_start_matches('0');
let normalized_card = if normalized_card.is_empty() {
"0"
} else {
normalized_card
};
let normalized_device = device_id.trim_start_matches('0');
let normalized_device = if normalized_device.is_empty() {
"0"
} else {
normalized_device
};
numeric_card_ids
.contains(normalized_card)
.then(|| format!("hw:{normalized_card},{normalized_device}"))
})
.collect()
}
/// Allocate a stream identifier for logging and correlation.
///
/// Inputs: none.
@ -477,9 +542,11 @@ pub async fn write_hid_report(
mod tests {
use super::{
allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry,
parse_uac_named_card_candidates, parse_uac_numeric_card_ids, parse_uac_pcm_candidates,
preferred_uac_device_candidates, should_recover_hid_error, write_hid_report,
};
use serial_test::serial;
use std::collections::BTreeSet;
use std::sync::Arc;
use temp_env::with_var;
use tempfile::NamedTempFile;
@ -538,6 +605,37 @@ mod tests {
assert!(live.iter().all(|value| value.starts_with("hw:")));
}
#[test]
fn parse_uac_card_helpers_collect_named_and_numeric_candidates() {
let cards = "\
0 [PCH ]: HDA-Intel - HDA Intel PCH\n\
2 [UAC2Gadget ]: USB-Audio - UAC2Gadget\n\
Lesavka USB Audio\n";
assert_eq!(
parse_uac_named_card_candidates(cards),
vec!["hw:UAC2Gadget,0"]
);
assert!(
parse_uac_numeric_card_ids(cards).contains("2"),
"expected numeric card index for the gadget card"
);
}
#[test]
fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() {
let pcm = "\
00-00: PCH device : playback 1 : capture 1\n\
02-00: USB Audio : USB Audio : playback 1 : capture 1\n\
02-01: USB Audio #1 : USB Audio #1 : playback 1 : capture 1\n";
let ids = BTreeSet::from(["2".to_string()]);
assert_eq!(
parse_uac_pcm_candidates(pcm, &ids),
vec!["hw:2,0", "hw:2,1"]
);
}
#[tokio::test]
#[serial]
async fn open_with_retry_opens_existing_file() {

View File

@ -422,7 +422,7 @@ pub async fn eye_ball_with_request(
let server_encoder_label = if use_test_src {
"x264enc(testsrc)".to_string()
} else {
"source-pass-through".to_string()
"source-pass-through(auto-caps)".to_string()
};
let server_process_cpu_tenths = server_process_cpu_metric();
if !use_test_src {
@ -444,12 +444,11 @@ pub async fn eye_ball_with_request(
} else {
format!(
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \
video/x-h264,width={},height={},framerate={}/1 ! \
video/x-h264 ! \
queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse disable-passthrough=true config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true",
request.width, request.height, request.fps,
)
};