fix(input): expose usb recovery state
This commit is contained in:
parent
2cce2c165c
commit
d60124ce94
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.41"
|
||||
version = "0.11.42"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -102,6 +102,7 @@ impl InputAggregator {
|
||||
#[cfg(not(coverage))]
|
||||
let clipboard_control_path =
|
||||
launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL");
|
||||
let remote_failsafe_timeout = remote_failsafe_timeout_from_env();
|
||||
Self {
|
||||
kbd_tx,
|
||||
mou_tx,
|
||||
@ -126,8 +127,9 @@ impl InputAggregator {
|
||||
last_quick_toggle_at: None,
|
||||
pending_release_started_at: None,
|
||||
pending_release_timeout: pending_release_timeout_from_env(),
|
||||
remote_failsafe_started_at: capture_remote_boot.then(Instant::now),
|
||||
remote_failsafe_timeout: remote_failsafe_timeout_from_env(),
|
||||
remote_failsafe_started_at: (capture_remote_boot && !remote_failsafe_timeout.is_zero())
|
||||
.then(Instant::now),
|
||||
remote_failsafe_timeout,
|
||||
#[cfg(not(coverage))]
|
||||
last_routing_request_raw: routing_control_path
|
||||
.as_deref()
|
||||
@ -1024,6 +1026,12 @@ fn pending_release_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")
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<u64>().ok())
|
||||
|
||||
@ -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 LAUNCHER_CLIPBOARD_CONTROL_ENV: &str = "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> {
|
||||
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(),
|
||||
);
|
||||
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) {
|
||||
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
|
||||
}
|
||||
@ -165,6 +174,10 @@ mod tests {
|
||||
envs.get("LESAVKA_CLIPBOARD_DELAY_MS"),
|
||||
Some(&"18".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV),
|
||||
Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get("LESAVKA_DISABLE_VIDEO_RENDER"),
|
||||
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]
|
||||
fn runtime_env_vars_do_not_disable_breakout_video_windows() {
|
||||
let mut state = LauncherState::new();
|
||||
|
||||
@ -110,13 +110,13 @@ pub struct LauncherView {
|
||||
pub const LESAVKA_ICON_NAME: &str = "dev.lesavka.launcher";
|
||||
const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons");
|
||||
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 CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 158;
|
||||
const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 280;
|
||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 226;
|
||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 402;
|
||||
const SIDE_LOG_HEIGHT: i32 = 104;
|
||||
const EYE_PREVIEW_MIN_HEIGHT: i32 = 258;
|
||||
const EYE_PREVIEW_MIN_WIDTH: i32 = 460;
|
||||
const SIDE_LOG_HEIGHT: i32 = 124;
|
||||
|
||||
pub fn build_launcher_view(
|
||||
app: >k::Application,
|
||||
@ -188,8 +188,8 @@ pub fn build_launcher_view(
|
||||
let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
|
||||
operations.set_hexpand(false);
|
||||
operations.set_vexpand(false);
|
||||
operations.set_valign(gtk::Align::Start);
|
||||
operations.set_vexpand(true);
|
||||
operations.set_valign(gtk::Align::Fill);
|
||||
content.append(&operations);
|
||||
|
||||
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.41"
|
||||
version = "0.11.42"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ INPUT_TESTS=(
|
||||
--test client_keyboard_include_extra_contract
|
||||
--test client_mouse_include_contract
|
||||
--test client_mouse_include_extra_contract
|
||||
--test server_gadget_include_contract
|
||||
--test server_main_binary_extra_contract
|
||||
--test server_runtime_smoke_contract
|
||||
)
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
VIDEO_TESTS=(
|
||||
--test client_launcher_layout_contract
|
||||
--test video_downstream_feed_contract
|
||||
--test server_video_include_contract
|
||||
--test video_support_contract
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.41"
|
||||
version = "0.11.42"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -38,6 +38,19 @@ impl UsbGadget {
|
||||
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 ----*/
|
||||
|
||||
/// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`)
|
||||
|
||||
@ -345,6 +345,30 @@ impl Handler {
|
||||
error!("💥 restart UVC helper failed: {e:#}");
|
||||
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 }))
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@ -463,23 +463,48 @@ mod inputs_contract {
|
||||
#[test]
|
||||
#[serial]
|
||||
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
|
||||
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_SECS", None::<&str>, || {
|
||||
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"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("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"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(60_000)
|
||||
);
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
||||
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
|
||||
});
|
||||
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
|
||||
assert_eq!(
|
||||
remote_failsafe_timeout_from_env(),
|
||||
Duration::from_millis(1_500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
84
testing/tests/client_launcher_layout_contract.rs
Normal file
84
testing/tests/client_launcher_layout_contract.rs
Normal 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(µphone_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
|
||||
);
|
||||
}
|
||||
@ -64,6 +64,29 @@ mod gadget_include_contract {
|
||||
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]
|
||||
fn wait_state_any_times_out_for_missing_controller() {
|
||||
let result = UsbGadget::wait_state_any("definitely-missing-udc", 0);
|
||||
|
||||
@ -441,7 +441,7 @@ mod server_main_binary_extra {
|
||||
|
||||
#[test]
|
||||
#[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 hid_dir = dir.path().join("hid");
|
||||
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 reply = rt
|
||||
let err = rt
|
||||
.block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await })
|
||||
.expect("reset usb should succeed on fake gadget tree")
|
||||
.into_inner();
|
||||
assert!(reply.ok);
|
||||
.expect_err("reset usb should report a host that never enumerates");
|
||||
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||
assert!(
|
||||
err.message().contains("still not attached"),
|
||||
"unexpected reset error: {}",
|
||||
err.message()
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user