fix(input): expose usb recovery state

This commit is contained in:
Brad Stein 2026-04-21 17:55:26 -03:00
parent 2cce2c165c
commit d60124ce94
14 changed files with 251 additions and 30 deletions

View File

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

View File

@ -102,6 +102,7 @@ impl InputAggregator {
#[cfg(not(coverage))] #[cfg(not(coverage))]
let clipboard_control_path = let clipboard_control_path =
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"); launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
let remote_failsafe_timeout = remote_failsafe_timeout_from_env();
Self { Self {
kbd_tx, kbd_tx,
mou_tx, mou_tx,
@ -126,8 +127,9 @@ impl InputAggregator {
last_quick_toggle_at: None, last_quick_toggle_at: None,
pending_release_started_at: None, pending_release_started_at: None,
pending_release_timeout: pending_release_timeout_from_env(), pending_release_timeout: pending_release_timeout_from_env(),
remote_failsafe_started_at: capture_remote_boot.then(Instant::now), remote_failsafe_started_at: (capture_remote_boot && !remote_failsafe_timeout.is_zero())
remote_failsafe_timeout: remote_failsafe_timeout_from_env(), .then(Instant::now),
remote_failsafe_timeout,
#[cfg(not(coverage))] #[cfg(not(coverage))]
last_routing_request_raw: routing_control_path last_routing_request_raw: routing_control_path
.as_deref() .as_deref()
@ -1024,6 +1026,12 @@ fn pending_release_timeout_from_env() -> Duration {
} }
fn remote_failsafe_timeout_from_env() -> Duration { fn remote_failsafe_timeout_from_env() -> Duration {
if let Some(secs) = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
{
return Duration::from_secs(secs);
}
let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS") let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS")
.ok() .ok()
.and_then(|raw| raw.parse::<u64>().ok()) .and_then(|raw| raw.parse::<u64>().ok())

View File

@ -27,6 +27,8 @@ pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL";
pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal"; pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal";
pub const LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"; pub const LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_CLIPBOARD_CONTROL";
pub const DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH: &str = "/tmp/lesavka-launcher-clipboard.control"; pub const DEFAULT_LAUNCHER_CLIPBOARD_CONTROL_PATH: &str = "/tmp/lesavka-launcher-clipboard.control";
pub const REMOTE_INPUT_FAILSAFE_SECONDS_ENV: &str = "LESAVKA_INPUT_REMOTE_FAILSAFE_SECS";
pub const DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS: &str = "0";
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> { pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
if should_run_launcher(args) { if should_run_launcher(args) {
@ -53,6 +55,13 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
state.view_mode.as_env().to_string(), state.view_mode.as_env().to_string(),
); );
envs.insert("LESAVKA_CLIPBOARD_DELAY_MS".to_string(), "18".to_string()); envs.insert("LESAVKA_CLIPBOARD_DELAY_MS".to_string(), "18".to_string());
envs.insert(
REMOTE_INPUT_FAILSAFE_SECONDS_ENV.to_string(),
std::env::var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV)
.ok()
.filter(|value| value.trim().parse::<u64>().is_ok())
.unwrap_or_else(|| DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()),
);
if matches!(state.view_mode, ViewMode::Unified) { if matches!(state.view_mode, ViewMode::Unified) {
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
} }
@ -165,6 +174,10 @@ mod tests {
envs.get("LESAVKA_CLIPBOARD_DELAY_MS"), envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
Some(&"18".to_string()) Some(&"18".to_string())
); );
assert_eq!(
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
);
assert_eq!( assert_eq!(
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"), envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
Some(&"1".to_string()) Some(&"1".to_string())
@ -216,6 +229,30 @@ mod tests {
); );
} }
#[test]
fn runtime_env_vars_passes_through_remote_failsafe_launch_option() {
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("60"), || {
let state = LauncherState::new();
let envs = runtime_env_vars(&state);
assert_eq!(
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
Some(&"60".to_string())
);
});
}
#[test]
fn runtime_env_vars_keeps_remote_failsafe_disabled_for_invalid_launch_option() {
temp_env::with_var(REMOTE_INPUT_FAILSAFE_SECONDS_ENV, Some("later"), || {
let state = LauncherState::new();
let envs = runtime_env_vars(&state);
assert_eq!(
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
);
});
}
#[test] #[test]
fn runtime_env_vars_do_not_disable_breakout_video_windows() { fn runtime_env_vars_do_not_disable_breakout_video_windows() {
let mut state = LauncherState::new(); let mut state = LauncherState::new();

View File

@ -110,13 +110,13 @@ pub struct LauncherView {
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher"; pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons"); const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
const LAUNCHER_DEFAULT_WIDTH: i32 = 1360; const LAUNCHER_DEFAULT_WIDTH: i32 = 1360;
const LAUNCHER_DEFAULT_HEIGHT: i32 = 780; const LAUNCHER_DEFAULT_HEIGHT: i32 = 820;
const OPERATIONS_RAIL_WIDTH: i32 = 288; const OPERATIONS_RAIL_WIDTH: i32 = 288;
const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 226; const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
const EYE_PREVIEW_MIN_WIDTH: i32 = 402; const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
const SIDE_LOG_HEIGHT: i32 = 104; const SIDE_LOG_HEIGHT: i32 = 124;
pub fn build_launcher_view( pub fn build_launcher_view(
app: &gtk::Application, app: &gtk::Application,
@ -188,8 +188,8 @@ pub fn build_launcher_view(
let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1); operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
operations.set_hexpand(false); operations.set_hexpand(false);
operations.set_vexpand(false); operations.set_vexpand(true);
operations.set_valign(gtk::Align::Start); operations.set_valign(gtk::Align::Fill);
content.append(&operations); content.append(&operations);
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);

View File

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

View File

@ -15,6 +15,8 @@ INPUT_TESTS=(
--test client_keyboard_include_extra_contract --test client_keyboard_include_extra_contract
--test client_mouse_include_contract --test client_mouse_include_contract
--test client_mouse_include_extra_contract --test client_mouse_include_extra_contract
--test server_gadget_include_contract
--test server_main_binary_extra_contract
--test server_runtime_smoke_contract --test server_runtime_smoke_contract
) )

View File

@ -6,6 +6,7 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
cd "$ROOT" cd "$ROOT"
VIDEO_TESTS=( VIDEO_TESTS=(
--test client_launcher_layout_contract
--test video_downstream_feed_contract --test video_downstream_feed_contract
--test server_video_include_contract --test server_video_include_contract
--test video_support_contract --test video_support_contract

View File

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

View File

@ -38,6 +38,19 @@ impl UsbGadget {
Ok(std::fs::read_to_string(p)?.trim().to_owned()) Ok(std::fs::read_to_string(p)?.trim().to_owned())
} }
pub fn current_controller_state() -> anyhow::Result<(String, String)> {
let ctrl = Self::find_controller()?;
let state = Self::state(&ctrl)?;
Ok((ctrl, state))
}
pub fn host_attached_state(state: &str) -> bool {
matches!(
state,
"configured" | "addressed" | "default" | "suspended" | "unknown"
)
}
/*---- helpers ----*/ /*---- helpers ----*/
/// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`) /// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`)

View File

@ -345,6 +345,30 @@ impl Handler {
error!("💥 restart UVC helper failed: {e:#}"); error!("💥 restart UVC helper failed: {e:#}");
return Err(Status::internal(e.to_string())); return Err(Status::internal(e.to_string()));
} }
match UsbGadget::current_controller_state() {
Ok((ctrl, state)) if UsbGadget::host_attached_state(&state) => {
#[cfg(not(coverage))]
info!(
"✅ USB host enumerated gadget after recovery ctrl={ctrl} state={state}"
);
}
Ok((ctrl, state)) => {
let message = format!(
"USB gadget recovery ran, but UDC {ctrl} is still {state}; the controlled host has not enumerated the relay HID/audio/video gadget"
);
#[cfg(not(coverage))]
warn!("⚠️ {message}");
return Err(Status::failed_precondition(message));
}
Err(err) => {
let message = format!(
"USB gadget recovery ran, but the relay cannot read UDC state: {err:#}"
);
#[cfg(not(coverage))]
warn!("⚠️ {message}");
return Err(Status::failed_precondition(message));
}
}
Ok(Response::new(ResetUsbReply { ok: true })) Ok(Response::new(ResetUsbReply { ok: true }))
} }
Err(e) => { Err(e) => {

View File

@ -463,23 +463,48 @@ mod inputs_contract {
#[test] #[test]
#[serial] #[serial]
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() { fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || { with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0)); with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(60_000)
);
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
assert_eq!(
remote_failsafe_timeout_from_env(),
Duration::from_millis(1_500)
);
});
}); });
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || { with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0)); with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
});
}); });
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || { with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
assert_eq!( assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
remote_failsafe_timeout_from_env(),
Duration::from_millis(60_000)
);
}); });
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || { }
assert_eq!(
remote_failsafe_timeout_from_env(), #[test]
Duration::from_millis(1_500) #[serial]
); fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
assert!(agg.remote_failsafe_started_at.is_none());
});
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
let agg = new_aggregator();
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
assert!(agg.remote_failsafe_started_at.is_some());
}); });
} }

View File

@ -0,0 +1,84 @@
//! Contract tests for the launcher layout proportions.
//!
//! Scope: statically guard the GTK layout constants and sizing glue used by
//! the launcher shell.
//! Targets: `client/src/launcher/ui_components.rs`.
//! Why: the launcher is an operational control surface; accidental spacing
//! regressions can hide diagnostics or make eye/device previews unusable.
const UI_SRC: &str = include_str!("../../client/src/launcher/ui_components.rs");
fn const_i32(name: &str) -> i32 {
let needle = format!("const {name}: i32 = ");
let line = UI_SRC
.lines()
.find(|line| line.trim_start().starts_with(&needle))
.unwrap_or_else(|| panic!("missing {name} constant"));
line.trim_start()
.trim_start_matches(&needle)
.trim_end_matches(';')
.parse()
.unwrap_or_else(|err| panic!("invalid {name} constant: {err}"))
}
#[test]
fn launcher_default_size_stays_inside_1080p() {
assert_eq!(const_i32("LAUNCHER_DEFAULT_WIDTH"), 1360);
assert_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 820);
assert!(const_i32("LAUNCHER_DEFAULT_WIDTH") <= 1920);
assert!(const_i32("LAUNCHER_DEFAULT_HEIGHT") <= 1080);
}
#[test]
fn eye_panes_keep_the_locked_larger_preview_footprint() {
assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 460);
assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 258);
assert!(
UI_SRC.contains("caption_label.set_halign(gtk::Align::End)")
|| UI_SRC.contains("capture_label.set_halign(gtk::Align::End)")
);
assert!(UI_SRC.contains("capture_label.set_ellipsize(pango::EllipsizeMode::Start);"));
}
#[test]
fn device_staging_and_testing_bottoms_stay_locked_together() {
assert!(UI_SRC.contains("staging_row.set_homogeneous(true);"));
assert!(UI_SRC.contains("devices_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("preview_panel.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains(
"let device_body_height_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Vertical);"
));
assert!(UI_SRC.contains("device_body_height_group.add_widget(&devices_body);"));
assert!(UI_SRC.contains("device_body_height_group.add_widget(&testing_row);"));
}
#[test]
fn device_testing_keeps_webcam_and_mic_playback_as_equal_bottom_columns() {
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_WIDTH"), 280);
assert_eq!(const_i32("CAMERA_PREVIEW_VIEWPORT_HEIGHT"), 158);
assert!(UI_SRC.contains("webcam_group.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("playback_group.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("playback_body.set_valign(gtk::Align::Fill);"));
assert!(UI_SRC.contains("audio_check_meter.set_vexpand(true);"));
assert!(UI_SRC.contains("playback_body.append(&audio_check_meter);"));
assert!(UI_SRC.contains("playback_body.append(&microphone_replay_button);"));
}
#[test]
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
assert_eq!(const_i32("SIDE_LOG_HEIGHT"), 124);
assert!(UI_SRC.contains("operations.set_vexpand(true);"));
assert!(UI_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
assert_eq!(
UI_SRC
.matches(".min_content_height(SIDE_LOG_HEIGHT)")
.count(),
2
);
assert_eq!(
UI_SRC
.matches(".max_content_height(SIDE_LOG_HEIGHT)")
.count(),
2
);
}

View File

@ -64,6 +64,29 @@ mod gadget_include_contract {
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn host_attached_state_matches_udc_states_that_can_accept_hid() {
for state in ["configured", "addressed", "default", "suspended", "unknown"] {
assert!(UsbGadget::host_attached_state(state), "{state}");
}
assert!(!UsbGadget::host_attached_state("not attached"));
assert!(!UsbGadget::host_attached_state("broken"));
}
#[test]
#[serial]
fn current_controller_state_reads_fake_udc_tree() {
let dir = tempdir().expect("tempdir");
let ctrl = "fake-ctrl.usb";
build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured");
with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
let (found_ctrl, state) = UsbGadget::current_controller_state().expect("current state");
assert_eq!(found_ctrl, ctrl);
assert_eq!(state, "configured");
});
}
#[test] #[test]
fn wait_state_any_times_out_for_missing_controller() { fn wait_state_any_times_out_for_missing_controller() {
let result = UsbGadget::wait_state_any("definitely-missing-udc", 0); let result = UsbGadget::wait_state_any("definitely-missing-udc", 0);

View File

@ -441,7 +441,7 @@ mod server_main_binary_extra {
#[test] #[test]
#[serial] #[serial]
fn reset_usb_succeeds_with_fake_cycle_and_override_hid_paths() { fn reset_usb_reports_host_not_attached_after_fake_cycle() {
let dir = tempdir().expect("tempdir"); let dir = tempdir().expect("tempdir");
let hid_dir = dir.path().join("hid"); let hid_dir = dir.path().join("hid");
std::fs::create_dir_all(&hid_dir).expect("create hid dir"); std::fs::create_dir_all(&hid_dir).expect("create hid dir");
@ -480,11 +480,15 @@ mod server_main_binary_extra {
)), )),
}; };
let rt = tokio::runtime::Runtime::new().expect("runtime"); let rt = tokio::runtime::Runtime::new().expect("runtime");
let reply = rt let err = rt
.block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await }) .block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await })
.expect("reset usb should succeed on fake gadget tree") .expect_err("reset usb should report a host that never enumerates");
.into_inner(); assert_eq!(err.code(), tonic::Code::FailedPrecondition);
assert!(reply.ok); assert!(
err.message().contains("still not attached"),
"unexpected reset error: {}",
err.message()
);
}, },
); );
}); });