From ba443371e6ed3841e533d6a3175a8ea7c4304588 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 20 Apr 2026 11:11:51 -0300 Subject: [PATCH] lesavka: refresh staged devices and steady diagnostics --- client/Cargo.toml | 2 +- client/src/launcher/device_test.rs | 4 +- client/src/launcher/ui.rs | 115 +++++++++++++++++- client/src/launcher/ui_components.rs | 46 ++++++- client/src/launcher/ui_runtime.rs | 71 +++++++---- client/src/output/audio.rs | 21 +++- common/Cargo.toml | 2 +- common/src/cli.rs | 2 +- server/Cargo.toml | 2 +- .../client_output_audio_include_contract.rs | 20 ++- 10 files changed, 243 insertions(+), 42 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 0d7d5c7..3fbed48 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.24" +version = "0.11.25" edition = "2024" [dependencies] diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index 03013a7..b15e58b 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -13,7 +13,7 @@ use std::time::Duration; const CAMERA_PREVIEW_WIDTH: i32 = 360; const CAMERA_PREVIEW_HEIGHT: i32 = 202; -const CAMERA_PREVIEW_IDLE: &str = "Select a camera and click Start Preview."; +const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; const MIC_MONITOR_RATE: i32 = 16_000; const MIC_MONITOR_CHANNELS: i32 = 1; const MIC_MONITOR_SAMPLE_BYTES: usize = 2; @@ -345,7 +345,7 @@ impl LocalCameraPreview { self.set_status(match self.selected_device.as_deref() { Some(camera) => format!( - "Selected {camera}. Start Preview to confirm framing here before you launch the relay." + "Selected {camera}. Start Preview to confirm webcam framing here before you launch the relay." ), None => CAMERA_PREVIEW_IDLE.to_string(), }); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index a364dd8..f835604 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -13,7 +13,7 @@ use { BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, }, - super::ui_components::build_launcher_view, + super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo}, super::ui_runtime::{ RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams, capture_swap_key, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview, @@ -125,6 +125,21 @@ impl NetworkTelemetry { } } +#[cfg(not(coverage))] +fn retained_stage_selection(current: Option<&str>, values: &[String]) -> Option { + current + .filter(|selected| values.iter().any(|value| value == *selected)) + .map(str::to_string) + .or_else(|| values.first().cloned()) +} + +#[cfg(not(coverage))] +fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option { + current + .filter(|selected| values.iter().any(|value| value == *selected)) + .map(str::to_string) +} + #[cfg(not(coverage))] fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { if samples.len() < 2 { @@ -1040,6 +1055,104 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let widgets_handle = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let camera_combo = camera_combo.clone(); + let microphone_combo = microphone_combo.clone(); + let speaker_combo = speaker_combo.clone(); + let keyboard_combo = keyboard_combo.clone(); + let mouse_combo = mouse_combo.clone(); + widgets.device_refresh_button.connect_clicked(move |_| { + let catalog = DeviceCatalog::discover(); + let ( + selected_camera, + selected_microphone, + selected_speaker, + selected_keyboard, + selected_mouse, + ) = { + let state = state.borrow(); + ( + retained_stage_selection( + state.devices.camera.as_deref(), + &catalog.cameras, + ), + retained_stage_selection( + state.devices.microphone.as_deref(), + &catalog.microphones, + ), + retained_stage_selection( + state.devices.speaker.as_deref(), + &catalog.speakers, + ), + retained_input_selection( + state.devices.keyboard.as_deref(), + &catalog.keyboards, + ), + retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice), + ) + }; + { + let mut state = state.borrow_mut(); + state.select_camera(selected_camera); + state.select_microphone(selected_microphone); + state.select_speaker(selected_speaker); + state.select_keyboard(selected_keyboard); + state.select_mouse(selected_mouse); + } + let state_snapshot = state.borrow().clone(); + sync_stage_device_combo( + &camera_combo, + &catalog.cameras, + state_snapshot.devices.camera.as_deref(), + ); + sync_stage_device_combo( + µphone_combo, + &catalog.microphones, + state_snapshot.devices.microphone.as_deref(), + ); + sync_stage_device_combo( + &speaker_combo, + &catalog.speakers, + state_snapshot.devices.speaker.as_deref(), + ); + sync_input_device_combo( + &keyboard_combo, + &catalog.keyboards, + state_snapshot.devices.keyboard.as_deref(), + "all keyboards", + ); + sync_input_device_combo( + &mouse_combo, + &catalog.mice, + state_snapshot.devices.mouse.as_deref(), + "all mice", + ); + if let Err(err) = tests + .borrow_mut() + .set_camera_selection(state_snapshot.devices.camera.as_deref()) + { + widgets_handle + .status_label + .set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}")); + } else { + widgets_handle.status_label.set_text( + "Device staging refreshed. Newly attached devices are ready for local tests; reconnect the relay if you want the live session to use a new webcam, mic, or speaker.", + ); + } + refresh_launcher_ui( + &widgets_handle, + &state.borrow(), + child_proc.borrow().is_some(), + ); + refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut()); + }); + } + { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 74a956d..fb4e912 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -55,6 +55,7 @@ pub struct LauncherWidgets { pub diagnostics_buffer: gtk::TextBuffer, pub diagnostics_scroll: gtk::ScrolledWindow, pub diagnostics_popout_scroll: Rc>>, + pub diagnostics_rendered_text: Rc>, pub session_log_buffer: gtk::TextBuffer, pub session_log_view: gtk::TextView, pub summary: SummaryWidgets, @@ -70,6 +71,7 @@ pub struct LauncherWidgets { pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, pub usb_recover_button: gtk::Button, + pub device_refresh_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, pub microphone_test_button: gtk::Button, @@ -201,6 +203,15 @@ pub fn build_launcher_view( devices_panel.set_hexpand(true); devices_panel.set_vexpand(false); devices_body.set_spacing(8); + let devices_actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + devices_actions.set_halign(gtk::Align::End); + let device_refresh_button = gtk::Button::with_label("Refresh Devices"); + stabilize_button(&device_refresh_button, 132); + device_refresh_button.set_tooltip_text(Some( + "Re-scan webcams, microphones, speakers, keyboards, and mice without restarting Lesavka.", + )); + devices_actions.append(&device_refresh_button); + devices_body.append(&devices_actions); let control_group = build_subgroup("Control Inputs"); let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12); @@ -307,7 +318,7 @@ pub fn build_launcher_view( devices_body.append(&media_group); staging_row.append(&devices_panel); - let (preview_panel, preview_body) = build_panel("Selected Camera Preview"); + let (preview_panel, preview_body) = build_panel("Webcam Test"); preview_panel.set_hexpand(true); preview_panel.set_vexpand(false); preview_body.set_spacing(6); @@ -321,7 +332,7 @@ pub fn build_launcher_view( ); camera_preview.set_keep_aspect_ratio(true); camera_preview.add_css_class("camera-preview-frame"); - let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview.")); + let camera_status = gtk::Label::new(Some("Select a webcam and click Start Preview.")); camera_status.add_css_class("dim-label"); camera_status.set_wrap(true); camera_status.set_xalign(0.0); @@ -464,7 +475,7 @@ pub fn build_launcher_view( diagnostics_view.set_editable(false); diagnostics_view.set_cursor_visible(false); diagnostics_view.set_monospace(true); - diagnostics_view.set_wrap_mode(gtk::WrapMode::WordChar); + diagnostics_view.set_wrap_mode(gtk::WrapMode::None); let diagnostics_scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(false) @@ -629,6 +640,7 @@ pub fn build_launcher_view( diagnostics_buffer: diagnostics_buffer.clone(), diagnostics_scroll: diagnostics_scroll.clone(), diagnostics_popout_scroll: diagnostics_popout_scroll.clone(), + diagnostics_rendered_text: Rc::new(RefCell::new(String::new())), session_log_buffer: session_log_buffer.clone(), session_log_view: session_log_view.clone(), summary: SummaryWidgets { @@ -652,6 +664,7 @@ pub fn build_launcher_view( clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), usb_recover_button: usb_recover_button.clone(), + device_refresh_button: device_refresh_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), microphone_test_button: microphone_test_button.clone(), @@ -976,6 +989,33 @@ pub fn sync_breakout_size_combo( combo.set_active_id(Some(selected.as_id())); } +pub fn sync_stage_device_combo( + combo: >k::ComboBoxText, + values: &[String], + selected: Option<&str>, +) { + combo.remove_all(); + combo.append(Some("auto"), "auto"); + for value in values { + append_stage_choice(combo, value); + } + super::ui_runtime::set_combo_active_text(combo, selected); +} + +pub fn sync_input_device_combo( + combo: >k::ComboBoxText, + values: &[String], + selected: Option<&str>, + all_label: &str, +) { + combo.remove_all(); + combo.append(Some("all"), all_label); + for value in values { + append_input_choice(combo, value); + } + super::ui_runtime::set_combo_active_text(combo, selected); +} + fn attach_device_row( grid: >k::Grid, row: i32, diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index fd77da2..78dfcd4 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -89,6 +89,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .usb_recover_button .set_sensitive(state.server_available); + widgets.device_refresh_button.set_sensitive(true); widgets.input_toggle_button.set_label("Change Routing"); widgets .input_toggle_button @@ -947,6 +948,22 @@ pub fn refresh_diagnostics_report( state: &LauncherState, child_running: bool, ) { + let mut snapshot = SnapshotReport::from_state( + state, + &widgets.diagnostics_log.borrow(), + quality_probe_command().to_string(), + ); + if child_running && !snapshot.remote_active { + snapshot.recommendations.insert( + 0, + "The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(), + ); + } + let rendered = snapshot.to_pretty_text(); + if *widgets.diagnostics_rendered_text.borrow() == rendered { + return; + } + let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment(); let previous_value = diagnostics_adjustment.value(); let previous_max = @@ -963,36 +980,28 @@ pub fn refresh_diagnostics_report( let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); (adjustment.clone(), previous_value, was_at_bottom) }); - let mut snapshot = SnapshotReport::from_state( - state, - &widgets.diagnostics_log.borrow(), - quality_probe_command().to_string(), - ); - if child_running && !snapshot.remote_active { - snapshot.recommendations.insert( - 0, - "The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(), - ); - } - widgets - .diagnostics_buffer - .set_text(&snapshot.to_pretty_text()); - glib::idle_add_local_once(move || { - let max = (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0); - let target = if was_at_bottom { - max - } else { - previous_value.min(max) - }; - diagnostics_adjustment.set_value(target); - if let Some((adjustment, previous_value, was_at_bottom)) = popout_state { + let restore_adjustment = + |adjustment: >k::Adjustment, previous_value: f64, was_at_bottom: bool| { let max = (adjustment.upper() - adjustment.page_size()).max(0.0); let target = if was_at_bottom { max } else { previous_value.min(max) }; - adjustment.set_value(target); + if (adjustment.value() - target).abs() > 1.0 { + adjustment.set_value(target); + } + }; + *widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone(); + widgets.diagnostics_buffer.set_text(&rendered); + restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); + if let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() { + restore_adjustment(adjustment, *previous_value, *was_at_bottom); + } + glib::idle_add_local_once(move || { + restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); + if let Some((adjustment, previous_value, was_at_bottom)) = popout_state { + restore_adjustment(&adjustment, previous_value, was_at_bottom); } }); } @@ -1002,7 +1011,15 @@ pub fn open_session_log_popout( handle: &Rc>>, buffer: >k::TextBuffer, ) { - open_text_buffer_popout(app, handle, None, buffer, "Lesavka Log", "Copy Log"); + open_text_buffer_popout( + app, + handle, + None, + buffer, + "Lesavka Log", + "Copy Log", + gtk::WrapMode::WordChar, + ); } pub fn open_diagnostics_popout( @@ -1018,6 +1035,7 @@ pub fn open_diagnostics_popout( buffer, "Lesavka Diagnostics", "Copy Report", + gtk::WrapMode::None, ); } @@ -1028,6 +1046,7 @@ fn open_text_buffer_popout( buffer: >k::TextBuffer, title: &str, copy_button_label: &str, + wrap_mode: gtk::WrapMode, ) { if let Some(window) = handle.borrow().as_ref() { window.present(); @@ -1059,7 +1078,7 @@ fn open_text_buffer_popout( view.set_editable(false); view.set_cursor_visible(false); view.set_monospace(true); - view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_wrap_mode(wrap_mode); let scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index 21432e9..fe31b87 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -26,15 +26,21 @@ impl AudioOut { let mut pipe = format!( "appsrc name=src is-live=true format=time do-timestamp=true \ block=false ! \ - queue leaky=downstream ! \ - aacparse ! avdec_aac ! audioresample ! audioconvert ! {}", + queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ + aacparse ! avdec_aac ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ + queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {}", sink, ); if tee_dump { pipe = format!( "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ tee name=t ! \ - queue leaky=downstream ! aacparse ! avdec_aac ! audioresample ! audioconvert ! {} \ + queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \ + aacparse ! avdec_aac ! audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,channels=2,rate=48000 ! \ + queue max-size-time=400000000 max-size-bytes=0 max-size-buffers=0 ! {} \ t. ! queue ! filesink location=/tmp/lesavka-audio.aac", sink, ); @@ -177,7 +183,14 @@ fn normalize_sink_override(raw: &str) -> String { fn pulsesink_device_element(device: &str) -> String { let escaped = device.replace('\\', "\\\\").replace('"', "\\\""); - format!("pulsesink device=\"{escaped}\"") + let (buffer_time, latency_time) = if device.starts_with("bluez_output.") { + (750_000, 250_000) + } else { + (350_000, 100_000) + }; + format!( + "pulsesink device=\"{escaped}\" buffer-time={buffer_time} latency-time={latency_time} sync=true" + ) } fn list_pw_sinks() -> Vec<(String, String)> { diff --git a/common/Cargo.toml b/common/Cargo.toml index 00069b6..82addd5 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.24" +version = "0.11.25" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index d50503b..6741fd3 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.24"), "lesavka-common CLI (v0.11.24)"); + assert_eq!(banner("0.11.25"), "lesavka-common CLI (v0.11.25)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 501426d..5a48acb 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.24" +version = "0.11.25" edition = "2024" autobins = false diff --git a/testing/tests/client_output_audio_include_contract.rs b/testing/tests/client_output_audio_include_contract.rs index 1ac3b9b..f74a952 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/testing/tests/client_output_audio_include_contract.rs @@ -56,7 +56,7 @@ mod audio_include_contract { let sink = pick_sink_element().expect("device sink"); assert_eq!( sink, - "pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\"" + "pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true" ); }, ); @@ -86,12 +86,28 @@ exit 0 let sink = pick_sink_element().expect("pick sink"); assert_eq!( sink, - "pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\"" + "pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true" ); }); }); } + #[test] + #[serial] + fn bluetooth_sink_override_uses_more_headroom() { + with_var( + "LESAVKA_AUDIO_SINK", + Some("bluez_output.80_C3_BA_76_26_AB.1"), + || { + let sink = pick_sink_element().expect("bluetooth sink"); + assert_eq!( + sink, + "pulsesink device=\"bluez_output.80_C3_BA_76_26_AB.1\" buffer-time=750000 latency-time=250000 sync=true" + ); + }, + ); + } + #[test] #[serial] fn pick_sink_element_falls_back_to_autoaudiosink_without_pactl_default() {