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]
name = "lesavka_client"
version = "0.11.24"
version = "0.11.25"
edition = "2024"
[dependencies]

View File

@ -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(),
});

View File

@ -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<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))]
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(
&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 child_proc = Rc::clone(&child_proc);

View File

@ -55,6 +55,7 @@ pub struct LauncherWidgets {
pub diagnostics_buffer: gtk::TextBuffer,
pub diagnostics_scroll: 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_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: &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(
grid: &gtk::Grid,
row: i32,

View File

@ -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: &gtk::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<RefCell<Option<gtk::ApplicationWindow>>>,
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(
@ -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: &gtk::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)

View File

@ -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)> {

View File

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

View File

@ -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)");
}
}

View File

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

View File

@ -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() {