launcher(ui): tighten recovery rail, chip health, and capture fidelity

This commit is contained in:
Brad Stein 2026-04-29 23:19:57 -03:00
parent 21ad7c6ee9
commit 9401f2b7cd
22 changed files with 178 additions and 106 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.15.2"
version = "0.15.3"
dependencies = [
"anyhow",
"async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.15.2"
version = "0.15.3"
dependencies = [
"anyhow",
"base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.15.2"
version = "0.15.3"
dependencies = [
"anyhow",
"base64",

View File

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

View File

@ -179,18 +179,16 @@ fn build_preview_pipeline(
profile: PreviewProfile,
decoder_name: &str,
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
let source_mode = eye_source_mode_for_request(
let _display_bounds = (profile.display_width, profile.display_height);
let _source_mode = eye_source_mode_for_request(
profile.requested_width.max(2) as u32,
profile.requested_height.max(2) as u32,
);
let (render_width, render_height) =
preview_render_size(profile, source_mode.width, source_mode.height);
let desc = format!(
"appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
h264parse name=preview_parse disable-passthrough=true ! {} name=decoder ! videoconvert ! \
videoscale add-borders=false ! \
video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \
video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
decoder_name,
);
@ -219,8 +217,6 @@ fn build_preview_pipeline(
appsink.set_caps(Some(
&gst::Caps::builder("video/x-raw")
.field("format", "RGBA")
.field("width", render_width)
.field("height", render_height)
.field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
.build(),
));
@ -228,27 +224,6 @@ fn build_preview_pipeline(
Ok((pipeline, appsrc, appsink, decoder_name.to_string()))
}
#[cfg(not(coverage))]
fn preview_render_size(
profile: PreviewProfile,
source_width: u32,
source_height: u32,
) -> (i32, i32) {
fn round_down_even(value: i32) -> i32 {
let clamped = value.max(2);
clamped - (clamped % 2)
}
let source_w = source_width.max(2) as f32;
let source_h = source_height.max(2) as f32;
let max_w = profile.display_width.max(2) as f32;
let max_h = profile.display_height.max(2) as f32;
let scale = (max_w / source_w).min(max_h / source_h).clamp(0.01, 1.0);
let render_w = round_down_even((source_w * scale).round() as i32);
let render_h = round_down_even((source_h * scale).round() as i32);
(render_w.max(2), render_h.max(2))
}
#[cfg(not(coverage))]
fn preview_decoder_candidates() -> Vec<String> {
let mut candidates = Vec::new();

View File

@ -2,7 +2,7 @@ use super::{
DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT,
INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH,
LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry,
preview_render_size, sanitize_preview_request,
sanitize_preview_request,
};
use crate::launcher::state::{CaptureSizePreset, LauncherState};
use futures::stream;
@ -155,18 +155,6 @@ fn breakout_preview_profile_defaults_to_higher_quality() {
assert_eq!(profile.max_bitrate_kbit, 18_000);
}
#[test]
fn preview_render_size_fits_source_into_display_budget() {
let profile = PreviewSurface::Inline.profile();
assert_eq!(preview_render_size(profile, 1920, 1080), (960, 540));
}
#[test]
fn preview_render_size_never_upscales_beyond_source_geometry() {
let profile = PreviewSurface::Window.profile();
assert_eq!(preview_render_size(profile, 1280, 720), (1280, 720));
}
#[test]
fn preview_request_sanitizer_keeps_requested_source_geometry() {
let adapted = sanitize_preview_request(1920, 1080, 60, 18_000);

View File

@ -377,7 +377,7 @@ fn breakout_size_changes_resize_the_open_popout_window() {
fn server_chip_state_tracks_connection_not_just_reachability() {
let mut state = LauncherState::new();
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
assert_eq!(server_version_label(&state), "-");
assert_eq!(server_version_label(&state), "???");
state.set_server_available(true);
state.set_server_version(Some(crate::VERSION.to_string()));
@ -396,7 +396,7 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
state.set_server_version(Some(" ".to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
assert_eq!(server_version_label(&state), "-");
assert_eq!(server_version_label(&state), "???");
}
#[test]

View File

@ -8,6 +8,7 @@ use {
super::diagnostics::PerformanceSample,
super::launcher_clipboard_control_path,
super::launcher_focus_signal_path,
super::preview::{LauncherPreview, PreviewSurface},
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
super::state::{
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,

View File

@ -157,6 +157,8 @@
let mut state = state.borrow_mut();
state.set_server_available(false);
state.set_server_version(None);
state.set_server_media_caps(None, None, None, None);
state.set_capture_power(CapturePowerStatus::default());
}
if let Some(preview) = preview.as_ref() {
preview.set_server_addr(server_addr.clone());

View File

@ -1,6 +1,5 @@
{
const EYE_RECORD_FPS: u32 = 20;
const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64;
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
#[derive(Default)]
struct EyeRecordState {
@ -10,6 +9,8 @@
output_path: Option<PathBuf>,
next_frame_index: u32,
captured_frames: u32,
encode_fps: u32,
encode_bitrate_kbit: u32,
}
fn eye_slug(title: &str) -> &'static str {
@ -97,6 +98,35 @@
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
}
fn recording_interval_ms(record_fps: u32) -> u64 {
let fps = record_fps.max(1);
(1000_u64 / fps as u64).max(1)
}
fn best_effort_recording_profile(
state: &LauncherState,
preview: Option<&LauncherPreview>,
monitor_id: usize,
) -> (u32, u32) {
let choice = state
.display_capture_size_choice(monitor_id)
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
let mut fps = if choice.fps == 0 {
DEFAULT_EYE_RECORD_FPS
} else {
choice.fps.max(1)
};
if let Some(snapshot) =
preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline))
&& snapshot.server_fps.is_finite()
&& snapshot.server_fps >= 1.0
{
fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32;
}
let bitrate_kbit = choice.max_bitrate_kbit.max(800);
(fps, bitrate_kbit)
}
fn write_record_frame(state: &mut EyeRecordState, picture: &gtk::Picture) -> Result<(), String> {
let frame_dir = state
.frame_dir
@ -121,8 +151,12 @@
.take()
.ok_or_else(|| "recording output path was not initialized".to_string())?;
let captured_frames = state.captured_frames;
let encode_fps = state.encode_fps.max(1);
let encode_bitrate_kbit = state.encode_bitrate_kbit.max(800);
state.captured_frames = 0;
state.next_frame_index = 0;
state.encode_fps = 0;
state.encode_bitrate_kbit = 0;
if captured_frames < 2 {
let _ = std::fs::remove_dir_all(&frame_dir);
@ -130,6 +164,7 @@
}
let frame_pattern = frame_dir.join("frame-%06d.png");
let bitrate_arg = format!("{encode_bitrate_kbit}k");
let encode = Command::new("ffmpeg")
.args([
"-hide_banner",
@ -137,13 +172,17 @@
"error",
"-y",
"-framerate",
&EYE_RECORD_FPS.to_string(),
&encode_fps.to_string(),
"-i",
&frame_pattern.to_string_lossy(),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-r",
&encode_fps.to_string(),
"-b:v",
&bitrate_arg,
&output_path.to_string_lossy(),
])
.status()
@ -300,6 +339,8 @@
let pane = pane.clone();
let widgets = widgets_for_ui.clone();
let save_state = Rc::clone(&save_state);
let state = Rc::clone(&state);
let preview = preview.clone();
let record_button = pane.record_button.clone();
record_button.connect_clicked(move |button| {
if save_state.borrow().timer.is_some() {
@ -330,6 +371,10 @@
return;
}
let (record_fps, record_bitrate_kbit) = {
let state = state.borrow();
best_effort_recording_profile(&state, preview.as_deref(), monitor_id)
};
let root = {
let borrowed = save_state.borrow();
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
@ -361,13 +406,15 @@
state.output_path = Some(output_path.clone());
state.next_frame_index = 0;
state.captured_frames = 0;
state.encode_fps = record_fps;
state.encode_bitrate_kbit = record_bitrate_kbit;
}
let pane_for_tick = pane.clone();
let widgets_for_tick = widgets.clone();
let save_state_for_tick = Rc::clone(&save_state);
let timer = glib::timeout_add_local(
Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS),
Duration::from_millis(recording_interval_ms(record_fps)),
move || {
let mut state = save_state_for_tick.borrow_mut();
if state.frame_dir.is_none() {
@ -385,8 +432,8 @@
save_state.borrow_mut().timer = Some(timer);
button.set_label("Stop");
widgets.status_label.set_text(&format!(
"Recording {}... press Stop to finish.",
pane.title
"Recording {} at {} fps (~{} kbit)... press Stop to finish.",
pane.title, record_fps, record_bitrate_kbit
));
});
}
@ -400,7 +447,7 @@
widgets.usb_recover_button.connect_clicked(move |_| {
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click.status_label.set_text(
"Requesting a forced USB gadget re-enumeration on the relay host...",
"Recover USB 1/3: sending gadget reset request to relay host...",
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@ -411,20 +458,20 @@
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
widgets.status_label.set_text(
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
"Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("USB gadget recovery failed: {err}"));
.set_text(&format!("Recover USB failed: {err}"));
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text(
"USB gadget recovery ended unexpectedly before the relay answered.",
"Recover USB failed: relay stopped responding before completion.",
);
glib::ControlFlow::Break
}
@ -441,7 +488,7 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click
.status_label
.set_text("Requesting UAC recovery (USB gadget rebuild) on the relay host...");
.set_text("Recover UAC 1/3: sending gadget reset request to relay host...");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
@ -451,20 +498,20 @@
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
widgets.status_label.set_text(
"UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.",
"Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("UAC recovery failed: {err}"));
.set_text(&format!("Recover UAC failed: {err}"));
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text(
"UAC recovery ended unexpectedly before the relay answered.",
"Recover UAC failed: relay stopped responding before completion.",
);
glib::ControlFlow::Break
}
@ -481,7 +528,7 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click
.status_label
.set_text("Requesting UVC recovery (USB gadget rebuild) on the relay host...");
.set_text("Recover UVC 1/3: sending gadget reset request to relay host...");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
@ -491,20 +538,20 @@
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
widgets.status_label.set_text(
"UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.",
"Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("UVC recovery failed: {err}"));
.set_text(&format!("Recover UVC failed: {err}"));
glib::ControlFlow::Break
}
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text(
"UVC recovery ended unexpectedly before the relay answered.",
"Recover UVC failed: relay stopped responding before completion.",
);
glib::ControlFlow::Break
}

View File

@ -72,6 +72,7 @@ pub fn build_launcher_view(
camera_preview_stack,
camera_preview_frame,
camera_preview,
webcam_transport_combo,
camera_mirror_button,
camera_mirror_revealer,
camera_status,

View File

@ -149,6 +149,7 @@
swap_key_button: swap_key_button.clone(),
camera_test_button: camera_test_button.clone(),
camera_preview_stack: camera_preview_stack.clone(),
webcam_transport_combo: webcam_transport_combo.clone(),
camera_mirror_button: camera_mirror_button.clone(),
camera_mirror_revealer: camera_mirror_revealer.clone(),
microphone_test_button: microphone_test_button.clone(),
@ -184,6 +185,7 @@
camera_preview_stack,
camera_preview_frame,
camera_preview,
webcam_transport_combo,
camera_mirror_button,
camera_status,
},

View File

@ -42,6 +42,7 @@ struct DeviceControlsContext {
camera_preview_stack: gtk::Stack,
camera_preview_frame: gtk::AspectFrame,
camera_preview: gtk::Picture,
webcam_transport_combo: gtk::ComboBoxText,
camera_mirror_button: gtk::ToggleButton,
camera_mirror_revealer: gtk::Revealer,
camera_status: gtk::Label,

View File

@ -328,7 +328,19 @@
camera_preview_stack.add_named(&camera_preview_overlay, Some("live"));
camera_preview_stack.set_visible_child_name("idle");
camera_preview_shell.append(&camera_preview_stack);
let webcam_group = build_subgroup("Webcam Preview");
let webcam_transport_combo = gtk::ComboBoxText::new();
webcam_transport_combo.add_css_class("compact-combo");
webcam_transport_combo.append(Some("mjpeg"), "MJPEG");
webcam_transport_combo.append(Some("yuy2"), "YUY2");
webcam_transport_combo.append(Some("h264"), "H.264");
webcam_transport_combo.set_active_id(Some("mjpeg"));
webcam_transport_combo.set_sensitive(false);
webcam_transport_combo.set_size_request(112, -1);
webcam_transport_combo.set_tooltip_text(Some(
"Upstream transport format. MJPEG is pinned while sync hardening is in progress.",
));
let webcam_group =
build_subgroup_with_action("Webcam Preview", Some(webcam_transport_combo.upcast_ref()));
webcam_group.set_hexpand(true);
webcam_group.set_vexpand(true);
webcam_group.set_valign(gtk::Align::Fill);
@ -383,6 +395,7 @@
camera_preview_stack,
camera_preview_frame,
camera_preview,
webcam_transport_combo,
camera_mirror_button,
camera_mirror_revealer,
camera_status,

View File

@ -19,7 +19,7 @@
connection_body.append(&relay_grid);
let recovery_heading = gtk::Label::new(Some("Recovery"));
let recovery_heading = gtk::Label::new(Some("Recover"));
recovery_heading.add_css_class("subgroup-title");
recovery_heading.set_halign(gtk::Align::Start);
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
@ -29,14 +29,15 @@
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_buttons.set_hexpand(true);
recovery_buttons.set_homogeneous(true);
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB gadget.");
let uac_recover_button = rail_button("Recover UAC", "Rebuild remote USB audio function.");
let uvc_recover_button = rail_button("Recover UVC", "Rebuild remote USB webcam function.");
let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget.");
let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function.");
let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function.");
recovery_buttons.append(&usb_recover_button);
recovery_buttons.append(&uac_recover_button);
recovery_buttons.append(&uvc_recover_button);
recovery_row.append(&recovery_buttons);
connection_body.append(&recovery_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let tools_heading = gtk::Label::new(Some("Tools"));
tools_heading.add_css_class("subgroup-title");

View File

@ -17,21 +17,25 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
stream_status.set_max_width_chars(18);
stream_status.set_tooltip_text(Some("Eye stream status."));
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.set_hexpand(true);
let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header_row.set_hexpand(true);
let title_label = gtk::Label::new(Some(title));
title_label.add_css_class("title-4");
title_label.set_halign(gtk::Align::Start);
title_label.set_hexpand(false);
title_label.set_hexpand(true);
let capture_label = gtk::Label::new(Some(capture_path));
capture_label.add_css_class("dim-label");
capture_label.set_halign(gtk::Align::End);
capture_label.set_hexpand(false);
capture_label.set_ellipsize(pango::EllipsizeMode::Start);
header.append(&title_label);
header.append(&stream_status);
header.append(&capture_label);
root.append(&header);
header_row.append(&title_label);
header_row.append(&capture_label);
let header_overlay = gtk::Overlay::new();
header_overlay.set_hexpand(true);
header_overlay.set_child(Some(&header_row));
header_overlay.add_overlay(&stream_status);
header_overlay.set_clip_overlay(&stream_status, true);
root.append(&header_overlay);
let picture = gtk::Picture::new();
picture.add_css_class("eye-preview-surface");

View File

@ -25,12 +25,23 @@ fn build_panel_with_action(title: &str, action: Option<&gtk::Widget>) -> (gtk::B
}
fn build_subgroup(title: &str) -> gtk::Box {
build_subgroup_with_action(title, None)
}
fn build_subgroup_with_action(title: &str, action: Option<&gtk::Widget>) -> gtk::Box {
let group = gtk::Box::new(gtk::Orientation::Vertical, 8);
group.add_css_class("subgroup");
let heading_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
heading_row.set_hexpand(true);
let heading = gtk::Label::new(Some(title));
heading.add_css_class("subgroup-title");
heading.set_halign(gtk::Align::Start);
group.append(&heading);
heading.set_hexpand(true);
heading_row.append(&heading);
if let Some(action) = action {
heading_row.append(action);
}
group.append(&heading_row);
group
}
@ -38,6 +49,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
chip.add_css_class("status-chip");
chip.set_hexpand(false);
chip.set_size_request(96, -1);
let label_widget = gtk::Label::new(Some(label));
label_widget.add_css_class("status-chip-label");
@ -47,6 +59,9 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) {
value_widget.add_css_class("status-chip-value");
value_widget.set_halign(gtk::Align::Center);
value_widget.set_xalign(0.5);
value_widget.set_ellipsize(pango::EllipsizeMode::End);
value_widget.set_single_line_mode(true);
value_widget.set_width_chars(9);
chip.append(&label_widget);
chip.append(&value_widget);
(chip, value_widget)
@ -56,6 +71,7 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
let chip = gtk::Box::new(gtk::Orientation::Vertical, 4);
chip.add_css_class("status-chip");
chip.set_hexpand(false);
chip.set_size_request(96, -1);
let meta = gtk::Box::new(gtk::Orientation::Horizontal, 6);
meta.add_css_class("status-chip-meta");
@ -73,6 +89,9 @@ fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box
value_widget.add_css_class("status-chip-value");
value_widget.set_halign(gtk::Align::Center);
value_widget.set_xalign(0.5);
value_widget.set_ellipsize(pango::EllipsizeMode::End);
value_widget.set_single_line_mode(true);
value_widget.set_width_chars(9);
chip.append(&meta);
chip.append(&value_widget);
(chip, light, value_widget)

View File

@ -156,6 +156,7 @@ pub struct LauncherWidgets {
pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button,
pub camera_preview_stack: gtk::Stack,
pub webcam_transport_combo: gtk::ComboBoxText,
pub camera_mirror_button: gtk::ToggleButton,
pub camera_mirror_revealer: gtk::Revealer,
pub microphone_test_button: gtk::Button,
@ -178,6 +179,7 @@ pub struct DeviceStageWidgets {
pub camera_preview_stack: gtk::Stack,
pub camera_preview_frame: gtk::AspectFrame,
pub camera_preview: gtk::Picture,
pub webcam_transport_combo: gtk::ComboBoxText,
pub camera_mirror_button: gtk::ToggleButton,
pub camera_status: gtk::Label,
}

View File

@ -103,7 +103,7 @@ fn normalize_version(version: &str) -> &str {
fn server_version_label(state: &LauncherState) -> String {
if !state.server_available {
return "-".to_string();
return "???".to_string();
}
let version = state
.server_version
@ -113,7 +113,7 @@ fn server_version_label(state: &LauncherState) -> String {
match version {
Some(version) if version.starts_with('v') => version.to_string(),
Some(version) => format!("v{version}"),
None => "-".to_string(),
None => "???".to_string(),
}
}

View File

@ -44,14 +44,16 @@ pub type RelayChild = Child;
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
let relay_live = child_running || state.remote_active;
let server_label = server_version_label(state);
set_status_light(
&widgets.summary.relay_light,
server_light_state(state, relay_live),
);
widgets.summary.relay_value.set_text(&server_label);
widgets
.summary
.relay_value
.set_text(&server_version_label(state));
.set_tooltip_text(Some(&server_label));
set_status_light(
&widgets.summary.routing_light,
StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)),
@ -60,14 +62,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.summary
.routing_value
.set_text(&capitalize(routing_name(state.routing)));
let gpio_label = if state.server_available {
gpio_power_label(&state.capture_power)
} else {
"Offline".to_string()
};
set_status_light(
&widgets.summary.gpio_light,
gpio_light_state(&state.capture_power),
if state.server_available {
gpio_light_state(&state.capture_power)
} else {
StatusLightState::Idle
},
);
widgets
.summary
.gpio_value
.set_text(&gpio_power_label(&state.capture_power));
widgets.summary.gpio_value.set_text(&gpio_label);
widgets.summary.gpio_value.set_tooltip_text(Some(&gpio_label));
widgets
.summary
.shortcut_value
@ -76,16 +85,22 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
let (usb_state, usb_value) = recovery_usb_health(state);
set_status_light(&widgets.summary.usb_light, usb_state);
widgets.summary.usb_value.set_text(&usb_value);
widgets.summary.usb_value.set_tooltip_text(Some(&usb_value));
let (uac_state, uac_value) = recovery_uac_health(state);
set_status_light(&widgets.summary.uac_light, uac_state);
widgets.summary.uac_value.set_text(&uac_value);
widgets.summary.uac_value.set_tooltip_text(Some(&uac_value));
let (uvc_state, uvc_value) = recovery_uvc_health(state);
set_status_light(&widgets.summary.uvc_light, uvc_state);
widgets.summary.uvc_value.set_text(&uvc_value);
widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value));
widgets
.power_detail
.set_text(&capture_power_detail(&state.capture_power));
let power_detail = if state.server_available {
capture_power_detail(&state.capture_power)
} else {
"relay host is offline; GPIO power state is unavailable".to_string()
};
widgets.power_detail.set_text(&power_detail);
if (widgets.audio_gain_scale.value() - state.audio_gain_percent as f64).abs() > f64::EPSILON {
widgets
.audio_gain_scale
@ -177,6 +192,7 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
widgets
.speaker_test_button
.set_sensitive(!relay_live && state.channels.audio);
widgets.webcam_transport_combo.set_sensitive(false);
widgets.input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Route Local",
InputRouting::Local => "Route Remote",

View File

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

View File

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

View File

@ -88,13 +88,13 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
assert!(UI_LAYOUT_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);"));
assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);"));
assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");"));
assert!(UI_LAYOUT_SRC.contains("let header_overlay = gtk::Overlay::new();"));
assert!(UI_LAYOUT_SRC.contains("header_row.append(&title_label);"));
assert!(UI_LAYOUT_SRC.contains("header_row.append(&capture_label);"));
assert!(UI_LAYOUT_SRC.contains("header_overlay.add_overlay(&stream_status);"));
assert!(
source_index("header.append(&title_label);")
< source_index("header.append(&stream_status);")
);
assert!(
source_index("header.append(&stream_status);")
< source_index("header.append(&capture_label);")
source_index("header_row.append(&title_label);")
< source_index("header_row.append(&capture_label);")
);
assert!(
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
@ -281,7 +281,7 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\""));
assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label("));
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);"));
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recovery\"));"));
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));"));
assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
@ -292,9 +292,9 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"Recover USB\""));
assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"Recover UAC\""));
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"Recover UVC\""));
assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"USB\""));
assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"UAC\""));
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"UVC\""));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
@ -373,8 +373,8 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() {
UI_LAYOUT_SRC
.matches("connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));")
.count(),
2,
"the operations rail should not gain extra vertical sections that stretch the lower layout"
3,
"recover/tools/gpio/inputs sections should remain visually separated"
);
assert!(
source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));")

View File

@ -174,10 +174,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() {
assert!(UI_SRC.contains("clip saved to"));
assert!(UI_SRC.contains("record_button.connect_clicked"));
assert!(UI_SRC.contains("recording saved to"));
assert!(UI_SRC.contains("Recording {}... press Stop to finish."));
assert!(UI_SRC.contains("press Stop to finish."));
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)"));
assert!(UI_SRC.contains("USB gadget recovery requested."));
assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset."));
}
#[test]
@ -188,7 +188,7 @@ fn server_chip_distinguishes_reachable_from_connected() {
assert!(UI_RUNTIME_SRC.contains("} else if relay_live {"));
assert!(UI_RUNTIME_SRC.contains("StatusLightState::Caution"));
assert!(UI_RUNTIME_SRC.contains("fn server_version_label("));
assert!(UI_RUNTIME_SRC.contains("return \"-\".to_string();"));
assert!(UI_RUNTIME_SRC.contains("return \"???\".to_string();"));
}
#[test]