diff --git a/client/Cargo.toml b/client/Cargo.toml index 56d7aa1..55a2e54 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.28" +version = "0.11.29" edition = "2024" [dependencies] diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index ae95df3..0066437 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -47,10 +47,16 @@ pub struct InputAggregator { quick_toggle_down: bool, quick_toggle_debounce: Duration, last_quick_toggle_at: Option, + pending_release_started_at: Option, + pending_release_timeout: Duration, #[cfg(not(coverage))] routing_control_path: Option, #[cfg(not(coverage))] - routing_control_marker: u128, + last_routing_request_raw: Option, + #[cfg(not(coverage))] + quick_toggle_control_path: Option, + #[cfg(not(coverage))] + last_quick_toggle_request_raw: Option, #[cfg(not(coverage))] clipboard_control_path: Option, #[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::().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 { +fn read_launcher_control_snapshot(path: &Path) -> Option { 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 { + match raw + .split_ascii_whitespace() + .next()? + .to_ascii_lowercase() + .as_str() + { "remote" => Some(true), "local" => Some(false), _ => None, diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index d2d8948..59b84e4 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -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(); } } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 1452ed1..e67e7b6 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -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); diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index d637782..8876bcb 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -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 { 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 { 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) { diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index fe31b87..93c5c7a 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -143,18 +143,8 @@ fn pick_sink_element() -> Result { 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 { + 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() } diff --git a/common/Cargo.toml b/common/Cargo.toml index b40b303..8680ab7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.28" +version = "0.11.29" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index f12c38c..5be5bee 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -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)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 083fcfc..ee489f9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.28" +version = "0.11.29" edition = "2024" autobins = false diff --git a/server/src/main.rs b/server/src/main.rs index a2c3e1a..c6330b3 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 } diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index 59b5a3f..6cb825a 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -388,10 +388,29 @@ fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, cand #[cfg(not(coverage))] fn detect_uac_card_candidates() -> Vec { - 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 { cards .lines() .filter_map(|line| { @@ -411,6 +430,52 @@ fn detect_uac_card_candidates() -> Vec { .collect() } +#[cfg(not(coverage))] +fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet { + 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) -> Vec { + 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() { diff --git a/server/src/video.rs b/server/src/video.rs index 51946ce..24fe29f 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -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, ) };