media: protect upstream epoch recovery
This commit is contained in:
parent
a75463669f
commit
e7a6d8f288
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,6 +1,29 @@
|
|||||||
use super::apply_preview_profiles;
|
use super::{apply_preview_profiles, disconnect_cooldown_remaining, disconnect_cooldown_status};
|
||||||
use crate::launcher::preview::{LauncherPreview, PreviewSurface};
|
use crate::launcher::preview::{LauncherPreview, PreviewSurface};
|
||||||
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
|
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Keeps `disconnect_cooldown_remaining_expires_cleanly` explicit because the connect-time audio epoch refresh should not be interrupted by an eager disconnect click.
|
||||||
|
/// Inputs are a synthetic monotonic clock and cooldown deadline; output is either the remaining guard duration or no guard after expiry.
|
||||||
|
fn disconnect_cooldown_remaining_expires_cleanly() {
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(disconnect_cooldown_remaining(now, None), None);
|
||||||
|
assert_eq!(disconnect_cooldown_remaining(now, Some(now)), None);
|
||||||
|
assert_eq!(
|
||||||
|
disconnect_cooldown_remaining(now, Some(now + Duration::from_secs(2))),
|
||||||
|
Some(Duration::from_secs(2))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Keeps `disconnect_cooldown_status_names_audio_refresh` explicit because users need the disabled Disconnect button to explain the protective auto-heal window.
|
||||||
|
/// Inputs are the remaining cooldown duration; output is the user-visible status text.
|
||||||
|
fn disconnect_cooldown_status_names_audio_refresh() {
|
||||||
|
let status = disconnect_cooldown_status(Duration::from_millis(1900));
|
||||||
|
assert!(status.contains("audio is refreshing"));
|
||||||
|
assert!(status.contains("disconnect unlocks in 2s"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
|
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
|
||||||
|
|||||||
@ -24,6 +24,13 @@ fn present_and_settle(window: >k::ApplicationWindow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rail_button_text(button: >k::Button) -> Option<String> {
|
||||||
|
button
|
||||||
|
.child()
|
||||||
|
.and_then(|child| child.downcast::<gtk::Label>().ok())
|
||||||
|
.map(|label| label.text().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_test_detail_mentions_idle_and_running_modes() {
|
fn local_test_detail_mentions_idle_and_running_modes() {
|
||||||
assert!(local_test_detail(false, false, false, false).contains("idle"));
|
assert!(local_test_detail(false, false, false, false).contains("idle"));
|
||||||
@ -258,6 +265,63 @@ fn launcher_shell_installs_native_window_chrome() {
|
|||||||
assert!(titlebar.shows_title_buttons());
|
assert!(titlebar.shows_title_buttons());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gtk::test]
|
||||||
|
#[serial]
|
||||||
|
fn recovery_buttons_use_upstream_device_labels() {
|
||||||
|
if gtk::gdk::Display::default().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = gtk::Application::builder()
|
||||||
|
.application_id("dev.lesavka.test-recovery-labels")
|
||||||
|
.build();
|
||||||
|
let _ = app.register(None::<>k::gio::Cancellable>);
|
||||||
|
|
||||||
|
let view = build_launcher_view(
|
||||||
|
&app,
|
||||||
|
"http://127.0.0.1:50051",
|
||||||
|
&DeviceCatalog::default(),
|
||||||
|
&LauncherState::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rail_button_text(&view.widgets.usb_recover_button),
|
||||||
|
Some("HID".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rail_button_text(&view.widgets.uac_recover_button),
|
||||||
|
Some("Audio".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rail_button_text(&view.widgets.uvc_recover_button),
|
||||||
|
Some("Video".to_string())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.widgets
|
||||||
|
.usb_recover_button
|
||||||
|
.tooltip_text()
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("keyboard/mouse HID")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.widgets
|
||||||
|
.uac_recover_button
|
||||||
|
.tooltip_text()
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("bundled mic+camera")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.widgets
|
||||||
|
.uvc_recover_button
|
||||||
|
.tooltip_text()
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("virtual webcam spool")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[gtk::test]
|
#[gtk::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn diagnostics_and_log_popouts_install_native_window_chrome() {
|
fn diagnostics_and_log_popouts_install_native_window_chrome() {
|
||||||
|
|||||||
@ -155,6 +155,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|||||||
caps_request_in_flight,
|
caps_request_in_flight,
|
||||||
diagnostics_network,
|
diagnostics_network,
|
||||||
diagnostics_process,
|
diagnostics_process,
|
||||||
|
disconnect_cooldown_until,
|
||||||
next_power_probe,
|
next_power_probe,
|
||||||
next_calibration_probe,
|
next_calibration_probe,
|
||||||
next_upstream_sync_probe,
|
next_upstream_sync_probe,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ struct ActivationContext {
|
|||||||
caps_request_in_flight: Rc<Cell<bool>>,
|
caps_request_in_flight: Rc<Cell<bool>>,
|
||||||
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
|
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
|
||||||
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
|
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
|
||||||
|
disconnect_cooldown_until: Rc<Cell<Option<Instant>>>,
|
||||||
next_power_probe: Rc<Cell<Instant>>,
|
next_power_probe: Rc<Cell<Instant>>,
|
||||||
next_calibration_probe: Rc<Cell<Instant>>,
|
next_calibration_probe: Rc<Cell<Instant>>,
|
||||||
next_upstream_sync_probe: Rc<Cell<Instant>>,
|
next_upstream_sync_probe: Rc<Cell<Instant>>,
|
||||||
|
|||||||
@ -121,6 +121,7 @@
|
|||||||
let caps_request_in_flight = Rc::new(Cell::new(false));
|
let caps_request_in_flight = Rc::new(Cell::new(false));
|
||||||
let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default()));
|
let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default()));
|
||||||
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
|
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
|
||||||
|
let disconnect_cooldown_until = Rc::new(Cell::new(None));
|
||||||
let next_power_probe =
|
let next_power_probe =
|
||||||
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
|
||||||
let next_calibration_probe =
|
let next_calibration_probe =
|
||||||
@ -173,6 +174,7 @@
|
|||||||
caps_request_in_flight,
|
caps_request_in_flight,
|
||||||
diagnostics_network,
|
diagnostics_network,
|
||||||
diagnostics_process,
|
diagnostics_process,
|
||||||
|
disconnect_cooldown_until,
|
||||||
next_power_probe,
|
next_power_probe,
|
||||||
next_calibration_probe,
|
next_calibration_probe,
|
||||||
next_upstream_sync_probe,
|
next_upstream_sync_probe,
|
||||||
|
|||||||
@ -33,6 +33,22 @@ enum ClipboardMessage {
|
|||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8);
|
const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
const UPSTREAM_EPOCH_HEAL_DISCONNECT_COOLDOWN: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn disconnect_cooldown_remaining(now: Instant, until: Option<Instant>) -> Option<Duration> {
|
||||||
|
until
|
||||||
|
.and_then(|deadline| deadline.checked_duration_since(now))
|
||||||
|
.filter(|remaining| !remaining.is_zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn disconnect_cooldown_status(remaining: Duration) -> String {
|
||||||
|
let seconds = remaining.as_millis().saturating_add(999) / 1_000;
|
||||||
|
format!("Upstream audio is refreshing; disconnect unlocks in {seconds}s.")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn usb_audio_kernel_support_missing() -> bool {
|
fn usb_audio_kernel_support_missing() -> bool {
|
||||||
Command::new("modinfo")
|
Command::new("modinfo")
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
let power_tx = power_tx.clone();
|
let power_tx = power_tx.clone();
|
||||||
let relay_tx = relay_tx.clone();
|
let relay_tx = relay_tx.clone();
|
||||||
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
||||||
|
let disconnect_cooldown_until = Rc::clone(&disconnect_cooldown_until);
|
||||||
let popouts = Rc::clone(&popouts);
|
let popouts = Rc::clone(&popouts);
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
let start_button = widgets.start_button.clone();
|
let start_button = widgets.start_button.clone();
|
||||||
@ -28,7 +29,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if child_proc.borrow().is_some() {
|
if child_proc.borrow().is_some() {
|
||||||
|
if let Some(remaining) = disconnect_cooldown_remaining(
|
||||||
|
Instant::now(),
|
||||||
|
disconnect_cooldown_until.get(),
|
||||||
|
) {
|
||||||
|
widgets_handle
|
||||||
|
.status_label
|
||||||
|
.set_text(&disconnect_cooldown_status(remaining));
|
||||||
|
return;
|
||||||
|
}
|
||||||
stop_child_process(&child_proc);
|
stop_child_process(&child_proc);
|
||||||
|
disconnect_cooldown_until.set(None);
|
||||||
let power_mode = {
|
let power_mode = {
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
let _ = state.stop_remote();
|
let _ = state.stop_remote();
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe);
|
let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe);
|
||||||
let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample);
|
let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample);
|
||||||
let preview_session_active = Rc::clone(&preview_session_active);
|
let preview_session_active = Rc::clone(&preview_session_active);
|
||||||
|
let disconnect_cooldown_until = Rc::clone(&disconnect_cooldown_until);
|
||||||
let log_tx = log_tx.clone();
|
let log_tx = log_tx.clone();
|
||||||
let camera_preview_path = uplink_camera_preview_path();
|
let camera_preview_path = uplink_camera_preview_path();
|
||||||
let mic_level_path = uplink_mic_level_path();
|
let mic_level_path = uplink_mic_level_path();
|
||||||
@ -123,6 +124,9 @@
|
|||||||
RelayMessage::Spawned(Ok(mut child)) => {
|
RelayMessage::Spawned(Ok(mut child)) => {
|
||||||
attach_child_log_streams(&mut child, log_tx.clone());
|
attach_child_log_streams(&mut child, log_tx.clone());
|
||||||
*child_proc.borrow_mut() = Some(child);
|
*child_proc.borrow_mut() = Some(child);
|
||||||
|
disconnect_cooldown_until.set(Some(
|
||||||
|
Instant::now() + UPSTREAM_EPOCH_HEAL_DISCONNECT_COOLDOWN,
|
||||||
|
));
|
||||||
{
|
{
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
state.set_server_available(true);
|
state.set_server_available(true);
|
||||||
@ -397,6 +401,11 @@
|
|||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let child_running = child_proc.borrow().is_some();
|
let child_running = child_proc.borrow().is_some();
|
||||||
|
let disconnect_cooldown =
|
||||||
|
disconnect_cooldown_remaining(now, disconnect_cooldown_until.get());
|
||||||
|
if child_running && disconnect_cooldown.is_none() {
|
||||||
|
disconnect_cooldown_until.set(None);
|
||||||
|
}
|
||||||
|
|
||||||
if now >= next_power_probe.get()
|
if now >= next_power_probe.get()
|
||||||
&& !power_request_in_flight.get()
|
&& !power_request_in_flight.get()
|
||||||
@ -490,6 +499,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
||||||
|
if child_running
|
||||||
|
&& let Some(remaining) = disconnect_cooldown
|
||||||
|
{
|
||||||
|
widgets.start_button.set_sensitive(false);
|
||||||
|
widgets
|
||||||
|
.start_button
|
||||||
|
.set_tooltip_text(Some(&disconnect_cooldown_status(remaining)));
|
||||||
|
}
|
||||||
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
|
|||||||
@ -161,7 +161,7 @@
|
|||||||
widgets.usb_recover_button.connect_clicked(move |_| {
|
widgets.usb_recover_button.connect_clicked(move |_| {
|
||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click.status_label.set_text(
|
widgets_for_click.status_label.set_text(
|
||||||
"Recover USB 1/3: reopening HID handles and checking enumeration...",
|
"Recover HID 1/3: reopening keyboard/mouse handles and checking enumeration...",
|
||||||
);
|
);
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@ -172,20 +172,20 @@
|
|||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Recover USB 2/3: HID reopened. Recover USB 3/3: watching chips for stable enumeration.",
|
"Recover HID 2/3: handles reopened. Recover HID 3/3: watching chips for stable enumeration.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Recover USB failed: {err}"));
|
.set_text(&format!("Recover HID failed: {err}"));
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Recover USB failed: relay stopped responding before completion.",
|
"Recover HID failed: relay stopped responding before completion.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
@ -202,7 +202,7 @@
|
|||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click
|
widgets_for_click
|
||||||
.status_label
|
.status_label
|
||||||
.set_text("Heal A/V 1/3: retiring the stale upstream media epoch cleanly...");
|
.set_text("Recover Audio 1/3: retiring the stale upstream audio epoch cleanly...");
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}"));
|
let result = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}"));
|
||||||
@ -212,20 +212,20 @@
|
|||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Heal A/V 2/3: old epoch released. Heal A/V 3/3: client will reconnect without resetting USB or calibration.",
|
"Recover Audio 2/3: old epoch released. Recover Audio 3/3: bundled media will reconnect without resetting USB or calibration.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Heal A/V failed: {err}"));
|
.set_text(&format!("Recover Audio failed: {err}"));
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Heal A/V failed: relay stopped responding before completion.",
|
"Recover Audio failed: relay stopped responding before completion.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
@ -242,7 +242,7 @@
|
|||||||
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
||||||
widgets_for_click
|
widgets_for_click
|
||||||
.status_label
|
.status_label
|
||||||
.set_text("Recover UVC 1/3: retiring the webcam spool pipeline safely...");
|
.set_text("Recover Video 1/3: retiring the webcam spool pipeline safely...");
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}"));
|
let result = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}"));
|
||||||
@ -252,20 +252,20 @@
|
|||||||
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Recover UVC 2/3: webcam sink retired. Recover UVC 3/3: client will recreate the UVC spool on reconnect.",
|
"Recover Video 2/3: webcam sink retired. Recover Video 3/3: client will recreate the UVC spool on reconnect.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
widgets
|
widgets
|
||||||
.status_label
|
.status_label
|
||||||
.set_text(&format!("Recover UVC failed: {err}"));
|
.set_text(&format!("Recover Video failed: {err}"));
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
widgets.status_label.set_text(
|
widgets.status_label.set_text(
|
||||||
"Recover UVC failed: relay stopped responding before completion.",
|
"Recover Video failed: relay stopped responding before completion.",
|
||||||
);
|
);
|
||||||
glib::ControlFlow::Break
|
glib::ControlFlow::Break
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,9 +28,10 @@
|
|||||||
|
|
||||||
connection_body.append(&relay_grid);
|
connection_body.append(&relay_grid);
|
||||||
|
|
||||||
let recovery_heading = gtk::Label::new(Some("Recover"));
|
let recovery_heading = gtk::Label::new(Some("Recover\nUpstream"));
|
||||||
recovery_heading.add_css_class("subgroup-title");
|
recovery_heading.add_css_class("subgroup-title");
|
||||||
recovery_heading.set_halign(gtk::Align::Start);
|
recovery_heading.set_halign(gtk::Align::Start);
|
||||||
|
recovery_heading.set_justify(gtk::Justification::Center);
|
||||||
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
recovery_row.set_hexpand(true);
|
recovery_row.set_hexpand(true);
|
||||||
recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);
|
recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);
|
||||||
@ -39,16 +40,16 @@
|
|||||||
recovery_buttons.set_hexpand(true);
|
recovery_buttons.set_hexpand(true);
|
||||||
recovery_buttons.set_homogeneous(true);
|
recovery_buttons.set_homogeneous(true);
|
||||||
let usb_recover_button = rail_button(
|
let usb_recover_button = rail_button(
|
||||||
"USB",
|
"HID",
|
||||||
"Soft recovery: reopen HID and verify enumeration without detaching the USB gadget.",
|
"Soft recovery: reopen keyboard/mouse HID handles and verify enumeration without detaching the USB gadget.",
|
||||||
);
|
);
|
||||||
let uac_recover_button = rail_button(
|
let uac_recover_button = rail_button(
|
||||||
"Heal A/V",
|
"Audio",
|
||||||
"Soft recovery: retire the stale upstream A/V epoch so bundled mic+camera reconnect without resetting USB or changing calibration.",
|
"Soft recovery: retire the stale upstream audio epoch so bundled mic+camera reconnect without resetting USB or changing calibration.",
|
||||||
);
|
);
|
||||||
let uvc_recover_button = rail_button(
|
let uvc_recover_button = rail_button(
|
||||||
"UVC",
|
"Video",
|
||||||
"Soft recovery: retire the webcam spool pipeline so video reconnects without restarting the gadget.",
|
"Soft recovery: retire the virtual webcam spool pipeline so video reconnects without restarting the gadget.",
|
||||||
);
|
);
|
||||||
recovery_buttons.append(&usb_recover_button);
|
recovery_buttons.append(&usb_recover_button);
|
||||||
recovery_buttons.append(&uac_recover_button);
|
recovery_buttons.append(&uac_recover_button);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.21.10"
|
version = "0.21.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -169,11 +169,31 @@ fn runtime_soft_microphone_recovery_cycles_only_the_microphone_generation() {
|
|||||||
with_clean_offset_env(|| {
|
with_clean_offset_env(|| {
|
||||||
let runtime = UpstreamMediaRuntime::new();
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
let camera = runtime.activate_camera();
|
let camera = runtime.activate_camera();
|
||||||
|
let microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
runtime.soft_recover_microphone();
|
runtime.soft_recover_microphone();
|
||||||
|
|
||||||
assert!(runtime.is_camera_active(camera.generation));
|
assert!(runtime.is_camera_active(camera.generation));
|
||||||
assert!(!runtime.is_microphone_active(1));
|
assert!(!runtime.is_microphone_active(microphone.generation));
|
||||||
|
runtime.close_camera(camera.generation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Keeps `runtime_soft_microphone_recovery_preserves_calibration_offsets` explicit because the manual and automatic audio healing paths must not masquerade as A/V calibration.
|
||||||
|
/// Inputs are active upstream leases plus non-default playout offsets; output keeps the offsets and camera generation intact while retiring only the microphone generation.
|
||||||
|
fn runtime_soft_microphone_recovery_preserves_calibration_offsets() {
|
||||||
|
with_clean_offset_env(|| {
|
||||||
|
let runtime = UpstreamMediaRuntime::new();
|
||||||
|
let camera = runtime.activate_camera();
|
||||||
|
let microphone = runtime.activate_microphone();
|
||||||
|
|
||||||
|
runtime.set_playout_offsets(135_090, 7_000);
|
||||||
|
runtime.soft_recover_microphone();
|
||||||
|
|
||||||
|
assert_eq!(runtime.playout_offsets(), (135_090, 7_000));
|
||||||
|
assert!(runtime.is_camera_active(camera.generation));
|
||||||
|
assert!(!runtime.is_microphone_active(microphone.generation));
|
||||||
runtime.close_camera(camera.generation);
|
runtime.close_camera(camera.generation);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user