diff --git a/client/Cargo.toml b/client/Cargo.toml index 84d0de4..211f16a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.22" +version = "0.11.23" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index dc8edeb..29faf51 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -10,6 +10,7 @@ enum CommandKind { Auto, On, Off, + ResetUsb, } impl CommandKind { @@ -19,6 +20,7 @@ impl CommandKind { "auto" => Some(Self::Auto), "on" | "force-on" => Some(Self::On), "off" | "force-off" => Some(Self::Off), + "reset-usb" | "recover-usb" => Some(Self::ResetUsb), _ => None, } } @@ -30,7 +32,7 @@ struct Config { } fn usage() -> &'static str { - "Usage: lesavka-relayctl [--server http://HOST:50051] " + "Usage: lesavka-relayctl [--server http://HOST:50051] " } fn parse_args() -> Result { @@ -122,6 +124,15 @@ async fn main() -> Result<()> { .await .context("forcing capture power off")? .into_inner(), + CommandKind::ResetUsb => { + let reply = client + .reset_usb(Request::new(Empty {})) + .await + .context("forcing USB gadget recovery")? + .into_inner(); + println!("ok={}", reply.ok); + return Ok(()); + } }; print_state(reply); diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 507d459..9b870ae 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -590,6 +590,8 @@ fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option { fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec { let mut items = Vec::new(); + let hardware_decode_active = log.latest().is_some_and(sample_uses_hardware_decode); + let software_decode_active = log.latest().is_some_and(sample_uses_software_decode); if !state.server_available { items.push( "The server is not reachable from this launcher yet, so stream-quality results would not be meaningful." @@ -618,10 +620,13 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec 40.0 || (sample.right_present_gap_peak_ms - sample.right_packet_gap_peak_ms) > 40.0 @@ -666,10 +671,13 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec= 85.0 { - items.push( + items.push(if hardware_decode_active { + "Client process CPU is high even though hardware decode is active. If motion still looks rough, favor lighter breakout layouts or a cheaper source mode before adding more bitrate." + .to_string() + } else { "Client process CPU is high. If motion still looks rough, favor lighter breakout layouts or a hardware decoder before adding more bitrate." - .to_string(), - ); + .to_string() + }); } if sample.server_process_cpu_pct >= 85.0 { items.push( @@ -710,6 +718,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec Vec bool { + decoder_label_is_hardware(&sample.left_decoder_label) + || decoder_label_is_hardware(&sample.right_decoder_label) +} + +fn sample_uses_software_decode(sample: &PerformanceSample) -> bool { + sample.left_decoder_label.contains("avdec") || sample.right_decoder_label.contains("avdec") +} + +fn decoder_label_is_hardware(label: &str) -> bool { + let lower = label.to_ascii_lowercase(); + lower.contains("nvh264dec") + || lower.contains("nvdec") + || lower.contains("vah264dec") + || lower.contains("vaapih264dec") + || lower.contains("v4l2slh264dec") + || lower.contains("d3d11") + || lower.contains("vtdec") +} + #[cfg(test)] mod tests { use super::*; @@ -959,4 +988,30 @@ mod tests { "1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit" ); } + + #[test] + fn recommendations_do_not_suggest_hardware_decode_when_nvdec_is_active() { + let mut log = DiagnosticsLog::new(1); + let mut sample = sample(1); + sample.client_process_cpu_pct = 96.0; + sample.left_receive_fps = 40.0; + sample.left_present_fps = 30.0; + sample.left_decoder_label = "nvh264dec".to_string(); + sample.right_decoder_label = "nvh264dec".to_string(); + log.record(sample); + + let items = recommendations_for(&LauncherState::new(), &log); + let joined = items.join("\n"); + assert!(!joined.contains("hardware decoder before adding more bitrate")); + assert!(!joined.contains("lighter breakout sizes or hardware decode")); + assert!(joined.contains("cheaper source mode")); + } + + #[test] + fn hardware_decoder_detection_recognizes_nvdec_labels() { + let mut sample = sample(1); + sample.left_decoder_label = "nvh264dec".to_string(); + assert!(sample_uses_hardware_decode(&sample)); + assert!(!sample_uses_software_decode(&sample)); + } } diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs index faaa6f6..c6c137d 100644 --- a/client/src/launcher/power.rs +++ b/client/src/launcher/power.rs @@ -36,9 +36,25 @@ pub fn set_capture_power_mode( }) } -fn with_runtime(future: F) -> Result +pub fn reset_usb_gadget(server_addr: &str) -> Result<()> { + with_runtime(async move { + let mut client = connect(server_addr).await?; + let reply = client + .reset_usb(Request::new(Empty {})) + .await + .context("requesting USB gadget reset")? + .into_inner(); + if reply.ok { + Ok(()) + } else { + anyhow::bail!("relay reported USB reset failure"); + } + }) +} + +fn with_runtime(future: F) -> Result where - F: std::future::Future>, + F: std::future::Future>, { tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 5203b51..36c067f 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -8,7 +8,7 @@ use { super::diagnostics::{PerformanceSample, quality_probe_command}, super::launcher_clipboard_control_path, super::launcher_focus_signal_path, - super::power::{fetch_capture_power, set_capture_power_mode}, + super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, super::state::{ BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, @@ -1296,6 +1296,49 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let widgets_for_click = widgets.clone(); + 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...", + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = reset_usb_gadget(&server_addr).map_err(|err| err.to_string()); + let _ = tx.send(result); + }); + let widgets = widgets_for_click.clone(); + 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.", + ); + glib::ControlFlow::Break + } + Ok(Err(err)) => { + widgets + .status_label + .set_text(&format!("USB gadget recovery 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.", + ); + glib::ControlFlow::Break + } + } + }); + }); + } + { let widgets = widgets.clone(); widgets.diagnostics_copy_button.connect_clicked(move |_| { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 07482d5..953fa24 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -53,6 +53,7 @@ pub struct LauncherWidgets { pub status_label: gtk::Label, pub diagnostics_log: Rc>, pub diagnostics_buffer: gtk::TextBuffer, + pub diagnostics_scroll: gtk::ScrolledWindow, pub session_log_buffer: gtk::TextBuffer, pub session_log_view: gtk::TextView, pub summary: SummaryWidgets, @@ -67,6 +68,7 @@ pub struct LauncherWidgets { pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, + pub usb_recover_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, pub microphone_test_button: gtk::Button, @@ -386,8 +388,15 @@ pub fn build_launcher_view( probe_button.set_tooltip_text(Some( "Copy the hygiene/quality probe command into the local clipboard.", )); + let usb_recover_button = gtk::Button::with_label("Recover USB"); + usb_recover_button.set_hexpand(true); + stabilize_button(&usb_recover_button, 108); + usb_recover_button.set_tooltip_text(Some( + "Force the remote USB gadget to re-enumerate when keyboard, mouse, webcam, or audio stop showing up on the host.", + )); live_actions_row.append(&clipboard_button); live_actions_row.append(&probe_button); + live_actions_row.append(&usb_recover_button); connection_body.append(&live_actions_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); @@ -616,6 +625,7 @@ pub fn build_launcher_view( status_label: status_label.clone(), diagnostics_log: diagnostics_log.clone(), diagnostics_buffer: diagnostics_buffer.clone(), + diagnostics_scroll: diagnostics_scroll.clone(), session_log_buffer: session_log_buffer.clone(), session_log_view: session_log_view.clone(), summary: SummaryWidgets { @@ -638,6 +648,7 @@ pub fn build_launcher_view( input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), + usb_recover_button: usb_recover_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), microphone_test_button: microphone_test_button.clone(), diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index b3b2d43..f84c545 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -86,6 +86,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi })); widgets.clipboard_button.set_sensitive(relay_live); widgets.probe_button.set_sensitive(true); + widgets + .usb_recover_button + .set_sensitive(state.server_available); widgets.input_toggle_button.set_label("Change Routing"); widgets .input_toggle_button @@ -944,6 +947,16 @@ pub fn refresh_diagnostics_report( state: &LauncherState, child_running: bool, ) { + let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment(); + let previous_max = + (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0); + let was_at_bottom = + previous_max <= 0.0 || diagnostics_adjustment.value() >= (previous_max - 4.0); + let previous_ratio = if previous_max > 0.0 { + (diagnostics_adjustment.value() / previous_max).clamp(0.0, 1.0) + } else { + 0.0 + }; let mut snapshot = SnapshotReport::from_state( state, &widgets.diagnostics_log.borrow(), @@ -958,6 +971,15 @@ pub fn refresh_diagnostics_report( 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_ratio * max).clamp(0.0, max) + }; + diagnostics_adjustment.set_value(target); + }); } pub fn open_session_log_popout( diff --git a/common/Cargo.toml b/common/Cargo.toml index 09a9b09..e49c95f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.22" +version = "0.11.23" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 65ee4be..ff85e83 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.22"), "lesavka-common CLI (v0.11.22)"); + assert_eq!(banner("0.11.23"), "lesavka-common CLI (v0.11.23)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 0b018fe..513bd9e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.22" +version = "0.11.23" edition = "2024" autobins = false diff --git a/server/src/gadget.rs b/server/src/gadget.rs index 365f4f9..15a5dbe 100644 --- a/server/src/gadget.rs +++ b/server/src/gadget.rs @@ -122,10 +122,19 @@ impl UsbGadget { /// Hard-reset the gadget → identical to a physical cable re-plug #[cfg(coverage)] pub fn cycle(&self) -> Result<()> { + self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) + } + + #[cfg(coverage)] + pub fn cycle_forced(&self) -> Result<()> { + self.cycle_internal(true) + } + + #[cfg(coverage)] + fn cycle_internal(&self, force_cycle: bool) -> Result<()> { let ctrl = Self::find_controller().or_else(|_| { Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) })?; - let force_cycle = env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok(); if !force_cycle { match Self::state(&ctrl) { @@ -153,11 +162,20 @@ impl UsbGadget { #[cfg(not(coverage))] pub fn cycle(&self) -> Result<()> { + self.cycle_internal(env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok()) + } + + #[cfg(not(coverage))] + pub fn cycle_forced(&self) -> Result<()> { + self.cycle_internal(true) + } + + #[cfg(not(coverage))] + fn cycle_internal(&self, force_cycle: bool) -> Result<()> { /* 0 - ensure we *know* the controller even after a previous crash */ let ctrl = Self::find_controller().or_else(|_| { Self::probe_platform_udc()?.ok_or_else(|| anyhow::anyhow!("no UDC present")) })?; - let force_cycle = env::var("LESAVKA_GADGET_FORCE_CYCLE").is_ok(); match Self::state(&ctrl) { Ok(state) if !force_cycle diff --git a/server/src/main.rs b/server/src/main.rs index a8a7709..a2c3e1a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,10 +2,12 @@ // server/src/main.rs #[allow(clippy::useless_attribute)] #[forbid(unsafe_code)] +use anyhow::Context; use futures_util::{Stream, StreamExt}; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; +use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration}; use tokio::sync::{Mutex, broadcast}; @@ -321,13 +323,18 @@ impl Handler { #[cfg(not(coverage))] info!("🔴 explicit ResetUsb() called"); - match self.gadget.cycle() { + match self.gadget.cycle_forced() { Ok(_) => { if let Err(e) = self.reopen_hid().await { #[cfg(not(coverage))] error!("💥 reopen HID failed: {e:#}"); return Err(Status::internal(e.to_string())); } + if let Err(e) = restart_uvc_helper() { + #[cfg(not(coverage))] + error!("💥 restart UVC helper failed: {e:#}"); + return Err(Status::internal(e.to_string())); + } Ok(Response::new(ResetUsbReply { ok: true })) } Err(e) => { @@ -369,6 +376,37 @@ impl Handler { } } +fn restart_uvc_helper() -> anyhow::Result<()> { + for args in [ + ["reset-failed", "lesavka-uvc.service"].as_slice(), + ["restart", "lesavka-uvc.service"].as_slice(), + ] { + let output = Command::new("systemctl") + .args(args) + .output() + .with_context(|| format!("running systemctl {}", args.join(" ")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + anyhow::bail!( + "systemctl {} failed: {}{}", + args.join(" "), + if stderr.is_empty() { + stdout.as_str() + } else { + stderr.as_str() + }, + if stderr.is_empty() || stdout.is_empty() || stderr == stdout { + "" + } else { + " / also see stdout" + } + ); + } + } + Ok(()) +} + impl EyeHub { fn spawn(mut stream: S, lease: lesavka_server::capture_power::CapturePowerLease) -> Arc where