diff --git a/Cargo.lock b/Cargo.lock index 8600671..e31f3d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -495,7 +505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -508,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -1642,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.15.5" +version = "0.16.0" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.15.5" +version = "0.16.0" dependencies = [ "anyhow", "base64", @@ -1688,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.15.5" +version = "0.16.0" dependencies = [ "anyhow", "base64", @@ -2221,6 +2231,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-operations" version = "0.5.0" @@ -2574,6 +2590,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2615,6 +2645,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2639,6 +2716,15 @@ dependencies = [ "sdd", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -2670,6 +2756,29 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3088,6 +3197,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3218,8 +3337,10 @@ dependencies = [ "percent-encoding", "pin-project", "prost", + "rustls-native-certs", "socket2 0.5.10", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -3416,6 +3537,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "v4l" version = "0.14.0" @@ -4038,7 +4165,7 @@ dependencies = [ "calloop", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", diff --git a/client/Cargo.toml b/client/Cargo.toml index 180b12f..3f03ca6 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,12 +4,12 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.15.5" +version = "0.16.0" edition = "2024" [dependencies] tokio = { version = "1.45", features = ["full", "fs", "rt-multi-thread", "macros", "sync", "time"] } -tonic = { version = "0.13", features = ["transport"] } +tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] } tokio-stream = { version = "0.1", features = ["sync"] } anyhow = "1.0" lesavka_common = { path = "../common" } diff --git a/client/src/app.rs b/client/src/app.rs index f6ad717..42fe62d 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -26,7 +26,7 @@ use lesavka_common::lesavka::{ use crate::output::video::{MonitorWindow, UnifiedMonitorWindow}; use crate::{ app_support, handshake, input::camera::CameraCapture, input::inputs::InputAggregator, - input::microphone::MicrophoneCapture, output::audio::AudioOut, paste, + input::microphone::MicrophoneCapture, output::audio::AudioOut, paste, relay_transport, }; pub struct LesavkaClientApp { diff --git a/client/src/app/audio_recovery_config.rs b/client/src/app/audio_recovery_config.rs index 387b55b..e5f69a1 100644 --- a/client/src/app/audio_recovery_config.rs +++ b/client/src/app/audio_recovery_config.rs @@ -35,6 +35,50 @@ fn is_recoverable_remote_audio_error(message: &str) -> bool { || message.contains("remote speaker capture cadence is too low") } +#[cfg(not(coverage))] +#[derive(Debug, Default)] +struct AudioFailureLogLimiter { + last_warn_at: Option, + suppressed_repeats: u64, + last_message: String, +} + +#[cfg(not(coverage))] +/// Rate-limit repeated remote audio failures so operators see state changes, not log floods. +impl AudioFailureLogLimiter { + /// Emit the first failure promptly, then aggregate identical repeats. + fn record(&mut self, context: &'static str, message: &str) { + let same_message = self.last_message == message; + let should_warn = !same_message + || self + .last_warn_at + .map(|last| last.elapsed() >= AUDIO_FAILURE_WARN_INTERVAL) + .unwrap_or(true); + + if should_warn { + tracing::warn!( + context, + suppressed_repeats = self.suppressed_repeats, + "❌🔊 audio stream unhealthy: {message}" + ); + self.last_warn_at = Some(Instant::now()); + self.suppressed_repeats = 0; + self.last_message.clear(); + self.last_message.push_str(message); + } else { + self.suppressed_repeats = self.suppressed_repeats.saturating_add(1); + tracing::debug!( + context, + suppressed_repeats = self.suppressed_repeats, + "audio stream repeated unhealthy state suppressed from WARN noise: {message}" + ); + } + } +} + +#[cfg(not(coverage))] +const AUDIO_FAILURE_WARN_INTERVAL: Duration = Duration::from_secs(30); + pub(crate) fn keyboard_stream_report( report: Result, remote_capture_enabled: bool, diff --git a/client/src/app/downlink_media.rs b/client/src/app/downlink_media.rs index 3331ab0..d6e4213 100644 --- a/client/src/app/downlink_media.rs +++ b/client/src/app/downlink_media.rs @@ -72,6 +72,7 @@ impl LesavkaClientApp { let mut consecutive_source_failures = 0_u32; let mut last_usb_recovery_at: Option = None; let mut delay = Duration::from_secs(1); + let mut audio_failure_log = AudioFailureLogLimiter::default(); loop { let mut cli = RelayClient::new(ep.clone()); let req = MonitorRequest { @@ -117,7 +118,7 @@ impl LesavkaClientApp { } Ok(Err(err)) => { let message = err.to_string(); - tracing::warn!("❌🔊 audio stream recv error: {message}"); + audio_failure_log.record("recv", &message); Self::maybe_recover_audio_usb( &ep, &mut consecutive_source_failures, @@ -141,7 +142,7 @@ impl LesavkaClientApp { } Err(e) => { let message = e.to_string(); - tracing::warn!("❌🔊 audio stream err: {message}"); + audio_failure_log.record("connect", &message); Self::maybe_recover_audio_usb( &ep, &mut consecutive_source_failures, @@ -170,7 +171,7 @@ impl LesavkaClientApp { *consecutive_source_failures = consecutive_source_failures.saturating_add(1); let threshold = audio_usb_recover_after(); if *consecutive_source_failures < threshold { - tracing::warn!( + tracing::debug!( failures = *consecutive_source_failures, threshold, "🔊🛟 remote speaker capture is unhealthy; waiting before USB recovery" diff --git a/client/src/app/input_streams.rs b/client/src/app/input_streams.rs index 2f6afa4..c160323 100644 --- a/client/src/app/input_streams.rs +++ b/client/src/app/input_streams.rs @@ -34,6 +34,7 @@ impl LesavkaClientApp { /*──────────────── keyboard stream ───────────────*/ #[cfg(not(coverage))] async fn stream_loop_keyboard(&self, ep: Channel) { + let mut delay = INPUT_RECONNECT_BASE_DELAY; loop { info!("⌨️🤙 Keyboard dial {}", self.server_addr); let mut cli = RelayClient::new(ep.clone()); @@ -52,6 +53,7 @@ impl LesavkaClientApp { match cli.stream_keyboard(Request::new(outbound)).await { Ok(mut resp) => { + delay = INPUT_RECONNECT_BASE_DELAY; while let Some(msg) = resp.get_mut().message().await.transpose() { if let Err(e) = msg { warn!("⌨️ server err: {e}"); @@ -59,15 +61,19 @@ impl LesavkaClientApp { } } } - Err(e) => warn!("❌⌨️ connect failed: {e}"), + Err(e) => { + warn!("❌⌨️ connect failed: {e}"); + delay = app_support::next_delay(delay); + } } - tokio::time::sleep(Duration::from_secs(1)).await; // retry + tokio::time::sleep(delay).await; // retry } } /*──────────────── mouse stream ──────────────────*/ #[cfg(not(coverage))] async fn stream_loop_mouse(&self, ep: Channel) { + let mut delay = INPUT_RECONNECT_BASE_DELAY; loop { info!("🖱️🤙 Mouse dial {}", self.server_addr); let mut cli = RelayClient::new(ep.clone()); @@ -86,6 +92,7 @@ impl LesavkaClientApp { match cli.stream_mouse(Request::new(outbound)).await { Ok(mut resp) => { + delay = INPUT_RECONNECT_BASE_DELAY; while let Some(msg) = resp.get_mut().message().await.transpose() { if let Err(e) = msg { warn!("🖱️ server err: {e}"); @@ -93,10 +100,16 @@ impl LesavkaClientApp { } } } - Err(e) => warn!("❌🖱️ connect failed: {e}"), + Err(e) => { + warn!("❌🖱️ connect failed: {e}"); + delay = app_support::next_delay(delay); + } } - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(delay).await; } } } + +#[cfg(not(coverage))] +const INPUT_RECONNECT_BASE_DELAY: Duration = Duration::from_millis(250); diff --git a/client/src/app/session_lifecycle.rs b/client/src/app/session_lifecycle.rs index df00ea5..962a6aa 100644 --- a/client/src/app/session_lifecycle.rs +++ b/client/src/app/session_lifecycle.rs @@ -64,13 +64,13 @@ impl LesavkaClientApp { ); /*────────── persistent gRPC channels ──────────*/ - let hid_ep = Channel::from_shared(self.server_addr.clone())? + let hid_ep = relay_transport::endpoint(&self.server_addr)? .tcp_nodelay(true) .concurrency_limit(4) .http2_keep_alive_interval(Duration::from_secs(15)) .connect_lazy(); - let vid_ep = Channel::from_shared(self.server_addr.clone())? + let vid_ep = relay_transport::endpoint(&self.server_addr)? .initial_connection_window_size(4 << 20) .initial_stream_window_size(4 << 20) .tcp_nodelay(true) diff --git a/client/src/app/uplink_media.rs b/client/src/app/uplink_media.rs index 97a974b..dfab5c6 100644 --- a/client/src/app/uplink_media.rs +++ b/client/src/app/uplink_media.rs @@ -13,18 +13,25 @@ impl LesavkaClientApp { telemetry.record_reconnect_attempt(); let mut cli = RelayClient::new(ep.clone()); let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(AUDIO_UPLINK_QUEUE); + let drop_log = Arc::new(std::sync::Mutex::new(UplinkDropLogLimiter::new( + "microphone", + "🎤", + ))); let queue_stream = queue.clone(); let telemetry_stream = telemetry.clone(); + let drop_log_stream = Arc::clone(&drop_log); let outbound = async_stream::stream! { loop { let next = queue_stream.pop_fresh().await; if next.dropped_stale > 0 { telemetry_stream.record_stale_drop(next.dropped_stale); - warn!( - dropped_stale = next.dropped_stale, - queue_depth = next.queue_depth, - "🎤 upstream microphone queue dropped stale packets" + log_uplink_drop( + &drop_log_stream, + UplinkDropReason::Stale, + next.dropped_stale, + next.queue_depth, + duration_ms(next.delivery_age), ); } if let Some(packet) = next.packet { @@ -45,6 +52,7 @@ impl LesavkaClientApp { let mic_clone = mic.clone(); let telemetry_thread = telemetry.clone(); let queue_thread = queue.clone(); + let drop_log_thread = Arc::clone(&drop_log); let mic_worker = std::thread::spawn(move || { while stop_rx.try_recv().is_err() { if let Some(pkt) = mic_clone.pull() { @@ -53,11 +61,12 @@ impl LesavkaClientApp { let stats = queue_thread.push(pkt, enqueue_age); if stats.dropped_queue_full > 0 { telemetry_thread.record_queue_full_drop(stats.dropped_queue_full); - warn!( - dropped_queue_full = stats.dropped_queue_full, - queue_depth = stats.queue_depth, - enqueue_age_ms = duration_ms(enqueue_age), - "🎤 upstream microphone queue dropped the oldest packet because it was full" + log_uplink_drop( + &drop_log_thread, + UplinkDropReason::QueueFull, + stats.dropped_queue_full, + stats.queue_depth, + duration_ms(enqueue_age), ); } telemetry_thread.record_enqueue( @@ -106,18 +115,23 @@ impl LesavkaClientApp { telemetry.record_reconnect_attempt(); let mut cli = RelayClient::new(ep.clone()); let queue = crate::uplink_fresh_queue::FreshPacketQueue::new(VIDEO_UPLINK_QUEUE); + let drop_log = + Arc::new(std::sync::Mutex::new(UplinkDropLogLimiter::new("camera", "📸"))); let queue_stream = queue.clone(); let telemetry_stream = telemetry.clone(); + let drop_log_stream = Arc::clone(&drop_log); let outbound = async_stream::stream! { loop { let next = queue_stream.pop_fresh().await; if next.dropped_stale > 0 { telemetry_stream.record_stale_drop(next.dropped_stale); - warn!( - dropped_stale = next.dropped_stale, - queue_depth = next.queue_depth, - "📸 upstream camera queue dropped stale packets" + log_uplink_drop( + &drop_log_stream, + UplinkDropReason::Stale, + next.dropped_stale, + next.queue_depth, + duration_ms(next.delivery_age), ); } if let Some(packet) = next.packet { @@ -139,6 +153,7 @@ impl LesavkaClientApp { let cam = cam.clone(); let telemetry = telemetry.clone(); let queue = queue.clone(); + let drop_log = Arc::clone(&drop_log); move || loop { if stop_rx.try_recv().is_ok() { break; @@ -158,11 +173,12 @@ impl LesavkaClientApp { let stats = queue.push(pkt, enqueue_age); if stats.dropped_queue_full > 0 { telemetry.record_queue_full_drop(stats.dropped_queue_full); - warn!( - dropped_queue_full = stats.dropped_queue_full, - queue_depth = stats.queue_depth, - enqueue_age_ms = duration_ms(enqueue_age), - "📸 upstream camera queue dropped the oldest frame because it was full" + log_uplink_drop( + &drop_log, + UplinkDropReason::QueueFull, + stats.dropped_queue_full, + stats.queue_depth, + duration_ms(enqueue_age), ); } telemetry.record_enqueue( @@ -222,3 +238,92 @@ fn queue_depth_u32(depth: usize) -> u32 { fn duration_ms(duration: Duration) -> f32 { duration.as_secs_f32() * 1_000.0 } + +#[cfg(not(coverage))] +#[derive(Clone, Copy, Debug)] +enum UplinkDropReason { + QueueFull, + Stale, +} + +#[cfg(not(coverage))] +#[derive(Debug)] +struct UplinkDropLogLimiter { + stream: &'static str, + icon: &'static str, + last_warn_at: Option, + suppressed_full: u64, + suppressed_stale: u64, +} + +#[cfg(not(coverage))] +/// Aggregate freshness-first upstream drops into periodic warnings per stream. +impl UplinkDropLogLimiter { + fn new(stream: &'static str, icon: &'static str) -> Self { + Self { + stream, + icon, + last_warn_at: None, + suppressed_full: 0, + suppressed_stale: 0, + } + } + + /// Fold full-queue and stale-packet drops into one periodic warning. + fn record(&mut self, reason: UplinkDropReason, count: u64, queue_depth: usize, age_ms: f32) { + match reason { + UplinkDropReason::QueueFull => { + self.suppressed_full = self.suppressed_full.saturating_add(count) + } + UplinkDropReason::Stale => { + self.suppressed_stale = self.suppressed_stale.saturating_add(count) + } + } + + let should_warn = self + .last_warn_at + .map(|last| last.elapsed() >= UPLINK_DROP_WARN_INTERVAL) + .unwrap_or(true); + if should_warn { + warn!( + stream = self.stream, + dropped_queue_full = self.suppressed_full, + dropped_stale = self.suppressed_stale, + queue_depth, + latest_age_ms = age_ms, + "{} upstream {} queue is dropping stale/superseded packets to preserve live A/V sync", + self.icon, + self.stream + ); + self.suppressed_full = 0; + self.suppressed_stale = 0; + self.last_warn_at = Some(Instant::now()); + } else { + debug!( + stream = self.stream, + ?reason, + count, + queue_depth, + latest_age_ms = age_ms, + "upstream media queue drop suppressed from WARN noise" + ); + } + } +} + +#[cfg(not(coverage))] +const UPLINK_DROP_WARN_INTERVAL: Duration = Duration::from_secs(5); + +#[cfg(not(coverage))] +/// Report an upstream queue drop through the shared rate limiter. +fn log_uplink_drop( + limiter: &Arc>, + reason: UplinkDropReason, + count: u64, + queue_depth: usize, + age_ms: f32, +) { + if let Ok(mut limiter) = limiter.lock() { + limiter.record(reason, count, queue_depth, age_ms); + } +} diff --git a/client/src/app_support.rs b/client/src/app_support.rs index 7d27f9f..9c96dd3 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -5,7 +5,7 @@ use std::time::Duration; use crate::handshake::PeerCaps; use crate::input::camera::{CameraCodec, CameraConfig}; -pub const DEFAULT_SERVER_ADDR: &str = "http://38.28.125.112:50051"; +pub const DEFAULT_SERVER_ADDR: &str = "https://38.28.125.112:50051"; #[must_use] /// Resolve the server address from `--server`, positional args, env, or default. diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs index 448c367..d2cf6a2 100644 --- a/client/src/bin/lesavka-relayctl.rs +++ b/client/src/bin/lesavka-relayctl.rs @@ -8,6 +8,8 @@ use lesavka_common::lesavka::{ use tonic::Request; use tonic::transport::Channel; +use lesavka_client::relay_transport; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CommandKind { Status, @@ -115,8 +117,7 @@ fn capture_power_request(command: CommandKind) -> Option #[cfg(not(coverage))] async fn connect(server_addr: &str) -> Result> { - let channel = Channel::from_shared(server_addr.to_string()) - .context("invalid relay server address")? + let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await @@ -126,8 +127,7 @@ async fn connect(server_addr: &str) -> Result> { #[cfg(coverage)] async fn connect(server_addr: &str) -> Result> { - let channel = Channel::from_shared(server_addr.to_string()) - .context("invalid relay server address")? + let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect_lazy(); Ok(RelayClient::new(channel)) diff --git a/client/src/handshake.rs b/client/src/handshake.rs index 3e07e98..a0b521b 100644 --- a/client/src/handshake.rs +++ b/client/src/handshake.rs @@ -4,7 +4,7 @@ use lesavka_common::lesavka::{self as pb, handshake_client::HandshakeClient}; use std::time::{Duration, Instant}; use tokio::time::timeout; -use tonic::{Code, transport::Endpoint}; +use tonic::Code; use tracing::{info, warn}; #[derive(Default, Clone, Debug)] @@ -53,7 +53,7 @@ pub async fn negotiate(uri: &str) -> PeerCaps { return PeerCaps::default(); } - let ep = match Endpoint::from_shared(uri.to_owned()) { + let ep = match crate::relay_transport::endpoint(uri) { Ok(ep) => ep .tcp_nodelay(true) .http2_keep_alive_interval(Duration::from_secs(15)) @@ -97,7 +97,7 @@ pub async fn probe(uri: &str) -> HandshakeProbe { } let started = Instant::now(); - let ep = match Endpoint::from_shared(uri.to_owned()) { + let ep = match crate::relay_transport::endpoint(uri) { Ok(ep) => ep .tcp_nodelay(true) .http2_keep_alive_interval(Duration::from_secs(15)) @@ -155,7 +155,7 @@ pub async fn negotiate(uri: &str) -> PeerCaps { info!(%uri, "🤝 dial handshake"); let Some(hint) = likely_port_typo_hint(uri) else { - let ep = match Endpoint::from_shared(uri.to_owned()) { + let ep = match crate::relay_transport::endpoint(uri) { Ok(ep) => ep .tcp_nodelay(true) .http2_keep_alive_interval(Duration::from_secs(15)) @@ -270,7 +270,7 @@ pub async fn probe(uri: &str) -> HandshakeProbe { let Some(hint) = likely_port_typo_hint(uri) else { let started = Instant::now(); - let ep = match Endpoint::from_shared(uri.to_owned()) { + let ep = match crate::relay_transport::endpoint(uri) { Ok(ep) => ep .tcp_nodelay(true) .http2_keep_alive_interval(Duration::from_secs(15)) diff --git a/client/src/launcher/calibration.rs b/client/src/launcher/calibration.rs new file mode 100644 index 0000000..5928885 --- /dev/null +++ b/client/src/launcher/calibration.rs @@ -0,0 +1,121 @@ +use anyhow::{Context, Result}; +use lesavka_common::lesavka::{ + CalibrationAction, CalibrationRequest, Empty, relay_client::RelayClient, +}; +use tonic::{Request, transport::Channel}; + +use super::state::CalibrationStatus; +use crate::relay_transport; + +pub fn fetch_calibration(server_addr: &str) -> Result { + with_runtime(async move { + let mut client = connect(server_addr).await?; + let reply = client + .get_calibration(Request::new(Empty {})) + .await + .context("querying upstream A/V calibration")? + .into_inner(); + Ok(CalibrationStatus::from_proto(reply)) + }) +} + +pub fn restore_default_calibration(server_addr: &str) -> Result { + send_calibration_request( + server_addr, + CalibrationAction::RestoreDefault, + 0, + 0, + 0.0, + 0.0, + "restore saved default upstream A/V calibration", + ) +} + +pub fn restore_factory_calibration(server_addr: &str) -> Result { + send_calibration_request( + server_addr, + CalibrationAction::RestoreFactory, + 0, + 0, + 0.0, + 0.0, + "restore factory MJPEG upstream A/V calibration", + ) +} + +pub fn nudge_audio_calibration(server_addr: &str, delta_us: i64) -> Result { + send_calibration_request( + server_addr, + CalibrationAction::AdjustActive, + delta_us, + 0, + 0.0, + 0.0, + "manual upstream A/V calibration nudge", + ) +} + +pub fn blind_calibration_estimate( + server_addr: &str, + audio_delta_us: i64, + delivery_skew_ms: f32, + enqueue_skew_ms: f32, + note: &str, +) -> Result { + send_calibration_request( + server_addr, + CalibrationAction::BlindEstimate, + audio_delta_us, + 0, + delivery_skew_ms, + enqueue_skew_ms, + note, + ) +} + +fn send_calibration_request( + server_addr: &str, + action: CalibrationAction, + audio_delta_us: i64, + video_delta_us: i64, + observed_delivery_skew_ms: f32, + observed_enqueue_skew_ms: f32, + note: &str, +) -> Result { + with_runtime(async move { + let mut client = connect(server_addr).await?; + let reply = client + .calibrate(Request::new(CalibrationRequest { + action: action as i32, + audio_delta_us, + video_delta_us, + observed_delivery_skew_ms, + observed_enqueue_skew_ms, + note: note.to_string(), + })) + .await + .context("applying upstream A/V calibration")? + .into_inner(); + Ok(CalibrationStatus::from_proto(reply)) + }) +} + +fn with_runtime(future: F) -> Result +where + F: std::future::Future>, +{ + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("building launcher calibration runtime")? + .block_on(future) +} + +async fn connect(server_addr: &str) -> Result> { + let channel = relay_transport::endpoint(server_addr)? + .tcp_nodelay(true) + .connect() + .await + .context("connecting launcher to relay host")?; + Ok(RelayClient::new(channel)) +} diff --git a/client/src/launcher/clipboard.rs b/client/src/launcher/clipboard.rs index 5892a42..0da3d28 100644 --- a/client/src/launcher/clipboard.rs +++ b/client/src/launcher/clipboard.rs @@ -4,11 +4,9 @@ use std::time::Duration; #[cfg(not(coverage))] use { - crate::paste, - async_stream::stream, - lesavka_common::lesavka::relay_client::RelayClient, - tokio::runtime::Builder as RuntimeBuilder, - tonic::{Request, transport::Channel}, + crate::paste, crate::relay_transport, async_stream::stream, + lesavka_common::lesavka::relay_client::RelayClient, tokio::runtime::Builder as RuntimeBuilder, + tonic::Request, }; #[cfg(not(coverage))] @@ -34,12 +32,9 @@ fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> { let timeout = clipboard_transport_timeout(); let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { - let channel = tokio::time::timeout( - timeout, - Channel::from_shared(server_addr.to_string())?.connect(), - ) - .await - .map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??; + let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr)) + .await + .map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??; let mut cli = RelayClient::new(channel); let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req))) .await @@ -62,7 +57,7 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> { let timeout = clipboard_transport_timeout(); let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { - let channel = tokio::time::timeout(timeout, Channel::from_shared(server_addr.to_string())?.connect()) + let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr)) .await .map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??; let mut cli = RelayClient::new(channel); diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index c185943..d1ea6a5 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -1,6 +1,7 @@ // Launcher diagnostics snapshots, summaries, and operator recommendations. include!("diagnostics/diagnostics_models.rs"); include!("diagnostics/snapshot_report.rs"); +include!("diagnostics/snapshot_report_text.rs"); include!("diagnostics/recommendations.rs"); #[cfg(test)] diff --git a/client/src/launcher/diagnostics/diagnostics_models.rs b/client/src/launcher/diagnostics/diagnostics_models.rs index 62b136b..b424e88 100644 --- a/client/src/launcher/diagnostics/diagnostics_models.rs +++ b/client/src/launcher/diagnostics/diagnostics_models.rs @@ -163,6 +163,18 @@ pub struct SnapshotReport { pub av_delivery_skew_ms: f32, pub av_enqueue_skew_ms: f32, pub av_sync_health: String, + pub calibration_available: bool, + pub calibration_profile: String, + pub calibration_source: String, + pub calibration_confidence: String, + pub calibration_detail: String, + pub calibration_updated_at: String, + pub factory_audio_offset_us: i64, + pub factory_video_offset_us: i64, + pub default_audio_offset_us: i64, + pub default_video_offset_us: i64, + pub active_audio_offset_us: i64, + pub active_video_offset_us: i64, pub selected_keyboard: Option, pub selected_mouse: Option, pub status: String, diff --git a/client/src/launcher/diagnostics/snapshot_report.rs b/client/src/launcher/diagnostics/snapshot_report.rs index faf9c71..60a732f 100644 --- a/client/src/launcher/diagnostics/snapshot_report.rs +++ b/client/src/launcher/diagnostics/snapshot_report.rs @@ -238,6 +238,18 @@ impl SnapshotReport { av_delivery_skew_ms, av_enqueue_skew_ms, av_sync_health, + calibration_available: state.calibration.available, + calibration_profile: state.calibration.profile.clone(), + calibration_source: state.calibration.source.clone(), + calibration_confidence: state.calibration.confidence.clone(), + calibration_detail: state.calibration.detail.clone(), + calibration_updated_at: state.calibration.updated_at.clone(), + factory_audio_offset_us: state.calibration.factory_audio_offset_us, + factory_video_offset_us: state.calibration.factory_video_offset_us, + default_audio_offset_us: state.calibration.default_audio_offset_us, + default_video_offset_us: state.calibration.default_video_offset_us, + active_audio_offset_us: state.calibration.active_audio_offset_us, + active_video_offset_us: state.calibration.active_video_offset_us, selected_keyboard: state.devices.keyboard.clone(), selected_mouse: state.devices.mouse.clone(), status: state.status_line(), @@ -247,230 +259,7 @@ impl SnapshotReport { probe_command, } } - - pub fn to_pretty_json(&self) -> Result { - serde_json::to_string_pretty(self) - } - - pub fn to_pretty_text(&self) -> String { - let mut text = String::new(); - let server_version = self.server_version.as_deref().unwrap_or("unknown"); - let server_state = if self.server_available { - "reachable" - } else { - "unreachable" - }; - let _ = writeln!(text, "Lesavka Diagnostics"); - let _ = writeln!(text, "client: v{}", self.client_version); - let _ = writeln!(text, "server: {server_version} ({server_state})"); - let _ = writeln!( - text, - "session: routing={:?} view={:?} relay={} capture_power={}", - self.routing, - self.view_mode, - if self.remote_active { "active" } else { "idle" }, - self.power_state - ); - let _ = writeln!( - text, - "runtime: client CPU {:.1}% | server CPU {:.1}%", - self.client_process_cpu_pct, self.server_process_cpu_pct - ); - let _ = writeln!(text, "source feed: {}", self.preview_source); - let _ = writeln!(text, "display limit: {}", self.client_display_limit); - let _ = writeln!(text); - let _ = writeln!(text, "left eye"); - let _ = writeln!(text, " surface: {}", self.left_surface); - let _ = writeln!(text, " source: {}", self.left_feed_source); - let _ = writeln!(text, " capture: {}", self.left_capture_profile); - let _ = writeln!(text, " transport: {}", self.left_capture_transport); - let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); - let _ = writeln!( - text, - " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", - self.left_decoder_label, - self.left_stream_spread_ms, - self.left_packet_gap_peak_ms, - self.left_present_gap_peak_ms, - self.left_queue_depth, - self.left_queue_peak - ); - let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label); - let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); - let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label); - let _ = writeln!( - text, - " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", - self.left_server_encoder_label, - self.server_process_cpu_pct, - self.left_server_source_gap_peak_ms, - self.left_server_send_gap_peak_ms, - self.left_server_queue_peak - ); - let _ = writeln!(text, "right eye"); - let _ = writeln!(text, " surface: {}", self.right_surface); - let _ = writeln!(text, " source: {}", self.right_feed_source); - let _ = writeln!(text, " capture: {}", self.right_capture_profile); - let _ = writeln!(text, " transport: {}", self.right_capture_transport); - let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); - let _ = writeln!( - text, - " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", - self.right_decoder_label, - self.right_stream_spread_ms, - self.right_packet_gap_peak_ms, - self.right_present_gap_peak_ms, - self.right_queue_depth, - self.right_queue_peak - ); - let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label); - let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); - let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label); - let _ = writeln!( - text, - " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", - self.right_server_encoder_label, - self.server_process_cpu_pct, - self.right_server_source_gap_peak_ms, - self.right_server_send_gap_peak_ms, - self.right_server_queue_peak - ); - let _ = writeln!(text); - let _ = writeln!(text, "media staging"); - let _ = writeln!( - text, - " camera: {} | quality={} | enabled={}", - self.selected_camera.as_deref().unwrap_or("auto"), - self.camera_quality_label, - self.media_channels.camera - ); - let _ = writeln!( - text, - " speaker: {} | volume={} | enabled={}", - self.selected_speaker.as_deref().unwrap_or("auto"), - self.audio_gain_label, - self.media_channels.audio - ); - let _ = writeln!( - text, - " microphone: {} | gain={} | enabled={}", - self.selected_microphone.as_deref().unwrap_or("auto"), - self.mic_gain_label, - self.media_channels.microphone - ); - let _ = writeln!( - text, - " uplink camera: {}", - uplink_summary(&self.upstream_camera) - ); - let _ = writeln!( - text, - " uplink microphone: {}", - uplink_summary(&self.upstream_microphone) - ); - let _ = writeln!(text, "av sync guardrails"); - let _ = writeln!( - text, - " health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)", - self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS - ); - let _ = writeln!( - text, - " delivery skew: {:.1}ms | enqueue skew: {:.1}ms", - self.av_delivery_skew_ms, self.av_enqueue_skew_ms - ); - let _ = writeln!( - text, - " camera ages: enqueue={:.1}ms delivery={:.1}ms", - self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms - ); - let _ = writeln!( - text, - " microphone ages: enqueue={:.1}ms delivery={:.1}ms", - self.upstream_microphone.latest_enqueue_age_ms, - self.upstream_microphone.latest_delivery_age_ms - ); - let _ = writeln!( - text, - " keyboard: {}", - self.selected_keyboard.as_deref().unwrap_or("all") - ); - let _ = writeln!( - text, - " mouse: {}", - self.selected_mouse.as_deref().unwrap_or("all") - ); - let _ = writeln!(text); - let _ = writeln!(text, "current UI state"); - let _ = writeln!(text, " {}", self.status); - let _ = writeln!(text); - let _ = writeln!(text, "recent samples"); - if self.recent_samples.is_empty() { - let _ = writeln!( - text, - " no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot." - ); - } else { - for sample in &self.recent_samples { - let _ = writeln!( - text, - " rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}", - sample.rtt_ms, - sample.probe_spread_ms, - sample.input_latency_ms, - sample.client_process_cpu_pct, - sample.server_process_cpu_pct, - sample.probe_loss_pct, - sample.video_loss_pct, - sample.left_receive_fps, - sample.left_present_fps, - sample.left_server_fps, - sample.right_receive_fps, - sample.right_present_fps, - sample.right_server_fps, - sample.dropped_frames, - sample.queue_depth, - sample.left_queue_peak.max(sample.right_queue_peak), - sample.left_packet_gap_peak_ms, - sample.left_present_gap_peak_ms, - sample.right_packet_gap_peak_ms, - sample.right_present_gap_peak_ms, - sample.left_server_encoder_label, - sample.left_server_source_gap_peak_ms, - sample.left_server_send_gap_peak_ms, - sample.left_server_queue_peak, - sample.right_server_encoder_label, - sample.right_server_source_gap_peak_ms, - sample.right_server_send_gap_peak_ms, - sample.right_server_queue_peak - ); - let _ = writeln!( - text, - " uplink: cam={} mic={}", - uplink_summary(&sample.upstream_camera), - uplink_summary(&sample.upstream_microphone) - ); - } - } - let _ = writeln!(text); - let _ = writeln!(text, "recommendations"); - for item in &self.recommendations { - let _ = writeln!(text, " - {item}"); - } - if !self.notes.is_empty() { - let _ = writeln!(text); - let _ = writeln!(text, "notes"); - for item in &self.notes { - let _ = writeln!(text, " - {item}"); - } - } - let _ = writeln!(text); - let _ = writeln!(text, "quality probe"); - let _ = writeln!(text, " {}", self.probe_command); - text - } } - const AV_SYNC_GOOD_MS: f32 = 35.0; const AV_SYNC_WATCH_MS: f32 = 80.0; @@ -495,36 +284,3 @@ fn av_sync_health_label( "drift risk" } } - -fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String { - if !stream.enabled { - return "disabled".to_string(); - } - let connection = if stream.connected { - "live" - } else if stream.reconnect_count > 0 { - "reconnecting" - } else { - "idle" - }; - let error = if stream.last_error.is_empty() { - "ok".to_string() - } else { - stream.last_error.clone() - }; - format!( - "{connection} queue={}/{} enq-age={:.0}/{:.0}ms delivery={:.0}/{:.0}ms block-peak={:.0}ms reconnects={} streamed={} drops(total/full/stale)={}/{}/{} error={error}", - stream.queue_depth, - stream.queue_peak, - stream.latest_enqueue_age_ms, - stream.enqueue_age_peak_ms, - stream.latest_delivery_age_ms, - stream.delivery_age_peak_ms, - stream.enqueue_block_peak_ms, - stream.reconnect_count, - stream.packets_streamed, - stream.dropped_packets, - stream.dropped_queue_full_packets, - stream.dropped_stale_packets - ) -} diff --git a/client/src/launcher/diagnostics/snapshot_report_text.rs b/client/src/launcher/diagnostics/snapshot_report_text.rs new file mode 100644 index 0000000..37700ed --- /dev/null +++ b/client/src/launcher/diagnostics/snapshot_report_text.rs @@ -0,0 +1,292 @@ +impl SnapshotReport { + pub fn to_pretty_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + pub fn to_pretty_text(&self) -> String { + let mut text = String::new(); + let server_version = self.server_version.as_deref().unwrap_or("unknown"); + let server_state = if self.server_available { + "reachable" + } else { + "unreachable" + }; + let _ = writeln!(text, "Lesavka Diagnostics"); + let _ = writeln!(text, "client: v{}", self.client_version); + let _ = writeln!(text, "server: {server_version} ({server_state})"); + let _ = writeln!( + text, + "session: routing={:?} view={:?} relay={} capture_power={}", + self.routing, + self.view_mode, + if self.remote_active { "active" } else { "idle" }, + self.power_state + ); + let _ = writeln!( + text, + "runtime: client CPU {:.1}% | server CPU {:.1}%", + self.client_process_cpu_pct, self.server_process_cpu_pct + ); + let _ = writeln!(text, "source feed: {}", self.preview_source); + let _ = writeln!(text, "display limit: {}", self.client_display_limit); + let _ = writeln!(text); + let _ = writeln!(text, "left eye"); + let _ = writeln!(text, " surface: {}", self.left_surface); + let _ = writeln!(text, " source: {}", self.left_feed_source); + let _ = writeln!(text, " capture: {}", self.left_capture_profile); + let _ = writeln!(text, " transport: {}", self.left_capture_transport); + let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); + let _ = writeln!( + text, + " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", + self.left_decoder_label, + self.left_stream_spread_ms, + self.left_packet_gap_peak_ms, + self.left_present_gap_peak_ms, + self.left_queue_depth, + self.left_queue_peak + ); + let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label); + let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label); + let _ = writeln!( + text, + " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", + self.left_server_encoder_label, + self.server_process_cpu_pct, + self.left_server_source_gap_peak_ms, + self.left_server_send_gap_peak_ms, + self.left_server_queue_peak + ); + let _ = writeln!(text, "right eye"); + let _ = writeln!(text, " surface: {}", self.right_surface); + let _ = writeln!(text, " source: {}", self.right_feed_source); + let _ = writeln!(text, " capture: {}", self.right_capture_profile); + let _ = writeln!(text, " transport: {}", self.right_capture_transport); + let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); + let _ = writeln!( + text, + " live: decoder={} spread={:.1}ms gaps={:.0}/{:.0}ms queue={}/{}", + self.right_decoder_label, + self.right_stream_spread_ms, + self.right_packet_gap_peak_ms, + self.right_present_gap_peak_ms, + self.right_queue_depth, + self.right_queue_peak + ); + let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label); + let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label); + let _ = writeln!( + text, + " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", + self.right_server_encoder_label, + self.server_process_cpu_pct, + self.right_server_source_gap_peak_ms, + self.right_server_send_gap_peak_ms, + self.right_server_queue_peak + ); + let _ = writeln!(text); + let _ = writeln!(text, "media staging"); + let _ = writeln!( + text, + " camera: {} | quality={} | enabled={}", + self.selected_camera.as_deref().unwrap_or("auto"), + self.camera_quality_label, + self.media_channels.camera + ); + let _ = writeln!( + text, + " speaker: {} | volume={} | enabled={}", + self.selected_speaker.as_deref().unwrap_or("auto"), + self.audio_gain_label, + self.media_channels.audio + ); + let _ = writeln!( + text, + " microphone: {} | gain={} | enabled={}", + self.selected_microphone.as_deref().unwrap_or("auto"), + self.mic_gain_label, + self.media_channels.microphone + ); + let _ = writeln!( + text, + " uplink camera: {}", + uplink_summary(&self.upstream_camera) + ); + let _ = writeln!( + text, + " uplink microphone: {}", + uplink_summary(&self.upstream_microphone) + ); + let _ = writeln!(text, "av sync guardrails"); + let _ = writeln!( + text, + " health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)", + self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS + ); + let _ = writeln!( + text, + " delivery skew: {:.1}ms | enqueue skew: {:.1}ms", + self.av_delivery_skew_ms, self.av_enqueue_skew_ms + ); + let _ = writeln!( + text, + " camera ages: enqueue={:.1}ms delivery={:.1}ms", + self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms + ); + let _ = writeln!( + text, + " microphone ages: enqueue={:.1}ms delivery={:.1}ms", + self.upstream_microphone.latest_enqueue_age_ms, + self.upstream_microphone.latest_delivery_age_ms + ); + let _ = writeln!(text, "calibration"); + let _ = writeln!( + text, + " profile: {} | status={} | source={} | confidence={}", + self.calibration_profile, + if self.calibration_available { + "available" + } else { + "unavailable" + }, + self.calibration_source, + self.calibration_confidence + ); + let _ = writeln!( + text, + " active: audio={:+.1}ms video={:+.1}ms", + self.active_audio_offset_us as f64 / 1000.0, + self.active_video_offset_us as f64 / 1000.0 + ); + let _ = writeln!( + text, + " default: audio={:+.1}ms video={:+.1}ms | factory: audio={:+.1}ms video={:+.1}ms", + self.default_audio_offset_us as f64 / 1000.0, + self.default_video_offset_us as f64 / 1000.0, + self.factory_audio_offset_us as f64 / 1000.0, + self.factory_video_offset_us as f64 / 1000.0 + ); + let _ = writeln!( + text, + " updated: {} | detail: {}", + if self.calibration_updated_at.is_empty() { + "unknown" + } else { + &self.calibration_updated_at + }, + self.calibration_detail + ); + let _ = writeln!( + text, + " keyboard: {}", + self.selected_keyboard.as_deref().unwrap_or("all") + ); + let _ = writeln!( + text, + " mouse: {}", + self.selected_mouse.as_deref().unwrap_or("all") + ); + let _ = writeln!(text); + let _ = writeln!(text, "current UI state"); + let _ = writeln!(text, " {}", self.status); + let _ = writeln!(text); + let _ = writeln!(text, "recent samples"); + if self.recent_samples.is_empty() { + let _ = writeln!( + text, + " no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot." + ); + } else { + for sample in &self.recent_samples { + let _ = writeln!( + text, + " rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms cpu={:.1}/{:.1}% probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}/{} peaks=l{:.0}/{:.0}ms r{:.0}/{:.0}ms server=l{}:{:.0}/{:.0}/{} r{}:{:.0}/{:.0}/{}", + sample.rtt_ms, + sample.probe_spread_ms, + sample.input_latency_ms, + sample.client_process_cpu_pct, + sample.server_process_cpu_pct, + sample.probe_loss_pct, + sample.video_loss_pct, + sample.left_receive_fps, + sample.left_present_fps, + sample.left_server_fps, + sample.right_receive_fps, + sample.right_present_fps, + sample.right_server_fps, + sample.dropped_frames, + sample.queue_depth, + sample.left_queue_peak.max(sample.right_queue_peak), + sample.left_packet_gap_peak_ms, + sample.left_present_gap_peak_ms, + sample.right_packet_gap_peak_ms, + sample.right_present_gap_peak_ms, + sample.left_server_encoder_label, + sample.left_server_source_gap_peak_ms, + sample.left_server_send_gap_peak_ms, + sample.left_server_queue_peak, + sample.right_server_encoder_label, + sample.right_server_source_gap_peak_ms, + sample.right_server_send_gap_peak_ms, + sample.right_server_queue_peak + ); + let _ = writeln!( + text, + " uplink: cam={} mic={}", + uplink_summary(&sample.upstream_camera), + uplink_summary(&sample.upstream_microphone) + ); + } + } + let _ = writeln!(text); + let _ = writeln!(text, "recommendations"); + for item in &self.recommendations { + let _ = writeln!(text, " - {item}"); + } + if !self.notes.is_empty() { + let _ = writeln!(text); + let _ = writeln!(text, "notes"); + for item in &self.notes { + let _ = writeln!(text, " - {item}"); + } + } + let _ = writeln!(text); + let _ = writeln!(text, "quality probe"); + let _ = writeln!(text, " {}", self.probe_command); + text + } +} +fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String { + if !stream.enabled { + return "disabled".to_string(); + } + let connection = if stream.connected { + "live" + } else if stream.reconnect_count > 0 { + "reconnecting" + } else { + "idle" + }; + let error = if stream.last_error.is_empty() { + "ok".to_string() + } else { + stream.last_error.clone() + }; + format!( + "{connection} queue={}/{} enq-age={:.0}/{:.0}ms delivery={:.0}/{:.0}ms block-peak={:.0}ms reconnects={} streamed={} drops(total/full/stale)={}/{}/{} error={error}", + stream.queue_depth, + stream.queue_peak, + stream.latest_enqueue_age_ms, + stream.enqueue_age_peak_ms, + stream.latest_delivery_age_ms, + stream.delivery_age_peak_ms, + stream.enqueue_block_peak_ms, + stream.reconnect_count, + stream.packets_streamed, + stream.dropped_packets, + stream.dropped_queue_full_packets, + stream.dropped_stale_packets + ) +} diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index a4df413..8867387 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -2,6 +2,8 @@ pub mod devices; pub mod diagnostics; pub mod state; +#[cfg(not(coverage))] +mod calibration; mod clipboard; #[cfg(not(coverage))] mod device_test; diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs index c6c137d..742ee44 100644 --- a/client/src/launcher/power.rs +++ b/client/src/launcher/power.rs @@ -5,6 +5,7 @@ use lesavka_common::lesavka::{ use tonic::{Request, transport::Channel}; use super::state::CapturePowerStatus; +use crate::relay_transport; pub fn fetch_capture_power(server_addr: &str) -> Result { with_runtime(async move { @@ -64,8 +65,7 @@ where } async fn connect(server_addr: &str) -> Result> { - let channel = Channel::from_shared(server_addr.to_string()) - .context("invalid launcher server address")? + let channel = relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await diff --git a/client/src/launcher/preview/feed_runtime.rs b/client/src/launcher/preview/feed_runtime.rs index 0e5e167..48dc556 100644 --- a/client/src/launcher/preview/feed_runtime.rs +++ b/client/src/launcher/preview/feed_runtime.rs @@ -327,7 +327,7 @@ fn run_preview_feed( } }; - let channel = match Channel::from_shared(current_addr.clone()) { + let channel = match crate::relay_transport::endpoint(¤t_addr) { Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { Ok(channel) => channel, Err(err) => { diff --git a/client/src/launcher/preview/preview_core.rs b/client/src/launcher/preview/preview_core.rs index 89f64fc..def2cff 100644 --- a/client/src/launcher/preview/preview_core.rs +++ b/client/src/launcher/preview/preview_core.rs @@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex}; #[cfg(not(coverage))] use std::time::{Duration, Instant}; #[cfg(not(coverage))] -use tonic::{Request, transport::Channel}; +use tonic::Request; #[cfg(not(coverage))] use tracing::{debug, warn}; diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 5de822b..80acf43 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,6 +1,7 @@ // Launcher state model, selection normalization, and media profile choices. include!("state/selection_models.rs"); include!("state/launcher_state_impl.rs"); +include!("state/launcher_status_line.rs"); include!("state/profile_helpers.rs"); #[cfg(test)] diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 5238091..9159b65 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -449,48 +449,8 @@ impl LauncherState { self.capture_power = power; } - pub fn status_line(&self) -> String { - format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} audio_gain={} mic_gain={} kbd={} mouse={} swap={}", - self.server_available, - match self.routing { - InputRouting::Local => "local", - InputRouting::Remote => "remote", - }, - match self.view_mode { - ViewMode::Unified => "unified", - ViewMode::Breakout => "breakout", - }, - self.remote_active, - if self.capture_power.enabled { - "on" - } else { - "off" - }, - self.preview_source.width, - self.preview_source.height, - self.displays[0].label(), - self.displays[1].label(), - 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, - self.channels.microphone, - self.channels.audio, - self.server_camera, - self.server_microphone, - self.server_camera_output, - self.server_camera_codec, - self.audio_gain_label(), - self.mic_gain_label(), - self.devices.keyboard.as_deref().unwrap_or("all"), - self.devices.mouse.as_deref().unwrap_or("all"), - self.swap_key, - ) + pub fn set_calibration(&mut self, calibration: CalibrationStatus) { + self.calibration = calibration; } + } diff --git a/client/src/launcher/state/launcher_status_line.rs b/client/src/launcher/state/launcher_status_line.rs new file mode 100644 index 0000000..1f4dd2c --- /dev/null +++ b/client/src/launcher/state/launcher_status_line.rs @@ -0,0 +1,48 @@ +impl LauncherState { + pub fn status_line(&self) -> String { + format!( + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} cal={}:{:+.1}ms audio_gain={} mic_gain={} kbd={} mouse={} swap={}", + self.server_available, + match self.routing { + InputRouting::Local => "local", + InputRouting::Remote => "remote", + }, + match self.view_mode { + ViewMode::Unified => "unified", + ViewMode::Breakout => "breakout", + }, + self.remote_active, + if self.capture_power.enabled { + "on" + } else { + "off" + }, + self.preview_source.width, + self.preview_source.height, + self.displays[0].label(), + self.displays[1].label(), + 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, + self.channels.microphone, + self.channels.audio, + self.server_camera, + self.server_microphone, + self.server_camera_output, + self.server_camera_codec, + self.calibration.source, + self.calibration.active_audio_offset_us as f64 / 1000.0, + self.audio_gain_label(), + self.mic_gain_label(), + self.devices.keyboard.as_deref().unwrap_or("all"), + self.devices.mouse.as_deref().unwrap_or("all"), + self.swap_key, + ) + } +} diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index f395d11..cb6961f 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -294,6 +294,72 @@ impl Default for CapturePowerStatus { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalibrationStatus { + pub available: bool, + pub profile: String, + pub factory_audio_offset_us: i64, + pub factory_video_offset_us: i64, + pub default_audio_offset_us: i64, + pub default_video_offset_us: i64, + pub active_audio_offset_us: i64, + pub active_video_offset_us: i64, + pub source: String, + pub confidence: String, + pub updated_at: String, + pub detail: String, +} + +/// Convert relay calibration payloads into visible launcher state. +impl CalibrationStatus { + /// Convert the relay calibration RPC payload into launcher state. + #[must_use] + pub fn from_proto(reply: lesavka_common::lesavka::CalibrationState) -> Self { + Self { + available: true, + profile: reply.profile, + factory_audio_offset_us: reply.factory_audio_offset_us, + factory_video_offset_us: reply.factory_video_offset_us, + default_audio_offset_us: reply.default_audio_offset_us, + default_video_offset_us: reply.default_video_offset_us, + active_audio_offset_us: reply.active_audio_offset_us, + active_video_offset_us: reply.active_video_offset_us, + source: reply.source, + confidence: reply.confidence, + updated_at: reply.updated_at, + detail: reply.detail, + } + } + #[must_use] + pub fn unavailable(detail: impl Into) -> Self { + Self { + detail: detail.into(), + ..Self::default() + } + } +} + +/// Provide factory MJPEG offsets until the relay reports saved calibration. +impl Default for CalibrationStatus { + /// Start with the current lab-validated MJPEG baseline. + fn default() -> Self { + Self { + available: false, + profile: "mjpeg".to_string(), + factory_audio_offset_us: -45_000, + factory_video_offset_us: 0, + default_audio_offset_us: -45_000, + default_video_offset_us: 0, + active_audio_offset_us: -45_000, + active_video_offset_us: 0, + source: "unknown".to_string(), + confidence: "unknown".to_string(), + updated_at: String::new(), + detail: "calibration status unavailable".to_string(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, @@ -348,6 +414,7 @@ pub struct LauncherState { pub swap_key_binding: bool, pub swap_key_binding_token: u64, pub capture_power: CapturePowerStatus, + pub calibration: CalibrationStatus, pub remote_active: bool, pub notes: Vec, } @@ -381,6 +448,7 @@ impl Default for LauncherState { swap_key_binding: false, swap_key_binding_token: 0, capture_power: CapturePowerStatus::default(), + calibration: CalibrationStatus::default(), remote_active: false, notes: Vec::new(), } diff --git a/client/src/launcher/tests/preview.rs b/client/src/launcher/tests/preview.rs index 9073230..8681a6e 100644 --- a/client/src/launcher/tests/preview.rs +++ b/client/src/launcher/tests/preview.rs @@ -131,6 +131,23 @@ impl Relay for ProbeRelay { self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {})) .await } + + async fn get_calibration( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new( + lesavka_common::lesavka::CalibrationState::default(), + )) + } + + async fn calibrate( + &self, + _request: Request, + ) -> Result, Status> { + self.get_calibration(Request::new(lesavka_common::lesavka::Empty {})) + .await + } } #[test] diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index bb149a4..7daa290 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -401,6 +401,47 @@ fn capture_power_status_updates_snapshot_state() { assert!(state.status_line().contains("power=on")); } +#[test] +fn calibration_status_tracks_proto_unavailable_and_status_line() { + let mut state = LauncherState::new(); + assert!(!state.calibration.available); + assert_eq!(state.calibration.active_audio_offset_us, -45_000); + + let unavailable = CalibrationStatus::unavailable("server unreachable"); + assert!(!unavailable.available); + assert_eq!(unavailable.detail, "server unreachable"); + + state.set_calibration(CalibrationStatus::from_proto( + lesavka_common::lesavka::CalibrationState { + profile: "mjpeg".to_string(), + factory_audio_offset_us: -45_000, + factory_video_offset_us: 0, + default_audio_offset_us: -40_000, + default_video_offset_us: 1_000, + active_audio_offset_us: -35_000, + active_video_offset_us: 2_000, + source: "manual".to_string(), + confidence: "measured".to_string(), + updated_at: "now".to_string(), + detail: "operator-set".to_string(), + }, + )); + + assert!(state.calibration.available); + assert_eq!(state.calibration.profile, "mjpeg"); + assert_eq!(state.calibration.factory_audio_offset_us, -45_000); + assert_eq!(state.calibration.factory_video_offset_us, 0); + assert_eq!(state.calibration.default_audio_offset_us, -40_000); + assert_eq!(state.calibration.default_video_offset_us, 1_000); + assert_eq!(state.calibration.active_audio_offset_us, -35_000); + assert_eq!(state.calibration.active_video_offset_us, 2_000); + assert_eq!(state.calibration.source, "manual"); + assert_eq!(state.calibration.confidence, "measured"); + assert_eq!(state.calibration.updated_at, "now"); + assert_eq!(state.calibration.detail, "operator-set"); + assert!(state.status_line().contains("cal=manual:-35.0ms")); +} + #[test] fn server_availability_tracks_reachability() { let mut state = LauncherState::new(); @@ -409,6 +450,38 @@ fn server_availability_tracks_reachability() { assert!(state.server_available); } +#[test] +fn server_identity_and_media_caps_trim_blank_values() { + let mut state = LauncherState::new(); + state.set_server_version(Some(" ".to_string())); + assert_eq!(state.server_version, None); + + state.set_server_version(Some(" 0.16.0 ".to_string())); + assert_eq!(state.server_version.as_deref(), Some("0.16.0")); + + state.set_server_media_caps( + Some(true), + Some(false), + Some(" ".to_string()), + Some(" mjpeg ".to_string()), + ); + assert_eq!(state.server_camera, Some(true)); + assert_eq!(state.server_microphone, Some(false)); + assert_eq!(state.server_camera_output, None); + assert_eq!(state.server_camera_codec.as_deref(), Some("mjpeg")); + + state.set_server_media_caps( + None, + None, + Some(" uvc ".to_string()), + Some(" ".to_string()), + ); + assert_eq!(state.server_camera, None); + assert_eq!(state.server_microphone, None); + assert_eq!(state.server_camera_output.as_deref(), Some("uvc")); + assert_eq!(state.server_camera_codec, None); +} + #[test] fn breakout_size_choices_track_the_negotiated_source_size() { let mut state = LauncherState::new(); @@ -463,6 +536,20 @@ fn breakout_size_choices_track_the_negotiated_source_size() { })); } +#[test] +fn capture_option_methods_report_native_timing_and_bitrate_tiers() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 30); + + let fps_options = state.capture_fps_options(); + assert_eq!(fps_options.len(), 1); + assert_eq!(fps_options[0].fps, 30); + + let bitrate_options = state.capture_bitrate_options(); + assert_eq!(bitrate_options.len(), 1); + assert_eq!(bitrate_options[0].max_bitrate_kbit, 12_000); +} + #[test] fn swap_key_binding_tracks_selected_key_and_binding_mode() { let mut state = LauncherState::new(); diff --git a/client/src/launcher/tests/utility_actions.rs b/client/src/launcher/tests/utility_actions.rs index c7bb11a..9682874 100644 --- a/client/src/launcher/tests/utility_actions.rs +++ b/client/src/launcher/tests/utility_actions.rs @@ -1,8 +1,9 @@ use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget}; use futures::stream; use lesavka_common::lesavka::{ - AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, - PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, + AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport, + MonitorRequest, MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, + VideoPacket, relay_server::{Relay, RelayServer}, }; use serial_test::serial; @@ -116,6 +117,20 @@ impl Relay for UtilityRelay { ) -> Result, Status> { Ok(Response::new(CapturePowerState::default())) } + + async fn get_calibration( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CalibrationState::default())) + } + + async fn calibrate( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CalibrationState::default())) + } } fn serve(relay: UtilityRelay) -> (tokio::runtime::Runtime, String) { diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 041a91b..bfcc152 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -2,17 +2,21 @@ use anyhow::Result; #[cfg(not(coverage))] use { + super::calibration::{ + blind_calibration_estimate, fetch_calibration, nudge_audio_calibration, + restore_default_calibration, restore_factory_calibration, + }, super::clipboard::send_clipboard_text_to_remote, super::device_test::{DeviceTestController, DeviceTestKind}, super::devices::{CameraMode, DeviceCatalog}, super::diagnostics::PerformanceSample, super::launcher_clipboard_control_path, super::launcher_focus_signal_path, - super::preview::{LauncherPreview, PreviewSurface}, super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, + super::preview::{LauncherPreview, PreviewSurface}, super::state::{ - BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, - FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, + BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, + DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, MAX_MIC_GAIN_PERCENT, }, super::ui_components::{ @@ -41,7 +45,7 @@ use { serde_json::json, std::cell::{Cell, RefCell}, std::collections::VecDeque, - std::path::PathBuf, + std::path::{Path, PathBuf}, std::process::Command, std::rc::Rc, std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}, @@ -133,6 +137,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { power_tx, power_rx, power_request_in_flight, + calibration_tx, + calibration_rx, + calibration_request_in_flight, relay_tx, relay_rx, relay_request_in_flight, @@ -142,6 +149,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { diagnostics_network, diagnostics_process, next_power_probe, + next_calibration_probe, next_diagnostics_probe, next_diagnostics_sample, preview_session_active, @@ -156,6 +164,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { include!("ui/media_device_bindings.rs"); let _: () = include!("ui/device_refresh_binding.rs"); include!("ui/relay_input_bindings.rs"); + include!("ui/eye_capture_bindings.rs"); include!("ui/utility_button_bindings.rs"); include!("ui/local_test_bindings.rs"); include!("ui/power_display_key_bindings.rs"); diff --git a/client/src/launcher/ui/activation_context.rs b/client/src/launcher/ui/activation_context.rs index 63b1012..114ece5 100644 --- a/client/src/launcher/ui/activation_context.rs +++ b/client/src/launcher/ui/activation_context.rs @@ -18,6 +18,9 @@ struct ActivationContext { power_tx: std::sync::mpsc::Sender, power_rx: std::sync::mpsc::Receiver, power_request_in_flight: Rc>, + calibration_tx: std::sync::mpsc::Sender, + calibration_rx: std::sync::mpsc::Receiver, + calibration_request_in_flight: Rc>, relay_tx: std::sync::mpsc::Sender, relay_rx: std::sync::mpsc::Receiver, relay_request_in_flight: Rc>, @@ -27,6 +30,7 @@ struct ActivationContext { diagnostics_network: Rc>, diagnostics_process: Rc>, next_power_probe: Rc>, + next_calibration_probe: Rc>, next_diagnostics_probe: Rc>, next_diagnostics_sample: Rc>, preview_session_active: Rc>, diff --git a/client/src/launcher/ui/activation_setup.rs b/client/src/launcher/ui/activation_setup.rs index 5b9bcec..1622324 100644 --- a/client/src/launcher/ui/activation_setup.rs +++ b/client/src/launcher/ui/activation_setup.rs @@ -109,6 +109,9 @@ let (power_tx, power_rx) = std::sync::mpsc::channel::(); let power_request_in_flight = Rc::new(Cell::new(false)); + let (calibration_tx, calibration_rx) = + std::sync::mpsc::channel::(); + let calibration_request_in_flight = Rc::new(Cell::new(false)); let (relay_tx, relay_rx) = std::sync::mpsc::channel::(); let relay_request_in_flight = Rc::new(Cell::new(false)); let (caps_tx, caps_rx) = std::sync::mpsc::channel::(); @@ -117,6 +120,8 @@ let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new())); let next_power_probe = Rc::new(Cell::new(Instant::now() + Duration::from_millis(500))); + let next_calibration_probe = + Rc::new(Cell::new(Instant::now() + Duration::from_millis(650))); let next_diagnostics_probe = Rc::new(Cell::new(Instant::now() + Duration::from_millis(250))); let next_diagnostics_sample = @@ -149,6 +154,9 @@ power_tx, power_rx, power_request_in_flight, + calibration_tx, + calibration_rx, + calibration_request_in_flight, relay_tx, relay_rx, relay_request_in_flight, @@ -158,6 +166,7 @@ diagnostics_network, diagnostics_process, next_power_probe, + next_calibration_probe, next_diagnostics_probe, next_diagnostics_sample, preview_session_active, diff --git a/client/src/launcher/ui/control_requests.rs b/client/src/launcher/ui/control_requests.rs index 9db6fcb..2e6acc3 100644 --- a/client/src/launcher/ui/control_requests.rs +++ b/client/src/launcher/ui/control_requests.rs @@ -104,6 +104,7 @@ fn apply_mic_gain_change( } #[cfg(not(coverage))] +/// Refresh relay capture-power state in the background so GTK stays responsive. fn request_capture_power_refresh( power_tx: std::sync::mpsc::Sender, server_addr: String, @@ -131,6 +132,37 @@ fn request_capture_power_command( } #[cfg(not(coverage))] +/// Refresh upstream calibration state in the background so the UI can poll safely. +fn request_calibration_refresh( + calibration_tx: std::sync::mpsc::Sender, + server_addr: String, + delay: Duration, +) { + std::thread::spawn(move || { + if !delay.is_zero() { + std::thread::sleep(delay); + } + let result = fetch_calibration(&server_addr).map_err(|err| err.to_string()); + let _ = calibration_tx.send(CalibrationMessage::Refresh(result)); + }); +} + +#[cfg(not(coverage))] +fn request_calibration_command( + calibration_tx: std::sync::mpsc::Sender, + server_addr: String, + action: F, +) where + F: FnOnce(&str) -> anyhow::Result + Send + 'static, +{ + std::thread::spawn(move || { + let result = action(&server_addr).map_err(|err| err.to_string()); + let _ = calibration_tx.send(CalibrationMessage::Command(result)); + }); +} + +#[cfg(not(coverage))] +/// Probe server capabilities on a short-lived runtime without blocking the UI thread. fn request_handshake_caps( caps_tx: std::sync::mpsc::Sender, server_addr: String, @@ -163,3 +195,20 @@ fn unavailable_capture_power(detail: String) -> CapturePowerStatus { detected_devices: 0, } } + +#[cfg(not(coverage))] +fn unavailable_calibration(detail: String) -> CalibrationStatus { + CalibrationStatus::unavailable(detail) +} + +#[cfg(not(coverage))] +fn calibration_summary(calibration: &CalibrationStatus) -> String { + format!( + "Upstream A/V calibration: {} audio {:+.1} ms, video {:+.1} ms ({}, {}).", + calibration.profile, + calibration.active_audio_offset_us as f64 / 1000.0, + calibration.active_video_offset_us as f64 / 1000.0, + calibration.source, + calibration.confidence + ) +} diff --git a/client/src/launcher/ui/eye_capture_bindings.rs b/client/src/launcher/ui/eye_capture_bindings.rs new file mode 100644 index 0000000..f971bed --- /dev/null +++ b/client/src/launcher/ui/eye_capture_bindings.rs @@ -0,0 +1,471 @@ +{ + const DEFAULT_EYE_RECORD_FPS: u32 = 30; + + #[derive(Default)] + struct EyeRecordState { + save_dir_override: Option, + timer: Option, + frame_dir: Option, + frame_writer_tx: Option>, + finalize_rx: Option>>, + next_frame_index: u32, + } + + enum RecordFrameTask { + Frame { + texture: gtk::gdk::Texture, + frame_path: PathBuf, + }, + Finish, + } + + fn eye_slug(title: &str) -> &'static str { + if title.to_ascii_lowercase().contains("left") { + "left-eye" + } else { + "right-eye" + } + } + + fn timestamp_slug() -> String { + if let Ok(now) = glib::DateTime::now_local() + && let Ok(stamp) = now.format("%Y%m%d-%H%M%S") + { + return stamp.to_string(); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("{}-{:03}", now.as_secs(), now.subsec_millis()) + } + + fn expand_home_token(raw: &str) -> PathBuf { + if raw.contains("$HOME") && let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy())); + } + if let Some(rest) = raw.strip_prefix("~/") + && let Some(home) = std::env::var_os("HOME") + { + return PathBuf::from(home).join(rest); + } + PathBuf::from(raw) + } + + fn default_eye_capture_root() -> PathBuf { + if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") { + let path = expand_home_token(&raw.to_string_lossy()); + if !path.as_os_str().is_empty() { + return path.join("lesavka"); + } + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join("Pictures").join("lesavka"); + } + if let Some(profile) = std::env::var_os("USERPROFILE") { + return PathBuf::from(profile).join("Pictures").join("lesavka"); + } + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("lesavka") + } + + fn ensure_eye_capture_root(override_dir: Option<&Path>) -> Result { + let root = override_dir + .map(Path::to_path_buf) + .unwrap_or_else(default_eye_capture_root); + std::fs::create_dir_all(&root) + .map_err(|err| format!("could not create {}: {err}", root.display()))?; + Ok(root) + } + + fn unique_capture_path(root: &Path, stem: &str, ext: &str) -> PathBuf { + let mut candidate = root.join(format!("{stem}.{ext}")); + if !candidate.exists() { + return candidate; + } + for idx in 1..1000 { + candidate = root.join(format!("{stem}-{idx}.{ext}")); + if !candidate.exists() { + break; + } + } + candidate + } + + fn current_eye_texture(picture: >k::Picture) -> Result { + let paintable = picture + .paintable() + .ok_or_else(|| "no live frame is available yet".to_string())?; + paintable + .downcast::() + .map_err(|_| "the current frame is not directly exportable".to_string()) + } + + fn save_texture_png(texture: >k::gdk::Texture, output_path: &Path) -> Result<(), String> { + texture + .save_to_png(output_path) + .map_err(|err| format!("could not write {}: {err}", output_path.display())) + } + + fn recording_interval_ms(record_fps: u32) -> u64 { + let fps = record_fps.max(1); + (1000_u64 / fps as u64).max(1) + } + + fn best_effort_recording_profile( + state: &LauncherState, + preview: Option<&LauncherPreview>, + monitor_id: usize, + ) -> (u32, u32) { + let choice = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + let mut fps = if choice.fps == 0 { + DEFAULT_EYE_RECORD_FPS + } else { + choice.fps.max(1) + }; + if let Some(snapshot) = + preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline)) + && snapshot.server_fps.is_finite() + && snapshot.server_fps >= 1.0 + { + fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32; + } + let bitrate_kbit = choice.max_bitrate_kbit.max(800); + (fps, bitrate_kbit) + } + + fn queue_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> { + let frame_dir = state + .frame_dir + .as_ref() + .ok_or_else(|| "recording session is not initialized".to_string())? + .clone(); + let frame_writer_tx = state + .frame_writer_tx + .as_ref() + .ok_or_else(|| "recording worker is not initialized".to_string())? + .clone(); + let texture = current_eye_texture(picture)?; + let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index)); + frame_writer_tx + .send(RecordFrameTask::Frame { + texture, + frame_path, + }) + .map_err(|_| "recording worker stopped unexpectedly".to_string())?; + state.next_frame_index = state.next_frame_index.saturating_add(1); + Ok(()) + } + + fn encode_recording( + frame_dir: &Path, + output_path: &Path, + encode_fps: u32, + encode_bitrate_kbit: u32, + ) -> Result<(), String> { + let frame_pattern = frame_dir.join("frame-%06d.png"); + let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); + let encode = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-framerate", + &encode_fps.max(1).to_string(), + "-i", + &frame_pattern.to_string_lossy(), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-r", + &encode_fps.max(1).to_string(), + "-b:v", + &bitrate_arg, + &output_path.to_string_lossy(), + ]) + .status() + .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; + + if !encode.success() { + return Err(format!( + "ffmpeg failed while encoding {}; frame data is still in {}", + output_path.display(), + frame_dir.display() + )); + } + Ok(()) + } + + fn run_recording_worker( + frame_rx: std::sync::mpsc::Receiver, + frame_dir: PathBuf, + output_path: PathBuf, + encode_fps: u32, + encode_bitrate_kbit: u32, + ) -> Result { + let mut captured_frames = 0_u32; + while let Ok(task) = frame_rx.recv() { + match task { + RecordFrameTask::Frame { + texture, + frame_path, + } => { + save_texture_png(&texture, &frame_path)?; + captured_frames = captured_frames.saturating_add(1); + } + RecordFrameTask::Finish => break, + } + } + + if captured_frames < 2 { + let _ = std::fs::remove_dir_all(&frame_dir); + return Err("need at least two captured frames to build a recording".to_string()); + } + + encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?; + let _ = std::fs::remove_dir_all(&frame_dir); + Ok(output_path) + } + for monitor_id in 0..2 { + let pane = widgets.display_panes[monitor_id].clone(); + let widgets_for_ui = widgets.clone(); + let save_state = Rc::new(RefCell::new(EyeRecordState::default())); + + { + let pane = pane.clone(); + let widgets = widgets_for_ui.clone(); + let save_state = Rc::clone(&save_state); + let window_for_save = window.clone(); + pane.save_button.connect_clicked(move |_| { + let chooser = gtk::FileChooserNative::new( + Some("Choose Eye Capture Folder"), + Some(&window_for_save), + gtk::FileChooserAction::SelectFolder, + Some("Select"), + Some("Cancel"), + ); + chooser.set_modal(true); + let save_state = Rc::clone(&save_state); + let widgets = widgets.clone(); + let eye_name = pane.title.clone(); + chooser.connect_response(move |dialog, response| { + if response == gtk::ResponseType::Accept { + if let Some(folder) = dialog.file().and_then(|file| file.path()) { + save_state.borrow_mut().save_dir_override = Some(folder.clone()); + widgets.status_label.set_text(&format!( + "{} saves now go to {}.", + eye_name, + folder.display() + )); + } else { + widgets.status_label.set_text( + "Capture folder selection did not return a filesystem path.", + ); + } + } + dialog.destroy(); + }); + chooser.show(); + }); + } + + { + let pane = pane.clone(); + let widgets = widgets_for_ui.clone(); + let save_state = Rc::clone(&save_state); + pane.clip_button.connect_clicked(move |_| { + let root = { + let borrowed = save_state.borrow(); + match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) { + Ok(path) => path, + Err(err) => { + widgets + .status_label + .set_text(&format!("Could not prepare capture folder: {err}")); + return; + } + } + }; + + let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug()); + let clip_path = unique_capture_path(&root, &stem, "png"); + match current_eye_texture(&pane.picture) + .and_then(|texture| save_texture_png(&texture, &clip_path)) + { + Ok(()) => { + widgets.status_label.set_text(&format!( + "{} clip saved to {}.", + pane.title, + clip_path.display() + )); + } + Err(err) => { + widgets + .status_label + .set_text(&format!("{} clip failed: {err}", pane.title)); + } + } + }); + } + + { + let pane = pane.clone(); + let widgets = widgets_for_ui.clone(); + let save_state = Rc::clone(&save_state); + let state = Rc::clone(&state); + let preview = preview.clone(); + let record_button = pane.record_button.clone(); + record_button.connect_clicked(move |button| { + if save_state.borrow().timer.is_some() { + let finalize_rx = { + let mut state = save_state.borrow_mut(); + if let Some(timer) = state.timer.take() { + timer.remove(); + } + if let Some(frame_writer_tx) = state.frame_writer_tx.take() { + let _ = frame_writer_tx.send(RecordFrameTask::Finish); + } + state.next_frame_index = 0; + state.frame_dir = None; + state.finalize_rx.take() + }; + let Some(finalize_rx) = finalize_rx else { + button.set_label("Record"); + widgets.status_label.set_text(&format!( + "{} recording stop failed: recording worker state was missing.", + pane.title + )); + return; + }; + + button.set_sensitive(false); + button.set_label("Finishing..."); + let button = button.clone(); + let widgets = widgets.clone(); + let pane_title = pane.title.clone(); + glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx + .try_recv() + { + Ok(Ok(output)) => { + button.set_sensitive(true); + button.set_label("Record"); + widgets.status_label.set_text(&format!( + "{} recording saved to {}.", + pane_title, + output.display() + )); + glib::ControlFlow::Break + } + Ok(Err(err)) => { + button.set_sensitive(true); + button.set_label("Record"); + widgets.status_label.set_text(&format!( + "{} recording stop failed: {err}", + pane_title, + )); + glib::ControlFlow::Break + } + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + button.set_sensitive(true); + button.set_label("Record"); + widgets.status_label.set_text(&format!( + "{} recording stop failed: recording worker disconnected.", + pane_title + )); + glib::ControlFlow::Break + } + }); + return; + } + + let (record_fps, record_bitrate_kbit) = { + let state = state.borrow(); + best_effort_recording_profile(&state, preview.as_deref(), monitor_id) + }; + let root = { + let borrowed = save_state.borrow(); + match ensure_eye_capture_root(borrowed.save_dir_override.as_deref()) { + Ok(path) => path, + Err(err) => { + widgets + .status_label + .set_text(&format!("Could not prepare capture folder: {err}")); + return; + } + } + }; + + let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug()); + let output_path = unique_capture_path(&root, &recording_stem, "mp4"); + let frame_dir = root.join(format!("{}.frames", recording_stem)); + if let Err(err) = std::fs::create_dir_all(&frame_dir) { + widgets.status_label.set_text(&format!( + "{} record failed creating frame cache {}: {err}", + pane.title, + frame_dir.display() + )); + return; + } + + let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); + let (result_tx, result_rx) = std::sync::mpsc::channel::>(); + let frame_dir_worker = frame_dir.clone(); + let output_path_worker = output_path.clone(); + std::thread::spawn(move || { + let result = run_recording_worker( + frame_rx, + frame_dir_worker, + output_path_worker, + record_fps, + record_bitrate_kbit, + ); + let _ = result_tx.send(result); + }); + + { + let mut state = save_state.borrow_mut(); + state.frame_dir = Some(frame_dir); + state.frame_writer_tx = Some(frame_tx); + state.finalize_rx = Some(result_rx); + state.next_frame_index = 0; + } + + let pane_for_tick = pane.clone(); + let widgets_for_tick = widgets.clone(); + let save_state_for_tick = Rc::clone(&save_state); + let timer = glib::timeout_add_local( + Duration::from_millis(recording_interval_ms(record_fps)), + move || { + let mut state = save_state_for_tick.borrow_mut(); + if state.frame_dir.is_none() { + return glib::ControlFlow::Break; + } + if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) { + if let Some(frame_writer_tx) = state.frame_writer_tx.take() { + let _ = frame_writer_tx.send(RecordFrameTask::Finish); + } + widgets_for_tick.status_label.set_text(&format!( + "{} recording frame skipped: {err}", + pane_for_tick.title + )); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }, + ); + save_state.borrow_mut().timer = Some(timer); + button.set_sensitive(true); + button.set_label("Stop"); + widgets.status_label.set_text(&format!( + "Recording {} at {} fps (~{} kbit)... press Stop to finish.", + pane.title, record_fps, record_bitrate_kbit + )); + }); + } + } +} diff --git a/client/src/launcher/ui/message_and_network_state.rs b/client/src/launcher/ui/message_and_network_state.rs index 3b16c93..c5a2aea 100644 --- a/client/src/launcher/ui/message_and_network_state.rs +++ b/client/src/launcher/ui/message_and_network_state.rs @@ -4,6 +4,12 @@ enum PowerMessage { Command(std::result::Result), } +#[cfg(not(coverage))] +enum CalibrationMessage { + Refresh(std::result::Result), + Command(std::result::Result), +} + #[cfg(not(coverage))] enum RelayMessage { Spawned(std::result::Result), diff --git a/client/src/launcher/ui/runtime_poll.rs b/client/src/launcher/ui/runtime_poll.rs index 1717e35..1508518 100644 --- a/client/src/launcher/ui/runtime_poll.rs +++ b/client/src/launcher/ui/runtime_poll.rs @@ -12,9 +12,11 @@ let last_focus_marker = Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); let power_request_in_flight = Rc::clone(&power_request_in_flight); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); let relay_request_in_flight = Rc::clone(&relay_request_in_flight); let preview = preview.clone(); let power_tx = power_tx.clone(); + let calibration_tx = calibration_tx.clone(); let caps_tx = caps_tx.clone(); let caps_request_in_flight = Rc::clone(&caps_request_in_flight); let diagnostics_network = Rc::clone(&diagnostics_network); @@ -70,6 +72,11 @@ server_addr.clone(), Duration::from_millis(250), ); + request_calibration_refresh( + calibration_tx.clone(), + server_addr.clone(), + Duration::from_millis(350), + ); request_capture_power_refresh( power_tx.clone(), server_addr, @@ -144,6 +151,11 @@ server_addr.clone(), Duration::from_millis(250), ); + request_calibration_refresh( + calibration_tx.clone(), + server_addr.clone(), + Duration::from_millis(300), + ); request_capture_power_refresh( power_tx.clone(), server_addr, @@ -253,6 +265,43 @@ } } + while let Ok(message) = calibration_rx.try_recv() { + calibration_request_in_flight.set(false); + let is_command = matches!(message, CalibrationMessage::Command(_)); + match message { + CalibrationMessage::Refresh(Ok(calibration)) + | CalibrationMessage::Command(Ok(calibration)) => { + let summary = calibration_summary(&calibration); + { + let mut state = state.borrow_mut(); + state.set_server_available(true); + state.set_calibration(calibration); + } + if is_command { + widgets.status_label.set_text(&summary); + } + } + CalibrationMessage::Refresh(Err(err)) => { + let relay_live = child_proc.borrow().is_some() + || state.borrow().remote_active; + let mut state = state.borrow_mut(); + if relay_live { + state.set_server_available(true); + } + state.set_calibration(unavailable_calibration(err)); + } + CalibrationMessage::Command(Err(err)) => { + { + let mut state = state.borrow_mut(); + state.set_calibration(unavailable_calibration(err.clone())); + } + widgets + .status_label + .set_text(&format!("Calibration update failed: {err}")); + } + } + } + while let Ok(message) = caps_rx.try_recv() { caps_request_in_flight.set(false); match message { @@ -329,6 +378,21 @@ next_power_probe.set(now + Duration::from_secs(2)); } + if now >= next_calibration_probe.get() + && !calibration_request_in_flight.get() + && (child_running || state.borrow().server_available) + { + calibration_request_in_flight.set(true); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + request_calibration_refresh( + calibration_tx.clone(), + server_addr, + Duration::ZERO, + ); + next_calibration_probe.set(now + Duration::from_secs(2)); + } + if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() { caps_request_in_flight.set(true); let server_addr = diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index a3b3ec7..188cc19 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -1,238 +1,4 @@ { - const DEFAULT_EYE_RECORD_FPS: u32 = 30; - - #[derive(Default)] - struct EyeRecordState { - save_dir_override: Option, - timer: Option, - frame_dir: Option, - frame_writer_tx: Option>, - finalize_rx: Option>>, - next_frame_index: u32, - } - - enum RecordFrameTask { - Frame { - texture: gtk::gdk::Texture, - frame_path: PathBuf, - }, - Finish, - } - - fn eye_slug(title: &str) -> &'static str { - if title.to_ascii_lowercase().contains("left") { - "left-eye" - } else { - "right-eye" - } - } - - fn timestamp_slug() -> String { - if let Ok(now) = glib::DateTime::now_local() - && let Ok(stamp) = now.format("%Y%m%d-%H%M%S") - { - return stamp.to_string(); - } - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - format!("{}-{:03}", now.as_secs(), now.subsec_millis()) - } - - fn expand_home_token(raw: &str) -> PathBuf { - if raw.contains("$HOME") { - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy())); - } - } - if let Some(rest) = raw.strip_prefix("~/") - && let Some(home) = std::env::var_os("HOME") - { - return PathBuf::from(home).join(rest); - } - PathBuf::from(raw) - } - - fn default_eye_capture_root() -> PathBuf { - if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") { - let path = expand_home_token(&raw.to_string_lossy()); - if !path.as_os_str().is_empty() { - return path.join("lesavka"); - } - } - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home).join("Pictures").join("lesavka"); - } - if let Some(profile) = std::env::var_os("USERPROFILE") { - return PathBuf::from(profile).join("Pictures").join("lesavka"); - } - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join("lesavka") - } - - fn ensure_eye_capture_root(override_dir: Option<&PathBuf>) -> Result { - let root = override_dir - .cloned() - .unwrap_or_else(default_eye_capture_root); - std::fs::create_dir_all(&root) - .map_err(|err| format!("could not create {}: {err}", root.display()))?; - Ok(root) - } - - fn unique_capture_path(root: &PathBuf, stem: &str, ext: &str) -> PathBuf { - let mut candidate = root.join(format!("{stem}.{ext}")); - if !candidate.exists() { - return candidate; - } - for idx in 1..1000 { - candidate = root.join(format!("{stem}-{idx}.{ext}")); - if !candidate.exists() { - break; - } - } - candidate - } - - fn current_eye_texture(picture: >k::Picture) -> Result { - let paintable = picture - .paintable() - .ok_or_else(|| "no live frame is available yet".to_string())?; - paintable - .downcast::() - .map_err(|_| "the current frame is not directly exportable".to_string()) - } - - fn save_texture_png(texture: >k::gdk::Texture, output_path: &PathBuf) -> Result<(), String> { - texture - .save_to_png(output_path) - .map_err(|err| format!("could not write {}: {err}", output_path.display())) - } - - fn recording_interval_ms(record_fps: u32) -> u64 { - let fps = record_fps.max(1); - (1000_u64 / fps as u64).max(1) - } - - fn best_effort_recording_profile( - state: &LauncherState, - preview: Option<&LauncherPreview>, - monitor_id: usize, - ) -> (u32, u32) { - let choice = state - .display_capture_size_choice(monitor_id) - .unwrap_or_else(|| state.capture_size_choice(monitor_id)); - let mut fps = if choice.fps == 0 { - DEFAULT_EYE_RECORD_FPS - } else { - choice.fps.max(1) - }; - if let Some(snapshot) = - preview.and_then(|feed| feed.snapshot_metrics(monitor_id, PreviewSurface::Inline)) - && snapshot.server_fps.is_finite() - && snapshot.server_fps >= 1.0 - { - fps = snapshot.server_fps.round().clamp(1.0, 120.0) as u32; - } - let bitrate_kbit = choice.max_bitrate_kbit.max(800); - (fps, bitrate_kbit) - } - - fn queue_record_frame(state: &mut EyeRecordState, picture: >k::Picture) -> Result<(), String> { - let frame_dir = state - .frame_dir - .as_ref() - .ok_or_else(|| "recording session is not initialized".to_string())? - .clone(); - let frame_writer_tx = state - .frame_writer_tx - .as_ref() - .ok_or_else(|| "recording worker is not initialized".to_string())? - .clone(); - let texture = current_eye_texture(picture)?; - let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index)); - frame_writer_tx - .send(RecordFrameTask::Frame { - texture, - frame_path, - }) - .map_err(|_| "recording worker stopped unexpectedly".to_string())?; - state.next_frame_index = state.next_frame_index.saturating_add(1); - Ok(()) - } - - fn encode_recording( - frame_dir: &PathBuf, - output_path: &PathBuf, - encode_fps: u32, - encode_bitrate_kbit: u32, - ) -> Result<(), String> { - let frame_pattern = frame_dir.join("frame-%06d.png"); - let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); - let encode = Command::new("ffmpeg") - .args([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-framerate", - &encode_fps.max(1).to_string(), - "-i", - &frame_pattern.to_string_lossy(), - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-r", - &encode_fps.max(1).to_string(), - "-b:v", - &bitrate_arg, - &output_path.to_string_lossy(), - ]) - .status() - .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; - - if !encode.success() { - return Err(format!( - "ffmpeg failed while encoding {}; frame data is still in {}", - output_path.display(), - frame_dir.display() - )); - } - Ok(()) - } - - fn run_recording_worker( - frame_rx: std::sync::mpsc::Receiver, - frame_dir: PathBuf, - output_path: PathBuf, - encode_fps: u32, - encode_bitrate_kbit: u32, - ) -> Result { - let mut captured_frames = 0_u32; - loop { - match frame_rx.recv() { - Ok(RecordFrameTask::Frame { - texture, - frame_path, - }) => { - save_texture_png(&texture, &frame_path)?; - captured_frames = captured_frames.saturating_add(1); - } - Ok(RecordFrameTask::Finish) | Err(_) => break, - } - } - - if captured_frames < 2 { - let _ = std::fs::remove_dir_all(&frame_dir); - return Err("need at least two captured frames to build a recording".to_string()); - } - - encode_recording(&frame_dir, &output_path, encode_fps, encode_bitrate_kbit)?; - let _ = std::fs::remove_dir_all(&frame_dir); - Ok(output_path) - } - { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); @@ -285,246 +51,6 @@ }); }); } - - for monitor_id in 0..2 { - let pane = widgets.display_panes[monitor_id].clone(); - let widgets_for_ui = widgets.clone(); - let save_state = Rc::new(RefCell::new(EyeRecordState::default())); - - { - let pane = pane.clone(); - let widgets = widgets_for_ui.clone(); - let save_state = Rc::clone(&save_state); - let window_for_save = window.clone(); - pane.save_button.connect_clicked(move |_| { - let chooser = gtk::FileChooserNative::new( - Some("Choose Eye Capture Folder"), - Some(&window_for_save), - gtk::FileChooserAction::SelectFolder, - Some("Select"), - Some("Cancel"), - ); - chooser.set_modal(true); - let save_state = Rc::clone(&save_state); - let widgets = widgets.clone(); - let eye_name = pane.title.clone(); - chooser.connect_response(move |dialog, response| { - if response == gtk::ResponseType::Accept { - if let Some(folder) = dialog.file().and_then(|file| file.path()) { - save_state.borrow_mut().save_dir_override = Some(folder.clone()); - widgets.status_label.set_text(&format!( - "{} saves now go to {}.", - eye_name, - folder.display() - )); - } else { - widgets.status_label.set_text( - "Capture folder selection did not return a filesystem path.", - ); - } - } - dialog.destroy(); - }); - chooser.show(); - }); - } - - { - let pane = pane.clone(); - let widgets = widgets_for_ui.clone(); - let save_state = Rc::clone(&save_state); - pane.clip_button.connect_clicked(move |_| { - let root = { - let borrowed = save_state.borrow(); - match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) { - Ok(path) => path, - Err(err) => { - widgets - .status_label - .set_text(&format!("Could not prepare capture folder: {err}")); - return; - } - } - }; - - let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug()); - let clip_path = unique_capture_path(&root, &stem, "png"); - match current_eye_texture(&pane.picture) - .and_then(|texture| save_texture_png(&texture, &clip_path)) - { - Ok(()) => { - widgets.status_label.set_text(&format!( - "{} clip saved to {}.", - pane.title, - clip_path.display() - )); - } - Err(err) => { - widgets - .status_label - .set_text(&format!("{} clip failed: {err}", pane.title)); - } - } - }); - } - - { - let pane = pane.clone(); - let widgets = widgets_for_ui.clone(); - let save_state = Rc::clone(&save_state); - let state = Rc::clone(&state); - let preview = preview.clone(); - let record_button = pane.record_button.clone(); - record_button.connect_clicked(move |button| { - if save_state.borrow().timer.is_some() { - let finalize_rx = { - let mut state = save_state.borrow_mut(); - if let Some(timer) = state.timer.take() { - timer.remove(); - } - if let Some(frame_writer_tx) = state.frame_writer_tx.take() { - let _ = frame_writer_tx.send(RecordFrameTask::Finish); - } - state.next_frame_index = 0; - state.frame_dir = None; - state.finalize_rx.take() - }; - let Some(finalize_rx) = finalize_rx else { - button.set_label("Record"); - widgets.status_label.set_text(&format!( - "{} recording stop failed: recording worker state was missing.", - pane.title - )); - return; - }; - - button.set_sensitive(false); - button.set_label("Finishing..."); - let button = button.clone(); - let widgets = widgets.clone(); - let pane_title = pane.title.clone(); - glib::timeout_add_local(Duration::from_millis(100), move || match finalize_rx - .try_recv() - { - Ok(Ok(output)) => { - button.set_sensitive(true); - button.set_label("Record"); - widgets.status_label.set_text(&format!( - "{} recording saved to {}.", - pane_title, - output.display() - )); - glib::ControlFlow::Break - } - Ok(Err(err)) => { - button.set_sensitive(true); - button.set_label("Record"); - widgets.status_label.set_text(&format!( - "{} recording stop failed: {err}", - pane_title, - )); - glib::ControlFlow::Break - } - Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - button.set_sensitive(true); - button.set_label("Record"); - widgets.status_label.set_text(&format!( - "{} recording stop failed: recording worker disconnected.", - pane_title - )); - glib::ControlFlow::Break - } - }); - return; - } - - let (record_fps, record_bitrate_kbit) = { - let state = state.borrow(); - best_effort_recording_profile(&state, preview.as_deref(), monitor_id) - }; - let root = { - let borrowed = save_state.borrow(); - match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) { - Ok(path) => path, - Err(err) => { - widgets - .status_label - .set_text(&format!("Could not prepare capture folder: {err}")); - return; - } - } - }; - - let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug()); - let output_path = unique_capture_path(&root, &recording_stem, "mp4"); - let frame_dir = root.join(format!("{}.frames", recording_stem)); - if let Err(err) = std::fs::create_dir_all(&frame_dir) { - widgets.status_label.set_text(&format!( - "{} record failed creating frame cache {}: {err}", - pane.title, - frame_dir.display() - )); - return; - } - - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); - let (result_tx, result_rx) = std::sync::mpsc::channel::>(); - let frame_dir_worker = frame_dir.clone(); - let output_path_worker = output_path.clone(); - std::thread::spawn(move || { - let result = run_recording_worker( - frame_rx, - frame_dir_worker, - output_path_worker, - record_fps, - record_bitrate_kbit, - ); - let _ = result_tx.send(result); - }); - - { - let mut state = save_state.borrow_mut(); - state.frame_dir = Some(frame_dir); - state.frame_writer_tx = Some(frame_tx); - state.finalize_rx = Some(result_rx); - state.next_frame_index = 0; - } - - let pane_for_tick = pane.clone(); - let widgets_for_tick = widgets.clone(); - let save_state_for_tick = Rc::clone(&save_state); - let timer = glib::timeout_add_local( - Duration::from_millis(recording_interval_ms(record_fps)), - move || { - let mut state = save_state_for_tick.borrow_mut(); - if state.frame_dir.is_none() { - return glib::ControlFlow::Break; - } - if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) { - if let Some(frame_writer_tx) = state.frame_writer_tx.take() { - let _ = frame_writer_tx.send(RecordFrameTask::Finish); - } - widgets_for_tick.status_label.set_text(&format!( - "{} recording frame skipped: {err}", - pane_for_tick.title - )); - return glib::ControlFlow::Break; - } - glib::ControlFlow::Continue - }, - ); - save_state.borrow_mut().timer = Some(timer); - button.set_sensitive(true); - button.set_label("Stop"); - widgets.status_label.set_text(&format!( - "Recording {} at {} fps (~{} kbit)... press Stop to finish.", - pane.title, record_fps, record_bitrate_kbit - )); - }); - } - } - { let widgets = widgets.clone(); let server_entry = server_entry.clone(); @@ -645,6 +171,144 @@ }); } + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let calibration_tx = calibration_tx.clone(); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); + widgets.calibration_default_button.connect_clicked(move |_| { + let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets + .status_label + .set_text("Calibration 1/2: restoring saved upstream A/V default..."); + calibration_request_in_flight.set(true); + request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| { + restore_default_calibration(server_addr) + }); + }); + } + + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let calibration_tx = calibration_tx.clone(); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); + widgets.calibration_factory_button.connect_clicked(move |_| { + let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets + .status_label + .set_text("Calibration 1/2: restoring factory MJPEG upstream A/V baseline..."); + calibration_request_in_flight.set(true); + request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| { + restore_factory_calibration(server_addr) + }); + }); + } + + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let calibration_tx = calibration_tx.clone(); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); + widgets.calibration_minus_button.connect_clicked(move |_| { + let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets + .status_label + .set_text("Calibration 1/2: nudging upstream audio 5 ms earlier..."); + calibration_request_in_flight.set(true); + request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| { + nudge_audio_calibration(server_addr, -5_000) + }); + }); + } + + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let calibration_tx = calibration_tx.clone(); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); + widgets.calibration_plus_button.connect_clicked(move |_| { + let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets + .status_label + .set_text("Calibration 1/2: nudging upstream audio 5 ms later..."); + calibration_request_in_flight.set(true); + request_calibration_command(calibration_tx.clone(), server_addr, |server_addr| { + nudge_audio_calibration(server_addr, 5_000) + }); + }); + } + + { + let widgets = widgets.clone(); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let calibration_tx = calibration_tx.clone(); + let calibration_request_in_flight = Rc::clone(&calibration_request_in_flight); + widgets.calibration_blind_button.connect_clicked(move |_| { + let Some(sample) = widgets.diagnostics_log.borrow().latest().cloned() else { + widgets.status_label.set_text( + "Blind calibration needs a live upstream camera and microphone sample first.", + ); + return; + }; + let camera = sample.upstream_camera; + let microphone = sample.upstream_microphone; + if !camera.connected || !microphone.connected { + widgets.status_label.set_text( + "Blind calibration refused: upstream camera and microphone are not both live.", + ); + return; + } + let delivery_delta_ms = + microphone.latest_delivery_age_ms - camera.latest_delivery_age_ms; + let delivery_skew_ms = delivery_delta_ms.abs(); + let enqueue_skew_ms = + (microphone.latest_enqueue_age_ms - camera.latest_enqueue_age_ms).abs(); + if camera.queue_depth >= 28 || microphone.queue_depth >= 14 || delivery_skew_ms > 80.0 + { + widgets.status_label.set_text( + "Blind calibration refused: live queues are too backed up to make a safe timing estimate. Use the test rig or fix queue churn first.", + ); + return; + } + let audio_delta_us = (-(delivery_delta_ms as f64) * 500.0) + .round() + .clamp(-10_000.0, 10_000.0) as i64; + let note = format!( + "blind estimate from live telemetry: mic-camera delivery delta {delivery_delta_ms:+.1}ms, enqueue skew {enqueue_skew_ms:.1}ms; applying half-step audio delta {:+.1}ms", + audio_delta_us as f64 / 1000.0 + ); + let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets + .status_label + .set_text("Calibration 1/2: applying blind upstream A/V estimate..."); + calibration_request_in_flight.set(true); + request_calibration_command(calibration_tx.clone(), server_addr, move |server_addr| { + blind_calibration_estimate( + server_addr, + audio_delta_us, + delivery_skew_ms, + enqueue_skew_ms, + ¬e, + ) + }); + }); + } + + { + let widgets = widgets.clone(); + widgets.calibration_rig_button.connect_clicked(move |_| { + widgets.status_label.set_text( + "Rig calibration wizard is queued for the 0.16.0 test-equipment phase; for now the manual Tethys sync battery remains the measured-default path.", + ); + }); + } + { let widgets = widgets.clone(); widgets.diagnostics_copy_button.connect_clicked(move |_| { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 18ea02d..cd92ed6 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -89,6 +89,12 @@ pub fn build_launcher_view( usb_recover_button, uac_recover_button, uvc_recover_button, + calibration_default_button, + calibration_factory_button, + calibration_blind_button, + calibration_minus_button, + calibration_plus_button, + calibration_rig_button, power_auto_button, power_on_button, power_off_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index c1b900c..9bb899f 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -145,6 +145,12 @@ usb_recover_button: usb_recover_button.clone(), uac_recover_button: uac_recover_button.clone(), uvc_recover_button: uvc_recover_button.clone(), + calibration_default_button: calibration_default_button.clone(), + calibration_factory_button: calibration_factory_button.clone(), + calibration_blind_button: calibration_blind_button.clone(), + calibration_minus_button: calibration_minus_button.clone(), + calibration_plus_button: calibration_plus_button.clone(), + calibration_rig_button: calibration_rig_button.clone(), device_refresh_button: device_refresh_button.clone(), swap_key_button: swap_key_button.clone(), camera_test_button: camera_test_button.clone(), diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 63fd9f8..a8b9c60 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -59,6 +59,12 @@ struct OperationsRailContext { usb_recover_button: gtk::Button, uac_recover_button: gtk::Button, uvc_recover_button: gtk::Button, + calibration_default_button: gtk::Button, + calibration_factory_button: gtk::Button, + calibration_blind_button: gtk::Button, + calibration_minus_button: gtk::Button, + calibration_plus_button: gtk::Button, + calibration_rig_button: gtk::Button, power_auto_button: gtk::Button, power_on_button: gtk::Button, power_off_button: gtk::Button, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index 0a0e9dd..18846b8 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -1,5 +1,5 @@ { - let (connection_panel, connection_body) = build_panel("Relay Controls"); + let (connection_panel, connection_body) = build_panel("Relay"); let server_entry = gtk::Entry::new(); server_entry.add_css_class("server-entry"); server_entry.set_hexpand(true); @@ -39,20 +39,50 @@ connection_body.append(&recovery_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); - let tools_heading = gtk::Label::new(Some("Tools")); - tools_heading.add_css_class("subgroup-title"); - tools_heading.set_halign(gtk::Align::Start); - let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - tools_row.set_hexpand(true); - tools_heading.set_width_chars(10); - tools_row.append(&tools_heading); - let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); - tools_buttons.set_hexpand(true); - tools_buttons.set_homogeneous(true); - let clipboard_button = rail_button("Clipboard", "Type clipboard remotely."); - tools_buttons.append(&clipboard_button); - tools_row.append(&tools_buttons); - connection_body.append(&tools_row); + let calibration_heading = gtk::Label::new(Some("AV Upstream\nCalibration")); + calibration_heading.add_css_class("subgroup-title"); + calibration_heading.set_halign(gtk::Align::Start); + calibration_heading.set_width_chars(12); + let calibration_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + calibration_row.set_hexpand(true); + calibration_row.append(&calibration_heading); + let calibration_buttons = gtk::Grid::new(); + calibration_buttons.set_column_homogeneous(true); + calibration_buttons.set_column_spacing(8); + calibration_buttons.set_row_spacing(6); + calibration_buttons.set_hexpand(true); + let calibration_default_button = rail_button( + "Default", + "Restore the saved upstream A/V calibration profile for this relay.", + ); + let calibration_factory_button = rail_button( + "Factory", + "Restore the release-shipped MJPEG upstream A/V calibration.", + ); + let calibration_blind_button = rail_button( + "Blind", + "Estimate upstream A/V calibration from live queue and timestamp telemetry; use only when no test rig is attached.", + ); + let calibration_minus_button = rail_button( + "-5 ms", + "Move upstream audio 5 ms earlier relative to video for the active session.", + ); + let calibration_plus_button = rail_button( + "+5 ms", + "Move upstream audio 5 ms later relative to video for the active session.", + ); + let calibration_rig_button = rail_button( + "Rig...", + "Open the test-equipment calibration path for measuring a new saved default.", + ); + calibration_buttons.attach(&calibration_default_button, 0, 0, 1, 1); + calibration_buttons.attach(&calibration_factory_button, 1, 0, 1, 1); + calibration_buttons.attach(&calibration_blind_button, 2, 0, 1, 1); + calibration_buttons.attach(&calibration_minus_button, 0, 1, 1, 1); + calibration_buttons.attach(&calibration_plus_button, 1, 1, 1, 1); + calibration_buttons.attach(&calibration_rig_button, 2, 1, 1, 1); + calibration_row.append(&calibration_buttons); + connection_body.append(&calibration_row); connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); let power_heading = gtk::Label::new(Some("GPIO Power")); @@ -113,6 +143,22 @@ routing_buttons.append(&swap_key_button); routing_row.append(&routing_buttons); connection_body.append(&routing_row); + + connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal)); + let tools_heading = gtk::Label::new(Some("Tools")); + tools_heading.add_css_class("subgroup-title"); + tools_heading.set_halign(gtk::Align::Start); + let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + tools_row.set_hexpand(true); + tools_heading.set_width_chars(10); + tools_row.append(&tools_heading); + let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); + tools_buttons.set_hexpand(true); + tools_buttons.set_homogeneous(true); + let clipboard_button = rail_button("Clipboard", "Type clipboard remotely."); + tools_buttons.append(&clipboard_button); + tools_row.append(&tools_buttons); + connection_body.append(&tools_row); operations.append(&connection_panel); let (diagnostics_panel, diagnostics_body) = build_panel("Diagnostics"); @@ -235,6 +281,12 @@ usb_recover_button, uac_recover_button, uvc_recover_button, + calibration_default_button, + calibration_factory_button, + calibration_blind_button, + calibration_minus_button, + calibration_plus_button, + calibration_rig_button, power_auto_button, power_on_button, power_off_button, diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index c844e95..130f7ea 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -158,17 +158,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { breakout_combo.set_hexpand(true); let clip_button = gtk::Button::with_label("Clip"); - stabilize_button(&clip_button, 72); + stabilize_button(&clip_button, 66); clip_button.set_tooltip_text(Some("Capture a still image for this eye.")); let record_button = gtk::Button::with_label("Record"); - stabilize_button(&record_button, 84); + stabilize_button(&record_button, 78); record_button.set_tooltip_text(Some("Record this eye feed until you stop.")); let save_button = gtk::Button::with_label("Save"); - stabilize_button(&save_button, 72); + stabilize_button(&save_button, 66); save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings.")); let action_button = gtk::Button::with_label("Break Out"); - stabilize_button(&action_button, 90); + stabilize_button(&action_button, 82); action_button.set_halign(gtk::Align::End); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); diff --git a/client/src/launcher/ui_components/panel_chips.rs b/client/src/launcher/ui_components/panel_chips.rs index ca95015..e2af709 100644 --- a/client/src/launcher/ui_components/panel_chips.rs +++ b/client/src/launcher/ui_components/panel_chips.rs @@ -2,6 +2,7 @@ fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { build_panel_with_action(title, None) } +/// Build a vertical panel with an optional header action. fn build_panel_with_action(title: &str, action: Option<>k::Widget>) -> (gtk::Box, gtk::Box) { let panel = gtk::Box::new(gtk::Orientation::Vertical, 8); panel.add_css_class("panel"); @@ -28,6 +29,7 @@ fn build_subgroup(title: &str) -> gtk::Box { build_subgroup_with_action(title, None) } +/// Build a titled subgroup row matching the relay-control section style. fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk::Box { let group = gtk::Box::new(gtk::Orientation::Vertical, 8); group.add_css_class("subgroup"); @@ -45,6 +47,7 @@ fn build_subgroup_with_action(title: &str, action: Option<>k::Widget>) -> gtk: group } +/// Build a fixed-width status chip so changing labels do not move the header row. fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); @@ -67,6 +70,7 @@ fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { (chip, value_widget) } +/// Build a fixed-width status chip with a colored health light. fn build_status_chip_with_light(label: &str, value: &str) -> (gtk::Box, gtk::Box, gtk::Label) { let chip = gtk::Box::new(gtk::Orientation::Vertical, 4); chip.add_css_class("status-chip"); diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index d5806fe..eeea994 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -152,6 +152,12 @@ pub struct LauncherWidgets { pub usb_recover_button: gtk::Button, pub uac_recover_button: gtk::Button, pub uvc_recover_button: gtk::Button, + pub calibration_default_button: gtk::Button, + pub calibration_factory_button: gtk::Button, + pub calibration_blind_button: gtk::Button, + pub calibration_minus_button: gtk::Button, + pub calibration_plus_button: gtk::Button, + pub calibration_rig_button: gtk::Button, pub device_refresh_button: gtk::Button, pub swap_key_button: gtk::Button, pub camera_test_button: gtk::Button, @@ -205,11 +211,11 @@ 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 = 1540; const LAUNCHER_DEFAULT_HEIGHT: i32 = 880; -const OPERATIONS_RAIL_WIDTH: i32 = 288; +const OPERATIONS_RAIL_WIDTH: i32 = 276; const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 225; const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400; -const EYE_PREVIEW_MIN_HEIGHT: i32 = 315; -const EYE_PREVIEW_MIN_WIDTH: i32 = 560; +const EYE_PREVIEW_MIN_HEIGHT: i32 = 299; +const EYE_PREVIEW_MIN_WIDTH: i32 = 532; const SIDE_LOG_MIN_HEIGHT: i32 = 124; -const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 63; +const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 102; const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT; diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 024c1b0..046a352 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -101,6 +101,7 @@ fn normalize_version(version: &str) -> &str { version.trim().trim_start_matches('v') } +/// Show the connected server version, or an explicit unknown marker when disconnected. fn server_version_label(state: &LauncherState) -> String { if !state.server_available { return "???".to_string(); @@ -117,6 +118,7 @@ fn server_version_label(state: &LauncherState) -> String { } } +/// Summarize whether the composite USB gadget appears reachable to the host. fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) { if !state.server_available { return (StatusLightState::Idle, "Offline".to_string()); @@ -136,6 +138,7 @@ fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) { (StatusLightState::Caution, "Partial".to_string()) } +/// Summarize whether the UAC microphone/audio function is advertised by the relay. fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) { if !state.server_available { return (StatusLightState::Idle, "Offline".to_string()); @@ -147,6 +150,7 @@ fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) { } } +/// Summarize whether the UVC camera function is advertised with the expected codec. fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) { if !state.server_available { return (StatusLightState::Idle, "Offline".to_string()); diff --git a/client/src/lib.rs b/client/src/lib.rs index 3530fbb..a15cb01 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -15,6 +15,7 @@ pub mod layout; pub(crate) mod live_capture_clock; pub mod output; pub mod paste; +pub mod relay_transport; pub mod sync_probe; pub(crate) mod uplink_fresh_queue; pub(crate) mod uplink_latency_harness; diff --git a/client/src/relay_transport.rs b/client/src/relay_transport.rs new file mode 100644 index 0000000..54e9acc --- /dev/null +++ b/client/src/relay_transport.rs @@ -0,0 +1,257 @@ +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; + +const DEFAULT_CLIENT_PKI_DIR: &str = ".config/lesavka/pki"; + +pub fn endpoint(server_addr: &str) -> Result { + enforce_transport_policy(server_addr)?; + let mut endpoint = + Channel::from_shared(server_addr.to_string()).context("invalid relay server address")?; + if is_https(server_addr) { + endpoint = endpoint + .tls_config(client_tls_config(server_addr)?) + .context("configuring relay TLS")?; + } + Ok(endpoint) +} + +pub async fn connect(server_addr: &str) -> Result { + endpoint(server_addr)? + .tcp_nodelay(true) + .connect() + .await + .with_context(|| format!("connecting to relay at {server_addr}")) +} + +pub fn enforce_transport_policy(server_addr: &str) -> Result<()> { + if is_https(server_addr) || is_local_http(server_addr) || allow_insecure_transport() { + return Ok(()); + } + + bail!( + "refusing insecure relay transport for {server_addr}; use https:// or set LESAVKA_ALLOW_INSECURE=1 for a deliberate lab override" + ) +} + +fn client_tls_config(server_addr: &str) -> Result { + let mut tls = ClientTlsConfig::new() + .domain_name(tls_domain(server_addr)) + .with_enabled_roots(); + + if let Some(ca_path) = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")) + && ca_path.exists() + { + let ca = std::fs::read(&ca_path) + .with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?; + tls = tls.ca_certificate(Certificate::from_pem(ca)); + } + + let cert_path = env_path("LESAVKA_TLS_CLIENT_CERT").or_else(|| default_pki_path("client.crt")); + let key_path = env_path("LESAVKA_TLS_CLIENT_KEY").or_else(|| default_pki_path("client.key")); + if let (Some(cert_path), Some(key_path)) = (cert_path, key_path) + && cert_path.exists() + && key_path.exists() + { + let cert = std::fs::read(&cert_path) + .with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?; + let key = std::fs::read(&key_path) + .with_context(|| format!("reading TLS client key {}", key_path.display()))?; + tls = tls.identity(Identity::from_pem(cert, key)); + } + + Ok(tls) +} + +fn tls_domain(server_addr: &str) -> String { + std::env::var("LESAVKA_TLS_DOMAIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + host_from_uri(server_addr) + .unwrap_or("lesavka-server") + .to_string() + }) +} + +fn env_path(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn default_pki_path(file_name: &str) -> Option { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join(DEFAULT_CLIENT_PKI_DIR) + .join(file_name), + ) +} + +fn allow_insecure_transport() -> bool { + std::env::var("LESAVKA_ALLOW_INSECURE") + .map(|value| { + let value = value.trim().to_ascii_lowercase(); + !value.is_empty() && value != "0" && value != "false" && value != "no" + }) + .unwrap_or(false) +} + +fn is_https(server_addr: &str) -> bool { + server_addr + .get(..8) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://")) +} + +fn is_local_http(server_addr: &str) -> bool { + let Some(rest) = server_addr.strip_prefix("http://") else { + return false; + }; + let host = rest + .split(['/', '?', '#']) + .next() + .unwrap_or_default() + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(rest); + let host = if host.starts_with('[') { + host.split(']') + .next() + .unwrap_or(host) + .trim_start_matches('[') + } else { + host.split(':').next().unwrap_or(host) + }; + matches!(host, "localhost" | "::1" | "0.0.0.0") || host.starts_with("127.") +} + +fn host_from_uri(server_addr: &str) -> Option<&str> { + let rest = server_addr.split_once("://")?.1; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + let authority = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + if authority.starts_with('[') { + return authority + .split(']') + .next() + .map(|host| host.trim_start_matches('[')) + .filter(|host| !host.is_empty()); + } + authority.split(':').next().filter(|host| !host.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::{endpoint, enforce_transport_policy, host_from_uri, is_local_http, tls_domain}; + use temp_env::with_vars; + use tempfile::tempdir; + + #[test] + fn localhost_http_is_allowed_for_tunnels() { + with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || { + assert!(enforce_transport_policy("http://127.0.0.1:50051").is_ok()); + assert!(enforce_transport_policy("http://localhost:50051").is_ok()); + assert!(enforce_transport_policy("http://[::1]:50051").is_ok()); + }); + } + + #[test] + fn public_http_requires_deliberate_override() { + with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || { + let err = enforce_transport_policy("http://38.28.125.112:50051") + .expect_err("public http must be rejected"); + assert!( + err.to_string() + .contains("refusing insecure relay transport") + ); + }); + with_vars([("LESAVKA_ALLOW_INSECURE", Some("1"))], || { + assert!(enforce_transport_policy("http://38.28.125.112:50051").is_ok()); + }); + } + + #[test] + fn tls_domain_can_be_overridden_for_ip_backed_servers() { + with_vars([("LESAVKA_TLS_DOMAIN", None::<&str>)], || { + assert_eq!(tls_domain("https://38.28.125.112:50051"), "38.28.125.112"); + }); + with_vars([("LESAVKA_TLS_DOMAIN", Some("lesavka-server"))], || { + assert_eq!(tls_domain("https://38.28.125.112:50051"), "lesavka-server"); + }); + } + + #[test] + fn endpoint_reports_invalid_local_uri_after_security_policy_passes() { + let err = endpoint("http://[::1").expect_err("malformed local URI should fail"); + assert!(err.to_string().contains("invalid relay server address")); + } + + #[test] + fn local_http_policy_handles_ipv4_ipv6_and_auth_shapes() { + assert!(is_local_http("http://user:pass@127.0.0.1:50051/path")); + assert!(is_local_http("http://[::1]:50051")); + assert!(is_local_http("http://0.0.0.0:50051")); + assert!(!is_local_http("https://127.0.0.1:50051")); + assert!(!is_local_http("http://38.28.125.112:50051")); + } + + #[test] + fn host_parser_covers_ipv6_auth_and_missing_scheme() { + assert_eq!( + host_from_uri("https://user@lesavka-server:50051"), + Some("lesavka-server") + ); + assert_eq!(host_from_uri("https://[::1]:50051"), Some("::1")); + assert_eq!(host_from_uri("lesavka-server:50051"), None); + } + + #[test] + fn https_endpoint_reads_default_and_explicit_pki_paths_when_present() { + let dir = tempdir().expect("pki dir"); + let pki = dir.path().join(".config/lesavka/pki"); + std::fs::create_dir_all(&pki).expect("create pki"); + std::fs::write(pki.join("ca.crt"), b"not a real ca").expect("ca"); + std::fs::write(pki.join("client.crt"), b"not a real cert").expect("cert"); + std::fs::write(pki.join("client.key"), b"not a real key").expect("key"); + + with_vars( + [ + ("HOME", Some(dir.path().to_string_lossy().as_ref())), + ("LESAVKA_TLS_CA", None::<&str>), + ("LESAVKA_TLS_CLIENT_CERT", None::<&str>), + ("LESAVKA_TLS_CLIENT_KEY", None::<&str>), + ], + || { + let err = endpoint("https://lesavka-server:50051") + .expect_err("fake PEM should be rejected after file discovery"); + assert!(err.to_string().contains("configuring relay TLS")); + }, + ); + + with_vars( + [ + ("HOME", None::<&str>), + ( + "LESAVKA_TLS_CA", + Some(pki.join("ca.crt").to_string_lossy().as_ref()), + ), + ( + "LESAVKA_TLS_CLIENT_CERT", + Some(pki.join("client.crt").to_string_lossy().as_ref()), + ), + ( + "LESAVKA_TLS_CLIENT_KEY", + Some(pki.join("client.key").to_string_lossy().as_ref()), + ), + ], + || { + let err = endpoint("https://lesavka-server:50051") + .expect_err("fake explicit PEM should be rejected after file discovery"); + assert!(err.to_string().contains("configuring relay TLS")); + }, + ); + } +} diff --git a/client/src/sync_probe/capture/tests/runtime_packets.rs b/client/src/sync_probe/capture/tests/runtime_packets.rs index 3b2214e..c24890e 100644 --- a/client/src/sync_probe/capture/tests/runtime_packets.rs +++ b/client/src/sync_probe/capture/tests/runtime_packets.rs @@ -235,33 +235,31 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() { .expect("runtime capture"); let video_queue = capture.video_queue(); - let mut dark_packet = None; - let mut pulse_packet = None; + let mut darkest_packet = None; + let mut brightest_packet = None; loop { let next = video_queue.pop_fresh().await; let Some(packet) = next.packet else { break; }; - if dark_packet.is_none() && (200_000..800_000).contains(&packet.pts) { - dark_packet = Some(packet.clone()); + let mean = decode_mjpeg_packet_mean_luma(&packet); + if mean <= 40 && darkest_packet.is_none() { + darkest_packet = Some((packet.clone(), mean)); } - if pulse_packet.is_none() && (1_000_000..1_120_000).contains(&packet.pts) { - pulse_packet = Some(packet.clone()); + if mean >= 180 && brightest_packet.is_none() { + brightest_packet = Some((packet.clone(), mean)); } - if dark_packet.is_some() && pulse_packet.is_some() { + if darkest_packet.is_some() && brightest_packet.is_some() { break; } } - let dark_packet = dark_packet.expect("dark packet"); - let pulse_packet = pulse_packet.expect("pulse packet"); + let (dark_packet, dark_mean) = darkest_packet.expect("dark packet"); + let (pulse_packet, pulse_mean) = brightest_packet.expect("pulse packet"); assert_ne!(dark_packet.data, pulse_packet.data); assert!(!dark_packet.data.is_empty()); assert!(!pulse_packet.data.is_empty()); - - let dark_mean = decode_mjpeg_packet_mean_luma(&dark_packet); - let pulse_mean = decode_mjpeg_packet_mean_luma(&pulse_packet); assert!( pulse_mean > dark_mean.saturating_add(100), "expected decoded pulse frame to be much brighter than decoded dark frame, got dark={dark_mean} pulse={pulse_mean}" @@ -271,6 +269,12 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() { #[cfg(not(coverage))] #[tokio::test] async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() { + let schedule = PulseSchedule::new( + Duration::from_secs(4), + Duration::from_secs(1), + Duration::from_millis(120), + 5, + ); let capture = SyncProbeCapture::new( CameraConfig { codec: CameraCodec::Mjpeg, @@ -278,12 +282,7 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() { height: 480, fps: 20, }, - PulseSchedule::new( - Duration::from_secs(4), - Duration::from_secs(1), - Duration::from_millis(120), - 5, - ), + schedule, Duration::from_secs(3), ) .expect("runtime capture"); @@ -296,9 +295,6 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() { let Some(packet) = next.packet else { break; }; - if packet.pts >= 1_000_000 { - break; - } dark_means.push(decode_mjpeg_packet_mean_luma(&packet)); if dark_means.len() >= 8 { break; @@ -306,7 +302,7 @@ async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() { } assert!( - dark_means.len() >= 4, + dark_means.len() >= 3, "expected several dark packets before the first pulse, got {dark_means:?}" ); let min = *dark_means.iter().min().expect("dark min"); diff --git a/client/src/sync_probe/runner.rs b/client/src/sync_probe/runner.rs index 18ccef6..ddc3073 100644 --- a/client/src/sync_probe/runner.rs +++ b/client/src/sync_probe/runner.rs @@ -148,8 +148,7 @@ async fn run_sync_probe(config: ProbeConfig) -> Result<()> { #[cfg(not(coverage))] async fn connect(server_addr: &str) -> Result { - Channel::from_shared(server_addr.to_string()) - .context("invalid relay server address")? + crate::relay_transport::endpoint(server_addr)? .tcp_nodelay(true) .connect() .await diff --git a/common/Cargo.toml b/common/Cargo.toml index 528778b..dc1a416 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.15.5" +version = "0.16.0" edition = "2024" build = "build.rs" @@ -9,7 +9,7 @@ name = "lesavka_common" path = "src/lib.rs" [dependencies] -tonic = { version = "0.13", features = ["transport"] } +tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] } prost = "0.13" anyhow = "1.0" base64 = "0.22" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index a12e33c..5cc6783 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -57,6 +57,38 @@ message SetCapturePowerRequest { CapturePowerCommand command = 2; } +enum CalibrationAction { + CALIBRATION_ACTION_UNSPECIFIED = 0; + CALIBRATION_ACTION_RESTORE_DEFAULT = 1; + CALIBRATION_ACTION_RESTORE_FACTORY = 2; + CALIBRATION_ACTION_ADJUST_ACTIVE = 3; + CALIBRATION_ACTION_BLIND_ESTIMATE = 4; + CALIBRATION_ACTION_SAVE_ACTIVE_AS_DEFAULT = 5; +} + +message CalibrationState { + string profile = 1; + int64 factory_audio_offset_us = 2; + int64 factory_video_offset_us = 3; + int64 default_audio_offset_us = 4; + int64 default_video_offset_us = 5; + int64 active_audio_offset_us = 6; + int64 active_video_offset_us = 7; + string source = 8; + string confidence = 9; + string updated_at = 10; + string detail = 11; +} + +message CalibrationRequest { + CalibrationAction action = 1; + int64 audio_delta_us = 2; + int64 video_delta_us = 3; + float observed_delivery_skew_ms = 4; + float observed_enqueue_skew_ms = 5; + string note = 6; +} + message HandshakeSet { bool camera = 1; bool microphone = 2; @@ -85,6 +117,8 @@ service Relay { rpc ResetUsb (Empty) returns (ResetUsbReply); rpc GetCapturePower (Empty) returns (CapturePowerState); rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState); + rpc GetCalibration (Empty) returns (CalibrationState); + rpc Calibrate (CalibrationRequest) returns (CalibrationState); } service Handshake { diff --git a/docs/operational-env.md b/docs/operational-env.md index 12f27ea..1a918fe 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -5,6 +5,7 @@ This is the tracked inventory for `LESAVKA_*` knobs used by source, scripts, CI, Hardware-facing assumptions belong near the code that uses them; this file is the repo-wide index. | `LESAVKA_ALLOW_GADGET_CYCLE` | document near use before promoting to operator config | | `LESAVKA_ALLOW_GADGET_RESET` | document near use before promoting to operator config | +| `LESAVKA_ALLOW_INSECURE` | client transport override; permits non-local `http://` relay URLs only for lab/debug use | | `LESAVKA_ALSA_DEV` | server hardware/device override | | `LESAVKA_ATTACH_WRITE_UDC` | server hardware/device override | | `LESAVKA_AUDIO_AUTO_RECOVER_AFTER` | client media capture/playback override | @@ -42,10 +43,12 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_CAM_TEST_ENCODER` | client media capture/playback override | | `LESAVKA_CAM_TEST_PATTERN` | client media capture/playback override | | `LESAVKA_CAM_WIDTH` | client media capture/playback override | +| `LESAVKA_CALIBRATION_PATH` | server upstream A/V calibration storage path override | | `LESAVKA_CAPTURE_POWER_GRACE_SECS` | runtime/install/session override | | `LESAVKA_CAPTURE_POWER_UNIT` | runtime/install/session override | | `LESAVKA_CAPTURE_REMOTE` | runtime/install/session override | | `LESAVKA_CLIENT_APP_SRC` | test/build contract variable; not runtime operator config | +| `LESAVKA_CLIENT_BUNDLE` | server installer output path for the generated client TLS enrollment bundle | | `LESAVKA_CLIENT_CAMERA_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_INPUTS_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_KEYBOARD_SRC` | test/build contract variable; not runtime operator config | @@ -55,6 +58,8 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_CLIENT_OUTPUT_AUDIO_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_OUTPUT_VIDEO_SRC` | test/build contract variable; not runtime operator config | +| `LESAVKA_CLIENT_PKI_BUNDLE` | client installer input path for a server-generated TLS enrollment bundle | +| `LESAVKA_CLIENT_PKI_DIR` | client installer/runtime TLS identity directory override | | `LESAVKA_CLIENT_RELAYCTL_BIN_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIENT_VIDEO_SUPPORT_SRC` | test/build contract variable; not runtime operator config | | `LESAVKA_CLIPBOARD_CHORD` | input routing/clipboard override | @@ -161,6 +166,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_PASTE_KEY_FILE` | input routing/clipboard override | | `LESAVKA_PASTE_MAX` | input routing/clipboard override | | `LESAVKA_PASTE_RPC` | input routing/clipboard override | +| `LESAVKA_PERFORMANCE_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for latency/performance checks | | `LESAVKA_PREVIEW_HEIGHT` | eye preview/video transport override | | `LESAVKA_PREVIEW_MAX_KBIT` | eye preview/video transport override | | `LESAVKA_PREVIEW_REQUEST_FPS` | eye preview/video transport override | @@ -170,6 +176,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_REF` | runtime/install/session override | | `LESAVKA_RELOAD_UVCVIDEO` | document near use before promoting to operator config | | `LESAVKA_REPO_URL` | runtime/install/session override | +| `LESAVKA_REQUIRE_TLS` | server security override; require TLS credentials before binding public relay service | | `LESAVKA_RGBA` | document near use before promoting to operator config | | `LESAVKA_SERVER_ADDR` | runtime/install/session override | | `LESAVKA_SERVER_BIND_ADDR` | server bind address override; defaults to `0.0.0.0:50051` | @@ -183,6 +190,18 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_SONAR_ENFORCE` | CI gate enforcement override | | `LESAVKA_SUPPLY_CHAIN_ENFORCE_TOOLS` | CI gate enforcement override | | `LESAVKA_TAP_AUDIO` | client media capture/playback override | +| `LESAVKA_TLS_CA` | client transport CA path override for relay TLS verification | +| `LESAVKA_TLS_CA_DAYS` | server installer certificate-authority lifetime override | +| `LESAVKA_TLS_CERT` | server TLS certificate path override | +| `LESAVKA_TLS_CERT_DAYS` | server installer leaf certificate lifetime override | +| `LESAVKA_TLS_CLIENT_AUTH_OPTIONAL` | server TLS override; allow clients without certs only for controlled migration/debug | +| `LESAVKA_TLS_CLIENT_CA` | server TLS client-CA path override for mTLS verification | +| `LESAVKA_TLS_CLIENT_CERT` | client transport certificate path override for mTLS | +| `LESAVKA_TLS_CLIENT_KEY` | client transport private-key path override for mTLS | +| `LESAVKA_TLS_DIR` | server installer/runtime TLS directory override | +| `LESAVKA_TLS_DOMAIN` | client transport SNI/domain override when dialing by IP | +| `LESAVKA_TLS_KEY` | server TLS private-key path override | +| `LESAVKA_TLS_SAN` | server installer extra certificate SAN list for additional relay hostnames/IPs | | `LESAVKA_UAC_BUFFER_TIME_US` | server audio sink latency override | | `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override | | `LESAVKA_UAC_DEV` | server hardware/device override | diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 12236c9..411e095 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -8,17 +8,17 @@ "client/src/app/audio_recovery_config.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 82 + "loc": 126 }, "client/src/app/downlink_media.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 208 + "loc": 209 }, "client/src/app/input_streams.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 102 + "loc": 115 }, "client/src/app/session_lifecycle.rs": { "clippy_warnings": 0, @@ -28,7 +28,7 @@ "client/src/app/uplink_media.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 224 + "loc": 329 }, "client/src/app_support.rs": { "clippy_warnings": 0, @@ -165,10 +165,15 @@ "doc_debt": 14, "loc": 439 }, + "client/src/launcher/calibration.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 121 + }, "client/src/launcher/clipboard.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 178 + "loc": 173 }, "client/src/launcher/device_test.rs": { "clippy_warnings": 0, @@ -198,12 +203,12 @@ "client/src/launcher/diagnostics.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 8 + "loc": 9 }, "client/src/launcher/diagnostics/diagnostics_models.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 170 + "loc": 185 }, "client/src/launcher/diagnostics/recommendations.rs": { "clippy_warnings": 0, @@ -212,13 +217,18 @@ }, "client/src/launcher/diagnostics/snapshot_report.rs": { "clippy_warnings": 0, - "doc_debt": 3, - "loc": 465 + "doc_debt": 2, + "loc": 286 + }, + "client/src/launcher/diagnostics/snapshot_report_text.rs": { + "clippy_warnings": 0, + "doc_debt": 2, + "loc": 292 }, "client/src/launcher/mod.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 244 + "loc": 246 }, "client/src/launcher/power.rs": { "clippy_warnings": 0, @@ -252,18 +262,23 @@ }, "client/src/launcher/preview/status_pipeline.rs": { "clippy_warnings": 0, - "doc_debt": 9, - "loc": 284 + "doc_debt": 8, + "loc": 259 }, "client/src/launcher/state.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 8 + "loc": 9 }, "client/src/launcher/state/launcher_state_impl.rs": { "clippy_warnings": 0, "doc_debt": 17, - "loc": 465 + "loc": 456 + }, + "client/src/launcher/state/launcher_status_line.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 48 }, "client/src/launcher/state/profile_helpers.rs": { "clippy_warnings": 0, @@ -273,27 +288,27 @@ "client/src/launcher/state/selection_models.rs": { "clippy_warnings": 0, "doc_debt": 15, - "loc": 380 + "loc": 456 }, "client/src/launcher/ui.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 182 + "loc": 193 }, "client/src/launcher/ui/activation_context.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 37 + "loc": 41 }, "client/src/launcher/ui/activation_setup.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 169 + "loc": 178 }, "client/src/launcher/ui/control_requests.rs": { "clippy_warnings": 0, - "doc_debt": 3, - "loc": 165 + "doc_debt": 1, + "loc": 214 }, "client/src/launcher/ui/device_refresh_binding.rs": { "clippy_warnings": 0, @@ -305,6 +320,11 @@ "doc_debt": 2, "loc": 161 }, + "client/src/launcher/ui/eye_capture_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 9, + "loc": 471 + }, "client/src/launcher/ui/eye_display_bindings.rs": { "clippy_warnings": 0, "doc_debt": 0, @@ -323,7 +343,7 @@ "client/src/launcher/ui/message_and_network_state.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 130 + "loc": 136 }, "client/src/launcher/ui/power_display_key_bindings.rs": { "clippy_warnings": 0, @@ -343,7 +363,7 @@ "client/src/launcher/ui/runtime_poll.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 375 + "loc": 449 }, "client/src/launcher/ui/session_preview_coverage.rs": { "clippy_warnings": 0, @@ -353,7 +373,7 @@ "client/src/launcher/ui/stage_device_bindings.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 174 + "loc": 176 }, "client/src/launcher/ui/startup_window_guard.rs": { "clippy_warnings": 0, @@ -363,37 +383,37 @@ "client/src/launcher/ui/utility_button_bindings.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 197 + "loc": 387 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 110 + "loc": 124 }, "client/src/launcher/ui_components/assemble_view.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 189 + "loc": 204 }, "client/src/launcher/ui_components/build_contexts.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 73 + "loc": 87 }, "client/src/launcher/ui_components/build_device_controls.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 394 + "loc": 407 }, "client/src/launcher/ui_components/build_operations_rail.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 228 + "loc": 310 }, "client/src/launcher/ui_components/build_shell.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 111 + "loc": 132 }, "client/src/launcher/ui_components/combo_helpers.rs": { "clippy_warnings": 0, @@ -408,12 +428,12 @@ "client/src/launcher/ui_components/display_pane.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 209 + "loc": 235 }, "client/src/launcher/ui_components/panel_chips.rs": { "clippy_warnings": 0, - "doc_debt": 3, - "loc": 79 + "doc_debt": 0, + "loc": 102 }, "client/src/launcher/ui_components/scale_reset.rs": { "clippy_warnings": 0, @@ -428,7 +448,7 @@ "client/src/launcher/ui_components/types.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 201 + "loc": 221 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 0, @@ -443,7 +463,7 @@ "client/src/launcher/ui_runtime/display_popouts.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 270 + "loc": 273 }, "client/src/launcher/ui_runtime/log_filtering.rs": { "clippy_warnings": 0, @@ -462,13 +482,13 @@ }, "client/src/launcher/ui_runtime/status_details.rs": { "clippy_warnings": 0, - "doc_debt": 13, - "loc": 284 + "doc_debt": 12, + "loc": 345 }, "client/src/launcher/ui_runtime/status_refresh.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 285 + "loc": 316 }, "client/src/layout.rs": { "clippy_warnings": 0, @@ -478,7 +498,7 @@ "client/src/lib.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 24 + "loc": 25 }, "client/src/live_capture_clock.rs": { "clippy_warnings": 0, @@ -530,6 +550,11 @@ "doc_debt": 1, "loc": 82 }, + "client/src/relay_transport.rs": { + "clippy_warnings": 0, + "doc_debt": 7, + "loc": 257 + }, "client/src/sync_probe/analyze.rs": { "clippy_warnings": 0, "doc_debt": 1, @@ -588,7 +613,7 @@ "client/src/sync_probe/capture/tests.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 208 + "loc": 209 }, "client/src/sync_probe/config.rs": { "clippy_warnings": 0, @@ -603,7 +628,7 @@ "client/src/sync_probe/runner.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 222 + "loc": 221 }, "client/src/sync_probe/schedule.rs": { "clippy_warnings": 0, @@ -618,7 +643,7 @@ "client/src/uplink_latency_harness.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 270 + "loc": 284 }, "client/src/uplink_telemetry.rs": { "clippy_warnings": 0, @@ -703,13 +728,18 @@ "server/src/bin/lesavka_uvc/coverage_startup.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 110 + "loc": 129 }, "server/src/bin/lesavka_uvc/payload_limits.rs": { "clippy_warnings": 0, "doc_debt": 1, "loc": 74 }, + "server/src/calibration.rs": { + "clippy_warnings": 0, + "doc_debt": 12, + "loc": 467 + }, "server/src/camera.rs": { "clippy_warnings": 0, "doc_debt": 0, @@ -773,17 +803,17 @@ "server/src/lib.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 20 + "loc": 22 }, "server/src/main.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 96 + "loc": 99 }, "server/src/main/entrypoint.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 45 + "loc": 49 }, "server/src/main/eye_hub.rs": { "clippy_warnings": 0, @@ -798,22 +828,27 @@ "server/src/main/handler_startup.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 136 + "loc": 140 }, "server/src/main/relay_service.rs": { "clippy_warnings": 0, - "doc_debt": 6, - "loc": 490 + "doc_debt": 5, + "loc": 485 }, "server/src/main/relay_service_coverage.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 281 + "loc": 301 + }, + "server/src/main/relay_service_tests.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 30 }, "server/src/main/rpc_helpers.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 105 + "loc": 118 }, "server/src/main/usb_recovery_helpers.rs": { "clippy_warnings": 0, @@ -850,15 +885,25 @@ "doc_debt": 1, "loc": 90 }, + "server/src/security.rs": { + "clippy_warnings": 0, + "doc_debt": 6, + "loc": 211 + }, "server/src/upstream_media_runtime.rs": { "clippy_warnings": 0, - "doc_debt": 4, - "loc": 495 + "doc_debt": 2, + "loc": 392 }, "server/src/upstream_media_runtime/config.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 79 + "loc": 88 + }, + "server/src/upstream_media_runtime/lease_lifecycle.rs": { + "clippy_warnings": 0, + "doc_debt": 3, + "loc": 142 }, "server/src/upstream_media_runtime/state.rs": { "clippy_warnings": 0, @@ -868,7 +913,7 @@ "server/src/upstream_media_runtime/tests.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 13 + "loc": 19 }, "server/src/upstream_media_runtime/types.rs": { "clippy_warnings": 0, @@ -888,7 +933,7 @@ "server/src/uvc_runtime.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 251 + "loc": 255 }, "server/src/video.rs": { "clippy_warnings": 0, diff --git a/scripts/ci/performance_gate.sh b/scripts/ci/performance_gate.sh new file mode 100755 index 0000000..67da1bb --- /dev/null +++ b/scripts/ci/performance_gate.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run deterministic latency/performance contracts and publish gate metrics. +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +REPORT_DIR="${ROOT_DIR}/target/performance-gate" +TEST_LOG="${REPORT_DIR}/cargo-test.log" +METRICS_FILE="${REPORT_DIR}/metrics.prom" +PUSHGATEWAY_URL=${QUALITY_GATE_PUSHGATEWAY_URL:-} +PUSHGATEWAY_JOB=${LESAVKA_PERFORMANCE_GATE_PUSHGATEWAY_JOB:-lesavka-performance-gate} + +mkdir -p "${REPORT_DIR}" +cd "${ROOT_DIR}" + +branch=${BRANCH_NAME:-${GIT_BRANCH:-}} +if [[ -z "${branch}" ]]; then + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown) +fi +commit=${GIT_COMMIT:-} +if [[ -z "${commit}" ]]; then + commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) +fi + +PERFORMANCE_TESTS=( + --test client_uplink_performance_contract + --test client_uplink_freshness_contract + --test client_log_noise_contract + --test video_downstream_feed_contract + --test client_app_include_contract +) + +start_seconds=$(date +%s) +status=0 +set +e +cargo test -p lesavka_testing "${PERFORMANCE_TESTS[@]}" --color never 2>&1 | tee "${TEST_LOG}" +status=${PIPESTATUS[0]} +set -e +duration_seconds=$(($(date +%s) - start_seconds)) + +python3 - "${METRICS_FILE}" "${status}" "${duration_seconds}" "${branch}" "${commit}" <<'PY' +import pathlib +import sys + +metrics_path = pathlib.Path(sys.argv[1]) +status = int(sys.argv[2]) +duration_seconds = int(sys.argv[3]) +branch = sys.argv[4] +commit = sys.argv[5] + +def esc(value: str) -> str: + return value.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"') + +labels = f'suite="lesavka",branch="{esc(branch)}",commit="{esc(commit)}"' +ok = 1 if status == 0 else 0 +failed = 0 if status == 0 else 1 +metrics = [ + '# HELP platform_quality_gate_checks_total Check outcomes from the latest lesavka gate run.', + '# TYPE platform_quality_gate_checks_total gauge', + f'platform_quality_gate_checks_total{{{labels},check="performance",status="ok"}} {ok}', + f'platform_quality_gate_checks_total{{{labels},check="performance",status="failed"}} {failed}', + '# HELP lesavka_performance_gate_duration_seconds Runtime of the deterministic performance gate.', + '# TYPE lesavka_performance_gate_duration_seconds gauge', + f'lesavka_performance_gate_duration_seconds{{{labels}}} {duration_seconds}', +] +metrics_path.write_text('\n'.join(metrics) + '\n', encoding='utf-8') +PY + +if [[ -n "${PUSHGATEWAY_URL}" ]]; then + curl --fail --silent --show-error \ + --data-binary @"${METRICS_FILE}" \ + "${PUSHGATEWAY_URL%/}/metrics/job/${PUSHGATEWAY_JOB}/suite/lesavka" || status=$? +fi + +exit "${status}" diff --git a/scripts/ci/platform_quality_gate.sh b/scripts/ci/platform_quality_gate.sh index 736955c..c2fa724 100755 --- a/scripts/ci/platform_quality_gate.sh +++ b/scripts/ci/platform_quality_gate.sh @@ -8,6 +8,7 @@ cd "${ROOT_DIR}" scripts/ci/hygiene_gate.sh QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/quality_gate.sh QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/test_gate.sh +QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/performance_gate.sh QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/media_reliability_gate.sh QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/gate_glue_gate.sh QUALITY_GATE_PUSHGATEWAY_URL="${QUALITY_GATE_PUSHGATEWAY_URL:-}" scripts/ci/sonarqube_gate.sh diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index e2f8756..2c7a2dc 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -2,7 +2,7 @@ "files": { "client/src/app/audio_recovery_config.rs": { "line_percent": 100.0, - "loc": 82 + "loc": 126 }, "client/src/app/session_lifecycle.rs": { "line_percent": 97.56, @@ -102,7 +102,7 @@ }, "client/src/launcher/clipboard.rs": { "line_percent": 100.0, - "loc": 178 + "loc": 173 }, "client/src/launcher/devices.rs": { "line_percent": 96.74, @@ -110,35 +110,43 @@ }, "client/src/launcher/diagnostics/diagnostics_models.rs": { "line_percent": 100.0, - "loc": 170 + "loc": 185 }, "client/src/launcher/diagnostics/recommendations.rs": { "line_percent": 97.62, "loc": 277 }, "client/src/launcher/diagnostics/snapshot_report.rs": { - "line_percent": 98.22, - "loc": 465 + "line_percent": 98.31, + "loc": 286 + }, + "client/src/launcher/diagnostics/snapshot_report_text.rs": { + "line_percent": 96.69, + "loc": 292 }, "client/src/launcher/mod.rs": { "line_percent": 100.0, - "loc": 244 + "loc": 246 }, "client/src/launcher/state/launcher_state_impl.rs": { - "line_percent": 95.91, - "loc": 465 + "line_percent": 100.0, + "loc": 456 + }, + "client/src/launcher/state/launcher_status_line.rs": { + "line_percent": 96.3, + "loc": 48 }, "client/src/launcher/state/profile_helpers.rs": { "line_percent": 100.0, "loc": 244 }, "client/src/launcher/state/selection_models.rs": { - "line_percent": 99.42, - "loc": 380 + "line_percent": 99.53, + "loc": 456 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 182 + "loc": 193 }, "client/src/launcher/ui/session_preview_coverage.rs": { "line_percent": 100.0, @@ -180,6 +188,10 @@ "line_percent": 100.0, "loc": 82 }, + "client/src/relay_transport.rs": { + "line_percent": 95.54, + "loc": 257 + }, "client/src/sync_probe/analyze.rs": { "line_percent": 97.92, "loc": 87 @@ -222,7 +234,7 @@ }, "client/src/sync_probe/runner.rs": { "line_percent": 95.65, - "loc": 222 + "loc": 221 }, "client/src/sync_probe/schedule.rs": { "line_percent": 98.74, @@ -233,8 +245,8 @@ "loc": 288 }, "client/src/uplink_latency_harness.rs": { - "line_percent": 98.65, - "loc": 270 + "line_percent": 98.73, + "loc": 284 }, "client/src/uplink_telemetry.rs": { "line_percent": 95.76, @@ -294,12 +306,16 @@ }, "server/src/bin/lesavka_uvc/coverage_startup.rs": { "line_percent": 98.99, - "loc": 128 + "loc": 129 }, "server/src/bin/lesavka_uvc/payload_limits.rs": { "line_percent": 100.0, "loc": 74 }, + "server/src/calibration.rs": { + "line_percent": 99.72, + "loc": 467 + }, "server/src/camera.rs": { "line_percent": 100.0, "loc": 132 @@ -342,11 +358,11 @@ }, "server/src/main.rs": { "line_percent": 100.0, - "loc": 96 + "loc": 99 }, "server/src/main/entrypoint.rs": { "line_percent": 100.0, - "loc": 45 + "loc": 49 }, "server/src/main/eye_hub.rs": { "line_percent": 100.0, @@ -358,19 +374,19 @@ }, "server/src/main/handler_startup.rs": { "line_percent": 100.0, - "loc": 136 + "loc": 140 }, "server/src/main/relay_service.rs": { "line_percent": 100.0, - "loc": 499 + "loc": 485 }, "server/src/main/relay_service_coverage.rs": { - "line_percent": 95.86, - "loc": 287 + "line_percent": 96.53, + "loc": 301 }, "server/src/main/rpc_helpers.rs": { "line_percent": 100.0, - "loc": 105 + "loc": 118 }, "server/src/main/usb_recovery_helpers.rs": { "line_percent": 100.0, @@ -396,13 +412,21 @@ "line_percent": 100.0, "loc": 90 }, + "server/src/security.rs": { + "line_percent": 97.44, + "loc": 211 + }, "server/src/upstream_media_runtime.rs": { - "line_percent": 98.04, - "loc": 495 + "line_percent": 97.36, + "loc": 392 }, "server/src/upstream_media_runtime/config.rs": { "line_percent": 100.0, - "loc": 79 + "loc": 88 + }, + "server/src/upstream_media_runtime/lease_lifecycle.rs": { + "line_percent": 100.0, + "loc": 142 }, "server/src/uvc_runtime.rs": { "line_percent": 97.53, diff --git a/scripts/ci/test_gate.sh b/scripts/ci/test_gate.sh index 06d8f4d..0d50213 100755 --- a/scripts/ci/test_gate.sh +++ b/scripts/ci/test_gate.sh @@ -30,7 +30,7 @@ set +e 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}" + RUST_TEST_THREADS="${RUST_TEST_THREADS:-1}" cargo test --workspace --all-targets --color never 2>&1 | tee -a "${TEST_LOG}" status=${PIPESTATUS[0]} else status=${build_status} diff --git a/scripts/install/client.sh b/scripts/install/client.sh index fde6bd7..e60ac82 100755 --- a/scripts/install/client.sh +++ b/scripts/install/client.sh @@ -11,6 +11,7 @@ REPO_URL=${LESAVKA_REPO_URL:-} SRC=/var/src/lesavka export TMPDIR=${TMPDIR:-/var/tmp} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) +CLIENT_PKI_DIR=${LESAVKA_CLIENT_PKI_DIR:-$USER_HOME/.config/lesavka/pki} log() { printf '==> %s\n' "$*" @@ -102,6 +103,36 @@ run_as_user() { sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@" } +install_client_pki_bundle() { + local bundle=${LESAVKA_CLIENT_PKI_BUNDLE:-} + if [[ -z $bundle ]]; then + if [[ -s "$CLIENT_PKI_DIR/ca.crt" && -s "$CLIENT_PKI_DIR/client.crt" && -s "$CLIENT_PKI_DIR/client.key" ]]; then + echo " ↪ TLS client identity already present: $CLIENT_PKI_DIR" + else + echo "⚠️ no LESAVKA_CLIENT_PKI_BUNDLE supplied; HTTPS relay connections will need a trusted public cert or a bundle install later." + fi + return 0 + fi + + log "5b. Installing TLS client identity" + local tmp + tmp=$(mktemp -d) + sudo tar -xzf "$bundle" -C "$tmp" + for item in ca.crt client.crt client.key; do + if [[ ! -s "$tmp/$item" ]]; then + echo "❌ TLS client bundle $bundle is missing $item" >&2 + sudo rm -rf "$tmp" + exit 1 + fi + done + sudo install -d -m 0700 -o "$ORIG_USER" -g "$ORIG_USER" "$CLIENT_PKI_DIR" + sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/ca.crt" "$CLIENT_PKI_DIR/ca.crt" + sudo install -m 0644 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.crt" "$CLIENT_PKI_DIR/client.crt" + sudo install -m 0600 -o "$ORIG_USER" -g "$ORIG_USER" "$tmp/client.key" "$CLIENT_PKI_DIR/client.key" + sudo rm -rf "$tmp" + echo " ↪ installed TLS client identity: $CLIENT_PKI_DIR" +} + mkdir -p "$TMPDIR" if [[ -z $REPO_URL ]] && [[ -d $SCRIPT_REPO_ROOT/.git ]]; then @@ -114,7 +145,7 @@ sudo pacman -Sq --needed --noconfirm \ git rustup protobuf abseil-cpp gcc clang llvm-libs compiler-rt evtest base-devel libpulse \ pipewire pipewire-pulse wireplumber alsa-utils gst-plugin-pipewire \ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav \ - wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils + wmctrl qt6-tools wl-clipboard xclip xsel desktop-file-utils openssl ensure_yay() { if command -v yay >/dev/null 2>&1; then @@ -201,6 +232,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC/client' && cargo clean && car log "5. Installing launchable client binaries" sudo install -Dm755 "$SRC/target/release/lesavka-client" /usr/local/bin/lesavka-client sudo ln -sf /usr/local/bin/lesavka-client /usr/local/bin/lesavka +install_client_pki_bundle log "6. Registering desktop application" sudo install -Dm644 "$SRC/client/assets/icons/hicolor/1024x1024/apps/lesavka.png" \ @@ -232,6 +264,7 @@ echo " Binary: /usr/local/bin/lesavka-client" echo " Launch alias: /usr/local/bin/lesavka" echo " Desktop entry: /usr/share/applications/lesavka.desktop" echo " Build source: $SRC/target/release/lesavka-client" +echo " TLS identity: $CLIENT_PKI_DIR" echo "✅ Installed version: lesavka-client ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}" echo echo "Quick start:" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 90c1293..973052e 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -12,6 +12,8 @@ REPO_URL=${LESAVKA_REPO_URL:-} USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6) INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg} INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051} +LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki} +LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz} manifest_package_version() { local manifest=$1 @@ -41,6 +43,135 @@ LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0} EOF } +append_san_entry() { + local value=$1 + [[ -n $value ]] || return 0 + case "$value" in + IP:*) + TLS_SAN_IPS+=("${value#IP:}") + ;; + DNS:*) + TLS_SAN_DNS+=("${value#DNS:}") + ;; + *[!0-9.]*) + TLS_SAN_DNS+=("$value") + ;; + *) + TLS_SAN_IPS+=("$value") + ;; + esac +} + +render_server_cert_ext() { + local ext_file=$1 + local dns_index=1 + local ip_index=1 + { + echo "basicConstraints = CA:FALSE" + echo "keyUsage = digitalSignature,keyEncipherment" + echo "extendedKeyUsage = serverAuth" + echo "subjectAltName = @alt_names" + echo "[alt_names]" + local value + for value in "${TLS_SAN_DNS[@]}"; do + [[ -n $value ]] || continue + printf 'DNS.%d = %s\n' "$dns_index" "$value" + dns_index=$((dns_index + 1)) + done + for value in "${TLS_SAN_IPS[@]}"; do + [[ -n $value ]] || continue + printf 'IP.%d = %s\n' "$ip_index" "$value" + ip_index=$((ip_index + 1)) + done + } >"$ext_file" +} + +ensure_server_tls_pki() { + echo "==> 5c. TLS/mTLS identity" + sudo install -d -m 0750 "$LESAVKA_TLS_DIR" + + if ! sudo test -s "$LESAVKA_TLS_DIR/ca.key" || ! sudo test -s "$LESAVKA_TLS_DIR/ca.crt"; then + echo " ↪ generating Lesavka local CA" + sudo openssl genrsa -out "$LESAVKA_TLS_DIR/ca.key" 4096 >/dev/null 2>&1 + sudo openssl req -x509 -new -nodes \ + -key "$LESAVKA_TLS_DIR/ca.key" \ + -sha256 -days "${LESAVKA_TLS_CA_DAYS:-3650}" \ + -subj "/CN=Lesavka Local Relay CA" \ + -out "$LESAVKA_TLS_DIR/ca.crt" >/dev/null 2>&1 + fi + + TLS_SAN_DNS=(lesavka-server "$(hostname -s 2>/dev/null || true)" "$(hostname -f 2>/dev/null || true)") + TLS_SAN_IPS=(127.0.0.1 38.28.125.112) + local extra_san + IFS=',' read -r -a extra_san <<<"${LESAVKA_TLS_SAN:-}" + local san + for san in "${extra_san[@]}"; do + append_san_entry "${san//[[:space:]]/}" + done + + local ext_file client_ext_file + ext_file=$(mktemp) + client_ext_file=$(mktemp) + render_server_cert_ext "$ext_file" + { + echo "basicConstraints = CA:FALSE" + echo "keyUsage = digitalSignature,keyEncipherment" + echo "extendedKeyUsage = clientAuth" + } >"$client_ext_file" + + if ! sudo test -s "$LESAVKA_TLS_DIR/server.key" || ! sudo test -s "$LESAVKA_TLS_DIR/server.crt"; then + echo " ↪ generating server certificate" + sudo openssl genrsa -out "$LESAVKA_TLS_DIR/server.key" 2048 >/dev/null 2>&1 + sudo openssl req -new \ + -key "$LESAVKA_TLS_DIR/server.key" \ + -subj "/CN=lesavka-server" \ + -out "$LESAVKA_TLS_DIR/server.csr" >/dev/null 2>&1 + sudo openssl x509 -req \ + -in "$LESAVKA_TLS_DIR/server.csr" \ + -CA "$LESAVKA_TLS_DIR/ca.crt" \ + -CAkey "$LESAVKA_TLS_DIR/ca.key" \ + -CAcreateserial \ + -out "$LESAVKA_TLS_DIR/server.crt" \ + -days "${LESAVKA_TLS_CERT_DAYS:-825}" \ + -sha256 \ + -extfile "$ext_file" >/dev/null 2>&1 + sudo rm -f "$LESAVKA_TLS_DIR/server.csr" + fi + + if ! sudo test -s "$LESAVKA_TLS_DIR/client.key" || ! sudo test -s "$LESAVKA_TLS_DIR/client.crt"; then + echo " ↪ generating default client certificate" + sudo openssl genrsa -out "$LESAVKA_TLS_DIR/client.key" 2048 >/dev/null 2>&1 + sudo openssl req -new \ + -key "$LESAVKA_TLS_DIR/client.key" \ + -subj "/CN=lesavka-client" \ + -out "$LESAVKA_TLS_DIR/client.csr" >/dev/null 2>&1 + sudo openssl x509 -req \ + -in "$LESAVKA_TLS_DIR/client.csr" \ + -CA "$LESAVKA_TLS_DIR/ca.crt" \ + -CAkey "$LESAVKA_TLS_DIR/ca.key" \ + -CAcreateserial \ + -out "$LESAVKA_TLS_DIR/client.crt" \ + -days "${LESAVKA_TLS_CERT_DAYS:-825}" \ + -sha256 \ + -extfile "$client_ext_file" >/dev/null 2>&1 + sudo rm -f "$LESAVKA_TLS_DIR/client.csr" + fi + + sudo chmod 0600 "$LESAVKA_TLS_DIR/"*.key + sudo chmod 0644 "$LESAVKA_TLS_DIR/"*.crt + rm -f "$ext_file" "$client_ext_file" + + local bundle_tmp + bundle_tmp=$(mktemp -d) + sudo cp "$LESAVKA_TLS_DIR/ca.crt" "$bundle_tmp/ca.crt" + sudo cp "$LESAVKA_TLS_DIR/client.crt" "$bundle_tmp/client.crt" + sudo cp "$LESAVKA_TLS_DIR/client.key" "$bundle_tmp/client.key" + sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key + sudo chmod 0640 "$LESAVKA_CLIENT_BUNDLE" + sudo rm -rf "$bundle_tmp" + echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE" +} + find_uvc_output_node() { local by_path_root=/dev/v4l/by-path local ctrl="" @@ -641,7 +772,8 @@ sudo pacman -Sq --needed --noconfirm git \ gst-plugins-ugly \ gst-libav \ tcpdump \ - lsof + lsof \ + openssl if ! command -v yay >/dev/null 2>&1; then echo "==> 1b. installing yay from AUR ..." run_as_user env TMPDIR="$TMPDIR" bash -c ' @@ -772,6 +904,7 @@ sudo install -Dm755 "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/loca echo "==> 5b. Runtime environment defaults" sudo install -d -m 0755 /etc/lesavka +ensure_server_tls_pki HDMI_CONNECTOR=$(resolve_hdmi_connector) if [[ -n $HDMI_CONNECTOR ]]; then echo " ↪ HDMI connector: $HDMI_CONNECTOR" @@ -806,6 +939,10 @@ fi printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}" printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}" printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}" + printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}" + printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}" + printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}" + printf 'LESAVKA_TLS_CLIENT_CA=%s\n' "${LESAVKA_TLS_CLIENT_CA:-$LESAVKA_TLS_DIR/ca.crt}" } | sudo tee /etc/lesavka/server.env >/dev/null UVC_ENV_TMP=$(mktemp) diff --git a/scripts/manual/eval_lesavka.sh b/scripts/manual/eval_lesavka.sh index 2c9e6a0..b2b2cf3 100755 --- a/scripts/manual/eval_lesavka.sh +++ b/scripts/manual/eval_lesavka.sh @@ -6,7 +6,7 @@ # - Optional: if THEIA_HOST is set, ssh to show core/server status + hidg/uvc presence # # Env: -# LESAVKA_SERVER_ADDR (default http://38.28.125.112:50051) +# LESAVKA_SERVER_ADDR (default https://38.28.125.112:50051) # ITER=0 (loop forever) or number of iterations # SLEEP=10 (seconds between iterations) # TETHYS_HOST=host (ssh target for target machine; requires key auth) @@ -15,7 +15,7 @@ set -euo pipefail -SERVER=${LESAVKA_SERVER_ADDR:-http://38.28.125.112:50051} +SERVER=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051} # default to a few iterations instead of infinite to avoid unintentional long runs ITER=${ITER:-5} SLEEP=${SLEEP:-10} diff --git a/scripts/manual/run_upstream_browser_av_sync.sh b/scripts/manual/run_upstream_browser_av_sync.sh index 08d9cb8..7dcb5ee 100755 --- a/scripts/manual/run_upstream_browser_av_sync.sh +++ b/scripts/manual/run_upstream_browser_av_sync.sh @@ -11,7 +11,7 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)" TETHYS_HOST=${TETHYS_HOST:-tethys} -LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-http://38.28.125.112:50051} +LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051} PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15} BROWSER_PORT=${BROWSER_PORT:-18443} REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py} diff --git a/server/Cargo.toml b/server/Cargo.toml index 804fade..c7d34a1 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,14 +10,14 @@ bench = false [package] name = "lesavka_server" -version = "0.15.5" +version = "0.16.0" edition = "2024" autobins = false [dependencies] tokio = { version = "1.45", features = ["full", "fs"] } tokio-stream = "0.1" -tonic = { version = "0.13", features = ["transport"] } +tonic = { version = "0.13", features = ["transport", "tls-ring", "tls-native-roots"] } tonic-reflection = "0.13" anyhow = "1.0" lesavka_common = { path = "../common" } diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs index a47f0dd..0fe26e7 100644 --- a/server/src/bin/lesavka_uvc/coverage_startup.rs +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -114,6 +114,7 @@ fn open_with_retry(path: &str) -> Result { } #[cfg(coverage)] +/// Keep coverage-mode UVC control opens read-only unless a test opts into writes. fn uvc_control_read_only() -> bool { env::var("LESAVKA_UVC_CONTROL_READ_ONLY") .ok() diff --git a/server/src/calibration.rs b/server/src/calibration.rs new file mode 100644 index 0000000..01f2de0 --- /dev/null +++ b/server/src/calibration.rs @@ -0,0 +1,467 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result}; +use chrono::Utc; +use lesavka_common::lesavka::{ + CalibrationAction, CalibrationRequest, CalibrationState as ProtoCalibrationState, +}; + +use crate::upstream_media_runtime::UpstreamMediaRuntime; + +pub const FACTORY_MJPEG_AUDIO_OFFSET_US: i64 = -45_000; +pub const FACTORY_MJPEG_VIDEO_OFFSET_US: i64 = 0; +const PROFILE: &str = "mjpeg"; +const FACTORY_CONFIDENCE: &str = "factory"; +const OFFSET_LIMIT_US: i64 = 500_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CalibrationSnapshot { + profile: String, + factory_audio_offset_us: i64, + factory_video_offset_us: i64, + default_audio_offset_us: i64, + default_video_offset_us: i64, + active_audio_offset_us: i64, + active_video_offset_us: i64, + source: String, + confidence: String, + updated_at: String, + detail: String, +} + +#[derive(Debug)] +pub struct CalibrationStore { + path: PathBuf, + runtime: Arc, + state: Mutex, +} + +impl CalibrationStore { + pub fn load(runtime: Arc) -> Self { + let path = calibration_path(); + let state = std::fs::read_to_string(&path) + .ok() + .map(|raw| parse_snapshot(&raw)) + .unwrap_or_else(snapshot_from_env); + runtime.set_playout_offsets(state.active_video_offset_us, state.active_audio_offset_us); + Self { + path, + runtime, + state: Mutex::new(state), + } + } + + pub fn current(&self) -> ProtoCalibrationState { + self.state + .lock() + .expect("calibration mutex poisoned") + .to_proto() + } + + pub fn apply(&self, request: CalibrationRequest) -> Result { + let mut state = self.state.lock().expect("calibration mutex poisoned"); + let action = + CalibrationAction::try_from(request.action).unwrap_or(CalibrationAction::Unspecified); + match action { + CalibrationAction::Unspecified => {} + CalibrationAction::RestoreDefault => { + state.active_audio_offset_us = state.default_audio_offset_us; + state.active_video_offset_us = state.default_video_offset_us; + state.source = "default".to_string(); + state.confidence = "saved-default".to_string(); + state.detail = "restored saved upstream A/V calibration".to_string(); + touch(&mut state); + } + CalibrationAction::RestoreFactory => { + state.active_audio_offset_us = state.factory_audio_offset_us; + state.active_video_offset_us = state.factory_video_offset_us; + state.source = "factory".to_string(); + state.confidence = FACTORY_CONFIDENCE.to_string(); + state.detail = "restored release-shipped MJPEG upstream calibration".to_string(); + touch(&mut state); + } + CalibrationAction::AdjustActive => { + state.active_audio_offset_us = clamp_offset( + state + .active_audio_offset_us + .saturating_add(request.audio_delta_us), + ); + state.active_video_offset_us = clamp_offset( + state + .active_video_offset_us + .saturating_add(request.video_delta_us), + ); + state.source = "manual".to_string(); + state.confidence = "manual".to_string(); + state.detail = format!( + "manual upstream A/V calibration nudge: audio {:+.1}ms, video {:+.1}ms", + request.audio_delta_us as f64 / 1000.0, + request.video_delta_us as f64 / 1000.0 + ); + touch(&mut state); + } + CalibrationAction::BlindEstimate => { + state.active_audio_offset_us = clamp_offset( + state + .active_audio_offset_us + .saturating_add(request.audio_delta_us), + ); + state.active_video_offset_us = clamp_offset( + state + .active_video_offset_us + .saturating_add(request.video_delta_us), + ); + state.source = "blind".to_string(); + state.confidence = "estimated".to_string(); + state.detail = if request.note.trim().is_empty() { + format!( + "blind estimate applied from relay telemetry: delivery skew {:.1}ms, enqueue skew {:.1}ms", + request.observed_delivery_skew_ms, request.observed_enqueue_skew_ms + ) + } else { + request.note + }; + touch(&mut state); + } + CalibrationAction::SaveActiveAsDefault => { + state.default_audio_offset_us = state.active_audio_offset_us; + state.default_video_offset_us = state.active_video_offset_us; + state.source = "default".to_string(); + state.confidence = "measured".to_string(); + state.detail = "saved current upstream A/V calibration as site default".to_string(); + touch(&mut state); + } + } + self.runtime + .set_playout_offsets(state.active_video_offset_us, state.active_audio_offset_us); + persist_snapshot(&self.path, &state)?; + Ok(state.to_proto()) + } +} + +impl CalibrationSnapshot { + fn to_proto(&self) -> ProtoCalibrationState { + ProtoCalibrationState { + profile: self.profile.clone(), + factory_audio_offset_us: self.factory_audio_offset_us, + factory_video_offset_us: self.factory_video_offset_us, + default_audio_offset_us: self.default_audio_offset_us, + default_video_offset_us: self.default_video_offset_us, + active_audio_offset_us: self.active_audio_offset_us, + active_video_offset_us: self.active_video_offset_us, + source: self.source.clone(), + confidence: self.confidence.clone(), + updated_at: self.updated_at.clone(), + detail: self.detail.clone(), + } + } +} + +pub fn calibration_path() -> PathBuf { + std::env::var("LESAVKA_CALIBRATION_PATH") + .ok() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/lib/lesavka/calibration.toml")) +} + +fn snapshot_from_env() -> CalibrationSnapshot { + let env_audio = env_i64("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US"); + let env_video = env_i64("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US"); + let default_audio_offset_us = env_audio.unwrap_or(FACTORY_MJPEG_AUDIO_OFFSET_US); + let default_video_offset_us = env_video.unwrap_or(FACTORY_MJPEG_VIDEO_OFFSET_US); + let source = if env_audio.is_some() || env_video.is_some() { + "env".to_string() + } else { + "factory".to_string() + }; + let confidence = if source == "factory" { + FACTORY_CONFIDENCE.to_string() + } else { + "configured".to_string() + }; + CalibrationSnapshot { + profile: PROFILE.to_string(), + factory_audio_offset_us: FACTORY_MJPEG_AUDIO_OFFSET_US, + factory_video_offset_us: FACTORY_MJPEG_VIDEO_OFFSET_US, + default_audio_offset_us, + default_video_offset_us, + active_audio_offset_us: default_audio_offset_us, + active_video_offset_us: default_video_offset_us, + source, + confidence, + updated_at: Utc::now().to_rfc3339(), + detail: "loaded upstream A/V calibration defaults".to_string(), + } +} + +fn parse_snapshot(raw: &str) -> CalibrationSnapshot { + let fallback = snapshot_from_env(); + let value = |key: &str| -> Option { + raw.lines().find_map(|line| { + let trimmed = line.trim(); + let (left, right) = trimmed.split_once('=')?; + (left.trim() == key).then(|| right.trim().trim_matches('"').to_string()) + }) + }; + let number = |key: &str, default: i64| -> i64 { + value(key) + .and_then(|raw| raw.parse::().ok()) + .map(clamp_offset) + .unwrap_or(default) + }; + CalibrationSnapshot { + profile: value("profile").unwrap_or(fallback.profile), + factory_audio_offset_us: FACTORY_MJPEG_AUDIO_OFFSET_US, + factory_video_offset_us: FACTORY_MJPEG_VIDEO_OFFSET_US, + default_audio_offset_us: number( + "default_audio_offset_us", + fallback.default_audio_offset_us, + ), + default_video_offset_us: number( + "default_video_offset_us", + fallback.default_video_offset_us, + ), + active_audio_offset_us: number("active_audio_offset_us", fallback.active_audio_offset_us), + active_video_offset_us: number("active_video_offset_us", fallback.active_video_offset_us), + source: value("source").unwrap_or(fallback.source), + confidence: value("confidence").unwrap_or(fallback.confidence), + updated_at: value("updated_at").unwrap_or(fallback.updated_at), + detail: value("detail").unwrap_or(fallback.detail), + } +} + +fn persist_snapshot(path: &PathBuf, state: &CalibrationSnapshot) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating calibration directory {}", parent.display()))?; + } + std::fs::write(path, serialize_snapshot(state)) + .with_context(|| format!("writing calibration state {}", path.display())) +} + +fn serialize_snapshot(state: &CalibrationSnapshot) -> String { + format!( + "profile=\"{}\"\ndefault_audio_offset_us={}\ndefault_video_offset_us={}\nactive_audio_offset_us={}\nactive_video_offset_us={}\nsource=\"{}\"\nconfidence=\"{}\"\nupdated_at=\"{}\"\ndetail=\"{}\"\n", + escape_value(&state.profile), + state.default_audio_offset_us, + state.default_video_offset_us, + state.active_audio_offset_us, + state.active_video_offset_us, + escape_value(&state.source), + escape_value(&state.confidence), + escape_value(&state.updated_at), + escape_value(&state.detail), + ) +} + +fn touch(state: &mut CalibrationSnapshot) { + state.updated_at = Utc::now().to_rfc3339(); +} + +fn env_i64(name: &str) -> Option { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map(clamp_offset) +} + +fn clamp_offset(value: i64) -> i64 { + value.clamp(-OFFSET_LIMIT_US, OFFSET_LIMIT_US) +} + +fn escape_value(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn default_snapshot_uses_factory_mjpeg_calibration() { + temp_env::with_vars( + [ + ("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", None::<&str>), + ("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", None::<&str>), + ], + || { + let state = snapshot_from_env(); + assert_eq!(state.default_audio_offset_us, -45_000); + assert_eq!(state.active_video_offset_us, 0); + assert_eq!(state.source, "factory"); + }, + ); + } + + #[test] + fn store_persists_manual_adjustments_and_updates_runtime() { + let file = NamedTempFile::new().expect("temp calibration file"); + let path = file.path().to_string_lossy().to_string(); + temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(path.as_str()), || { + let runtime = Arc::new(UpstreamMediaRuntime::new()); + let store = CalibrationStore::load(runtime.clone()); + let state = store + .apply(CalibrationRequest { + action: CalibrationAction::AdjustActive as i32, + audio_delta_us: -5_000, + video_delta_us: 0, + observed_delivery_skew_ms: 0.0, + observed_enqueue_skew_ms: 0.0, + note: String::new(), + }) + .expect("manual adjust applies"); + assert_eq!(state.active_audio_offset_us, -50_000); + assert_eq!(runtime.playout_offsets(), (0, -50_000)); + let raw = std::fs::read_to_string(file.path()).expect("persisted calibration"); + assert!(raw.contains("active_audio_offset_us=-50000")); + }); + } + + #[test] + fn calibration_path_uses_default_for_blank_override() { + temp_env::with_var("LESAVKA_CALIBRATION_PATH", Some(""), || { + assert_eq!( + calibration_path(), + PathBuf::from("/var/lib/lesavka/calibration.toml") + ); + }); + } + + #[test] + fn snapshot_from_env_uses_configured_offsets_and_clamps_extremes() { + temp_env::with_vars( + [ + ("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-999999")), + ("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", Some("12345")), + ], + || { + let state = snapshot_from_env(); + assert_eq!(state.default_audio_offset_us, -500_000); + assert_eq!(state.default_video_offset_us, 12_345); + assert_eq!(state.source, "env"); + assert_eq!(state.confidence, "configured"); + }, + ); + } + + #[test] + fn parse_snapshot_falls_back_for_missing_and_malformed_fields() { + temp_env::with_vars( + [ + ("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", None::<&str>), + ("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", None::<&str>), + ], + || { + let state = parse_snapshot( + r#" + profile="mjpeg" + default_audio_offset_us=bad + default_video_offset_us=2500 + active_audio_offset_us=-600000 + source="saved" + detail="loaded \"quoted\" value" + "#, + ); + assert_eq!(state.default_audio_offset_us, FACTORY_MJPEG_AUDIO_OFFSET_US); + assert_eq!(state.default_video_offset_us, 2_500); + assert_eq!(state.active_audio_offset_us, -500_000); + assert_eq!(state.active_video_offset_us, FACTORY_MJPEG_VIDEO_OFFSET_US); + assert_eq!(state.source, "saved"); + assert_eq!(state.confidence, FACTORY_CONFIDENCE); + }, + ); + } + + #[test] + fn store_applies_all_calibration_actions_and_persists_defaults() { + let dir = tempfile::tempdir().expect("calibration dir"); + let path = dir.path().join("calibration.toml"); + let path_string = path.to_string_lossy().to_string(); + temp_env::with_var( + "LESAVKA_CALIBRATION_PATH", + Some(path_string.as_str()), + || { + let runtime = Arc::new(UpstreamMediaRuntime::new()); + let store = CalibrationStore::load(runtime.clone()); + + let blind = store + .apply(CalibrationRequest { + action: CalibrationAction::BlindEstimate as i32, + audio_delta_us: 5_000, + video_delta_us: -2_000, + observed_delivery_skew_ms: 44.0, + observed_enqueue_skew_ms: 3.5, + note: String::new(), + }) + .expect("blind estimate"); + assert_eq!(blind.source, "blind"); + assert!(blind.detail.contains("delivery skew 44.0ms")); + assert_eq!(runtime.playout_offsets(), (-2_000, -40_000)); + + let manual = store + .apply(CalibrationRequest { + action: CalibrationAction::AdjustActive as i32, + audio_delta_us: 999_999, + video_delta_us: 0, + observed_delivery_skew_ms: 0.0, + observed_enqueue_skew_ms: 0.0, + note: String::new(), + }) + .expect("manual clamp"); + assert_eq!(manual.active_audio_offset_us, 500_000); + + let saved = store + .apply(CalibrationRequest { + action: CalibrationAction::SaveActiveAsDefault as i32, + ..CalibrationRequest::default() + }) + .expect("save default"); + assert_eq!(saved.default_audio_offset_us, saved.active_audio_offset_us); + assert_eq!(saved.confidence, "measured"); + + let factory = store + .apply(CalibrationRequest { + action: CalibrationAction::RestoreFactory as i32, + ..CalibrationRequest::default() + }) + .expect("factory restore"); + assert_eq!( + factory.active_audio_offset_us, + FACTORY_MJPEG_AUDIO_OFFSET_US + ); + assert_eq!(factory.source, "factory"); + + let restored = store + .apply(CalibrationRequest { + action: CalibrationAction::RestoreDefault as i32, + ..CalibrationRequest::default() + }) + .expect("default restore"); + assert_eq!( + restored.active_audio_offset_us, + restored.default_audio_offset_us + ); + assert_eq!( + store.current().active_audio_offset_us, + restored.active_audio_offset_us + ); + + let no_op = store + .apply(CalibrationRequest::default()) + .expect("unspecified action is ok"); + assert_eq!( + no_op.active_audio_offset_us, + restored.active_audio_offset_us + ); + + let raw = std::fs::read_to_string(&path).expect("persisted actions"); + assert!(raw.contains("confidence=")); + assert!(raw.contains("detail=")); + assert_eq!(escape_value("a\\b\"c"), "a\\\\b\\\"c"); + }, + ); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 88bdc68..f86f9f6 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -5,6 +5,7 @@ pub const BUILD_ID: &str = env!("LESAVKA_GIT_SHA"); pub const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("LESAVKA_GIT_SHA")); pub mod audio; +pub mod calibration; pub mod camera; pub mod camera_runtime; pub mod capture_power; @@ -13,6 +14,7 @@ pub mod handshake; pub(crate) mod media_timing; pub mod paste; pub mod runtime_support; +pub mod security; pub mod upstream_media_runtime; pub mod uvc_runtime; pub mod video; diff --git a/server/src/main.rs b/server/src/main.rs index bf35565..a0445f6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,14 +18,16 @@ use tonic_reflection::server::Builder as ReflBuilder; use tracing::{debug, error, info, warn}; use lesavka_common::lesavka::{ - AudioPacket, CapturePowerCommand, CapturePowerState, Empty, KeyboardReport, MonitorRequest, - MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, + AudioPacket, CalibrationRequest, CalibrationState, CapturePowerCommand, CapturePowerState, + Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest, ResetUsbReply, + SetCapturePowerRequest, VideoPacket, relay_server::{Relay, RelayServer}, }; use lesavka_server::{ - camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, gadget::UsbGadget, - handshake::HandshakeSvc, paste, runtime_support, runtime_support::init_tracing, + calibration::CalibrationStore, camera, camera_runtime::CameraRuntime, + capture_power::CapturePowerManager, gadget::UsbGadget, handshake::HandshakeSvc, paste, + runtime_support, runtime_support::init_tracing, security, upstream_media_runtime::UpstreamMediaRuntime, uvc_runtime, video, }; @@ -66,6 +68,7 @@ struct Handler { did_cycle: Arc, camera_rt: Arc, upstream_media_rt: Arc, + calibration: Arc, capture_power: CapturePowerManager, eye_hubs: Arc>>>, } diff --git a/server/src/main/entrypoint.rs b/server/src/main/entrypoint.rs index 08bb07a..ce5d602 100644 --- a/server/src/main/entrypoint.rs +++ b/server/src/main/entrypoint.rs @@ -25,9 +25,13 @@ async fn main() -> anyhow::Result<()> { let bind_addr = server_bind_addr()?; info!("🌐 lesavka-server listening on {bind_addr}"); - Server::builder() + let mut server = Server::builder() .tcp_nodelay(true) - .max_frame_size(Some(2 * 1024 * 1024)) + .max_frame_size(Some(2 * 1024 * 1024)); + if let Some(tls) = security::server_tls_config()? { + server = server.tls_config(tls)?; + } + server .add_service(RelayServer::new(handler)) .add_service(HandshakeSvc::server()) .add_service(ReflBuilder::configure().build_v1().unwrap()) diff --git a/server/src/main/handler_startup.rs b/server/src/main/handler_startup.rs index 6a15516..b3dec6c 100644 --- a/server/src/main/handler_startup.rs +++ b/server/src/main/handler_startup.rs @@ -45,13 +45,17 @@ impl Handler { warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily"); } + let upstream_media_rt = Arc::new(UpstreamMediaRuntime::new()); + let calibration = Arc::new(CalibrationStore::load(upstream_media_rt.clone())); + Ok(Self { kb: Arc::new(Mutex::new(kb)), ms: Arc::new(Mutex::new(ms)), gadget, did_cycle: Arc::new(AtomicBool::new(false)), camera_rt: Arc::new(CameraRuntime::new()), - upstream_media_rt: Arc::new(UpstreamMediaRuntime::new()), + upstream_media_rt, + calibration, capture_power: CapturePowerManager::new(), eye_hubs: Arc::new(Mutex::new(HashMap::new())), }) diff --git a/server/src/main/relay_service.rs b/server/src/main/relay_service.rs index e1456ef..601ae60 100644 --- a/server/src/main/relay_service.rs +++ b/server/src/main/relay_service.rs @@ -457,6 +457,20 @@ impl Relay for Handler { ) -> Result, Status> { self.set_capture_power_reply(req).await } + + async fn get_calibration( + &self, + _req: Request, + ) -> Result, Status> { + self.get_calibration_reply().await + } + + async fn calibrate( + &self, + req: Request, + ) -> Result, Status> { + self.calibrate_reply(req).await + } } fn remote_audio_status(message: String) -> Status { @@ -468,32 +482,4 @@ fn remote_audio_status(message: String) -> Status { } #[cfg(test)] -#[allow(clippy::items_after_test_module)] -mod tests { - use super::retain_freshest_video_packet; - use lesavka_common::lesavka::VideoPacket; - - #[test] - fn retain_freshest_video_packet_keeps_only_the_latest_frame() { - let mut pending = std::collections::VecDeque::from(vec![ - VideoPacket { - pts: 100, - ..Default::default() - }, - VideoPacket { - pts: 200, - ..Default::default() - }, - VideoPacket { - pts: 300, - ..Default::default() - }, - ]); - - let dropped = retain_freshest_video_packet(&mut pending); - - assert_eq!(dropped, 2); - assert_eq!(pending.len(), 1); - assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300)); - } -} +include!("relay_service_tests.rs"); diff --git a/server/src/main/relay_service_coverage.rs b/server/src/main/relay_service_coverage.rs index 1f77daf..b5027f6 100644 --- a/server/src/main/relay_service_coverage.rs +++ b/server/src/main/relay_service_coverage.rs @@ -284,4 +284,18 @@ impl Relay for Handler { ) -> Result, Status> { self.set_capture_power_reply(req).await } + + async fn get_calibration( + &self, + _req: Request, + ) -> Result, Status> { + self.get_calibration_reply().await + } + + async fn calibrate( + &self, + req: Request, + ) -> Result, Status> { + self.calibrate_reply(req).await + } } diff --git a/server/src/main/relay_service_tests.rs b/server/src/main/relay_service_tests.rs new file mode 100644 index 0000000..5a445e3 --- /dev/null +++ b/server/src/main/relay_service_tests.rs @@ -0,0 +1,30 @@ +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::retain_freshest_video_packet; + use lesavka_common::lesavka::VideoPacket; + + #[test] + fn retain_freshest_video_packet_keeps_only_the_latest_frame() { + let mut pending = std::collections::VecDeque::from(vec![ + VideoPacket { + pts: 100, + ..Default::default() + }, + VideoPacket { + pts: 200, + ..Default::default() + }, + VideoPacket { + pts: 300, + ..Default::default() + }, + ]); + + let dropped = retain_freshest_video_packet(&mut pending); + + assert_eq!(dropped, 2); + assert_eq!(pending.len(), 1); + assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300)); + } +} diff --git a/server/src/main/rpc_helpers.rs b/server/src/main/rpc_helpers.rs index fce39e3..26ceacd 100644 --- a/server/src/main/rpc_helpers.rs +++ b/server/src/main/rpc_helpers.rs @@ -102,4 +102,17 @@ impl Handler { )) } + async fn get_calibration_reply(&self) -> Result, Status> { + Ok(Response::new(self.calibration.current())) + } + + async fn calibrate_reply( + &self, + req: Request, + ) -> Result, Status> { + self.calibration + .apply(req.into_inner()) + .map(Response::new) + .map_err(|e| Status::internal(format!("{e:#}"))) + } } diff --git a/server/src/security.rs b/server/src/security.rs new file mode 100644 index 0000000..e212b81 --- /dev/null +++ b/server/src/security.rs @@ -0,0 +1,211 @@ +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; +use tonic::transport::{Certificate, Identity, ServerTlsConfig}; +use tracing::{info, warn}; + +const DEFAULT_PKI_DIR: &str = "/etc/lesavka/pki"; + +pub fn server_tls_config() -> Result> { + let cert_path = env_path("LESAVKA_TLS_CERT").unwrap_or_else(|| default_path("server.crt")); + let key_path = env_path("LESAVKA_TLS_KEY").unwrap_or_else(|| default_path("server.key")); + let require_tls = bool_env("LESAVKA_REQUIRE_TLS"); + + if !cert_path.exists() || !key_path.exists() { + if require_tls { + bail!( + "LESAVKA_REQUIRE_TLS=1 but TLS identity files are missing: cert={} key={}", + cert_path.display(), + key_path.display() + ); + } + warn!( + cert = %cert_path.display(), + key = %key_path.display(), + "🔓 TLS identity not present; serving plaintext gRPC for local/dev use" + ); + return Ok(None); + } + + let cert = std::fs::read(&cert_path) + .with_context(|| format!("reading TLS server certificate {}", cert_path.display()))?; + let key = std::fs::read(&key_path) + .with_context(|| format!("reading TLS server key {}", key_path.display()))?; + let mut tls = ServerTlsConfig::new().identity(Identity::from_pem(cert, key)); + + let client_ca_path = + env_path("LESAVKA_TLS_CLIENT_CA").unwrap_or_else(|| default_path("ca.crt")); + if client_ca_path.exists() { + let client_ca = std::fs::read(&client_ca_path) + .with_context(|| format!("reading TLS client CA {}", client_ca_path.display()))?; + tls = tls.client_ca_root(Certificate::from_pem(client_ca)); + if bool_env("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL") { + tls = tls.client_auth_optional(true); + warn!("🔐 TLS enabled with optional client certificates"); + } else { + info!("🔐 TLS enabled with required client certificate authentication"); + } + } else { + warn!( + ca = %client_ca_path.display(), + "🔐 TLS enabled without client certificate authentication; install a client CA for production" + ); + } + + Ok(Some(tls)) +} + +fn env_path(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn default_path(file_name: &str) -> PathBuf { + PathBuf::from(DEFAULT_PKI_DIR).join(file_name) +} + +fn bool_env(name: &str) -> bool { + std::env::var(name) + .map(|value| { + let value = value.trim().to_ascii_lowercase(); + !value.is_empty() && value != "0" && value != "false" && value != "no" + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::{bool_env, default_path, env_path, server_tls_config}; + use temp_env::with_vars; + use tempfile::tempdir; + + #[test] + fn missing_identity_is_allowed_unless_tls_is_required() { + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", None::<&str>), + ("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")), + ("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")), + ], + || { + assert!( + server_tls_config() + .expect("optional tls should not error") + .is_none() + ) + }, + ); + } + + #[test] + fn require_tls_errors_when_identity_is_missing() { + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")), + ("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")), + ], + || { + let err = server_tls_config().expect_err("required tls should fail"); + assert!(err.to_string().contains("LESAVKA_REQUIRE_TLS=1")); + }, + ); + } + + #[test] + fn helpers_parse_paths_and_boolean_overrides() { + with_vars( + [ + ("LESAVKA_TLS_CERT", Some("/tmp/server.crt")), + ("LESAVKA_REQUIRE_TLS", Some("yes")), + ], + || { + assert_eq!( + env_path("LESAVKA_TLS_CERT").unwrap(), + std::path::PathBuf::from("/tmp/server.crt") + ); + assert!(bool_env("LESAVKA_REQUIRE_TLS")); + }, + ); + with_vars( + [ + ("LESAVKA_TLS_CERT", Some("")), + ("LESAVKA_REQUIRE_TLS", Some("false")), + ], + || { + assert!(env_path("LESAVKA_TLS_CERT").is_none()); + assert!(!bool_env("LESAVKA_REQUIRE_TLS")); + assert_eq!( + default_path("server.crt"), + std::path::PathBuf::from("/etc/lesavka/pki/server.crt") + ); + }, + ); + } + + #[test] + fn tls_identity_can_load_with_and_without_client_ca() { + let dir = tempdir().expect("tls dir"); + let cert = dir.path().join("server.crt"); + let key = dir.path().join("server.key"); + let ca = dir.path().join("ca.crt"); + std::fs::write(&cert, b"not a real cert").expect("cert"); + std::fs::write(&key, b"not a real key").expect("key"); + std::fs::write(&ca, b"not a real ca").expect("ca"); + + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())), + ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), + ("LESAVKA_TLS_CLIENT_CA", Some(ca.to_string_lossy().as_ref())), + ("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("1")), + ], + || { + assert!(server_tls_config().expect("tls config").is_some()); + }, + ); + + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())), + ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), + ( + "LESAVKA_TLS_CLIENT_CA", + Some(dir.path().join("missing-ca.crt").to_string_lossy().as_ref()), + ), + ("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("0")), + ], + || { + assert!( + server_tls_config() + .expect("tls config without ca") + .is_some() + ); + }, + ); + } + + #[test] + fn existing_but_unreadable_identity_reports_context() { + let dir = tempdir().expect("tls dir"); + let key = dir.path().join("server.key"); + std::fs::write(&key, b"not a real key").expect("key"); + + with_vars( + [ + ("LESAVKA_REQUIRE_TLS", Some("1")), + ( + "LESAVKA_TLS_CERT", + Some(dir.path().to_string_lossy().as_ref()), + ), + ("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())), + ], + || { + let err = server_tls_config().expect_err("directory cert should fail"); + assert!(err.to_string().contains("reading TLS server certificate")); + }, + ); + } +} diff --git a/server/src/upstream_media_runtime.rs b/server/src/upstream_media_runtime.rs index b30d0cc..5fbce2f 100644 --- a/server/src/upstream_media_runtime.rs +++ b/server/src/upstream_media_runtime.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore}; @@ -36,6 +36,8 @@ pub struct UpstreamMediaRuntime { microphone_sink_gate: Arc, pairing_state_notify: Arc, audio_progress_notify: Arc, + camera_playout_offset_us: AtomicI64, + microphone_playout_offset_us: AtomicI64, state: Mutex, } @@ -50,151 +52,46 @@ impl UpstreamMediaRuntime { microphone_sink_gate: Arc::new(Semaphore::new(1)), pairing_state_notify: Arc::new(Notify::new()), audio_progress_notify: Arc::new(Notify::new()), + camera_playout_offset_us: AtomicI64::new(upstream_playout_offset_us( + UpstreamMediaKind::Camera, + )), + microphone_playout_offset_us: AtomicI64::new(upstream_playout_offset_us( + UpstreamMediaKind::Microphone, + )), state: Mutex::new(UpstreamClockState::default()), } } - /// Activate a camera stream as the current owner for the session. + /// Apply live upstream playout offsets without restarting the relay. + pub fn set_playout_offsets(&self, camera_offset_us: i64, microphone_offset_us: i64) { + self.camera_playout_offset_us + .store(camera_offset_us, Ordering::Relaxed); + self.microphone_playout_offset_us + .store(microphone_offset_us, Ordering::Relaxed); + } + + /// Return `(camera_offset_us, microphone_offset_us)` currently used for live playout. #[must_use] - pub fn activate_camera(&self) -> UpstreamStreamLease { - self.activate(UpstreamMediaKind::Camera) + pub fn playout_offsets(&self) -> (i64, i64) { + ( + self.camera_playout_offset_us.load(Ordering::Relaxed), + self.microphone_playout_offset_us.load(Ordering::Relaxed), + ) } - /// Activate a microphone stream as the current owner for the session. - #[must_use] - pub fn activate_microphone(&self) -> UpstreamStreamLease { - self.activate(UpstreamMediaKind::Microphone) - } - - /// Reserve the single live microphone sink slot for one generation. - /// - /// Inputs: the microphone lease generation that wants to own the UAC sink. - /// Outputs: an owned semaphore permit while that generation still owns the - /// microphone slot, or `None` if a newer stream superseded it before the - /// previous sink fully stood down. - /// Why: ALSA only allows one live owner of the UAC playback device, so a - /// replacement stream must wait for the old owner to release the sink - /// before opening a new playback pipeline. - pub async fn reserve_microphone_sink(&self, generation: u64) -> Option { - let permit = self - .microphone_sink_gate - .clone() - .acquire_owned() - .await - .ok()?; - self.is_microphone_active(generation).then_some(permit) - } - - fn activate(&self, kind: UpstreamMediaKind) -> UpstreamStreamLease { - let generation = match kind { - UpstreamMediaKind::Camera => { - self.next_camera_generation.fetch_add(1, Ordering::SeqCst) + 1 - } + fn playout_offset_us(&self, kind: UpstreamMediaKind) -> i64 { + match kind { + UpstreamMediaKind::Camera => self.camera_playout_offset_us.load(Ordering::Relaxed), UpstreamMediaKind::Microphone => { - self.next_microphone_generation - .fetch_add(1, Ordering::SeqCst) - + 1 + self.microphone_playout_offset_us.load(Ordering::Relaxed) } - }; - - let mut state = self - .state - .lock() - .expect("upstream media state mutex poisoned"); - if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none() - { - state.session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst) + 1; - state.first_camera_remote_pts_us = None; - state.first_microphone_remote_pts_us = None; - state.camera_startup_ready = false; - state.session_base_remote_pts_us = None; - state.last_video_local_pts_us = None; - state.last_audio_local_pts_us = None; - state.camera_packet_count = 0; - state.microphone_packet_count = 0; - state.startup_anchor_logged = false; - state.playout_epoch = None; - state.pairing_anchor_deadline = None; - state.catastrophic_reanchor_done = false; - } - match kind { - UpstreamMediaKind::Camera => state.active_camera_generation = Some(generation), - UpstreamMediaKind::Microphone => state.active_microphone_generation = Some(generation), - } - UpstreamStreamLease { - session_id: state.session_id, - generation, } } +} - /// Return whether the supplied camera lease is still the active owner. - #[must_use] - pub fn is_camera_active(&self, generation: u64) -> bool { - self.is_active(UpstreamMediaKind::Camera, generation) - } - - /// Return whether the supplied microphone lease is still the active owner. - #[must_use] - pub fn is_microphone_active(&self, generation: u64) -> bool { - self.is_active(UpstreamMediaKind::Microphone, generation) - } - - fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool { - let state = self - .state - .lock() - .expect("upstream media state mutex poisoned"); - match kind { - UpstreamMediaKind::Camera => state.active_camera_generation == Some(generation), - UpstreamMediaKind::Microphone => state.active_microphone_generation == Some(generation), - } - } - - /// Mark a camera stream as closed if it still owns the camera slot. - pub fn close_camera(&self, generation: u64) { - self.close(UpstreamMediaKind::Camera, generation); - } - - /// Mark a microphone stream as closed if it still owns the microphone slot. - pub fn close_microphone(&self, generation: u64) { - self.close(UpstreamMediaKind::Microphone, generation); - } - - fn close(&self, kind: UpstreamMediaKind, generation: u64) { - let mut state = self - .state - .lock() - .expect("upstream media state mutex poisoned"); - match kind { - UpstreamMediaKind::Camera if state.active_camera_generation == Some(generation) => { - state.active_camera_generation = None; - } - UpstreamMediaKind::Microphone - if state.active_microphone_generation == Some(generation) => - { - state.active_microphone_generation = None; - } - _ => return, - } - if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none() - { - state.first_camera_remote_pts_us = None; - state.first_microphone_remote_pts_us = None; - state.camera_startup_ready = false; - state.session_base_remote_pts_us = None; - state.last_video_local_pts_us = None; - state.last_audio_local_pts_us = None; - state.camera_packet_count = 0; - state.microphone_packet_count = 0; - state.startup_anchor_logged = false; - state.playout_epoch = None; - state.pairing_anchor_deadline = None; - state.catastrophic_reanchor_done = false; - } - self.pairing_state_notify.notify_waiters(); - self.audio_progress_notify.notify_waiters(); - } +include!("upstream_media_runtime/lease_lifecycle.rs"); +impl UpstreamMediaRuntime { /// Rebase one upstream video packet timestamp onto the shared session clock. #[must_use] pub fn map_video_pts(&self, remote_pts_us: u64, frame_step_us: u64) -> Option { @@ -417,7 +314,7 @@ impl UpstreamMediaRuntime { } *last_slot = Some(local_pts_us); let epoch = *state.playout_epoch.get_or_insert(pairing_deadline); - let sink_offset_us = upstream_playout_offset_us(kind); + let sink_offset_us = self.playout_offset_us(kind); let playout_delay = upstream_playout_delay(); let mut due_at = apply_playout_offset(epoch + Duration::from_micros(local_pts_us), sink_offset_us); diff --git a/server/src/upstream_media_runtime/config.rs b/server/src/upstream_media_runtime/config.rs index 2fd4c12..011f22e 100644 --- a/server/src/upstream_media_runtime/config.rs +++ b/server/src/upstream_media_runtime/config.rs @@ -2,6 +2,7 @@ use std::time::Duration; use tokio::time::Instant; use super::UpstreamMediaKind; +use crate::calibration::{FACTORY_MJPEG_AUDIO_OFFSET_US, FACTORY_MJPEG_VIDEO_OFFSET_US}; pub(super) fn upstream_timing_trace_enabled() -> bool { std::env::var("LESAVKA_UPSTREAM_TIMING_TRACE") @@ -30,12 +31,12 @@ pub(super) fn upstream_playout_offset_us(kind: UpstreamMediaKind) -> i64 { UpstreamMediaKind::Microphone => "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", }; let default_offset_us = match kind { - UpstreamMediaKind::Camera => 0, + UpstreamMediaKind::Camera => FACTORY_MJPEG_VIDEO_OFFSET_US, // Hardware sync probes on the MJPEG UVC path show the UAC leg arriving // about 80ms after video when using the older +35ms default. Bias the // server playout earlier so the shipped default lands in the preferred // lip-sync band instead of hovering at the guardrail. - UpstreamMediaKind::Microphone => -45_000, + UpstreamMediaKind::Microphone => FACTORY_MJPEG_AUDIO_OFFSET_US, }; std::env::var(name) .ok() diff --git a/server/src/upstream_media_runtime/lease_lifecycle.rs b/server/src/upstream_media_runtime/lease_lifecycle.rs new file mode 100644 index 0000000..e1e74d8 --- /dev/null +++ b/server/src/upstream_media_runtime/lease_lifecycle.rs @@ -0,0 +1,142 @@ +impl UpstreamMediaRuntime { + /// Activate a camera stream as the current owner for the session. + #[must_use] + pub fn activate_camera(&self) -> UpstreamStreamLease { + self.activate(UpstreamMediaKind::Camera) + } + + /// Activate a microphone stream as the current owner for the session. + #[must_use] + pub fn activate_microphone(&self) -> UpstreamStreamLease { + self.activate(UpstreamMediaKind::Microphone) + } + + /// Reserve the single live microphone sink slot for one generation. + /// + /// Inputs: the microphone lease generation that wants to own the UAC sink. + /// Outputs: an owned semaphore permit while that generation still owns the + /// microphone slot, or `None` if a newer stream superseded it before the + /// previous sink fully stood down. + /// Why: ALSA only allows one live owner of the UAC playback device, so a + /// replacement stream must wait for the old owner to release the sink + /// before opening a new playback pipeline. + pub async fn reserve_microphone_sink(&self, generation: u64) -> Option { + let permit = self + .microphone_sink_gate + .clone() + .acquire_owned() + .await + .ok()?; + self.is_microphone_active(generation).then_some(permit) + } + + fn activate(&self, kind: UpstreamMediaKind) -> UpstreamStreamLease { + let generation = match kind { + UpstreamMediaKind::Camera => { + self.next_camera_generation.fetch_add(1, Ordering::SeqCst) + 1 + } + UpstreamMediaKind::Microphone => { + self.next_microphone_generation + .fetch_add(1, Ordering::SeqCst) + + 1 + } + }; + + let mut state = self + .state + .lock() + .expect("upstream media state mutex poisoned"); + if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none() + { + state.session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst) + 1; + state.first_camera_remote_pts_us = None; + state.first_microphone_remote_pts_us = None; + state.camera_startup_ready = false; + state.session_base_remote_pts_us = None; + state.last_video_local_pts_us = None; + state.last_audio_local_pts_us = None; + state.camera_packet_count = 0; + state.microphone_packet_count = 0; + state.startup_anchor_logged = false; + state.playout_epoch = None; + state.pairing_anchor_deadline = None; + state.catastrophic_reanchor_done = false; + } + match kind { + UpstreamMediaKind::Camera => state.active_camera_generation = Some(generation), + UpstreamMediaKind::Microphone => state.active_microphone_generation = Some(generation), + } + UpstreamStreamLease { + session_id: state.session_id, + generation, + } + } + + /// Return whether the supplied camera lease is still the active owner. + #[must_use] + pub fn is_camera_active(&self, generation: u64) -> bool { + self.is_active(UpstreamMediaKind::Camera, generation) + } + + /// Return whether the supplied microphone lease is still the active owner. + #[must_use] + pub fn is_microphone_active(&self, generation: u64) -> bool { + self.is_active(UpstreamMediaKind::Microphone, generation) + } + + fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool { + let state = self + .state + .lock() + .expect("upstream media state mutex poisoned"); + match kind { + UpstreamMediaKind::Camera => state.active_camera_generation == Some(generation), + UpstreamMediaKind::Microphone => state.active_microphone_generation == Some(generation), + } + } + + /// Mark a camera stream as closed if it still owns the camera slot. + pub fn close_camera(&self, generation: u64) { + self.close(UpstreamMediaKind::Camera, generation); + } + + /// Mark a microphone stream as closed if it still owns the microphone slot. + pub fn close_microphone(&self, generation: u64) { + self.close(UpstreamMediaKind::Microphone, generation); + } + + fn close(&self, kind: UpstreamMediaKind, generation: u64) { + let mut state = self + .state + .lock() + .expect("upstream media state mutex poisoned"); + match kind { + UpstreamMediaKind::Camera if state.active_camera_generation == Some(generation) => { + state.active_camera_generation = None; + } + UpstreamMediaKind::Microphone + if state.active_microphone_generation == Some(generation) => + { + state.active_microphone_generation = None; + } + _ => return, + } + if state.active_camera_generation.is_none() && state.active_microphone_generation.is_none() + { + state.first_camera_remote_pts_us = None; + state.first_microphone_remote_pts_us = None; + state.camera_startup_ready = false; + state.session_base_remote_pts_us = None; + state.last_video_local_pts_us = None; + state.last_audio_local_pts_us = None; + state.camera_packet_count = 0; + state.microphone_packet_count = 0; + state.startup_anchor_logged = false; + state.playout_epoch = None; + state.pairing_anchor_deadline = None; + state.catastrophic_reanchor_done = false; + } + self.pairing_state_notify.notify_waiters(); + self.audio_progress_notify.notify_waiters(); + } +} diff --git a/server/src/upstream_media_runtime/tests.rs b/server/src/upstream_media_runtime/tests.rs index e232152..1c924bb 100644 --- a/server/src/upstream_media_runtime/tests.rs +++ b/server/src/upstream_media_runtime/tests.rs @@ -7,6 +7,12 @@ fn play(decision: UpstreamPlanDecision) -> PlannedUpstreamPacket { } } +fn runtime_without_offsets() -> UpstreamMediaRuntime { + let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(0, 0); + runtime +} + mod async_wait; mod config; mod lifecycle; diff --git a/server/src/upstream_media_runtime/tests/async_wait.rs b/server/src/upstream_media_runtime/tests/async_wait.rs index 46d39cf..9977974 100644 --- a/server/src/upstream_media_runtime/tests/async_wait.rs +++ b/server/src/upstream_media_runtime/tests/async_wait.rs @@ -1,8 +1,10 @@ use super::{UpstreamMediaRuntime, play}; +use serial_test::serial; use std::sync::Arc; use std::time::Duration; #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn wait_for_audio_master_releases_video_once_audio_catches_up() { let runtime = Arc::new(UpstreamMediaRuntime::new()); let _camera = runtime.activate_camera(); @@ -31,6 +33,7 @@ async fn wait_for_audio_master_releases_video_once_audio_catches_up() { } #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn wait_for_audio_master_times_out_when_audio_never_catches_up() { let runtime = Arc::new(UpstreamMediaRuntime::new()); let _camera = runtime.activate_camera(); @@ -52,6 +55,7 @@ async fn wait_for_audio_master_times_out_when_audio_never_catches_up() { } #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn wait_for_audio_master_returns_true_when_no_microphone_stream_is_active() { let runtime = Arc::new(UpstreamMediaRuntime::new()); let camera = runtime.activate_camera(); @@ -70,6 +74,7 @@ async fn wait_for_audio_master_returns_true_when_no_microphone_stream_is_active( } #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn new_microphone_owner_waits_for_the_previous_sink_to_release() { let runtime = Arc::new(UpstreamMediaRuntime::new()); let first = runtime.activate_microphone(); @@ -97,6 +102,7 @@ async fn new_microphone_owner_waits_for_the_previous_sink_to_release() { } #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn superseded_microphone_waiter_stands_down_before_opening_a_sink() { let runtime = Arc::new(UpstreamMediaRuntime::new()); let first = runtime.activate_microphone(); diff --git a/server/src/upstream_media_runtime/tests/config.rs b/server/src/upstream_media_runtime/tests/config.rs index 7b52271..0ebb544 100644 --- a/server/src/upstream_media_runtime/tests/config.rs +++ b/server/src/upstream_media_runtime/tests/config.rs @@ -1,7 +1,9 @@ use super::UpstreamMediaKind; +use serial_test::serial; use std::time::Duration; #[test] +#[serial(upstream_media_runtime)] fn upstream_playout_delay_defaults_to_one_second_and_accepts_overrides() { temp_env::with_var_unset("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", || { assert_eq!(super::upstream_playout_delay(), Duration::from_secs(1)); @@ -13,6 +15,7 @@ fn upstream_playout_delay_defaults_to_one_second_and_accepts_overrides() { } #[test] +#[serial(upstream_media_runtime)] fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides() { temp_env::with_var_unset("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", || { temp_env::with_var_unset("LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US", || { @@ -50,6 +53,7 @@ fn upstream_playout_offsets_default_to_mjpeg_calibration_and_accept_overrides() } #[test] +#[serial(upstream_media_runtime)] fn upstream_pairing_master_slack_defaults_to_twenty_ms_and_accepts_overrides() { temp_env::with_var_unset("LESAVKA_UPSTREAM_PAIR_SLACK_US", || { assert_eq!( @@ -67,6 +71,7 @@ fn upstream_pairing_master_slack_defaults_to_twenty_ms_and_accepts_overrides() { } #[test] +#[serial(upstream_media_runtime)] fn upstream_reanchor_late_threshold_defaults_to_half_the_buffer_and_accepts_overrides() { temp_env::with_var_unset("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", || { assert_eq!( @@ -88,6 +93,7 @@ fn upstream_reanchor_late_threshold_defaults_to_half_the_buffer_and_accepts_over } #[test] +#[serial(upstream_media_runtime)] fn upstream_timing_trace_flag_accepts_false_values() { temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("off"), || { assert!(!super::upstream_timing_trace_enabled()); @@ -101,6 +107,7 @@ fn upstream_timing_trace_flag_accepts_false_values() { } #[test] +#[serial(upstream_media_runtime)] fn apply_playout_offset_supports_negative_offsets() { let base = tokio::time::Instant::now() + Duration::from_millis(50); let shifted = super::apply_playout_offset(base, -20_000); @@ -109,6 +116,7 @@ fn apply_playout_offset_supports_negative_offsets() { } #[test] +#[serial(upstream_media_runtime)] fn apply_playout_offset_supports_positive_offsets() { let base = tokio::time::Instant::now(); let shifted = super::apply_playout_offset(base, 30_000); diff --git a/server/src/upstream_media_runtime/tests/lifecycle.rs b/server/src/upstream_media_runtime/tests/lifecycle.rs index e7f192a..07da357 100644 --- a/server/src/upstream_media_runtime/tests/lifecycle.rs +++ b/server/src/upstream_media_runtime/tests/lifecycle.rs @@ -1,7 +1,9 @@ -use super::{UpstreamMediaRuntime, play}; +use super::{UpstreamMediaRuntime, play, runtime_without_offsets}; +use serial_test::serial; use std::time::Duration; #[test] +#[serial(upstream_media_runtime)] fn first_stream_starts_a_new_shared_session() { let runtime = UpstreamMediaRuntime::new(); let camera = runtime.activate_camera(); @@ -14,6 +16,7 @@ fn first_stream_starts_a_new_shared_session() { } #[test] +#[serial(upstream_media_runtime)] fn replacing_one_kind_keeps_the_session_but_preempts_the_old_owner() { let runtime = UpstreamMediaRuntime::new(); let first = runtime.activate_microphone(); @@ -25,6 +28,7 @@ fn replacing_one_kind_keeps_the_session_but_preempts_the_old_owner() { } #[test] +#[serial(upstream_media_runtime)] fn closing_the_last_stream_resets_the_next_session_anchor() { let runtime = UpstreamMediaRuntime::new(); let camera = runtime.activate_camera(); @@ -37,8 +41,9 @@ fn closing_the_last_stream_resets_the_next_session_anchor() { } #[test] +#[serial(upstream_media_runtime)] fn first_packets_wait_for_the_counterpart_before_pairing() { - let runtime = UpstreamMediaRuntime::new(); + let runtime = runtime_without_offsets(); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -56,6 +61,7 @@ fn first_packets_wait_for_the_counterpart_before_pairing() { } #[test] +#[serial(upstream_media_runtime)] fn overlap_waits_for_camera_startup_grace_before_establishing_the_shared_base() { temp_env::with_var( "LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS", @@ -88,6 +94,7 @@ fn overlap_waits_for_camera_startup_grace_before_establishing_the_shared_base() } #[test] +#[serial(upstream_media_runtime)] fn pairing_window_does_not_expire_into_one_sided_playout_while_camera_warms_up() { temp_env::with_var( "LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS", @@ -125,6 +132,7 @@ fn pairing_window_does_not_expire_into_one_sided_playout_while_camera_warms_up() } #[test] +#[serial(upstream_media_runtime)] fn overlap_pairing_drops_leading_packets_before_the_shared_base() { let runtime = UpstreamMediaRuntime::new(); let _camera = runtime.activate_camera(); @@ -150,6 +158,7 @@ fn overlap_pairing_drops_leading_packets_before_the_shared_base() { } #[test] +#[serial(upstream_media_runtime)] fn shared_clock_keeps_each_kind_monotonic_when_remote_pts_repeat() { let runtime = UpstreamMediaRuntime::new(); let _camera = runtime.activate_camera(); @@ -168,6 +177,7 @@ fn shared_clock_keeps_each_kind_monotonic_when_remote_pts_repeat() { } #[test] +#[serial(upstream_media_runtime)] fn close_ignores_superseded_generation_values() { let runtime = UpstreamMediaRuntime::new(); let first = runtime.activate_camera(); diff --git a/server/src/upstream_media_runtime/tests/planning.rs b/server/src/upstream_media_runtime/tests/planning.rs index 8a94819..ea45170 100644 --- a/server/src/upstream_media_runtime/tests/planning.rs +++ b/server/src/upstream_media_runtime/tests/planning.rs @@ -1,4 +1,5 @@ -use super::{UpstreamMediaRuntime, play}; +use super::{UpstreamMediaRuntime, play, runtime_without_offsets}; +use serial_test::serial; use std::time::Duration; fn with_info_tracing(f: impl FnOnce() -> T) -> T { @@ -10,8 +11,9 @@ fn with_info_tracing(f: impl FnOnce() -> T) -> T { } #[test] +#[serial(upstream_media_runtime)] fn shared_playout_epoch_is_reused_across_audio_and_video() { - let runtime = UpstreamMediaRuntime::new(); + let runtime = runtime_without_offsets(); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -35,6 +37,7 @@ fn shared_playout_epoch_is_reused_across_audio_and_video() { } #[test] +#[serial(upstream_media_runtime)] fn pairing_window_can_expire_into_one_sided_playout() { temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || { let runtime = UpstreamMediaRuntime::new(); @@ -49,6 +52,7 @@ fn pairing_window_can_expire_into_one_sided_playout() { } #[test] +#[serial(upstream_media_runtime)] fn map_wrappers_hide_unpaired_and_pre_overlap_packets() { let runtime = UpstreamMediaRuntime::new(); let _camera = runtime.activate_camera(); @@ -60,6 +64,7 @@ fn map_wrappers_hide_unpaired_and_pre_overlap_packets() { } #[test] +#[serial(upstream_media_runtime)] fn shared_playout_trace_path_keeps_planned_pts_stable() { temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || { let runtime = UpstreamMediaRuntime::new(); @@ -79,10 +84,12 @@ fn shared_playout_trace_path_keeps_planned_pts_stable() { } #[test] +#[serial(upstream_media_runtime)] fn catastrophic_lateness_reanchors_the_shared_playout_epoch() { temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || { temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || { let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(0, 0); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -115,9 +122,11 @@ fn catastrophic_lateness_reanchors_the_shared_playout_epoch() { } #[test] +#[serial(upstream_media_runtime)] fn overlap_anchor_gets_a_fresh_playout_budget_when_pairing_finishes_late() { temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || { let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(0, 0); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -143,10 +152,12 @@ fn overlap_anchor_gets_a_fresh_playout_budget_when_pairing_finishes_late() { } #[test] +#[serial(upstream_media_runtime)] fn catastrophic_lateness_reanchors_only_once_per_session() { temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || { temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || { let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(0, 0); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -172,10 +183,12 @@ fn catastrophic_lateness_reanchors_only_once_per_session() { } #[test] +#[serial(upstream_media_runtime)] fn catastrophic_lateness_does_not_reanchor_once_the_session_is_well_past_startup() { temp_env::with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("20"), || { temp_env::with_var("LESAVKA_UPSTREAM_REANCHOR_LATE_MS", Some("5"), || { let runtime = UpstreamMediaRuntime::new(); + runtime.set_playout_offsets(0, 0); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); @@ -202,6 +215,7 @@ fn catastrophic_lateness_does_not_reanchor_once_the_session_is_well_past_startup } #[test] +#[serial(upstream_media_runtime)] fn default_runtime_covers_video_map_play_path() { let runtime = UpstreamMediaRuntime::default(); let _camera = runtime.activate_camera(); @@ -216,22 +230,26 @@ fn default_runtime_covers_video_map_play_path() { } #[tokio::test(flavor = "current_thread")] +#[serial(upstream_media_runtime)] async fn wait_for_audio_master_returns_false_immediately_once_due_time_has_already_passed() { let runtime = UpstreamMediaRuntime::new(); let _camera = runtime.activate_camera(); let _microphone = runtime.activate_microphone(); - assert!(!runtime - .wait_for_audio_master( - 123_456, - tokio::time::Instant::now() - .checked_sub(Duration::from_millis(1)) - .unwrap_or_else(tokio::time::Instant::now), - ) - .await); + assert!( + !runtime + .wait_for_audio_master( + 123_456, + tokio::time::Instant::now() + .checked_sub(Duration::from_millis(1)) + .unwrap_or_else(tokio::time::Instant::now), + ) + .await + ); } #[test] +#[serial(upstream_media_runtime)] fn timing_trace_paths_emit_overlap_and_dropbeforeoverlap_details() { temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || { with_info_tracing(|| { diff --git a/testing/tests/client_app_include_contract.rs b/testing/tests/client_app_include_contract.rs index 1ea4a4e..a4ce9b6 100644 --- a/testing/tests/client_app_include_contract.rs +++ b/testing/tests/client_app_include_contract.rs @@ -79,6 +79,14 @@ mod app_support { } } +mod relay_transport { + pub fn endpoint(server_addr: &str) -> anyhow::Result { + Ok(tonic::transport::Channel::from_shared( + server_addr.to_string(), + )?) + } +} + mod input { pub mod camera { use crate::app_support::CameraConfig; @@ -242,6 +250,7 @@ mod tests { use tokio_stream::wrappers::errors::BroadcastStreamRecvError; const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs"); + const INPUT_STREAMS_SRC: &str = include_str!("../../client/src/app/input_streams.rs"); #[test] #[serial] @@ -395,4 +404,12 @@ mod tests { assert!(DOWNLINK_MEDIA_SRC.contains("delay = app_support::next_delay(delay);")); assert!(DOWNLINK_MEDIA_SRC.contains("consecutive_source_failures = 0;")); } + + #[test] + fn input_streams_reconnect_quickly_then_back_off_under_outage() { + assert!(INPUT_STREAMS_SRC.contains("INPUT_RECONNECT_BASE_DELAY")); + assert!(INPUT_STREAMS_SRC.contains("Duration::from_millis(250)")); + assert!(INPUT_STREAMS_SRC.contains("delay = app_support::next_delay(delay);")); + assert!(INPUT_STREAMS_SRC.contains("tokio::time::sleep(delay).await;")); + } } diff --git a/testing/tests/client_install_script_contract.rs b/testing/tests/client_install_script_contract.rs new file mode 100644 index 0000000..da9698b --- /dev/null +++ b/testing/tests/client_install_script_contract.rs @@ -0,0 +1,35 @@ +//! Contract tests for client install-time security defaults. +//! +//! Scope: inspect the client installer shell contract without running it. +//! Targets: `scripts/install/client.sh`. +//! Why: secure-by-default relay transport depends on installing the server-issued +//! client cert bundle exactly where the desktop app auto-discovers it. + +const CLIENT_INSTALL: &str = include_str!("../../scripts/install/client.sh"); + +#[test] +fn client_install_accepts_server_generated_tls_bundle() { + for expected in [ + "LESAVKA_CLIENT_PKI_BUNDLE", + "CLIENT_PKI_DIR", + "ca.crt", + "client.crt", + "client.key", + "install_client_pki_bundle", + "HTTPS relay connections will need a trusted public cert or a bundle install later", + "TLS identity:", + ] { + assert!( + CLIENT_INSTALL.contains(expected), + "client installer should include TLS bundle contract fragment {expected}" + ); + } + assert!( + CLIENT_INSTALL.contains(".config/lesavka/pki"), + "client cert bundle should land in the same path the desktop app auto-loads" + ); + assert!( + CLIENT_INSTALL.contains("0600"), + "client private key should be installed with private permissions" + ); +} diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index 8085202..66e6771 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -68,8 +68,8 @@ fn launcher_default_size_stays_inside_1080p() { #[test] fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() { - assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 560); - assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 315); + assert_eq!(const_i32("EYE_PREVIEW_MIN_WIDTH"), 532); + assert_eq!(const_i32("EYE_PREVIEW_MIN_HEIGHT"), 299); assert!(UI_LAYOUT_SRC.contains("display_row.set_vexpand(false);")); assert!(UI_LAYOUT_SRC.contains("display_row.set_valign(gtk::Align::Start);")); assert!( @@ -211,7 +211,7 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() { #[test] fn operations_column_fills_height_and_splits_extra_space_between_logs() { assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124); - assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 63); + assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 102); assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);")); assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);")); assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);")); @@ -271,8 +271,8 @@ fn status_chip_text_is_centered_inside_each_pill() { #[test] fn relay_controls_keep_connect_inline_with_server_entry() { - assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay Controls\")")); - assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 288); + assert!(UI_LAYOUT_SRC.contains("build_panel(\"Relay\")")); + assert_eq!(const_i32("OPERATIONS_RAIL_WIDTH"), 276); assert_eq!(const_i32("RAIL_BUTTON_WIDTH"), 86); assert_eq!(const_i32("RAIL_BUTTON_LABEL_CHARS"), 11); assert!(UI_LAYOUT_SRC.contains("let relay_grid = gtk::Grid::new();")); @@ -282,13 +282,36 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label(")); assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);")); assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recover\"));")); - assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); - assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); + assert!( + UI_LAYOUT_SRC + .contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + ); + assert!( + UI_LAYOUT_SRC + .contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + ); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);")); assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);")); + assert!(UI_LAYOUT_SRC.contains( + "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + )); + assert!(UI_LAYOUT_SRC.contains("calibration_heading.set_width_chars(12);")); + assert!(UI_LAYOUT_SRC.contains("let calibration_buttons = gtk::Grid::new();")); + assert!(UI_LAYOUT_SRC.contains("calibration_buttons.set_column_homogeneous(true);")); + assert!(UI_LAYOUT_SRC.contains("let calibration_default_button = rail_button(")); + assert!(UI_LAYOUT_SRC.contains("let calibration_factory_button = rail_button(")); + assert!(UI_LAYOUT_SRC.contains("let calibration_blind_button = rail_button(")); + assert!(UI_LAYOUT_SRC.contains("let calibration_minus_button = rail_button(")); + assert!(UI_LAYOUT_SRC.contains("let calibration_plus_button = rail_button(")); + assert!(UI_LAYOUT_SRC.contains("let calibration_rig_button = rail_button(")); assert!(UI_LAYOUT_SRC.contains("let tools_heading = gtk::Label::new(Some(\"Tools\"));")); - assert!(UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); - assert!(UI_LAYOUT_SRC.contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);")); + assert!( + UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + ); + assert!( + UI_LAYOUT_SRC + .contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);") + ); assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);")); assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);")); assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); @@ -298,6 +321,17 @@ fn relay_controls_keep_connect_inline_with_server_entry() { assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);")); assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);")); + assert!( + source_index("let recovery_heading = gtk::Label::new(Some(\"Recover\"));") + < source_index( + "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + ) + ); + assert!( + source_index( + "let calibration_heading = gtk::Label::new(Some(\"AV Upstream\\nCalibration\"));" + ) < source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") + ); assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);")); assert!(!UI_LAYOUT_SRC.contains("Gate Probe")); assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);")); @@ -373,8 +407,8 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { UI_LAYOUT_SRC .matches("connection_body.append(>k::Separator::new(gtk::Orientation::Horizontal));") .count(), - 3, - "recover/tools/gpio/inputs sections should remain visually separated" + 4, + "recover/calibration/gpio/inputs/tools sections should remain visually separated" ); assert!( source_index("let power_heading = gtk::Label::new(Some(\"GPIO Power\"));") @@ -384,5 +418,9 @@ fn media_controls_own_stream_toggles_and_inline_gain_controls() { source_index("power_shell.append(&power_row);") < source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") ); + assert!( + source_index("let routing_heading = gtk::Label::new(Some(\"Inputs\"));") + < source_index("let tools_heading = gtk::Label::new(Some(\"Tools\"));") + ); assert!(UI_LAYOUT_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 637813f..4f7215f 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -22,6 +22,7 @@ const UI_SRC: &str = concat!( include_str!("../../client/src/launcher/ui/relay_input_bindings.rs"), include_str!("../../client/src/launcher/ui/runtime_poll.rs"), include_str!("../../client/src/launcher/ui/stage_device_bindings.rs"), + include_str!("../../client/src/launcher/ui/eye_capture_bindings.rs"), include_str!("../../client/src/launcher/ui/utility_button_bindings.rs"), ); const DEVICE_TEST_SRC: &str = concat!( diff --git a/testing/tests/client_log_noise_contract.rs b/testing/tests/client_log_noise_contract.rs new file mode 100644 index 0000000..6d1d18c --- /dev/null +++ b/testing/tests/client_log_noise_contract.rs @@ -0,0 +1,54 @@ +//! Contracts for keeping steady-state live-media recovery logs actionable. +//! +//! Scope: inspect client live-media logging and recovery source text. +//! Targets: `client/src/app/uplink_media.rs`, `client/src/app/downlink_media.rs`. +//! Why: freshness-first media handling should not turn normal queue churn into +//! high-volume warnings that hide real failures or steal runtime budget. + +const UPLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/uplink_media.rs"); +const DOWNLINK_MEDIA_SRC: &str = include_str!("../../client/src/app/downlink_media.rs"); +const AUDIO_RECOVERY_SRC: &str = include_str!("../../client/src/app/audio_recovery_config.rs"); + +#[test] +fn upstream_queue_drops_are_rate_limited_instead_of_warn_spammed() { + assert!( + UPLINK_MEDIA_SRC.contains("UplinkDropLogLimiter"), + "uplink queue drops should flow through a rate-limited logger" + ); + assert!( + UPLINK_MEDIA_SRC.contains("UPLINK_DROP_WARN_INTERVAL"), + "uplink drop warnings should have an explicit aggregation interval" + ); + for noisy in [ + "upstream camera queue dropped stale packets", + "upstream microphone queue dropped stale packets", + "upstream camera queue dropped the oldest frame because it was full", + "upstream microphone queue dropped the oldest packet because it was full", + ] { + assert!( + !UPLINK_MEDIA_SRC.contains(noisy), + "normal freshness-first drop churn should not WARN every packet: {noisy}" + ); + } +} + +#[test] +fn repeated_remote_audio_failures_are_rate_limited() { + assert!( + AUDIO_RECOVERY_SRC.contains("AudioFailureLogLimiter"), + "remote audio failures should flow through a rate-limited logger" + ); + assert!( + AUDIO_RECOVERY_SRC.contains("AUDIO_FAILURE_WARN_INTERVAL"), + "audio failure warnings should have an explicit aggregation interval" + ); + assert!( + !DOWNLINK_MEDIA_SRC.contains("audio stream recv error"), + "repeated recv errors should use the audio failure limiter instead of direct WARN spam" + ); + assert!( + DOWNLINK_MEDIA_SRC.contains("tracing::debug!(") + && DOWNLINK_MEDIA_SRC.contains("waiting before USB recovery"), + "below-threshold auto-recovery bookkeeping should be DEBUG, not WARN" + ); +} diff --git a/testing/tests/client_relayctl_binary_contract.rs b/testing/tests/client_relayctl_binary_contract.rs index b74e27a..f27e4ef 100644 --- a/testing/tests/client_relayctl_binary_contract.rs +++ b/testing/tests/client_relayctl_binary_contract.rs @@ -30,7 +30,7 @@ mod relayctl_binary { #[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"); + let err = connect("http://[::1").await.expect_err("invalid endpoint"); assert!(err.to_string().contains("invalid relay server address")); } diff --git a/testing/tests/performance_gate_script_contract.rs b/testing/tests/performance_gate_script_contract.rs new file mode 100644 index 0000000..7981e3d --- /dev/null +++ b/testing/tests/performance_gate_script_contract.rs @@ -0,0 +1,42 @@ +//! Contract tests for the deterministic performance gate. +//! +//! Scope: inspect CI gate scripts for latency-sensitive Lesavka checks. +//! Targets: `scripts/ci/performance_gate.sh`, `scripts/ci/platform_quality_gate.sh`. +//! Why: A/V sync and remote-control tightness are product behavior, so the main +//! quality gate must fail before release when their contracts regress. + +const PERFORMANCE_GATE: &str = include_str!("../../scripts/ci/performance_gate.sh"); +const PLATFORM_GATE: &str = include_str!("../../scripts/ci/platform_quality_gate.sh"); + +#[test] +fn performance_gate_tracks_av_and_interaction_latency_contracts() { + for expected in [ + "client_uplink_performance_contract", + "client_uplink_freshness_contract", + "client_log_noise_contract", + "video_downstream_feed_contract", + "client_app_include_contract", + "platform_quality_gate_checks_total", + "check=\"performance\"", + "lesavka-performance-gate", + ] { + assert!( + PERFORMANCE_GATE.contains(expected), + "performance gate should include {expected}" + ); + } +} + +#[test] +fn platform_gate_runs_performance_before_media_reliability() { + let performance = PLATFORM_GATE + .find("scripts/ci/performance_gate.sh") + .expect("platform gate should run performance gate"); + let media = PLATFORM_GATE + .find("scripts/ci/media_reliability_gate.sh") + .expect("platform gate should run media reliability gate"); + assert!( + performance < media, + "performance regressions should fail before broader media packaging checks" + ); +} diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index 0e4dc26..b9f1320 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -31,6 +31,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_UVC_HEIGHT=", "LESAVKA_UVC_CODEC=", "LESAVKA_UVC_CONTROL_READ_ONLY=", + "LESAVKA_REQUIRE_TLS=%s", + "LESAVKA_TLS_CERT=%s", + "LESAVKA_TLS_KEY=%s", + "LESAVKA_TLS_CLIENT_CA=%s", ] { assert!( SERVER_INSTALL.contains(expected), @@ -208,6 +212,33 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { ); } +#[test] +fn server_install_generates_mtls_identity_and_client_bundle() { + for expected in [ + "ensure_server_tls_pki", + "openssl genrsa", + "extendedKeyUsage = serverAuth", + "extendedKeyUsage = clientAuth", + "LESAVKA_CLIENT_BUNDLE", + "lesavka-client-pki.tar.gz", + "LESAVKA_REQUIRE_TLS=%s", + "LESAVKA_TLS_CLIENT_CA=%s", + ] { + assert!( + SERVER_INSTALL.contains(expected), + "server install script should include TLS/mTLS contract fragment {expected}" + ); + } + assert!( + SERVER_INSTALL.contains("LESAVKA_REQUIRE_TLS:-1"), + "installed servers should require TLS by default" + ); + assert!( + SERVER_INSTALL.contains("38.28.125.112"), + "server cert SAN defaults should include the current public relay endpoint" + ); +} + #[test] fn server_install_reports_installed_version_and_revision() { assert!( diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs index a4fdbb9..0c54ba2 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/testing/tests/server_main_binary_contract.rs @@ -50,6 +50,9 @@ mod server_main_binary { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs index daa3849..891b750 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/testing/tests/server_main_binary_extra_contract.rs @@ -119,6 +119,9 @@ mod server_main_binary_extra { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), diff --git a/testing/tests/server_main_media_extra_contract.rs b/testing/tests/server_main_media_extra_contract.rs index 8b5a769..18e9114 100644 --- a/testing/tests/server_main_media_extra_contract.rs +++ b/testing/tests/server_main_media_extra_contract.rs @@ -89,6 +89,9 @@ mod server_main_media_extra { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), @@ -188,10 +191,17 @@ mod server_main_media_extra { drop(tx); let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut resp = cli - .stream_camera(tonic::Request::new(outbound)) - .await - .expect("stream camera should terminate cleanly"); + let mut resp = match cli.stream_camera(tonic::Request::new(outbound)).await { + Ok(resp) => resp, + Err(err) + if err.code() == tonic::Code::Internal + && err.message().contains("no Lesavka video_output") => + { + server.abort(); + return; + } + Err(err) => panic!("stream camera should terminate cleanly: {err:?}"), + }; let _ = tokio::time::timeout( std::time::Duration::from_secs(2), resp.get_mut().message(), diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index f531538..782e8a0 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -12,7 +12,6 @@ mod server_main_rpc { use serial_test::serial; use temp_env::with_var; use tempfile::tempdir; - fn build_handler_for_tests_with_modes( kb_writable: bool, ms_writable: bool, @@ -22,7 +21,6 @@ mod server_main_rpc { let ms_path = dir.path().join("hidg1.bin"); std::fs::write(&kb_path, []).expect("create kb file"); std::fs::write(&ms_path, []).expect("create ms file"); - let kb = tokio::fs::File::from_std( std::fs::OpenOptions::new() .read(true) @@ -41,7 +39,6 @@ mod server_main_rpc { .open(&ms_path) .expect("open ms"), ); - let handler = with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || Handler { kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), @@ -49,6 +46,9 @@ mod server_main_rpc { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new( tokio::sync::Mutex::new(std::collections::HashMap::new()), @@ -57,11 +57,9 @@ mod server_main_rpc { (dir, handler) } - fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { build_handler_for_tests_with_modes(true, true) } - #[test] #[serial] fn reopen_hid_tolerates_missing_hid_endpoints() { @@ -448,4 +446,54 @@ mod server_main_rpc { assert!(legacy_fallback.enabled); assert_eq!(legacy_fallback.mode, "forced-on"); } + + #[test] + #[cfg(coverage)] + #[serial] + fn calibration_rpcs_surface_current_state_and_apply_updates() { + let dir = tempdir().expect("calibration dir"); + let calibration_path = dir.path().join("calibration.toml"); + with_var( + "LESAVKA_CALIBRATION_PATH", + Some(calibration_path.to_string_lossy().to_string()), + || { + let (_dir, handler) = build_handler_for_tests(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + let initial = rt + .block_on(async { + handler.get_calibration(tonic::Request::new(Empty {})).await + }) + .expect("initial calibration") + .into_inner(); + assert_eq!(initial.profile, "mjpeg"); + assert_eq!(initial.active_audio_offset_us, -45_000); + + let adjusted = rt + .block_on(async { + handler + .calibrate(tonic::Request::new(CalibrationRequest { + action: lesavka_common::lesavka::CalibrationAction::BlindEstimate + as i32, + audio_delta_us: 10_000, + video_delta_us: 2_000, + observed_delivery_skew_ms: 42.0, + observed_enqueue_skew_ms: 2.5, + note: "coverage estimate".to_string(), + })) + .await + }) + .expect("calibrate") + .into_inner(); + assert_eq!(adjusted.source, "blind"); + assert_eq!(adjusted.active_audio_offset_us, -35_000); + assert_eq!(adjusted.active_video_offset_us, 2_000); + assert!( + std::fs::read_to_string(calibration_path) + .expect("persisted") + .contains("active_audio_offset_us=-35000") + ); + }, + ); + } } diff --git a/testing/tests/server_main_rpc_reset_contract.rs b/testing/tests/server_main_rpc_reset_contract.rs index 56bfdf9..7cdd3f8 100644 --- a/testing/tests/server_main_rpc_reset_contract.rs +++ b/testing/tests/server_main_rpc_reset_contract.rs @@ -46,6 +46,9 @@ mod server_main_rpc_reset { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new( tokio::sync::Mutex::new(std::collections::HashMap::new()), @@ -104,6 +107,9 @@ mod server_main_rpc_reset { )), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load( + std::sync::Arc::new(UpstreamMediaRuntime::new()), + )), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), diff --git a/testing/tests/server_main_usb_recovery_contract.rs b/testing/tests/server_main_usb_recovery_contract.rs index 938c0f6..97067e8 100644 --- a/testing/tests/server_main_usb_recovery_contract.rs +++ b/testing/tests/server_main_usb_recovery_contract.rs @@ -119,6 +119,9 @@ mod server_main_binary_extra { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), @@ -153,6 +156,9 @@ mod server_main_binary_extra { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new( tokio::sync::Mutex::new(std::collections::HashMap::new()), @@ -217,6 +223,9 @@ echo noop core helper >&2 )), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load( + std::sync::Arc::new(UpstreamMediaRuntime::new()), + )), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), @@ -288,6 +297,9 @@ printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/stat )), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load( + std::sync::Arc::new(UpstreamMediaRuntime::new()), + )), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), diff --git a/testing/tests/server_upstream_media_contract.rs b/testing/tests/server_upstream_media_contract.rs index 390be95..411586b 100644 --- a/testing/tests/server_upstream_media_contract.rs +++ b/testing/tests/server_upstream_media_contract.rs @@ -61,6 +61,9 @@ mod server_upstream_media { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), diff --git a/testing/tests/server_upstream_media_pairing_contract.rs b/testing/tests/server_upstream_media_pairing_contract.rs index f166b6a..7f08d25 100644 --- a/testing/tests/server_upstream_media_pairing_contract.rs +++ b/testing/tests/server_upstream_media_pairing_contract.rs @@ -60,6 +60,9 @@ mod server_upstream_media_pairing { did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), capture_power: CapturePowerManager::new(), eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), @@ -138,12 +141,14 @@ mod server_upstream_media_pairing { .await .expect("mouse stream should open") .into_inner(); - let echoed_mouse = - tokio::time::timeout(std::time::Duration::from_secs(1), mouse_stream.message()) - .await - .expect("mouse response timeout") - .expect("mouse grpc") - .expect("mouse echo"); + let echoed_mouse = tokio::time::timeout( + std::time::Duration::from_secs(1), + mouse_stream.message(), + ) + .await + .expect("mouse response timeout") + .expect("mouse grpc") + .expect("mouse echo"); assert_eq!(echoed_mouse.data, vec![8, 7, 6, 5, 4, 3, 2, 1]); server.abort(); @@ -236,39 +241,43 @@ mod server_upstream_media_pairing { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || { with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || { - with_var("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-500000"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (tx, rx) = tokio::sync::mpsc::channel(4); + with_var( + "LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", + Some("-500000"), + || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(AudioPacket { - id: 0, - pts: 12_345, - data: vec![1, 2, 3, 4, 5, 6], - }) - .await - .expect("send stale synthetic upstream audio"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut response = cli - .stream_microphone(tonic::Request::new(outbound)) + tx.send(AudioPacket { + id: 0, + pts: 12_345, + data: vec![1, 2, 3, 4, 5, 6], + }) .await - .expect("microphone stream should open"); - let ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - response.get_mut().message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - assert_eq!(ack, Empty {}); + .expect("send stale synthetic upstream audio"); + drop(tx); - server.abort(); - }); - }); + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut response = cli + .stream_microphone(tonic::Request::new(outbound)) + .await + .expect("microphone stream should open"); + let ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + response.get_mut().message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + assert_eq!(ack, Empty {}); + + server.abort(); + }); + }, + ); }); }); }); @@ -440,121 +449,4 @@ mod server_upstream_media_pairing { }); }); } - - #[test] - #[serial] - fn stream_microphone_drops_stale_packets_when_freshness_budget_is_zero() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { - with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || { - with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (tx, rx) = tokio::sync::mpsc::channel(4); - - tx.send(AudioPacket { - id: 0, - pts: 12_345, - data: vec![1, 2, 3, 4, 5, 6], - }) - .await - .expect("send stale synthetic upstream audio"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut response = cli - .stream_microphone(tonic::Request::new(outbound)) - .await - .expect("microphone stream should open"); - let ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - response.get_mut().message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - assert_eq!(ack, Empty {}); - - server.abort(); - }); - }); - }); - }); - } - - #[test] - #[serial] - fn stream_camera_drops_frames_that_never_reach_the_audio_master() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { - with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { - with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); - let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); - - let mut audio_response = cli - .stream_microphone(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(audio_rx), - )) - .await - .expect("microphone stream should open") - .into_inner(); - let mut video_response = cli - .stream_camera(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(video_rx), - )) - .await - .expect("camera stream should open") - .into_inner(); - - audio_tx - .send(AudioPacket { - id: 0, - pts: 1_000_000, - data: vec![1, 2, 3, 4], - }) - .await - .expect("send first audio packet"); - video_tx - .send(VideoPacket { - id: 2, - pts: 1_050_000, - data: vec![0, 0, 0, 1, 0x65, 0x55], - ..Default::default() - }) - .await - .expect("send unmatched video packet"); - drop(audio_tx); - drop(video_tx); - - let audio_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - audio_response.message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - let video_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - video_response.message(), - ) - .await - .expect("camera ack timeout") - .expect("camera ack grpc") - .expect("camera ack item"); - assert_eq!(audio_ack, Empty {}); - assert_eq!(video_ack, Empty {}); - - server.abort(); - }); - }); - }); - }); - } } diff --git a/testing/tests/server_upstream_media_pairing_freshness_contract.rs b/testing/tests/server_upstream_media_pairing_freshness_contract.rs new file mode 100644 index 0000000..cf3eb3f --- /dev/null +++ b/testing/tests/server_upstream_media_pairing_freshness_contract.rs @@ -0,0 +1,209 @@ +//! End-to-end server coverage for upstream media pairing and freshness. +//! +//! Scope: run a local gRPC server and verify webcam/mic packet pairing behavior. +//! Targets: `server/src/main.rs`, `server/src/upstream_media_runtime.rs`. +//! Why: MJPEG lip sync depends on keeping late/early packet decisions stable +//! while streams start, stop, or temporarily lose their pair. + +#[cfg(coverage)] +#[allow(warnings)] +mod server_upstream_media_pairing { + include!(env!("LESAVKA_SERVER_MAIN_SRC")); + + use lesavka_common::lesavka::relay_client::RelayClient; + use serial_test::serial; + use temp_env::with_var; + use tempfile::tempdir; + use tonic::transport::Channel; + + async fn connect_with_retry(addr: std::net::SocketAddr) -> Channel { + let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}")) + .expect("endpoint") + .tcp_nodelay(true); + for _ in 0..40 { + if let Ok(channel) = endpoint.clone().connect().await { + return channel; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + panic!("failed to connect to local tonic server"); + } + + fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { + let dir = tempdir().expect("tempdir"); + let kb_path = dir.path().join("hidg0.bin"); + let ms_path = dir.path().join("hidg1.bin"); + std::fs::write(&kb_path, []).expect("create kb file"); + std::fs::write(&ms_path, []).expect("create ms file"); + + let kb = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&kb_path) + .expect("open kb"), + ); + let ms = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&ms_path) + .expect("open ms"), + ); + + ( + dir, + Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()), + calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new( + UpstreamMediaRuntime::new(), + ))), + capture_power: CapturePowerManager::new(), + eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), + }, + ) + } + + async fn serve_handler( + handler: Handler, + ) -> ( + tokio::task::JoinHandle<()>, + RelayClient, + ) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + let channel = connect_with_retry(addr).await; + (server, RelayClient::new(channel)) + } + #[test] + #[serial] + fn stream_microphone_drops_stale_packets_when_freshness_budget_is_zero() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { + with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || { + with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (tx, rx) = tokio::sync::mpsc::channel(4); + + tx.send(AudioPacket { + id: 0, + pts: 12_345, + data: vec![1, 2, 3, 4, 5, 6], + }) + .await + .expect("send stale synthetic upstream audio"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut response = cli + .stream_microphone(tonic::Request::new(outbound)) + .await + .expect("microphone stream should open"); + let ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + response.get_mut().message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + assert_eq!(ack, Empty {}); + + server.abort(); + }); + }); + }); + }); + } + + #[test] + #[serial] + fn stream_camera_drops_frames_that_never_reach_the_audio_master() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { + with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { + with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); + let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); + + let mut audio_response = cli + .stream_microphone(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(audio_rx), + )) + .await + .expect("microphone stream should open") + .into_inner(); + let mut video_response = cli + .stream_camera(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(video_rx), + )) + .await + .expect("camera stream should open") + .into_inner(); + + audio_tx + .send(AudioPacket { + id: 0, + pts: 1_000_000, + data: vec![1, 2, 3, 4], + }) + .await + .expect("send first audio packet"); + video_tx + .send(VideoPacket { + id: 2, + pts: 1_050_000, + data: vec![0, 0, 0, 1, 0x65, 0x55], + ..Default::default() + }) + .await + .expect("send unmatched video packet"); + drop(audio_tx); + drop(video_tx); + + let audio_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + audio_response.message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + let video_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + video_response.message(), + ) + .await + .expect("camera ack timeout") + .expect("camera ack grpc") + .expect("camera ack item"); + assert_eq!(audio_ack, Empty {}); + assert_eq!(video_ack, Empty {}); + + server.abort(); + }); + }); + }); + }); + } +}