From 3b112996dd5553bc7bd4814afc741de692a2f453 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 22 Apr 2026 22:10:39 -0300 Subject: [PATCH] feat(launcher): add webcam quality controls --- client/Cargo.toml | 2 +- client/src/app.rs | 10 +- client/src/input/camera.rs | 8 +- client/src/launcher/device_test.rs | 87 ++++++- client/src/launcher/devices.rs | 164 ++++++++++++ client/src/launcher/mod.rs | 17 ++ client/src/launcher/state.rs | 88 ++++++- client/src/launcher/ui.rs | 131 ++++++++-- client/src/launcher/ui_components.rs | 236 ++++++++++-------- client/src/launcher/ui_runtime.rs | 12 + client/src/video_support.rs | 3 + common/Cargo.toml | 2 +- scripts/ci/hygiene_gate_baseline.json | 58 ++--- scripts/ci/quality_gate_baseline.json | 42 ++-- scripts/ci/test_gate.sh | 10 +- scripts/install/server.sh | 8 + server/Cargo.toml | 2 +- server/src/audio.rs | 121 ++++----- server/src/camera.rs | 40 ++- server/src/video_sinks.rs | 99 +++++++- testing/build.rs | 16 ++ testing/tests/client_app_include_contract.rs | 10 + .../tests/client_launcher_layout_contract.rs | 63 +++-- .../tests/client_launcher_runtime_contract.rs | 21 ++ .../client_output_audio_include_contract.rs | 45 ++++ .../tests/client_relayctl_binary_contract.rs | 49 ++++ .../tests/client_relayctl_process_contract.rs | 65 +++++ .../client_video_support_include_contract.rs | 31 +++ .../tests/server_audio_include_contract.rs | 37 ++- testing/tests/server_camera_contract.rs | 19 ++ .../tests/server_install_script_contract.rs | 33 +++ testing/tests/server_main_process_contract.rs | 91 +++++-- .../server_video_sinks_include_contract.rs | 77 ++++-- 33 files changed, 1375 insertions(+), 322 deletions(-) create mode 100644 testing/tests/client_relayctl_binary_contract.rs create mode 100644 testing/tests/client_relayctl_process_contract.rs create mode 100644 testing/tests/client_video_support_include_contract.rs create mode 100644 testing/tests/server_install_script_contract.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 2faa63b..2f90aa0 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.48" +version = "0.12.0" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index 5997e44..c4f3653 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -500,6 +500,7 @@ impl LesavkaClientApp { async fn audio_loop(ep: Channel, out: AudioOut) { let mut consecutive_source_failures = 0_u32; let mut last_usb_recovery_at: Option = None; + let mut delay = Duration::from_secs(1); loop { let mut cli = RelayClient::new(ep.clone()); let req = MonitorRequest { @@ -515,6 +516,7 @@ impl LesavkaClientApp { tracing::info!("πŸ”Š audio stream opened"); let mut packet_count: u64 = 0; let mut warned_no_packets = false; + delay = Duration::from_secs(1); loop { match tokio::time::timeout( Duration::from_secs(1), @@ -533,9 +535,13 @@ impl LesavkaClientApp { ); } out.push(pkt); + consecutive_source_failures = 0; } Ok(Ok(None)) => { tracing::warn!(packets = packet_count, "βš οΈπŸ”Š audio stream ended"); + if packet_count == 0 { + delay = app_support::next_delay(delay); + } break; } Ok(Err(err)) => { @@ -548,6 +554,7 @@ impl LesavkaClientApp { &message, ) .await; + delay = app_support::next_delay(delay); break; } Err(_) => { @@ -571,9 +578,10 @@ impl LesavkaClientApp { &message, ) .await; + delay = app_support::next_delay(delay); } } - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(delay).await; } } diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 73cd48c..1e8f3b5 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -96,11 +96,9 @@ impl CameraCapture { |cfg| matches!(cfg.codec, CameraCodec::Mjpeg), ); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); - let width = cfg.map_or_else(|| env_u32("LESAVKA_CAM_WIDTH", 1280), |cfg| cfg.width); - let height = cfg.map_or_else(|| env_u32("LESAVKA_CAM_HEIGHT", 720), |cfg| cfg.height); - let fps = cfg - .map_or_else(|| env_u32("LESAVKA_CAM_FPS", 25), |cfg| cfg.fps) - .max(1); + let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)); + let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)); + let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1); let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); let source_profile = camera_source_profile(allow_mjpg_source); let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index 63e8da3..be01117 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use super::devices::CameraMode; + const CAMERA_PREVIEW_WIDTH: i32 = 128; const CAMERA_PREVIEW_HEIGHT: i32 = 72; const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview."; @@ -36,6 +38,7 @@ pub enum DeviceTestKind { pub struct DeviceTestController { camera: Option, selected_camera: Option, + selected_camera_mode: Option, microphone: Option, microphone_probe: Option, speaker: Option, @@ -49,6 +52,7 @@ impl Default for DeviceTestController { Self { camera: None, selected_camera: None, + selected_camera_mode: None, microphone: None, microphone_probe: None, speaker: None, @@ -74,6 +78,7 @@ impl DeviceTestController { } let mut preview = LocalCameraPreview::new(camera_picture, camera_status); preview.set_selected(self.selected_camera.as_deref())?; + preview.set_selected_mode(self.selected_camera_mode)?; self.camera = Some(preview); Ok(()) } @@ -107,6 +112,14 @@ impl DeviceTestController { Ok(()) } + pub fn set_camera_quality(&mut self, mode: Option) -> Result<()> { + self.selected_camera_mode = mode; + if let Some(preview) = self.camera.as_mut() { + preview.set_selected_mode(mode)?; + } + Ok(()) + } + pub fn toggle_camera(&mut self) -> Result { let preview = self .camera @@ -361,6 +374,7 @@ struct LocalCameraPreview { generation: Arc, running: Arc, selected_device: Option, + selected_mode: Option, relay_preview_path: Option, } @@ -422,6 +436,7 @@ impl LocalCameraPreview { generation, running, selected_device: None, + selected_mode: None, relay_preview_path: None, } } @@ -456,6 +471,27 @@ impl LocalCameraPreview { Ok(()) } + fn set_selected_mode(&mut self, mode: Option) -> Result<()> { + self.selected_mode = mode; + + if self.is_device_preview_running() { + self.stop(); + self.start()?; + return Ok(()); + } + + if let Some(camera) = self.selected_device.as_deref() { + let quality = self + .selected_mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + self.set_status(format!( + "Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay." + )); + } + Ok(()) + } + fn toggle(&mut self) -> Result { if self.is_running() { self.stop(); @@ -472,6 +508,7 @@ impl LocalCameraPreview { .clone() .ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?; self.relay_preview_path = None; + let mode = self.selected_mode; let device = resolve_camera_device(&selected); let latest = Arc::clone(&self.latest); let status_text = Arc::clone(&self.status_text); @@ -485,6 +522,7 @@ impl LocalCameraPreview { if let Err(err) = run_camera_preview_feed( selected, device, + mode, token, latest, status_text.clone(), @@ -551,8 +589,12 @@ impl LocalCameraPreview { } else { match self.selected_device.as_deref() { Some(camera) => { + let quality = self + .selected_mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); format!( - "Local preview stopped. {camera} stays selected for the next relay launch." + "Local preview stopped. {camera} at {quality} stays selected for the next relay launch." ) } None => CAMERA_PREVIEW_IDLE.to_string(), @@ -728,19 +770,23 @@ fn run_microphone_monitor_feed( fn run_camera_preview_feed( selected: String, device: String, + mode: Option, token: u64, latest: Arc>>, status_text: Arc>, generation: Arc, running: Arc, ) -> Result<()> { - let (pipeline, appsink) = build_camera_preview_pipeline(&device)?; + let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?; pipeline .set_state(gst::State::Playing) .context("starting in-launcher camera preview pipeline")?; if let Ok(mut status) = status_text.lock() { - *status = format!("Local preview live for {selected}."); + let quality = mode + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()); + *status = format!("Local preview live for {selected} at {quality}."); } while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { @@ -815,8 +861,11 @@ fn run_microphone_level_probe( running.store(false, Ordering::Release); } -fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> { - let desc = camera_preview_pipeline_desc(device); +fn build_camera_preview_pipeline( + device: &str, + mode: Option, +) -> Result<(gst::Pipeline, gst_app::AppSink)> { + let desc = camera_preview_pipeline_desc(device, mode); let pipeline = gst::parse::launch(&desc)? .downcast::() .expect("camera preview pipeline"); @@ -858,11 +907,19 @@ fn build_microphone_monitor_pipeline( Ok((pipeline, appsink)) } -fn camera_preview_pipeline_desc(device: &str) -> String { +fn camera_preview_pipeline_desc(device: &str, mode: Option) -> String { let device = gst_quote(device); + let source_caps = mode + .map(|mode| { + format!( + "capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ", + mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps + ) + }) + .unwrap_or_default(); format!( "v4l2src device=\"{device}\" do-timestamp=true ! \ - videoconvert ! videoscale ! videorate ! \ + {source_caps}videoconvert ! videoscale ! videorate ! \ video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1,pixel-aspect-ratio=1/1 ! \ appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" ) @@ -1053,6 +1110,7 @@ mod tests { microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio, read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device, }; + use crate::launcher::devices::CameraMode; use std::sync::{Arc, Mutex}; #[test] @@ -1077,12 +1135,25 @@ mod tests { #[test] fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() { - let desc = camera_preview_pipeline_desc("/dev/video0"); + let desc = camera_preview_pipeline_desc("/dev/video0", None); assert!(desc.contains("v4l2src device=\"/dev/video0\"")); assert!(desc.contains("videoconvert ! videoscale ! videorate !")); assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,")); } + #[test] + fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() { + let desc = + camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30))); + assert!( + desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1") + ); + assert!( + desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1") + ); + assert!(desc.contains("decodebin ! videoconvert ! videoscale")); + } + #[test] fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() { let desc = microphone_monitor_pipeline_desc( diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index 317ccf4..0bb55c6 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -1,17 +1,61 @@ use std::collections::{BTreeMap, BTreeSet}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; +use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DeviceCatalog { pub cameras: Vec, + pub camera_modes: BTreeMap>, pub microphones: Vec, pub speakers: Vec, pub keyboards: Vec, pub mice: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct CameraMode { + pub width: u32, + pub height: u32, + pub fps: u32, +} + +impl CameraMode { + pub const fn new(width: u32, height: u32, fps: u32) -> Self { + Self { width, height, fps } + } + + pub fn id(self) -> String { + format!("{}x{}@{}", self.width, self.height, self.fps) + } + + pub fn from_id(raw: &str) -> Option { + let (size, fps) = raw.split_once('@')?; + let (width, height) = size.split_once('x')?; + Some(Self { + width: width.parse().ok()?, + height: height.parse().ok()?, + fps: fps.parse().ok()?, + }) + .filter(|mode| mode.width > 0 && mode.height > 0 && mode.fps > 0) + } + + pub fn short_label(self) -> String { + format!("{}p@{}", self.height, self.fps) + } + + pub fn h264_bitrate_kbit(self) -> u32 { + if self.width >= 1920 || self.height >= 1080 { + 12_000 + } else if self.width >= 1280 || self.height >= 720 { + 6_000 + } else { + 3_000 + } + } +} + impl DeviceCatalog { pub fn discover() -> Self { Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok()) @@ -27,12 +71,14 @@ impl DeviceCatalog { fn discover_with_camera_override(override_dir: Option) -> Self { let cameras = discover_camera_devices(override_dir); + let camera_modes = discover_camera_modes(&cameras); let microphones = discover_microphone_devices(); let speakers = discover_speaker_devices(); let keyboards = discover_input_devices(InputDeviceKind::Keyboard); let mice = discover_input_devices(InputDeviceKind::Mouse); Self { cameras, + camera_modes, microphones, speakers, keyboards, @@ -118,6 +164,87 @@ fn discover_camera_devices(override_dir: Option) -> Vec { dedupe_camera_devices(set) } +fn discover_camera_modes(cameras: &[String]) -> BTreeMap> { + cameras + .iter() + .filter_map(|camera| { + let modes = discover_camera_modes_for(camera); + (!modes.is_empty()).then(|| (camera.clone(), modes)) + }) + .collect() +} + +fn discover_camera_modes_for(camera: &str) -> Vec { + let device = if camera.starts_with("/dev/") { + camera.to_string() + } else { + format!("/dev/v4l/by-id/{camera}") + }; + let output = std::process::Command::new("v4l2-ctl") + .args(["--list-formats-ext", "-d", &device]) + .output(); + + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + parse_supported_camera_modes(&String::from_utf8_lossy(&output.stdout)) +} + +pub fn lesavka_supported_camera_modes() -> Vec { + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30), + ] +} + +pub fn parse_supported_camera_modes(stdout: &str) -> Vec { + let mut discovered = BTreeSet::new(); + let mut current_size = None::<(u32, u32)>; + + for line in stdout.lines() { + let trimmed = line.trim(); + if let Some(size) = parse_v4l2_size_line(trimmed) { + current_size = Some(size); + continue; + } + if trimmed.starts_with("Interval:") + && let Some((width, height)) = current_size + && let Some(fps) = parse_v4l2_interval_fps(trimmed) + { + discovered.insert(CameraMode::new(width, height, fps)); + } + } + + lesavka_supported_camera_modes() + .into_iter() + .filter(|supported| { + discovered.iter().any(|actual| { + actual.width == supported.width + && actual.height == supported.height + && actual.fps >= supported.fps + }) + }) + .collect() +} + +fn parse_v4l2_size_line(line: &str) -> Option<(u32, u32)> { + let (_, rest) = line.split_once("Size:")?; + let token = rest.split_whitespace().find(|part| part.contains('x'))?; + let (width, height) = token.split_once('x')?; + Some((width.parse().ok()?, height.parse().ok()?)) + .filter(|(width, height)| *width > 0 && *height > 0) +} + +fn parse_v4l2_interval_fps(line: &str) -> Option { + let (_, rest) = line.split_once('(')?; + let (fps, _) = rest.split_once(" fps")?; + let rounded = fps.parse::().ok()?.round() as u32; + (rounded > 0).then_some(rounded) +} + fn discover_pactl_devices(kind: &str) -> Vec { let output = std::process::Command::new("pactl") .args(["list", "short", kind]) @@ -331,6 +458,43 @@ mod tests { let _ = discover_camera_devices(None); } + #[test] + fn camera_mode_parser_keeps_only_supported_lesavka_qualities() { + let stdout = r#" +ioctl: VIDIOC_ENUM_FMT + Type: Video Capture + + [0]: 'MJPG' (Motion-JPEG, compressed) + Size: Discrete 1920x1080 + Interval: Discrete 0.033s (30.000 fps) + Interval: Discrete 0.067s (15.000 fps) + Size: Discrete 1280x720 + Interval: Discrete 0.017s (60.000 fps) + Size: Discrete 640x480 + Interval: Discrete 0.033s (30.000 fps) + [1]: 'YUYV' (YUYV 4:2:2) + Size: Discrete 1920x1080 + Interval: Discrete 0.200s (5.000 fps) +"#; + + assert_eq!( + parse_supported_camera_modes(stdout), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30) + ] + ); + } + + #[test] + fn camera_mode_ids_are_short_and_round_trippable() { + let mode = CameraMode::new(1920, 1080, 30); + assert_eq!(mode.id(), "1920x1080@30"); + assert_eq!(mode.short_label(), "1080p@30"); + assert_eq!(CameraMode::from_id("1920x1080@30"), Some(mode)); + assert_eq!(CameraMode::from_id("not-a-mode"), None); + } + #[test] fn discover_uses_override_and_tolerates_missing_pactl() { let tmp = mk_temp_dir("discover-override"); diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index ed65273..542898f 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -157,6 +157,15 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap { && let Some(camera) = state.devices.camera.as_ref() { envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); + if let Some(mode) = state.camera_quality { + envs.insert("LESAVKA_CAM_WIDTH".to_string(), mode.width.to_string()); + envs.insert("LESAVKA_CAM_HEIGHT".to_string(), mode.height.to_string()); + envs.insert("LESAVKA_CAM_FPS".to_string(), mode.fps.to_string()); + envs.insert( + "LESAVKA_CAM_H264_KBIT".to_string(), + mode.h264_bitrate_kbit().to_string(), + ); + } } else { envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string()); } @@ -256,6 +265,7 @@ mod tests { state.set_routing(InputRouting::Local); state.set_view_mode(ViewMode::Unified); state.select_camera(Some("/dev/video0".to_string())); + state.select_camera_quality(Some(devices::CameraMode::new(1920, 1080, 30))); state.select_microphone(Some("alsa_input.test".to_string())); state.select_speaker(Some("alsa_output.test".to_string())); state.set_camera_channel_enabled(true); @@ -275,6 +285,13 @@ mod tests { ); assert_eq!(envs.get("LESAVKA_AUDIO_GAIN"), Some(&"2.000".to_string())); assert_eq!(envs.get("LESAVKA_MIC_GAIN"), Some(&"1.000".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_WIDTH"), Some(&"1920".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_HEIGHT"), Some(&"1080".to_string())); + assert_eq!(envs.get("LESAVKA_CAM_FPS"), Some(&"30".to_string())); + assert_eq!( + envs.get("LESAVKA_CAM_H264_KBIT"), + Some(&"12000".to_string()) + ); assert_eq!( envs.get(REMOTE_INPUT_FAILSAFE_SECONDS_ENV), Some(&DEFAULT_REMOTE_INPUT_FAILSAFE_SECONDS.to_string()) diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index bf6526c..82a9cb8 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::devices::DeviceCatalog; +use super::devices::{CameraMode, DeviceCatalog}; use lesavka_common::eye_source::{ EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, }; @@ -336,6 +336,7 @@ pub struct LauncherState { pub capture_bitrates_kbit: [u32; 2], pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, + pub camera_quality: Option, pub channels: ChannelSelection, pub audio_gain_percent: u32, pub mic_gain_percent: u32, @@ -364,6 +365,7 @@ impl Default for LauncherState { capture_bitrates_kbit: [18_000, 18_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), + camera_quality: None, channels: ChannelSelection::default(), audio_gain_percent: DEFAULT_AUDIO_GAIN_PERCENT, mic_gain_percent: DEFAULT_MIC_GAIN_PERCENT, @@ -658,6 +660,30 @@ impl LauncherState { self.devices.camera = normalize_selection(camera); } + pub fn select_camera_quality(&mut self, mode: Option) { + self.camera_quality = mode; + } + + pub fn camera_quality_options(&self, catalog: &DeviceCatalog) -> Vec { + self.devices + .camera + .as_ref() + .and_then(|camera| catalog.camera_modes.get(camera)) + .cloned() + .unwrap_or_default() + } + + pub fn selected_camera_quality(&self, catalog: &DeviceCatalog) -> Option { + let options = self.camera_quality_options(catalog); + self.camera_quality + .filter(|selected| options.contains(selected)) + .or_else(|| options.first().copied()) + } + + pub fn normalize_camera_quality(&mut self, catalog: &DeviceCatalog) { + self.camera_quality = self.selected_camera_quality(catalog); + } + pub fn select_microphone(&mut self, microphone: Option) { self.devices.microphone = normalize_selection(microphone); } @@ -720,6 +746,7 @@ impl LauncherState { pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) { keep_or_select_first(&mut self.devices.camera, &catalog.cameras); + self.normalize_camera_quality(catalog); keep_or_select_first(&mut self.devices.microphone, &catalog.microphones); keep_or_select_first(&mut self.devices.speaker, &catalog.speakers); } @@ -778,7 +805,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -801,6 +828,9 @@ impl LauncherState { self.feed_source_preset(0).as_id(), self.feed_source_preset(1).as_id(), media_status_label(self.channels.camera, self.devices.camera.as_deref()), + self.camera_quality + .map(CameraMode::short_label) + .unwrap_or_else(|| "default".to_string()), media_status_label(self.channels.microphone, self.devices.microphone.as_deref()), media_status_label(self.channels.audio, self.devices.speaker.as_deref()), self.channels.camera, @@ -1179,6 +1209,12 @@ mod tests { let catalog = DeviceCatalog { cameras: vec!["/dev/video0".to_string()], + camera_modes: [( + "/dev/video0".to_string(), + vec![CameraMode::new(1920, 1080, 30)], + )] + .into_iter() + .collect(), microphones: vec!["alsa_input.usb".to_string()], speakers: vec!["alsa_output.usb".to_string()], keyboards: vec!["/dev/input/event10".to_string()], @@ -1197,10 +1233,56 @@ mod tests { let mut fresh = LauncherState::new(); fresh.apply_catalog_defaults(&catalog); assert_eq!(fresh.devices.camera.as_deref(), Some("/dev/video0")); + assert_eq!(fresh.camera_quality, Some(CameraMode::new(1920, 1080, 30))); assert_eq!(fresh.devices.microphone.as_deref(), Some("alsa_input.usb")); assert_eq!(fresh.devices.speaker.as_deref(), Some("alsa_output.usb")); } + #[test] + fn camera_quality_tracks_selected_camera_supported_modes() { + let catalog = DeviceCatalog { + cameras: vec!["cam-a".to_string(), "cam-b".to_string()], + camera_modes: [ + ( + "cam-a".to_string(), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30), + ], + ), + ("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]), + ] + .into_iter() + .collect(), + ..DeviceCatalog::default() + }; + + let mut state = LauncherState::new(); + state.apply_catalog_defaults(&catalog); + assert_eq!(state.devices.camera.as_deref(), Some("cam-a")); + assert_eq!( + state.camera_quality_options(&catalog), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1280, 720, 30) + ] + ); + + state.select_camera_quality(Some(CameraMode::new(1280, 720, 30))); + assert_eq!( + state.selected_camera_quality(&catalog), + Some(CameraMode::new(1280, 720, 30)) + ); + + state.select_camera(Some("cam-b".to_string())); + state.normalize_camera_quality(&catalog); + assert_eq!(state.camera_quality, Some(CameraMode::new(1280, 720, 30))); + + state.select_camera(None); + state.normalize_camera_quality(&catalog); + assert_eq!(state.camera_quality, None); + } + #[test] fn audio_gain_is_clamped_and_formatted_for_launcher_and_runtime() { let mut state = LauncherState::new(); @@ -1244,6 +1326,7 @@ mod tests { state.set_routing(InputRouting::Local); state.set_view_mode(ViewMode::Unified); state.select_camera(Some("/dev/video0".to_string())); + state.select_camera_quality(Some(CameraMode::new(1920, 1080, 30))); state.select_microphone(Some("alsa_input.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string())); state.set_camera_channel_enabled(true); @@ -1263,6 +1346,7 @@ mod tests { assert!(status.contains("d1=preview")); assert!(status.contains("d2=preview")); assert!(status.contains("camera=/dev/video0")); + assert!(status.contains("camera_quality=1080p@30")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); assert!(status.contains("audio_gain=200%")); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 74a4fec..9847fb9 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -4,7 +4,7 @@ use anyhow::Result; use { super::clipboard::send_clipboard_text_to_remote, super::device_test::{DeviceTestController, DeviceTestKind}, - super::devices::DeviceCatalog, + super::devices::{CameraMode, DeviceCatalog}, super::diagnostics::{PerformanceSample, quality_probe_command}, super::launcher_clipboard_control_path, super::launcher_focus_signal_path, @@ -14,7 +14,10 @@ use { FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, MAX_MIC_GAIN_PERCENT, }, - super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo}, + super::ui_components::{ + build_launcher_view, sync_camera_quality_combo, sync_input_device_combo, + sync_stage_device_combo, + }, super::ui_runtime::{ RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams, audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log, @@ -153,6 +156,25 @@ fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option< .map(str::to_string) } +#[cfg(not(coverage))] +fn selected_camera_quality(combo: >k::ComboBoxText) -> Option { + combo.active_id().as_deref().and_then(CameraMode::from_id) +} + +#[cfg(not(coverage))] +fn sync_camera_quality_selection( + combo: >k::ComboBoxText, + state: &mut LauncherState, + catalog: &DeviceCatalog, +) { + state.normalize_camera_quality(catalog); + sync_camera_quality_combo( + combo, + &state.camera_quality_options(catalog), + state.camera_quality, + ); +} + #[cfg(not(coverage))] fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { if samples.len() < 2 { @@ -705,9 +727,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let app = gtk::Application::builder() .application_id("dev.lesavka.launcher") .build(); - let catalog = Rc::new(DeviceCatalog::discover()); + let catalog = Rc::new(RefCell::new(DeviceCatalog::discover())); let state = Rc::new(RefCell::new(LauncherState::new())); - state.borrow_mut().apply_catalog_defaults(&catalog); + state.borrow_mut().apply_catalog_defaults(&catalog.borrow()); let child_proc = Rc::new(RefCell::new(None::)); let tests = Rc::new(RefCell::new(DeviceTestController::new())); let server_addr = Rc::new(server_addr); @@ -760,12 +782,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { state.set_breakout_display_size(display_width, display_height); state.set_breakout_limit_size(physical_width, physical_height); } - let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow()); + let view = + build_launcher_view(app, server_addr.as_ref(), &catalog.borrow(), &state.borrow()); let window = view.window.clone(); let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height); window.set_default_size(launcher_width, launcher_height); let server_entry = view.server_entry.clone(); let camera_combo = view.camera_combo.clone(); + let camera_quality_combo = view.camera_quality_combo.clone(); let microphone_combo = view.microphone_combo.clone(); let speaker_combo = view.speaker_combo.clone(); let keyboard_combo = view.keyboard_combo.clone(); @@ -848,6 +872,11 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { .status_label .set_text(&format!("Camera staging setup failed: {err}")); } + if let Err(err) = tests.set_camera_quality(state.borrow().camera_quality) { + widgets + .status_label + .set_text(&format!("Camera quality staging setup failed: {err}")); + } } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); @@ -878,24 +907,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { { let state = Rc::clone(&state); + let catalog = Rc::clone(&catalog); let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); let tests = Rc::clone(&tests); let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); let camera_combo_read = camera_combo.clone(); camera_combo.connect_changed(move |_| { let selected = selected_combo_value(&camera_combo_read); let preview_was_running = tests.borrow_mut().is_running(DeviceTestKind::Camera); - state.borrow_mut().select_camera(selected.clone()); + { + let catalog = catalog.borrow(); + let mut state = state.borrow_mut(); + state.select_camera(selected.clone()); + sync_camera_quality_selection(&camera_quality_combo, &mut state, &catalog); + } + let quality = state.borrow().camera_quality; if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) { widgets .status_label .set_text(&format!("Camera preview update failed: {err}")); + } else if let Err(err) = tests.borrow_mut().set_camera_quality(quality) { + widgets + .status_label + .set_text(&format!("Camera quality update failed: {err}")); + } else if preview_was_running { + widgets.status_label.set_text(&format!( + "Local camera preview switched to {}{}.", + selected.as_deref().unwrap_or("no camera"), + quality + .map(|mode| format!(" at {}", mode.short_label())) + .unwrap_or_default() + )); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); + let camera_quality_combo = camera_quality_combo.clone(); + let camera_quality_combo_read = camera_quality_combo.clone(); + camera_quality_combo.connect_changed(move |_| { + let selected = selected_camera_quality(&camera_quality_combo_read); + let preview_was_running = + tests.borrow_mut().is_running(DeviceTestKind::Camera); + state.borrow_mut().select_camera_quality(selected); + if let Err(err) = tests.borrow_mut().set_camera_quality(selected) { + widgets + .status_label + .set_text(&format!("Camera quality update failed: {err}")); } else if preview_was_running { widgets.status_label.set_text(&format!( "Local camera preview switched to {}.", - selected.as_deref().unwrap_or("no camera") + selected + .map(CameraMode::short_label) + .unwrap_or_else(|| "default quality".to_string()) )); } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); @@ -1259,17 +1332,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { { let state = Rc::clone(&state); + let catalog_state = Rc::clone(&catalog); let widgets = widgets.clone(); let widgets_handle = widgets.clone(); let child_proc = Rc::clone(&child_proc); let tests = Rc::clone(&tests); let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); let microphone_combo = microphone_combo.clone(); let speaker_combo = speaker_combo.clone(); let keyboard_combo = keyboard_combo.clone(); let mouse_combo = mouse_combo.clone(); widgets.device_refresh_button.connect_clicked(move |_| { - let catalog = DeviceCatalog::discover(); + let fresh_catalog = DeviceCatalog::discover(); let ( selected_camera, selected_microphone, @@ -1281,56 +1356,65 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { ( retained_stage_selection( state.devices.camera.as_deref(), - &catalog.cameras, + &fresh_catalog.cameras, ), retained_stage_selection( state.devices.microphone.as_deref(), - &catalog.microphones, + &fresh_catalog.microphones, ), retained_stage_selection( state.devices.speaker.as_deref(), - &catalog.speakers, + &fresh_catalog.speakers, ), retained_input_selection( state.devices.keyboard.as_deref(), - &catalog.keyboards, + &fresh_catalog.keyboards, + ), + retained_input_selection( + state.devices.mouse.as_deref(), + &fresh_catalog.mice, ), - retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice), ) }; { let mut state = state.borrow_mut(); state.select_camera(selected_camera); + sync_camera_quality_selection( + &camera_quality_combo, + &mut state, + &fresh_catalog, + ); state.select_microphone(selected_microphone); state.select_speaker(selected_speaker); state.select_keyboard(selected_keyboard); state.select_mouse(selected_mouse); } + *catalog_state.borrow_mut() = fresh_catalog.clone(); let state_snapshot = state.borrow().clone(); sync_stage_device_combo( &camera_combo, - &catalog.cameras, + &fresh_catalog.cameras, state_snapshot.devices.camera.as_deref(), ); sync_stage_device_combo( µphone_combo, - &catalog.microphones, + &fresh_catalog.microphones, state_snapshot.devices.microphone.as_deref(), ); sync_stage_device_combo( &speaker_combo, - &catalog.speakers, + &fresh_catalog.speakers, state_snapshot.devices.speaker.as_deref(), ); sync_input_device_combo( &keyboard_combo, - &catalog.keyboards, + &fresh_catalog.keyboards, state_snapshot.devices.keyboard.as_deref(), "all keyboards", ); sync_input_device_combo( &mouse_combo, - &catalog.mice, + &fresh_catalog.mice, state_snapshot.devices.mouse.as_deref(), "all mice", ); @@ -1341,6 +1425,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { widgets_handle .status_label .set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}")); + } else if let Err(err) = + tests.borrow_mut().set_camera_quality(state_snapshot.camera_quality) + { + widgets_handle.status_label.set_text(&format!( + "Device refresh succeeded, but the webcam quality test could not switch cleanly: {err}" + )); } else { let message = if usb_audio_kernel_support_missing() { "Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker." @@ -1365,6 +1455,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let widgets = widgets.clone(); let server_entry = server_entry.clone(); let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); let microphone_combo = microphone_combo.clone(); let speaker_combo = speaker_combo.clone(); let input_control_path = Rc::clone(&input_control_path); @@ -1433,6 +1524,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { { let mut state = state.borrow_mut(); state.select_camera(selected_combo_value(&camera_combo)); + state.select_camera_quality(selected_camera_quality(&camera_quality_combo)); state.select_microphone(selected_combo_value(µphone_combo)); state.select_speaker(selected_combo_value(&speaker_combo)); state.select_keyboard(selected_combo_value(&keyboard_combo)); @@ -1729,13 +1821,16 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let widgets = widgets.clone(); let tests = Rc::clone(&tests); let camera_combo = camera_combo.clone(); + let camera_quality_combo = camera_quality_combo.clone(); let camera_test_button = widgets.camera_test_button.clone(); let widgets_handle = widgets.clone(); camera_test_button.connect_clicked(move |_| { let selected = selected_combo_value(&camera_combo); + let quality = selected_camera_quality(&camera_quality_combo); let result = { let mut tests = tests.borrow_mut(); let _ = tests.set_camera_selection(selected.as_deref()); + let _ = tests.set_camera_quality(quality); tests.toggle_camera() }; update_test_action_result( diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 251874b..e26901f 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -4,7 +4,7 @@ use evdev::Device; use gtk::{pango, prelude::*}; use super::{ - devices::DeviceCatalog, + devices::{CameraMode, DeviceCatalog}, diagnostics::DiagnosticsLog, preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ @@ -67,6 +67,7 @@ pub struct LauncherWidgets { pub server_entry: gtk::Entry, pub start_button: gtk::Button, pub camera_combo: gtk::ComboBoxText, + pub camera_quality_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText, pub keyboard_combo: gtk::ComboBoxText, @@ -108,6 +109,7 @@ pub struct LauncherView { pub window: gtk::ApplicationWindow, pub server_entry: gtk::Entry, pub camera_combo: gtk::ComboBoxText, + pub camera_quality_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText, pub keyboard_combo: gtk::ComboBoxText, @@ -123,12 +125,12 @@ 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 = 820; +const LAUNCHER_DEFAULT_HEIGHT: i32 = 940; 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 = 258; -const EYE_PREVIEW_MIN_WIDTH: i32 = 460; +const EYE_PREVIEW_MIN_HEIGHT: i32 = 320; +const EYE_PREVIEW_MIN_WIDTH: i32 = 568; const SIDE_LOG_MIN_HEIGHT: i32 = 124; pub fn build_launcher_view( @@ -244,6 +246,16 @@ pub fn build_launcher_view( &catalog.cameras, state.devices.camera.as_deref(), ); + let camera_quality_combo = gtk::ComboBoxText::new(); + sync_camera_quality_combo( + &camera_quality_combo, + &state.camera_quality_options(catalog), + state.selected_camera_quality(catalog), + ); + camera_quality_combo.set_size_request(88, -1); + camera_quality_combo.set_tooltip_text(Some( + "Choose the webcam quality Lesavka should capture and send to the relay host.", + )); let camera_test_button = gtk::Button::with_label("Start Preview"); stabilize_button(&camera_test_button, 118); camera_test_button.set_tooltip_text(Some( @@ -292,14 +304,82 @@ pub fn build_launcher_view( media_grid.set_row_spacing(10); media_grid.set_column_spacing(8); media_group.append(&media_grid); + let camera_channel_toggle = gtk::CheckButton::with_label("Camera"); + camera_channel_toggle.set_active(state.channels.camera); + camera_channel_toggle.set_tooltip_text(Some( + "Include the local webcam uplink in the next relay session.", + )); + let audio_channel_toggle = gtk::CheckButton::with_label("Speaker"); + audio_channel_toggle.set_active(state.channels.audio); + audio_channel_toggle.set_tooltip_text(Some( + "Play remote audio on this client during the next relay session.", + )); + let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); + microphone_channel_toggle.set_active(state.channels.microphone); + microphone_channel_toggle.set_tooltip_text(Some( + "Include the local microphone uplink in the next relay session.", + )); + + let audio_gain_adjustment = gtk::Adjustment::new( + state.audio_gain_percent as f64, + 0.0, + super::state::MAX_AUDIO_GAIN_PERCENT as f64, + 25.0, + 100.0, + 0.0, + ); + let audio_gain_scale = + gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); + audio_gain_scale.set_draw_value(false); + audio_gain_scale.set_hexpand(false); + audio_gain_scale.set_size_request(96, -1); + audio_gain_scale.set_tooltip_text(Some( + "Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.", + )); + let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); + audio_gain_value.set_visible(false); + + let mic_gain_adjustment = gtk::Adjustment::new( + state.mic_gain_percent as f64, + 0.0, + super::state::MAX_MIC_GAIN_PERCENT as f64, + 25.0, + 100.0, + 0.0, + ); + let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment)); + mic_gain_scale.set_draw_value(false); + mic_gain_scale.set_hexpand(false); + mic_gain_scale.set_size_request(96, -1); + mic_gain_scale.set_tooltip_text(Some( + "Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.", + )); + let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); + mic_gain_value.set_visible(false); + camera_combo.set_size_request(0, -1); + let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + camera_combo.set_hexpand(true); + camera_quality_combo.set_hexpand(false); + camera_selectors.append(&camera_combo); + camera_selectors.append(&camera_quality_combo); speaker_combo.set_size_request(0, -1); - attach_device_row(&media_grid, 0, "Camera", &camera_combo, &camera_test_button); - attach_device_row( + attach_device_control_row( + &media_grid, + 0, + &camera_channel_toggle, + &camera_selectors, + &camera_test_button, + ); + let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + speaker_combo.set_hexpand(true); + speaker_selectors.append(&speaker_combo); + speaker_selectors.append(&audio_gain_scale); + attach_device_control_row( &media_grid, 1, - "Speaker", - &speaker_combo, + &audio_channel_toggle, + &speaker_selectors, &speaker_test_button, ); @@ -315,11 +395,15 @@ pub fn build_launcher_view( "Monitor the selected microphone through the selected speaker until you stop the test.", )); microphone_combo.set_size_request(0, -1); - attach_device_row( + let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6); + microphone_combo.set_hexpand(true); + microphone_selectors.append(µphone_combo); + microphone_selectors.append(&mic_gain_scale); + attach_device_control_row( &media_grid, 2, - "Microphone", - µphone_combo, + µphone_channel_toggle, + µphone_selectors, µphone_test_button, ); @@ -464,37 +548,6 @@ pub fn build_launcher_view( live_actions_row.append(&usb_recover_button); connection_body.append(&live_actions_row); - let channel_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - channel_row.set_hexpand(true); - let channel_heading = gtk::Label::new(Some("Streams")); - channel_heading.add_css_class("subgroup-title"); - channel_heading.set_halign(gtk::Align::Start); - channel_heading.set_width_chars(10); - channel_row.append(&channel_heading); - let channel_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); - channel_buttons.set_hexpand(true); - channel_buttons.set_homogeneous(true); - let camera_channel_toggle = gtk::CheckButton::with_label("Webcam"); - camera_channel_toggle.set_active(state.channels.camera); - camera_channel_toggle.set_tooltip_text(Some( - "Include the local webcam uplink in the next relay session.", - )); - let microphone_channel_toggle = gtk::CheckButton::with_label("Mic"); - microphone_channel_toggle.set_active(state.channels.microphone); - microphone_channel_toggle.set_tooltip_text(Some( - "Include the local microphone uplink in the next relay session.", - )); - let audio_channel_toggle = gtk::CheckButton::with_label("Audio"); - audio_channel_toggle.set_active(state.channels.audio); - audio_channel_toggle.set_tooltip_text(Some( - "Play remote audio on this client during the next relay session.", - )); - channel_buttons.append(&camera_channel_toggle); - channel_buttons.append(µphone_channel_toggle); - channel_buttons.append(&audio_channel_toggle); - channel_row.append(&channel_buttons); - connection_body.append(&channel_row); - connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let power_heading = gtk::Label::new(Some("GPIO Power")); power_heading.add_css_class("subgroup-title"); @@ -529,66 +582,7 @@ pub fn build_launcher_view( power_buttons.append(&power_auto_button); power_buttons.append(&power_off_button); power_row.append(&power_buttons); - let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - audio_gain_row.set_size_request(220, -1); - audio_gain_row.set_hexpand(true); - let audio_gain_label = gtk::Label::new(Some("Audio")); - audio_gain_label.add_css_class("dim-label"); - audio_gain_label.set_halign(gtk::Align::Start); - audio_gain_label.set_width_chars(10); - let audio_gain_adjustment = gtk::Adjustment::new( - state.audio_gain_percent as f64, - 0.0, - super::state::MAX_AUDIO_GAIN_PERCENT as f64, - 25.0, - 100.0, - 0.0, - ); - let audio_gain_scale = - gtk::Scale::new(gtk::Orientation::Horizontal, Some(&audio_gain_adjustment)); - audio_gain_scale.set_draw_value(false); - audio_gain_scale.set_hexpand(true); - audio_gain_scale.set_tooltip_text(Some( - "Boost or lower remote speaker playback on this client. Changes apply live while the relay is connected.", - )); - let audio_gain_value = gtk::Label::new(Some(&state.audio_gain_label())); - audio_gain_value.add_css_class("dim-label"); - audio_gain_value.set_width_chars(5); - audio_gain_value.set_xalign(1.0); - audio_gain_row.append(&audio_gain_label); - audio_gain_row.append(&audio_gain_scale); - audio_gain_row.append(&audio_gain_value); - let mic_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - mic_gain_row.set_size_request(220, -1); - mic_gain_row.set_hexpand(true); - let mic_gain_label = gtk::Label::new(Some("Mic Gain")); - mic_gain_label.add_css_class("dim-label"); - mic_gain_label.set_halign(gtk::Align::Start); - mic_gain_label.set_width_chars(10); - let mic_gain_adjustment = gtk::Adjustment::new( - state.mic_gain_percent as f64, - 0.0, - super::state::MAX_MIC_GAIN_PERCENT as f64, - 25.0, - 100.0, - 0.0, - ); - let mic_gain_scale = gtk::Scale::new(gtk::Orientation::Horizontal, Some(&mic_gain_adjustment)); - mic_gain_scale.set_draw_value(false); - mic_gain_scale.set_hexpand(true); - mic_gain_scale.set_tooltip_text(Some( - "Boost or lower local microphone uplink gain. Changes apply live while microphone uplink is running.", - )); - let mic_gain_value = gtk::Label::new(Some(&state.mic_gain_label())); - mic_gain_value.add_css_class("dim-label"); - mic_gain_value.set_width_chars(5); - mic_gain_value.set_xalign(1.0); - mic_gain_row.append(&mic_gain_label); - mic_gain_row.append(&mic_gain_scale); - mic_gain_row.append(&mic_gain_value); power_shell.append(&power_row); - power_shell.append(&audio_gain_row); - power_shell.append(&mic_gain_row); connection_body.append(&power_shell); let routing_heading = gtk::Label::new(Some("Inputs")); routing_heading.add_css_class("subgroup-title"); @@ -830,6 +824,7 @@ pub fn build_launcher_view( server_entry: server_entry.clone(), start_button: start_button.clone(), camera_combo: camera_combo.clone(), + camera_quality_combo: camera_quality_combo.clone(), microphone_combo: microphone_combo.clone(), speaker_combo: speaker_combo.clone(), keyboard_combo: keyboard_combo.clone(), @@ -872,6 +867,7 @@ pub fn build_launcher_view( window, server_entry, camera_combo, + camera_quality_combo, microphone_combo, speaker_combo, keyboard_combo, @@ -1207,6 +1203,29 @@ pub fn sync_stage_device_combo( set_stage_combo_active_text(combo, selected); } +pub fn sync_camera_quality_combo( + combo: >k::ComboBoxText, + options: &[CameraMode], + selected: Option, +) { + combo.remove_all(); + if options.is_empty() { + combo.append(Some("none"), "Quality"); + combo.set_active_id(Some("none")); + combo.set_sensitive(false); + return; + } + + for option in options { + combo.append(Some(&option.id()), &option.short_label()); + } + let active = selected + .filter(|mode| options.contains(mode)) + .or_else(|| options.first().copied()) + .map(CameraMode::id); + combo.set_active_id(active.as_deref()); +} + pub fn sync_input_device_combo( combo: >k::ComboBoxText, values: &[String], @@ -1221,18 +1240,17 @@ pub fn sync_input_device_combo( super::ui_runtime::set_combo_active_text(combo, selected); } -fn attach_device_row( +fn attach_device_control_row( grid: >k::Grid, row: i32, - label: &str, - combo: >k::ComboBoxText, + stream_toggle: >k::CheckButton, + selector: &impl IsA, test_button: >k::Button, ) { - let label_widget = gtk::Label::new(Some(label)); - label_widget.set_halign(gtk::Align::Start); - combo.set_hexpand(true); - grid.attach(&label_widget, 0, row, 1, 1); - grid.attach(combo, 1, row, 1, 1); + stream_toggle.set_halign(gtk::Align::Start); + selector.set_hexpand(true); + grid.attach(stream_toggle, 0, row, 1, 1); + grid.attach(selector, 1, row, 1, 1); grid.attach(test_button, 2, row, 1, 1); } diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 6a17c3e..ee95c58 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -130,12 +130,21 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .camera_combo .set_sensitive(!relay_live && state.channels.camera); + widgets.camera_quality_combo.set_sensitive( + !relay_live + && state.channels.camera + && state.devices.camera.is_some() + && state.camera_quality.is_some(), + ); widgets .microphone_combo .set_sensitive(!relay_live && state.channels.microphone); widgets .speaker_combo .set_sensitive(!relay_live && state.channels.audio); + widgets + .audio_gain_scale + .set_sensitive(!relay_live && state.channels.audio); widgets.keyboard_combo.set_sensitive(!relay_live); widgets.mouse_combo.set_sensitive(!relay_live); widgets.camera_channel_toggle.set_sensitive(!relay_live); @@ -147,6 +156,9 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi widgets .microphone_test_button .set_sensitive(!relay_live && state.channels.microphone); + widgets + .mic_gain_scale + .set_sensitive(!relay_live && state.channels.microphone); widgets .speaker_test_button .set_sensitive(!relay_live && state.channels.audio); diff --git a/client/src/video_support.rs b/client/src/video_support.rs index b2f01af..be00f11 100644 --- a/client/src/video_support.rs +++ b/client/src/video_support.rs @@ -41,5 +41,8 @@ pub fn pick_h264_decoder() -> String { } fn buildable_decoder(name: &str) -> bool { + if gst::init().is_err() { + return false; + } gst::ElementFactory::find(name).is_some() && gst::ElementFactory::make(name).build().is_ok() } diff --git a/common/Cargo.toml b/common/Cargo.toml index b0482c3..8ae3ffb 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.48" +version = "0.12.0" edition = "2024" build = "build.rs" diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 6e28d37..7d7c8e0 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -3,7 +3,7 @@ "client/src/app.rs": { "clippy_warnings": 40, "doc_debt": 13, - "loc": 808 + "loc": 816 }, "client/src/app_support.rs": { "clippy_warnings": 0, @@ -23,7 +23,7 @@ "client/src/input/camera.rs": { "clippy_warnings": 14, "doc_debt": 10, - "loc": 719 + "loc": 717 }, "client/src/input/inputs.rs": { "clippy_warnings": 40, @@ -61,14 +61,14 @@ "loc": 178 }, "client/src/launcher/device_test.rs": { - "clippy_warnings": 67, - "doc_debt": 40, - "loc": 1148 + "clippy_warnings": 75, + "doc_debt": 43, + "loc": 1219 }, "client/src/launcher/devices.rs": { - "clippy_warnings": 6, - "doc_debt": 14, - "loc": 400 + "clippy_warnings": 25, + "doc_debt": 19, + "loc": 564 }, "client/src/launcher/diagnostics.rs": { "clippy_warnings": 92, @@ -78,7 +78,7 @@ "client/src/launcher/mod.rs": { "clippy_warnings": 8, "doc_debt": 8, - "loc": 480 + "loc": 497 }, "client/src/launcher/power.rs": { "clippy_warnings": 0, @@ -91,24 +91,24 @@ "loc": 2216 }, "client/src/launcher/state.rs": { - "clippy_warnings": 168, - "doc_debt": 57, - "loc": 1478 + "clippy_warnings": 172, + "doc_debt": 58, + "loc": 1562 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 68, + "clippy_warnings": 70, "doc_debt": 23, - "loc": 2524 + "loc": 2619 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 22, - "doc_debt": 17, - "loc": 1497 + "doc_debt": 18, + "loc": 1515 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 74, "doc_debt": 44, - "loc": 1790 + "loc": 1802 }, "client/src/layout.rs": { "clippy_warnings": 6, @@ -157,8 +157,8 @@ }, "client/src/video_support.rs": { "clippy_warnings": 0, - "doc_debt": 1, - "loc": 45 + "doc_debt": 2, + "loc": 48 }, "common/src/bin/cli.rs": { "clippy_warnings": 0, @@ -196,9 +196,9 @@ "loc": 105 }, "server/src/audio.rs": { - "clippy_warnings": 43, - "doc_debt": 13, - "loc": 680 + "clippy_warnings": 47, + "doc_debt": 15, + "loc": 737 }, "server/src/bin/lesavka-uvc.real.inc": { "clippy_warnings": 33, @@ -211,14 +211,14 @@ "loc": 712 }, "server/src/camera.rs": { - "clippy_warnings": 12, - "doc_debt": 11, - "loc": 392 + "clippy_warnings": 18, + "doc_debt": 19, + "loc": 623 }, "server/src/camera_runtime.rs": { "clippy_warnings": 10, "doc_debt": 5, - "loc": 200 + "loc": 204 }, "server/src/capture_power.rs": { "clippy_warnings": 12, @@ -276,9 +276,9 @@ "loc": 844 }, "server/src/video_sinks.rs": { - "clippy_warnings": 78, - "doc_debt": 11, - "loc": 574 + "clippy_warnings": 80, + "doc_debt": 15, + "loc": 679 }, "server/src/video_support.rs": { "clippy_warnings": 8, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 8c5bc51..7bb70ff 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -2,14 +2,14 @@ "files": { "client/src/app.rs": { "line_percent": 97.4, - "loc": 808 + "loc": 816 }, "client/src/app_support.rs": { "line_percent": 100.0, "loc": 132 }, "client/src/bin/lesavka-relayctl.rs": { - "line_percent": 0.0, + "line_percent": 25.24, "loc": 140 }, "client/src/handshake.rs": { @@ -17,8 +17,8 @@ "loc": 381 }, "client/src/input/camera.rs": { - "line_percent": 95.24, - "loc": 719 + "line_percent": 96.51, + "loc": 717 }, "client/src/input/inputs.rs": { "line_percent": 96.39, @@ -45,24 +45,24 @@ "loc": 178 }, "client/src/launcher/devices.rs": { - "line_percent": 95.93, - "loc": 400 + "line_percent": 96.0, + "loc": 564 }, "client/src/launcher/diagnostics.rs": { "line_percent": 84.3, "loc": 1021 }, "client/src/launcher/mod.rs": { - "line_percent": 84.2, - "loc": 480 + "line_percent": 84.85, + "loc": 497 }, "client/src/launcher/state.rs": { - "line_percent": 85.06, - "loc": 1478 + "line_percent": 86.04, + "loc": 1562 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 2524 + "loc": 2619 }, "client/src/layout.rs": { "line_percent": 97.73, @@ -73,7 +73,7 @@ "loc": 100 }, "client/src/output/audio.rs": { - "line_percent": 76.92, + "line_percent": 89.42, "loc": 371 }, "client/src/output/display.rs": { @@ -93,8 +93,8 @@ "loc": 82 }, "client/src/video_support.rs": { - "line_percent": 0.0, - "loc": 45 + "line_percent": 83.87, + "loc": 48 }, "common/src/bin/cli.rs": { "line_percent": 100.0, @@ -125,20 +125,20 @@ "loc": 105 }, "server/src/audio.rs": { - "line_percent": 98.97, - "loc": 680 + "line_percent": 96.88, + "loc": 737 }, "server/src/bin/lesavka-uvc.rs": { "line_percent": 95.92, "loc": 712 }, "server/src/camera.rs": { - "line_percent": 99.1, - "loc": 392 + "line_percent": 96.6, + "loc": 623 }, "server/src/camera_runtime.rs": { "line_percent": 100.0, - "loc": 200 + "loc": 204 }, "server/src/capture_power.rs": { "line_percent": 100.0, @@ -173,8 +173,8 @@ "loc": 844 }, "server/src/video_sinks.rs": { - "line_percent": 100.0, - "loc": 574 + "line_percent": 95.8, + "loc": 679 }, "server/src/video_support.rs": { "line_percent": 97.62, diff --git a/scripts/ci/test_gate.sh b/scripts/ci/test_gate.sh index 7ed6f42..06d8f4d 100755 --- a/scripts/ci/test_gate.sh +++ b/scripts/ci/test_gate.sh @@ -27,8 +27,14 @@ build_url=${BUILD_URL:-} start_seconds=$(date +%s) status=0 set +e -cargo test --workspace --all-targets --color never 2>&1 | tee "${TEST_LOG}" -status=${PIPESTATUS[0]} +cargo build --workspace --bins --color never 2>&1 | tee "${TEST_LOG}" +build_status=${PIPESTATUS[0]} +if [[ "${build_status}" -eq 0 ]]; then + cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}" + status=${PIPESTATUS[0]} +else + status=${build_status} +fi set -e end_seconds=$(date +%s) duration_seconds=$((end_seconds - start_seconds)) diff --git a/scripts/install/server.sh b/scripts/install/server.sh index d63917b..91cbc0f 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -423,7 +423,15 @@ fi echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults." if [[ -n $HDMI_CONNECTOR ]]; then printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR" + printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_CAM_OUTPUT:-hdmi}" fi + printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}" + printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}" + printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}" + printf 'LESAVKA_HDMI_WIDTH=%s\n' "${LESAVKA_HDMI_WIDTH:-1920}" + printf 'LESAVKA_HDMI_HEIGHT=%s\n' "${LESAVKA_HDMI_HEIGHT:-1080}" + printf 'LESAVKA_HDMI_SINK=%s\n' "${LESAVKA_HDMI_SINK:-fbdevsink}" + printf 'LESAVKA_HDMI_FBDEV=%s\n' "${LESAVKA_HDMI_FBDEV:-/dev/fb0}" printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}" printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}" printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 8589fce..0563f56 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.48" +version = "0.12.0" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index 774df40..5cbb5eb 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -44,6 +44,57 @@ impl Drop for AudioStream { } } +pub(crate) fn start_pipeline_or_reset( + pipeline: &gst::Pipeline, + context: &'static str, +) -> anyhow::Result<()> { + match pipeline.set_state(gst::State::Playing) { + Ok(_) => Ok(()), + Err(error) => { + let _ = pipeline.set_state(gst::State::Null); + Err(error).context(context) + } + } +} + +#[cfg(not(coverage))] +fn spawn_pipeline_bus_logger(bus: gst::Bus, label: &'static str, playing_message: &'static str) { + std::thread::spawn(move || { + for msg in bus.iter_timed(gst::ClockTime::NONE) { + match msg.view() { + Error(e) => error!( + "πŸ’₯ {label} pipeline from {:?}: {} ({})", + msg.src().map(gst::prelude::GstObjectExt::path_string), + e.error(), + e.debug().unwrap_or_default() + ), + Warning(w) => warn!( + "⚠️ {label} pipeline from {:?}: {} ({})", + msg.src().map(gst::prelude::GstObjectExt::path_string), + w.error(), + w.debug().unwrap_or_default() + ), + StateChanged(s) + if s.current() == gst::State::Playing + && msg.src().map(|s| s.is::()).unwrap_or(false) => + { + debug!("{playing_message}") + } + Element(e) => { + if let Some(structure) = e.structure() { + if structure.name() == "level" { + info!("πŸ”Š source audio level {}", structure); + } else { + debug!("πŸ”Ž audio element message: {}", structure); + } + } + } + _ => {} + } + } + }); +} + /*───────────────────────────────────────────────────────────────────────────*/ /* ear() - capture from ALSA (β€œspeaker”) and push AAC AUs via gRPC */ /*───────────────────────────────────────────────────────────────────────────*/ @@ -104,35 +155,6 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { let source_health = Arc::new(AudioSourceHealth::new()); let bus = pipeline.bus().expect("bus"); - std::thread::spawn(move || { - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - Error(e) => error!( - "πŸ’₯ audio pipeline: {} ({})", - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => warn!( - "⚠️ audio pipeline: {} ({})", - w.error(), - w.debug().unwrap_or_default() - ), - StateChanged(s) if s.current() == gst::State::Playing => { - debug!("🎢 audio pipeline PLAYING") - } - Element(e) => { - if let Some(structure) = e.structure() { - if structure.name() == "level" { - info!("πŸ”Š source audio level {}", structure); - } else { - debug!("πŸ”Ž audio element message: {}", structure); - } - } - } - _ => {} - } - } - }); /*──────────── callbacks ────────────*/ sink.set_callbacks( @@ -183,9 +205,8 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { .build(), ); - pipeline - .set_state(gst::State::Playing) - .context("starting audio pipeline")?; + start_pipeline_or_reset(&pipeline, "starting audio pipeline")?; + spawn_pipeline_bus_logger(bus, "audio", "🎢 audio pipeline PLAYING"); spawn_audio_source_watchdog( pipeline.clone(), @@ -465,6 +486,12 @@ pub struct Voice { tap: ClipTap, } +impl Drop for Voice { + fn drop(&mut self) { + let _ = self._pipe.set_state(gst::State::Null); + } +} + fn voice_input_caps() -> gst::Caps { gst::Caps::builder("audio/mpeg") .field("mpegversion", 4i32) @@ -494,7 +521,7 @@ impl Voice { .context("make fakesink")?; pipeline.add_many(&[appsrc.upcast_ref(), &sink])?; gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?; - pipeline.set_state(gst::State::Playing)?; + start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; Ok(Self { appsrc, @@ -582,31 +609,6 @@ impl Voice { }); let bus = pipeline.bus().context("voice pipeline bus")?; - std::thread::spawn(move || { - for msg in bus.iter_timed(gst::ClockTime::NONE) { - match msg.view() { - Error(e) => error!( - "🎀πŸ’₯ voice pipeline from {:?}: {} ({})", - msg.src().map(gst::prelude::GstObjectExt::path_string), - e.error(), - e.debug().unwrap_or_default() - ), - Warning(w) => warn!( - "🎀⚠️ voice pipeline from {:?}: {} ({})", - msg.src().map(gst::prelude::GstObjectExt::path_string), - w.error(), - w.debug().unwrap_or_default() - ), - StateChanged(s) - if s.current() == gst::State::Playing - && msg.src().map(|s| s.is::()).unwrap_or(false) => - { - debug!("🎀 voice pipeline ▢️") - } - _ => {} - } - } - }); // underrun β‰  error – just show a warning // let _id = alsa_sink.connect("underrun", false, |_| { @@ -614,7 +616,8 @@ impl Voice { // None // }); - pipeline.set_state(gst::State::Playing)?; + start_pipeline_or_reset(&pipeline, "starting voice pipeline")?; + spawn_pipeline_bus_logger(bus, "voice", "🎀 voice pipeline ▢️"); Ok(Self { appsrc, diff --git a/server/src/camera.rs b/server/src/camera.rs index a888810..7aa9d16 100644 --- a/server/src/camera.rs +++ b/server/src/camera.rs @@ -193,13 +193,24 @@ fn parse_camera_output(raw: &str) -> Option { fn select_hdmi_config(hdmi: Option) -> CameraConfig { let hw_decode = has_hw_h264_decode(); - let (width, height) = if hw_decode { (1920, 1080) } else { (1280, 720) }; - let fps = 30; + let (default_width, default_height) = if hw_decode { (1920, 1080) } else { (1280, 720) }; + let width = read_u32_from_env("LESAVKA_CAM_WIDTH").unwrap_or(default_width); + let height = read_u32_from_env("LESAVKA_CAM_HEIGHT").unwrap_or(default_height); + let fps = read_u32_from_env("LESAVKA_CAM_FPS").unwrap_or(30).max(1); #[cfg(not(coverage))] if !hw_decode { - warn!( - "πŸ“· HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink" - ); + if width == default_width && height == default_height { + warn!( + "πŸ“· HDMI output: hardware H264 decoder not detected; requesting 720p30 camera uplink" + ); + } else { + warn!( + width, + height, + fps, + "πŸ“· HDMI output: hardware H264 decoder not detected; using configured camera uplink size" + ); + } } CameraConfig { output: CameraOutput::Hdmi, @@ -490,6 +501,25 @@ mod tests { }); } + #[test] + #[serial] + fn hdmi_camera_profile_honors_installed_1080p_override() { + with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { + with_var("LESAVKA_CAM_WIDTH", Some("1920"), || { + with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || { + with_var("LESAVKA_CAM_FPS", Some("30"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Hdmi); + assert_eq!(cfg.codec, CameraCodec::H264); + assert_eq!(cfg.width, 1920); + assert_eq!(cfg.height, 1080); + assert_eq!(cfg.fps, 30); + }); + }); + }); + }); + } + #[test] fn hdmi_mode_parsing_accepts_sysfs_and_override_shapes() { assert_eq!( diff --git a/server/src/video_sinks.rs b/server/src/video_sinks.rs index 79bc99a..082202f 100644 --- a/server/src/video_sinks.rs +++ b/server/src/video_sinks.rs @@ -3,6 +3,8 @@ use gstreamer as gst; use gstreamer::prelude::*; use gstreamer_app as gst_app; use lesavka_common::lesavka::VideoPacket; +use std::fs; +use std::path::Path; use std::sync::atomic::AtomicU64; use tracing::warn; @@ -423,12 +425,20 @@ fn build_hdmi_sink(_cfg: &CameraConfig) -> anyhow::Result { #[cfg(not(coverage))] fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result { if let Ok(name) = std::env::var("LESAVKA_HDMI_SINK") { - return gst::ElementFactory::make(&name) + let normalized = name.trim().to_ascii_lowercase(); + if normalized == "fbdev" || normalized == "fbdevsink" { + return build_fbdev_hdmi_sink(); + } + + let sink = gst::ElementFactory::make(&name) .build() - .context("building HDMI sink"); + .context("building HDMI sink")?; + disable_sink_clock_sync(&sink); + return Ok(sink); } if gst::ElementFactory::find("kmssink").is_some() { + unblank_framebuffer(&hdmi_fbdev_device()); let sink = gst::ElementFactory::make("kmssink").build()?; if sink.has_property("driver-name", None) { let driver = std::env::var("LESAVKA_HDMI_DRIVER").unwrap_or_else(|_| "vc4".to_string()); @@ -448,17 +458,98 @@ fn build_hdmi_sink(cfg: &CameraConfig) -> anyhow::Result { if sink.has_property("force-modesetting", None) { sink.set_property("force-modesetting", &true); } - sink.set_property("sync", &false); + if sink.has_property("restore-crtc", None) { + let restore = read_bool_env("LESAVKA_HDMI_RESTORE_CRTC").unwrap_or(false); + sink.set_property("restore-crtc", &restore); + } + if sink.has_property("skip-vsync", None) { + let skip = read_bool_env("LESAVKA_HDMI_SKIP_VSYNC").unwrap_or(false); + sink.set_property("skip-vsync", &skip); + } + disable_sink_clock_sync(&sink); return Ok(sink); } let sink = gst::ElementFactory::make("autovideosink") .build() .context("building HDMI sink")?; - let _ = sink.set_property("sync", &false); + disable_sink_clock_sync(&sink); Ok(sink) } +#[cfg(not(coverage))] +fn build_fbdev_hdmi_sink() -> anyhow::Result { + let device = hdmi_fbdev_device(); + unblank_framebuffer(&device); + + let sink = gst::ElementFactory::make("fbdevsink") + .property("device", &device) + .build() + .context("building framebuffer HDMI sink")?; + disable_sink_clock_sync(&sink); + + tracing::info!( + target: "lesavka_server::video", + %device, + "πŸ“Ί HDMI sink using framebuffer scanout" + ); + + Ok(sink) +} + +#[cfg(not(coverage))] +fn hdmi_fbdev_device() -> String { + std::env::var("LESAVKA_HDMI_FBDEV") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "/dev/fb0".to_string()) +} + +#[cfg(not(coverage))] +fn unblank_framebuffer(device: &str) { + let Some(name) = Path::new(device) + .file_name() + .and_then(|value| value.to_str()) + else { + return; + }; + if !name.starts_with("fb") { + return; + } + + let blank_path = format!("/sys/class/graphics/{name}/blank"); + match fs::write(&blank_path, b"0\n") { + Ok(()) => tracing::debug!( + target: "lesavka_server::video", + %device, + %blank_path, + "πŸ“Ί HDMI framebuffer unblanked" + ), + Err(error) => tracing::debug!( + target: "lesavka_server::video", + %device, + %blank_path, + %error, + "πŸ“Ί HDMI framebuffer unblank skipped" + ), + } +} + +fn disable_sink_clock_sync(sink: &gst::Element) { + if sink.has_property("sync", None) { + sink.set_property("sync", &false); + } +} + +fn read_bool_env(name: &str) -> Option { + let value = std::env::var(name).ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + enum CameraSink { Uvc(WebcamSink), Hdmi(HdmiSink), diff --git a/testing/build.rs b/testing/build.rs index 737f34f..76935c8 100644 --- a/testing/build.rs +++ b/testing/build.rs @@ -66,6 +66,14 @@ fn main() { .join("client/src/output/video.rs") .canonicalize() .expect("canonical client output video path"); + let client_video_support = workspace_dir + .join("client/src/video_support.rs") + .canonicalize() + .expect("canonical client video_support path"); + let client_relayctl = workspace_dir + .join("client/src/bin/lesavka-relayctl.rs") + .canonicalize() + .expect("canonical client relayctl bin path"); let common_cli = workspace_dir .join("common/src/bin/cli.rs") .canonicalize() @@ -131,6 +139,14 @@ fn main() { "cargo:rustc-env=LESAVKA_CLIENT_OUTPUT_VIDEO_SRC={}", client_output_video.display() ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_VIDEO_SUPPORT_SRC={}", + client_video_support.display() + ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_RELAYCTL_BIN_SRC={}", + client_relayctl.display() + ); println!( "cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}", common_cli.display() diff --git a/testing/tests/client_app_include_contract.rs b/testing/tests/client_app_include_contract.rs index 9880689..1071f9c 100644 --- a/testing/tests/client_app_include_contract.rs +++ b/testing/tests/client_app_include_contract.rs @@ -228,6 +228,8 @@ mod tests { use temp_env::with_var; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; + const APP_SRC: &str = include_str!("../../client/src/app.rs"); + #[test] #[serial] fn run_headless_reaches_pending_reactor_branch() { @@ -372,4 +374,12 @@ mod tests { "stale mouse packets should be dropped locally" ); } + + #[test] + fn audio_loop_backoff_contract_protects_server_from_reconnect_storms() { + assert!(APP_SRC.contains("let mut delay = Duration::from_secs(1);")); + assert!(APP_SRC.contains("tokio::time::sleep(delay).await;")); + assert!(APP_SRC.contains("delay = app_support::next_delay(delay);")); + assert!(APP_SRC.contains("consecutive_source_failures = 0;")); + } } diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 7c83930..376ad06 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -30,15 +30,15 @@ fn source_index(needle: &str) -> usize { #[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_eq!(const_i32("LAUNCHER_DEFAULT_HEIGHT"), 940); 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_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 568); + assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 320); assert!( UI_SRC.contains("caption_label.set_halign(gtk::Align::End)") || UI_SRC.contains("capture_label.set_halign(gtk::Align::End)") @@ -117,13 +117,42 @@ fn relay_controls_keep_connect_inline_with_server_entry() { } #[test] -fn remote_audio_gain_control_stays_in_the_operations_rail() { +fn media_controls_own_stream_toggles_and_inline_gain_controls() { assert!(!UI_SRC.contains("Remote Audio")); assert!(UI_SRC.contains("let power_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);")); - assert!(UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); - assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Webcam\")")); + assert!(!UI_SRC.contains("let channel_heading = gtk::Label::new(Some(\"Streams\"));")); + assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Camera\")")); assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Mic\")")); - assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Audio\")")); + assert!(UI_SRC.contains("gtk::CheckButton::with_label(\"Speaker\")")); + assert!(UI_SRC.contains("let camera_quality_combo = gtk::ComboBoxText::new();")); + assert!(UI_SRC.contains("sync_camera_quality_combo(")); + assert!(UI_SRC.contains("camera_quality_combo.set_size_request(88, -1);")); + assert!( + UI_SRC.contains("let camera_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + ); + assert!(UI_SRC.contains("camera_selectors.append(&camera_combo);")); + assert!(UI_SRC.contains("camera_selectors.append(&camera_quality_combo);")); + assert!( + source_index("camera_selectors.append(&camera_combo);") + < source_index("camera_selectors.append(&camera_quality_combo);") + ); + assert!( + UI_SRC.contains("let speaker_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + ); + assert!(UI_SRC.contains("speaker_selectors.append(&speaker_combo);")); + assert!(UI_SRC.contains("speaker_selectors.append(&audio_gain_scale);")); + assert!( + UI_SRC + .contains("let microphone_selectors = gtk::Box::new(gtk::Orientation::Horizontal, 6);") + ); + assert!(UI_SRC.contains("microphone_selectors.append(µphone_combo);")); + assert!(UI_SRC.contains("microphone_selectors.append(&mic_gain_scale);")); + assert_eq!( + UI_SRC + .matches("attach_device_control_row(\n &media_grid") + .count(), + 3 + ); assert!(!UI_SRC.contains("camera_combo.append(Some(\"auto\")")); assert!(!UI_SRC.contains("speaker_combo.append(Some(\"auto\")")); assert!(!UI_SRC.contains("microphone_combo.append(Some(\"auto\")")); @@ -132,14 +161,14 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() { assert!(UI_SRC.contains("power_buttons.set_homogeneous(true);")); assert!(UI_SRC.contains("let audio_gain_scale =")); assert!(UI_SRC.contains("audio_gain_scale.set_draw_value(false);")); - assert!(UI_SRC.contains("audio_gain_value.set_width_chars(5);")); + assert!(UI_SRC.contains("audio_gain_scale.set_size_request(96, -1);")); assert!(UI_SRC.contains("let mic_gain_scale =")); assert!(UI_SRC.contains("mic_gain_scale.set_draw_value(false);")); - assert!(UI_SRC.contains("mic_gain_value.set_width_chars(5);")); - assert!(UI_SRC.contains("audio_gain_row.set_size_request(220, -1);")); - assert!(UI_SRC.contains("mic_gain_row.set_size_request(220, -1);")); - assert!(UI_SRC.contains("power_shell.append(&audio_gain_row);")); - assert!(UI_SRC.contains("power_shell.append(&mic_gain_row);")); + assert!(UI_SRC.contains("mic_gain_scale.set_size_request(96, -1);")); + assert!(!UI_SRC.contains("audio_gain_row.set_size_request(220, -1);")); + assert!(!UI_SRC.contains("mic_gain_row.set_size_request(220, -1);")); + assert!(!UI_SRC.contains("power_shell.append(&audio_gain_row);")); + assert!(!UI_SRC.contains("power_shell.append(&mic_gain_row);")); assert_eq!( UI_SRC .matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));") @@ -149,14 +178,10 @@ fn remote_audio_gain_control_stays_in_the_operations_rail() { ); assert!( source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") - < source_index("let audio_gain_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); assert!( - source_index("power_shell.append(&audio_gain_row);") - < source_index("power_shell.append(&mic_gain_row);") - ); - assert!( - source_index("power_shell.append(&mic_gain_row);") + source_index("power_shell.append(&power_row);") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); assert!(UI_SRC.contains("routing_buttons.set_homogeneous(true);")); diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index ed0548e..bfac7e8 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -38,6 +38,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() { ".camera_combo\n .set_sensitive(!relay_live && state.channels.camera);" ) ); + assert!(UI_RUNTIME_SRC.contains(".camera_quality_combo")); + assert!(UI_RUNTIME_SRC.contains("widgets.camera_quality_combo.set_sensitive(")); + assert!(UI_RUNTIME_SRC.contains("state.devices.camera.is_some()")); + assert!(UI_RUNTIME_SRC.contains("state.camera_quality.is_some()")); assert!(UI_RUNTIME_SRC.contains( ".microphone_combo\n .set_sensitive(!relay_live && state.channels.microphone);" )); @@ -46,6 +50,10 @@ fn relay_address_entry_is_locked_while_relay_is_live() { ".speaker_combo\n .set_sensitive(!relay_live && state.channels.audio);" ) ); + assert!(UI_RUNTIME_SRC.contains(".audio_gain_scale")); + assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.audio);")); + assert!(UI_RUNTIME_SRC.contains(".mic_gain_scale")); + assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);")); assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);")); @@ -98,3 +106,16 @@ fn active_relay_keeps_local_upstream_camera_and_microphone_evidence_visible() { assert!(MICROPHONE_SRC.contains("appsink name=level_sink")); assert!(MICROPHONE_SRC.contains("spawn_mic_level_tap")); } + +#[test] +fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() { + assert!(UI_SRC.contains("selected_camera_quality(&camera_quality_combo")); + assert!(UI_SRC.contains("sync_camera_quality_selection")); + assert!(UI_SRC.contains("tests.set_camera_quality")); + assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality")); + assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)")); + assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw")); + assert!(CAMERA_SRC.contains("env_u32(\"LESAVKA_CAM_WIDTH\", cfg.map_or")); + assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\"")); + assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\"")); +} diff --git a/testing/tests/client_output_audio_include_contract.rs b/testing/tests/client_output_audio_include_contract.rs index fff283e..0e831a2 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/testing/tests/client_output_audio_include_contract.rs @@ -231,4 +231,49 @@ exit 0 fs::write(&path, "bad nonce\n").expect("write invalid gain"); assert_eq!(read_audio_gain_control(&path), None); } + + #[test] + fn audio_gain_parsing_and_formatting_are_stable() { + assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5)); + assert_eq!(parse_audio_gain("99"), Some(MAX_AUDIO_GAIN)); + assert_eq!(parse_audio_gain("-1"), Some(0.0)); + assert_eq!(parse_audio_gain("nan"), None); + assert_eq!(parse_audio_gain(""), None); + assert_eq!(format_audio_gain_for_gst(2.1256), "2.126"); + } + + #[test] + fn sink_override_escaping_and_pactl_ranking_are_stable() { + assert_eq!(normalize_sink_override("autoaudiosink"), "autoaudiosink"); + assert_eq!( + normalize_sink_override("fakesink sync=false"), + "fakesink sync=false" + ); + let normal = normalize_sink_override("alsa_output.pci"); + assert!(normal.contains("pulsesink device=\"alsa_output.pci\"")); + assert!(normal.contains("buffer-time=350000")); + let bluetooth = pulsesink_device_element("bluez_output.headset"); + assert!(bluetooth.contains("buffer-time=750000")); + let escaped = pulsesink_device_element("sink\\\"name"); + assert!(escaped.contains("sink\\\\\\\"name")); + + assert_eq!( + parse_pactl_default_sink("Server: x\nDefault Sink: my.default \n"), + Some("my.default".to_string()) + ); + assert_eq!(parse_pactl_default_sink("Default Sink: \n"), None); + let sinks = parse_pactl_short_sinks( + "bad\n1 idle.sink module IDLE\n2 run.sink module RUNNING\n3 suspended.sink module SUSPENDED\n", + Some("missing.default"), + ); + assert_eq!( + sinks[0], + ("missing.default".to_string(), "DEFAULT".to_string()) + ); + assert_eq!(sinks[1], ("run.sink".to_string(), "RUNNING".to_string())); + assert_eq!(sink_state_rank("RUNNING"), 0); + assert_eq!(sink_state_rank("IDLE"), 1); + assert_eq!(sink_state_rank("SUSPENDED"), 2); + assert_eq!(sink_state_rank("UNKNOWN"), 3); + } } diff --git a/testing/tests/client_relayctl_binary_contract.rs b/testing/tests/client_relayctl_binary_contract.rs new file mode 100644 index 0000000..b74e27a --- /dev/null +++ b/testing/tests/client_relayctl_binary_contract.rs @@ -0,0 +1,49 @@ +//! Include-based coverage for relay control CLI parsing and helpers. +//! +//! Scope: include `client/src/bin/lesavka-relayctl.rs` and exercise helper +//! branches that do not require a live relay server. +//! Targets: `client/src/bin/lesavka-relayctl.rs`. +//! Why: relay power recovery controls need parser coverage without depending on +//! a live relay endpoint. + +#[allow(warnings)] +mod relayctl_binary { + include!(env!("LESAVKA_CLIENT_RELAYCTL_BIN_SRC")); + + #[test] + fn command_aliases_and_usage_are_stable() { + assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status)); + assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); + assert_eq!(CommandKind::parse("auto"), Some(CommandKind::Auto)); + assert_eq!(CommandKind::parse("on"), Some(CommandKind::On)); + assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On)); + assert_eq!(CommandKind::parse("off"), Some(CommandKind::Off)); + assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off)); + assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb)); + assert_eq!( + CommandKind::parse("recover-usb"), + Some(CommandKind::ResetUsb) + ); + assert_eq!(CommandKind::parse("bad"), None); + assert!(usage().contains("lesavka-relayctl")); + } + + #[tokio::test(flavor = "current_thread")] + async fn connect_rejects_invalid_endpoint_without_network_retry() { + let err = connect("not a uri").await.expect_err("invalid endpoint"); + assert!(err.to_string().contains("invalid relay server address")); + } + + #[test] + fn print_state_is_non_panicking_for_populated_payload() { + print_state(lesavka_common::lesavka::CapturePowerState { + available: true, + enabled: true, + mode: "manual".to_string(), + detected_devices: 2, + active_leases: 1, + unit: "lesavka-capture.service".to_string(), + detail: "ok".to_string(), + }); + } +} diff --git a/testing/tests/client_relayctl_process_contract.rs b/testing/tests/client_relayctl_process_contract.rs new file mode 100644 index 0000000..9eb2d87 --- /dev/null +++ b/testing/tests/client_relayctl_process_contract.rs @@ -0,0 +1,65 @@ +//! Process-level coverage for relay control CLI argument handling. +//! +//! Scope: launch the real `lesavka-relayctl` binary for argument-only paths. +//! Targets: `client/src/bin/lesavka-relayctl.rs`. +//! Why: CLI-only failures should stay fast and local instead of retrying a bad +//! network endpoint. + +use serial_test::serial; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn candidate_dirs() -> Vec { + let exe = std::env::current_exe().expect("current exe path"); + let mut dirs = Vec::new(); + if let Some(parent) = exe.parent() { + dirs.push(parent.to_path_buf()); + if let Some(grand) = parent.parent() { + dirs.push(grand.to_path_buf()); + } + } + dirs.push(PathBuf::from("target/debug")); + dirs.push(PathBuf::from("target/llvm-cov-target/debug")); + dirs +} + +fn find_binary(name: &str) -> Option { + candidate_dirs() + .into_iter() + .map(|dir| dir.join(name)) + .find(|path| path.exists() && path.is_file()) +} + +#[test] +#[serial] +fn relayctl_help_exits_successfully() { + let Some(bin) = find_binary("lesavka-relayctl") else { + return; + }; + + let output = Command::new(Path::new(&bin)) + .arg("--help") + .output() + .expect("spawn lesavka-relayctl --help"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("lesavka-relayctl")); +} + +#[test] +#[serial] +fn relayctl_rejects_bad_arguments_before_network_use() { + let Some(bin) = find_binary("lesavka-relayctl") else { + return; + }; + + let output = Command::new(Path::new(&bin)) + .arg("nonsense") + .output() + .expect("spawn lesavka-relayctl bad command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unknown command")); +} diff --git a/testing/tests/client_video_support_include_contract.rs b/testing/tests/client_video_support_include_contract.rs new file mode 100644 index 0000000..8f7f646 --- /dev/null +++ b/testing/tests/client_video_support_include_contract.rs @@ -0,0 +1,31 @@ +//! Module-path coverage for client-side H.264 decoder selection. +//! +//! Scope: include the client decoder selection helper directly. +//! Targets: `client/src/video_support.rs`. +//! Why: operator decoder overrides should fall back cleanly on machines with +//! different GStreamer plugin sets. + +#[path = "../../client/src/video_support.rs"] +mod video_support; + +use serial_test::serial; +use temp_env::with_var; + +#[test] +#[serial] +fn decoder_override_accepts_decodebin_without_factory_lookup() { + with_var("LESAVKA_H264_DECODER", Some("decodebin"), || { + assert_eq!(video_support::pick_h264_decoder(), "decodebin"); + }); +} + +#[test] +#[serial] +fn decoder_override_ignores_blank_or_unknown_values() { + with_var("LESAVKA_H264_DECODER", Some(" "), || { + assert!(!video_support::pick_h264_decoder().trim().is_empty()); + }); + with_var("LESAVKA_H264_DECODER", Some("not-a-real-decoder"), || { + assert!(!video_support::pick_h264_decoder().trim().is_empty()); + }); +} diff --git a/testing/tests/server_audio_include_contract.rs b/testing/tests/server_audio_include_contract.rs index 1bdaf0f..e62d675 100644 --- a/testing/tests/server_audio_include_contract.rs +++ b/testing/tests/server_audio_include_contract.rs @@ -11,12 +11,47 @@ mod server_audio_contract; mod tests { - use super::server_audio_contract::{ClipTap, Voice, ear}; + use super::server_audio_contract::{ClipTap, Voice, ear, start_pipeline_or_reset}; #[cfg(coverage)] use futures_util::StreamExt; + use gstreamer as gst; + use gstreamer::prelude::*; use lesavka_common::lesavka::AudioPacket; use serial_test::serial; + const AUDIO_SRC: &str = include_str!("../../server/src/audio.rs"); + + fn source_index(needle: &str) -> usize { + AUDIO_SRC + .find(needle) + .unwrap_or_else(|| panic!("missing source marker: {needle}")) + } + + #[test] + fn failed_pipeline_start_contract_resets_and_does_not_spawn_bus_first() { + assert!(AUDIO_SRC.contains("let _ = pipeline.set_state(gst::State::Null);")); + assert!(AUDIO_SRC.contains("impl Drop for Voice")); + assert!( + source_index("start_pipeline_or_reset(&pipeline, \"starting audio pipeline\")?") + < source_index("spawn_pipeline_bus_logger(bus, \"audio\"") + ); + assert!( + source_index("start_pipeline_or_reset(&pipeline, \"starting voice pipeline\")?") + < source_index("spawn_pipeline_bus_logger(bus, \"voice\"") + ); + } + + #[test] + #[serial] + fn start_pipeline_or_reset_starts_empty_pipeline() { + let _ = gst::init(); + let pipeline = gst::Pipeline::new(); + start_pipeline_or_reset(&pipeline, "starting contract pipeline") + .expect("empty pipeline should enter playing"); + assert_eq!(pipeline.current_state(), gst::State::Playing); + let _ = pipeline.set_state(gst::State::Null); + } + #[test] #[serial] fn ear_rejects_malformed_pipeline_device_string() { diff --git a/testing/tests/server_camera_contract.rs b/testing/tests/server_camera_contract.rs index 8527cbe..0e529cc 100644 --- a/testing/tests/server_camera_contract.rs +++ b/testing/tests/server_camera_contract.rs @@ -93,6 +93,25 @@ fn camera_config_forced_hdmi_tracks_cached_state() { }); } +#[test] +#[serial] +fn camera_config_forced_hdmi_honors_1080p_uplink_override() { + with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { + with_var("LESAVKA_CAM_WIDTH", Some("1920"), || { + with_var("LESAVKA_CAM_HEIGHT", Some("1080"), || { + with_var("LESAVKA_CAM_FPS", Some("30"), || { + let cfg = update_camera_config(); + assert_eq!(cfg.output, CameraOutput::Hdmi); + assert_eq!(cfg.codec, CameraCodec::H264); + assert_eq!(cfg.width, 1920); + assert_eq!(cfg.height, 1080); + assert_eq!(cfg.fps, 30); + }); + }); + }); + }); +} + #[test] #[serial] fn camera_config_output_override_is_case_insensitive() { diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs new file mode 100644 index 0000000..6c19772 --- /dev/null +++ b/testing/tests/server_install_script_contract.rs @@ -0,0 +1,33 @@ +//! Contract tests for server install-time operational defaults. +//! +//! Scope: statically guard the generated `/etc/lesavka/server.env` values. +//! Targets: `scripts/install/server.sh`. +//! Why: HDMI capture adapter settings should be reproducible after reboot or +//! reinstall instead of living as one-off shell state. + +const SERVER_INSTALL: &str = include_str!("../../scripts/install/server.sh"); + +#[test] +fn server_install_pins_hdmi_camera_and_display_defaults() { + for expected in [ + "LESAVKA_CAM_OUTPUT=%s", + "LESAVKA_CAM_WIDTH=%s", + "LESAVKA_CAM_HEIGHT=%s", + "LESAVKA_CAM_FPS=%s", + "LESAVKA_HDMI_WIDTH=%s", + "LESAVKA_HDMI_HEIGHT=%s", + "LESAVKA_HDMI_SINK=%s", + "LESAVKA_HDMI_FBDEV=%s", + ] { + assert!( + SERVER_INSTALL.contains(expected), + "install script should emit {expected}" + ); + } + assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_WIDTH:-1920}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_HEIGHT:-1080}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_CAM_FPS:-30}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_WIDTH:-1920}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_HEIGHT:-1080}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_SINK:-fbdevsink}")); +} diff --git a/testing/tests/server_main_process_contract.rs b/testing/tests/server_main_process_contract.rs index 95ebf94..17be176 100644 --- a/testing/tests/server_main_process_contract.rs +++ b/testing/tests/server_main_process_contract.rs @@ -1,16 +1,26 @@ //! Integration coverage for server process startup behavior. //! -//! Scope: launch the real `lesavka-server` binary and assert startup reaches a -//! terminal state quickly in this non-gadget test environment. +//! Scope: launch the real `lesavka-server` binary and assert startup stays +//! resilient in this non-gadget test environment. //! Targets: `server/src/main.rs`. -//! Why: process-level boot behavior should remain deterministic when required -//! gadget endpoints are unavailable. +//! Why: missing gadget endpoints should not crash the relay; the server keeps +//! running and opens HID lazily when the device nodes appear. use serial_test::serial; +use std::fs; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant}; +fn cargo_binary(name: &str) -> Option { + let key = format!("CARGO_BIN_EXE_{name}"); + option_env!("CARGO_BIN_EXE_lesavka-server") + .filter(|_| name == "lesavka-server") + .map(PathBuf::from) + .or_else(|| std::env::var_os(key).map(PathBuf::from)) + .filter(|path| path.exists() && path.is_file()) +} + fn candidate_dirs() -> Vec { let exe = std::env::current_exe().expect("current exe path"); let mut dirs = Vec::new(); @@ -26,18 +36,51 @@ fn candidate_dirs() -> Vec { } fn find_binary(name: &str) -> Option { - candidate_dirs() - .into_iter() - .map(|dir| dir.join(name)) - .find(|path| path.exists() && path.is_file()) + cargo_binary(name).or_else(|| { + candidate_dirs() + .into_iter() + .map(|dir| dir.join(name)) + .find(|path| path.exists() && path.is_file()) + }) +} + +fn server_package_version() -> Option { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent()? + .join("server/Cargo.toml"); + fs::read_to_string(manifest).ok()?.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("version") + .and_then(|value| value.split('=').nth(1)) + .map(str::trim) + .and_then(|value| value.strip_prefix('"')?.strip_suffix('"')) + .map(str::to_string) + }) +} + +fn wait_for_log(path: &PathBuf, needle: &str, deadline: Instant) -> String { + loop { + let log = fs::read_to_string(path).unwrap_or_default(); + if log.contains(needle) { + return log; + } + assert!( + Instant::now() < deadline, + "timed out waiting for log line {needle:?}; log was:\n{log}" + ); + std::thread::sleep(Duration::from_millis(50)); + } } #[test] #[serial] -fn server_binary_exits_quickly_without_hid_nodes() { +fn server_binary_stays_up_with_missing_hid_nodes_and_current_version() { let Some(bin) = find_binary("lesavka-server") else { return; }; + let log_path = PathBuf::from("/tmp/lesavka-server.log"); + let _ = fs::remove_file(&log_path); let mut child = Command::new(bin) .env("LESAVKA_DISABLE_UVC", "1") @@ -45,18 +88,22 @@ fn server_binary_exits_quickly_without_hid_nodes() { .expect("spawn lesavka-server"); let deadline = Instant::now() + Duration::from_secs(3); - loop { - if let Some(status) = child.try_wait().expect("poll child") { - assert!( - !status.success(), - "server unexpectedly succeeded in test environment" - ); - break; - } - if Instant::now() >= deadline { - let _ = child.kill(); - panic!("server did not terminate within startup timeout"); - } - std::thread::sleep(Duration::from_millis(50)); + if let Some(version) = server_package_version() { + let _ = wait_for_log( + &log_path, + &format!("lesavka_server v{version} starting up"), + deadline, + ); } + let log = wait_for_log( + &log_path, + "HID endpoints are not ready; relay will keep running and open them lazily", + deadline, + ); + assert!( + child.try_wait().expect("poll child").is_none(), + "server should stay alive with lazy HID recovery; log was:\n{log}" + ); + let _ = child.kill(); + let _ = child.wait(); } diff --git a/testing/tests/server_video_sinks_include_contract.rs b/testing/tests/server_video_sinks_include_contract.rs index 8e70cc9..a41fa4a 100644 --- a/testing/tests/server_video_sinks_include_contract.rs +++ b/testing/tests/server_video_sinks_include_contract.rs @@ -70,6 +70,33 @@ mod video_sinks_include_contract { }); } + #[test] + #[serial] + #[cfg(not(coverage))] + fn build_hdmi_sink_configures_fbdev_override_for_capture_adapters() { + init_gst(); + if gst::ElementFactory::find("fbdevsink").is_none() { + return; + } + + with_var("LESAVKA_HDMI_SINK", Some("fbdevsink"), || { + with_var("LESAVKA_HDMI_FBDEV", Some("/dev/fb42"), || { + let sink = build_hdmi_sink(&cfg(CameraCodec::H264)) + .expect("fbdevsink override should build"); + + if sink.has_property("device", None) { + assert_eq!(sink.property::("device"), "/dev/fb42"); + } + if sink.has_property("sync", None) { + assert!( + !sink.property::("sync"), + "fbdev HDMI output should not clock-sync WAN camera frames" + ); + } + }); + }); + } + #[test] #[serial] fn build_hdmi_sink_invalid_override_surfaces_error() { @@ -112,25 +139,47 @@ mod video_sinks_include_contract { with_var("LESAVKA_HDMI_SINK", None::<&str>, || { with_var("LESAVKA_HDMI_DRIVER", Some("vc4"), || { - let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264)) - .expect("kmssink should build"); + with_var("LESAVKA_HDMI_RESTORE_CRTC", None::<&str>, || { + let sink = build_hdmi_sink(&hdmi_cfg_with_ugreen_like_modes(CameraCodec::H264)) + .expect("kmssink should build"); - if sink.has_property("force-modesetting", None) { - assert!( - sink.property::("force-modesetting"), - "kmssink must drive the HDMI mode instead of relying on desktop state" - ); - } - if sink.has_property("connector-id", None) { - assert_eq!(sink.property::("connector-id"), 43); - } - if sink.has_property("driver-name", None) { - assert_eq!(sink.property::("driver-name"), "vc4"); - } + if sink.has_property("force-modesetting", None) { + assert!( + sink.property::("force-modesetting"), + "kmssink must drive the HDMI mode instead of relying on desktop state" + ); + } + if sink.has_property("restore-crtc", None) { + assert!( + !sink.property::("restore-crtc"), + "dedicated HDMI capture output should not restore the console CRTC" + ); + } + if sink.has_property("connector-id", None) { + assert_eq!(sink.property::("connector-id"), 43); + } + if sink.has_property("driver-name", None) { + assert_eq!(sink.property::("driver-name"), "vc4"); + } + }); }); }); } + #[test] + #[serial] + fn bool_env_parser_accepts_operator_friendly_values() { + with_var("LESAVKA_BOOL_TEST", Some("yes"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(true)); + }); + with_var("LESAVKA_BOOL_TEST", Some("off"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), Some(false)); + }); + with_var("LESAVKA_BOOL_TEST", Some("shrug"), || { + assert_eq!(read_bool_env("LESAVKA_BOOL_TEST"), None); + }); + } + #[test] #[serial] fn camera_sink_dispatch_is_stable_for_hdmi_variant() {