lesavka: refresh staged devices and steady diagnostics

This commit is contained in:
Brad Stein 2026-04-20 11:11:51 -03:00
parent c24bef1bf2
commit ba443371e6
10 changed files with 243 additions and 42 deletions

View File

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

View File

@ -13,7 +13,7 @@ use std::time::Duration;
const CAMERA_PREVIEW_WIDTH: i32 = 360; const CAMERA_PREVIEW_WIDTH: i32 = 360;
const CAMERA_PREVIEW_HEIGHT: i32 = 202; 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_RATE: i32 = 16_000;
const MIC_MONITOR_CHANNELS: i32 = 1; const MIC_MONITOR_CHANNELS: i32 = 1;
const MIC_MONITOR_SAMPLE_BYTES: usize = 2; const MIC_MONITOR_SAMPLE_BYTES: usize = 2;
@ -345,7 +345,7 @@ impl LocalCameraPreview {
self.set_status(match self.selected_device.as_deref() { self.set_status(match self.selected_device.as_deref() {
Some(camera) => format!( 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(), None => CAMERA_PREVIEW_IDLE.to_string(),
}); });

View File

@ -13,7 +13,7 @@ use {
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
FeedSourcePreset, InputRouting, LauncherState, 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::{ super::ui_runtime::{
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams, 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, 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<String> {
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<String> {
current
.filter(|selected| values.iter().any(|value| value == *selected))
.map(str::to_string)
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
if samples.len() < 2 { 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(
&microphone_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 state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc); let child_proc = Rc::clone(&child_proc);

View File

@ -55,6 +55,7 @@ pub struct LauncherWidgets {
pub diagnostics_buffer: gtk::TextBuffer, pub diagnostics_buffer: gtk::TextBuffer,
pub diagnostics_scroll: gtk::ScrolledWindow, pub diagnostics_scroll: gtk::ScrolledWindow,
pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>, pub diagnostics_popout_scroll: Rc<RefCell<Option<gtk::ScrolledWindow>>>,
pub diagnostics_rendered_text: Rc<RefCell<String>>,
pub session_log_buffer: gtk::TextBuffer, pub session_log_buffer: gtk::TextBuffer,
pub session_log_view: gtk::TextView, pub session_log_view: gtk::TextView,
pub summary: SummaryWidgets, pub summary: SummaryWidgets,
@ -70,6 +71,7 @@ pub struct LauncherWidgets {
pub clipboard_button: gtk::Button, pub clipboard_button: gtk::Button,
pub probe_button: gtk::Button, pub probe_button: gtk::Button,
pub usb_recover_button: gtk::Button, pub usb_recover_button: gtk::Button,
pub device_refresh_button: gtk::Button,
pub swap_key_button: gtk::Button, pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button, pub camera_test_button: gtk::Button,
pub microphone_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_hexpand(true);
devices_panel.set_vexpand(false); devices_panel.set_vexpand(false);
devices_body.set_spacing(8); 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_group = build_subgroup("Control Inputs");
let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12); let control_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
@ -307,7 +318,7 @@ pub fn build_launcher_view(
devices_body.append(&media_group); devices_body.append(&media_group);
staging_row.append(&devices_panel); 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_hexpand(true);
preview_panel.set_vexpand(false); preview_panel.set_vexpand(false);
preview_body.set_spacing(6); preview_body.set_spacing(6);
@ -321,7 +332,7 @@ pub fn build_launcher_view(
); );
camera_preview.set_keep_aspect_ratio(true); camera_preview.set_keep_aspect_ratio(true);
camera_preview.add_css_class("camera-preview-frame"); 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.add_css_class("dim-label");
camera_status.set_wrap(true); camera_status.set_wrap(true);
camera_status.set_xalign(0.0); camera_status.set_xalign(0.0);
@ -464,7 +475,7 @@ pub fn build_launcher_view(
diagnostics_view.set_editable(false); diagnostics_view.set_editable(false);
diagnostics_view.set_cursor_visible(false); diagnostics_view.set_cursor_visible(false);
diagnostics_view.set_monospace(true); 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() let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true) .hexpand(true)
.vexpand(false) .vexpand(false)
@ -629,6 +640,7 @@ pub fn build_launcher_view(
diagnostics_buffer: diagnostics_buffer.clone(), diagnostics_buffer: diagnostics_buffer.clone(),
diagnostics_scroll: diagnostics_scroll.clone(), diagnostics_scroll: diagnostics_scroll.clone(),
diagnostics_popout_scroll: diagnostics_popout_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_buffer: session_log_buffer.clone(),
session_log_view: session_log_view.clone(), session_log_view: session_log_view.clone(),
summary: SummaryWidgets { summary: SummaryWidgets {
@ -652,6 +664,7 @@ pub fn build_launcher_view(
clipboard_button: clipboard_button.clone(), clipboard_button: clipboard_button.clone(),
probe_button: probe_button.clone(), probe_button: probe_button.clone(),
usb_recover_button: usb_recover_button.clone(), usb_recover_button: usb_recover_button.clone(),
device_refresh_button: device_refresh_button.clone(),
swap_key_button: swap_key_button.clone(), swap_key_button: swap_key_button.clone(),
camera_test_button: camera_test_button.clone(), camera_test_button: camera_test_button.clone(),
microphone_test_button: microphone_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())); combo.set_active_id(Some(selected.as_id()));
} }
pub fn sync_stage_device_combo(
combo: &gtk::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: &gtk::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( fn attach_device_row(
grid: &gtk::Grid, grid: &gtk::Grid,
row: i32, row: i32,

View File

@ -89,6 +89,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets widgets
.usb_recover_button .usb_recover_button
.set_sensitive(state.server_available); .set_sensitive(state.server_available);
widgets.device_refresh_button.set_sensitive(true);
widgets.input_toggle_button.set_label("Change Routing"); widgets.input_toggle_button.set_label("Change Routing");
widgets widgets
.input_toggle_button .input_toggle_button
@ -947,6 +948,22 @@ pub fn refresh_diagnostics_report(
state: &LauncherState, state: &LauncherState,
child_running: bool, 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 diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment();
let previous_value = diagnostics_adjustment.value(); let previous_value = diagnostics_adjustment.value();
let previous_max = 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); let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0);
(adjustment.clone(), previous_value, was_at_bottom) (adjustment.clone(), previous_value, was_at_bottom)
}); });
let mut snapshot = SnapshotReport::from_state( let restore_adjustment =
state, |adjustment: &gtk::Adjustment, previous_value: f64, was_at_bottom: bool| {
&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 max = (adjustment.upper() - adjustment.page_size()).max(0.0); let max = (adjustment.upper() - adjustment.page_size()).max(0.0);
let target = if was_at_bottom { let target = if was_at_bottom {
max max
} else { } else {
previous_value.min(max) 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<RefCell<Option<gtk::ApplicationWindow>>>, handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
buffer: &gtk::TextBuffer, buffer: &gtk::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( pub fn open_diagnostics_popout(
@ -1018,6 +1035,7 @@ pub fn open_diagnostics_popout(
buffer, buffer,
"Lesavka Diagnostics", "Lesavka Diagnostics",
"Copy Report", "Copy Report",
gtk::WrapMode::None,
); );
} }
@ -1028,6 +1046,7 @@ fn open_text_buffer_popout(
buffer: &gtk::TextBuffer, buffer: &gtk::TextBuffer,
title: &str, title: &str,
copy_button_label: &str, copy_button_label: &str,
wrap_mode: gtk::WrapMode,
) { ) {
if let Some(window) = handle.borrow().as_ref() { if let Some(window) = handle.borrow().as_ref() {
window.present(); window.present();
@ -1059,7 +1078,7 @@ fn open_text_buffer_popout(
view.set_editable(false); view.set_editable(false);
view.set_cursor_visible(false); view.set_cursor_visible(false);
view.set_monospace(true); view.set_monospace(true);
view.set_wrap_mode(gtk::WrapMode::WordChar); view.set_wrap_mode(wrap_mode);
let scroll = gtk::ScrolledWindow::builder() let scroll = gtk::ScrolledWindow::builder()
.hexpand(true) .hexpand(true)
.vexpand(true) .vexpand(true)

View File

@ -26,15 +26,21 @@ impl AudioOut {
let mut pipe = format!( let mut pipe = format!(
"appsrc name=src is-live=true format=time do-timestamp=true \ "appsrc name=src is-live=true format=time do-timestamp=true \
block=false ! \ block=false ! \
queue leaky=downstream ! \ queue max-size-time=500000000 max-size-bytes=0 max-size-buffers=0 ! \
aacparse ! avdec_aac ! audioresample ! audioconvert ! {}", 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, sink,
); );
if tee_dump { if tee_dump {
pipe = format!( pipe = format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
tee name=t ! \ 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", t. ! queue ! filesink location=/tmp/lesavka-audio.aac",
sink, sink,
); );
@ -177,7 +183,14 @@ fn normalize_sink_override(raw: &str) -> String {
fn pulsesink_device_element(device: &str) -> String { fn pulsesink_device_element(device: &str) -> String {
let escaped = device.replace('\\', "\\\\").replace('"', "\\\""); 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)> { fn list_pw_sinks() -> Vec<(String, String)> {

View File

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

View File

@ -17,6 +17,6 @@ mod tests {
#[test] #[test]
fn banner_includes_version() { 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)");
} }
} }

View File

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

View File

@ -56,7 +56,7 @@ mod audio_include_contract {
let sink = pick_sink_element().expect("device sink"); let sink = pick_sink_element().expect("device sink");
assert_eq!( assert_eq!(
sink, 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"); let sink = pick_sink_element().expect("pick sink");
assert_eq!( assert_eq!(
sink, 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] #[test]
#[serial] #[serial]
fn pick_sink_element_falls_back_to_autoaudiosink_without_pactl_default() { fn pick_sink_element_falls_back_to_autoaudiosink_without_pactl_default() {