fix(relay): restore stable startup and media defaults

This commit is contained in:
Brad Stein 2026-04-20 22:13:58 -03:00
parent d4fab3f958
commit 8dcdbb7770
12 changed files with 142 additions and 43 deletions

View File

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

View File

@ -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 ───────────────────*/

View File

@ -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,

View File

@ -58,9 +58,13 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
}
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()];

View File

@ -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<String>) {
@ -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]

View File

@ -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);

View File

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

View File

@ -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)");
}
}

View File

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

View File

@ -347,12 +347,12 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
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
}

View File

@ -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,
)
};

View File

@ -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::<evdev::KeyCode>::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();