From 8dcdbb7770fc885318caf081fc620fbab6d76d12 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 20 Apr 2026 22:13:58 -0300 Subject: [PATCH] fix(relay): restore stable startup and media defaults --- client/Cargo.toml | 2 +- client/src/app.rs | 65 ++++++++++++++++--------- client/src/input/camera.rs | 12 +++-- client/src/launcher/mod.rs | 15 ++++++ client/src/launcher/state.rs | 12 +++-- client/src/launcher/ui_components.rs | 7 ++- common/Cargo.toml | 2 +- common/src/cli.rs | 2 +- server/Cargo.toml | 2 +- server/src/runtime_support.rs | 6 +-- server/src/video.rs | 5 +- testing/tests/client_inputs_contract.rs | 55 ++++++++++++++++++++- 12 files changed, 142 insertions(+), 43 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index c222754..7115727 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.30" +version = "0.11.31" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index 3e0606c..f1cdbd3 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -263,33 +263,52 @@ impl LesavkaClientApp { "📸 using camera settings from server" ); } - match CameraCapture::new( - std::env::var("LESAVKA_CAM_SOURCE").ok().as_deref(), - camera_cfg, - ) { - Ok(cam) => { - let cam = Arc::new(cam); - tokio::spawn(Self::cam_loop(vid_ep.clone(), cam)); + let ep = vid_ep.clone(); + let cam_source = std::env::var("LESAVKA_CAM_SOURCE").ok(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + CameraCapture::new(cam_source.as_deref(), camera_cfg) + }) + .await; + match result { + Ok(Ok(cam)) => { + let cam = Arc::new(cam); + tokio::spawn(Self::cam_loop(ep, cam)); + } + Ok(Err(err)) => { + warn!( + "📸 webcam uplink is unavailable for this relay session; continuing without StreamCamera: {err:#}" + ); + } + Err(err) => { + warn!( + "📸 webcam uplink setup task failed before StreamCamera could start: {err}" + ); + } } - Err(err) => { - warn!( - "📸 webcam uplink is unavailable for this relay session; continuing without StreamCamera: {err:#}" - ); - } - } + }); } if caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err() { - match MicrophoneCapture::new() { - Ok(mic) => { - let mic = Arc::new(mic); - tokio::spawn(Self::voice_loop(vid_ep.clone(), mic)); // renamed + let ep = vid_ep.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(MicrophoneCapture::new).await; + match result { + Ok(Ok(mic)) => { + let mic = Arc::new(mic); + tokio::spawn(Self::voice_loop(ep, mic)); + } + Ok(Err(err)) => { + warn!( + "🎤 microphone uplink is unavailable for this relay session; continuing without StreamMicrophone: {err:#}" + ); + } + Err(err) => { + warn!( + "🎤 microphone uplink setup task failed before StreamMicrophone could start: {err}" + ); + } } - Err(err) => { - warn!( - "🎤 microphone uplink is unavailable for this relay session; continuing without StreamMicrophone: {err:#}" - ); - } - } + }); } /*────────── central reactor ───────────────────*/ diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 08973b1..8bf0125 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -343,7 +343,7 @@ impl CameraCapture { #[cfg(not(coverage))] fn choose_encoder() -> (&'static str, Option<&'static str>) { - if gst::ElementFactory::find("nvh264enc").is_some() { + if buildable_encoder("nvh264enc") { return ( "nvh264enc", supported_encoder_property( @@ -352,13 +352,13 @@ impl CameraCapture { ), ); } - if gst::ElementFactory::find("vaapih264enc").is_some() { + if buildable_encoder("vaapih264enc") { return ( "vaapih264enc", supported_encoder_property("vaapih264enc", &["keyframe-period"]), ); } - if gst::ElementFactory::find("v4l2h264enc").is_some() { + if buildable_encoder("v4l2h264enc") { return ( "v4l2h264enc", supported_encoder_property("v4l2h264enc", &["idrcount"]), @@ -382,6 +382,12 @@ impl CameraCapture { } } +#[cfg(not(coverage))] +fn buildable_encoder(encoder: &'static str) -> bool { + gst::ElementFactory::find(encoder).is_some() + && gst::ElementFactory::make(encoder).build().is_ok() +} + #[cfg(not(coverage))] fn supported_encoder_property( encoder: &'static str, diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index f142578..17e1b83 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -58,9 +58,13 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { } if let Some(camera) = state.devices.camera.as_ref() { envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); + } else { + envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string()); } if let Some(microphone) = state.devices.microphone.as_ref() { envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone()); + } else { + envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string()); } if let Some(speaker) = state.devices.speaker.as_ref() { envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone()); @@ -232,6 +236,17 @@ mod tests { assert!(!envs.contains_key("LESAVKA_AUDIO_SINK")); } + #[test] + fn runtime_env_vars_disable_uplink_media_when_unstaged() { + let state = LauncherState::new(); + + let envs = runtime_env_vars(&state); + assert_eq!(envs.get("LESAVKA_CAM_DISABLE"), Some(&"1".to_string())); + assert_eq!(envs.get("LESAVKA_MIC_DISABLE"), Some(&"1".to_string())); + assert!(!envs.contains_key("LESAVKA_CAM_SOURCE")); + assert!(!envs.contains_key("LESAVKA_MIC_SOURCE")); + } + #[test] fn maybe_run_launcher_returns_false_with_explicit_opt_out() { let args = vec!["--no-launcher".to_string()]; diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index c67869e..6360ab0 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -647,9 +647,7 @@ impl LauncherState { } pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { - if self.devices.camera.is_none() { - self.devices.camera = catalog.cameras.first().cloned(); - } + let _ = catalog; } pub fn set_swap_key(&mut self, swap_key: impl Into) { @@ -1057,7 +1055,7 @@ mod tests { } #[test] - fn catalog_defaults_fill_only_missing_values() { + fn catalog_defaults_do_not_auto_stage_media_devices() { let mut state = LauncherState::new(); state.select_camera(Some("/dev/video-special".to_string())); @@ -1074,6 +1072,12 @@ mod tests { assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special")); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); + + let mut fresh = LauncherState::new(); + fresh.apply_catalog_defaults(&catalog); + assert!(fresh.devices.camera.is_none()); + assert!(fresh.devices.microphone.is_none()); + assert!(fresh.devices.speaker.is_none()); } #[test] diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 86980e8..fbc3bdd 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -198,6 +198,7 @@ pub fn build_launcher_view( let staging_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); staging_row.set_hexpand(true); staging_row.set_vexpand(false); + staging_row.set_valign(gtk::Align::Start); staging_row.set_homogeneous(true); workspace.append(&staging_row); @@ -210,6 +211,7 @@ pub fn build_launcher_view( build_panel_with_action("Device Staging", Some(device_refresh_button.upcast_ref())); devices_panel.set_hexpand(true); devices_panel.set_vexpand(false); + devices_panel.set_valign(gtk::Align::Start); devices_body.set_spacing(8); let control_group = build_subgroup("Control Inputs"); @@ -319,6 +321,7 @@ pub fn build_launcher_view( let (preview_panel, preview_body) = build_panel("Device Testing"); preview_panel.set_hexpand(true); preview_panel.set_vexpand(false); + preview_panel.set_valign(gtk::Align::Start); preview_body.set_spacing(8); let camera_preview = gtk::Picture::new(); camera_preview.set_can_shrink(false); @@ -337,11 +340,11 @@ pub fn build_launcher_view( camera_status.set_visible(false); let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); camera_preview_shell.set_hexpand(true); - camera_preview_shell.set_vexpand(true); + camera_preview_shell.set_vexpand(false); camera_preview_shell.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT); let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); camera_preview_frame.set_hexpand(true); - camera_preview_frame.set_vexpand(true); + camera_preview_frame.set_vexpand(false); camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT); camera_preview_frame.set_child(Some(&camera_preview)); camera_preview_shell.append(&camera_preview_frame); diff --git a/common/Cargo.toml b/common/Cargo.toml index 6f87a54..6cc5044 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.30" +version = "0.11.31" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 8628df9..b8f8824 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.30"), "lesavka-common CLI (v0.11.30)"); + assert_eq!(banner("0.11.31"), "lesavka-common CLI (v0.11.31)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 157ac80..4a7164f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.30" +version = "0.11.31" edition = "2024" autobins = false diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index 6cb825a..ffb2b26 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -347,12 +347,12 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec { let allow_aliases = auto_family.contains(&preferred); push_audio_candidate_family(&mut out, &mut seen, preferred); if allow_aliases { - for alias in auto_family { - push_audio_candidate_family(&mut out, &mut seen, alias); - } for detected in detect_uac_card_candidates() { push_audio_candidate_family(&mut out, &mut seen, &detected); } + for alias in auto_family { + push_audio_candidate_family(&mut out, &mut seen, alias); + } } out } diff --git a/server/src/video.rs b/server/src/video.rs index 24fe29f..51946ce 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -422,7 +422,7 @@ pub async fn eye_ball_with_request( let server_encoder_label = if use_test_src { "x264enc(testsrc)".to_string() } else { - "source-pass-through(auto-caps)".to_string() + "source-pass-through".to_string() }; let server_process_cpu_tenths = server_process_cpu_metric(); if !use_test_src { @@ -444,11 +444,12 @@ pub async fn eye_ball_with_request( } else { format!( "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ - video/x-h264 ! \ + video/x-h264,width={},height={},framerate={}/1 ! \ queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ h264parse disable-passthrough=true config-interval=-1 ! \ video/x-h264,stream-format=byte-stream,alignment=au ! \ appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", + request.width, request.height, request.fps, ) }; diff --git a/testing/tests/client_inputs_contract.rs b/testing/tests/client_inputs_contract.rs index cd0b021..dd82be6 100644 --- a/testing/tests/client_inputs_contract.rs +++ b/testing/tests/client_inputs_contract.rs @@ -137,9 +137,17 @@ mod inputs_contract { } fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { + build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER]) + } + + fn build_keyboard_pair_with_keys( + name: &str, + supported_keys: &[evdev::KeyCode], + ) -> Option<(VirtualDevice, evdev::Device)> { let mut keys = AttributeSet::::new(); - keys.insert(evdev::KeyCode::KEY_A); - keys.insert(evdev::KeyCode::KEY_ENTER); + for key in supported_keys { + keys.insert(*key); + } let mut vdev = VirtualDevice::builder() .ok()? @@ -518,6 +526,49 @@ mod inputs_contract { ); } + #[test] + #[serial] + fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() { + let Some((mut vdev, dev)) = build_keyboard_pair_with_keys( + "lesavka-input-toggle-pause", + &[ + evdev::KeyCode::KEY_A, + evdev::KeyCode::KEY_ENTER, + evdev::KeyCode::KEY_PAUSE, + ], + ) else { + return; + }; + + let (kbd_tx, _) = tokio::sync::broadcast::channel(16); + let (mou_tx, _) = tokio::sync::broadcast::channel(16); + let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None); + let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); + agg.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE); + agg.quick_toggle_debounce = Duration::from_millis(0); + agg.keyboards.push(keyboard); + + vdev.emit(&[ + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1), + evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0), + ]) + .expect("emit pause tap"); + thread::sleep(std::time::Duration::from_millis(20)); + + agg.process_keyboard_updates(); + let quick_toggle_now = agg.quick_toggle_active(); + agg.observe_quick_toggle(quick_toggle_now); + + assert!( + agg.pending_release, + "a quick swap-key tap should start the local handoff path" + ); + assert!( + !agg.released, + "the relay should still be in pending-release until the local handoff completes" + ); + } + #[test] fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() { let mut agg = new_aggregator();