diff --git a/client/Cargo.toml b/client/Cargo.toml index dade317..21d90e3 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.41" +version = "0.11.42" edition = "2024" [dependencies] diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 8365fe3..92d3f62 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -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::().ok()) + { + return Duration::from_secs(secs); + } let millis = std::env::var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS") .ok() .and_then(|raw| raw.parse::().ok()) diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 17e1b83..064fc00 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -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 { if should_run_launcher(args) { @@ -53,6 +55,13 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { 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::().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(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index d273bed..c560a90 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -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); diff --git a/common/Cargo.toml b/common/Cargo.toml index fe78621..4a48e95 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.41" +version = "0.11.42" edition = "2024" build = "build.rs" diff --git a/scripts/ci/input_transport_gate.sh b/scripts/ci/input_transport_gate.sh index 0b78ee0..7eb1a4c 100755 --- a/scripts/ci/input_transport_gate.sh +++ b/scripts/ci/input_transport_gate.sh @@ -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 ) diff --git a/scripts/ci/video_downstream_gate.sh b/scripts/ci/video_downstream_gate.sh index 5b3be09..2ff42f5 100755 --- a/scripts/ci/video_downstream_gate.sh +++ b/scripts/ci/video_downstream_gate.sh @@ -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 diff --git a/server/Cargo.toml b/server/Cargo.toml index 67de696..0670a37 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.41" +version = "0.11.42" edition = "2024" autobins = false diff --git a/server/src/gadget.rs b/server/src/gadget.rs index 15a5dbe..423dfe7 100644 --- a/server/src/gadget.rs +++ b/server/src/gadget.rs @@ -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`) diff --git a/server/src/main.rs b/server/src/main.rs index 72232e7..dd21052 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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) => { diff --git a/testing/tests/client_inputs_contract.rs b/testing/tests/client_inputs_contract.rs index 3223c8d..2a317dd 100644 --- a/testing/tests/client_inputs_contract.rs +++ b/testing/tests/client_inputs_contract.rs @@ -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()); }); } diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs new file mode 100644 index 0000000..a4bb13e --- /dev/null +++ b/testing/tests/client_launcher_layout_contract.rs @@ -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 + ); +} diff --git a/testing/tests/server_gadget_include_contract.rs b/testing/tests/server_gadget_include_contract.rs index 6741f2c..dc75bdd 100644 --- a/testing/tests/server_gadget_include_contract.rs +++ b/testing/tests/server_gadget_include_contract.rs @@ -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); diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs index 42b1523..a3069a9 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/testing/tests/server_main_binary_extra_contract.rs @@ -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() + ); }, ); });