release: ship lesavka 0.16.0
This commit is contained in:
parent
0e3da31b29
commit
9ec915f91c
139
Cargo.lock
generated
139
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Instant>,
|
||||
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<KeyboardReport, BroadcastStreamRecvError>,
|
||||
remote_capture_enabled: bool,
|
||||
|
||||
@ -72,6 +72,7 @@ impl LesavkaClientApp {
|
||||
let mut consecutive_source_failures = 0_u32;
|
||||
let mut last_usb_recovery_at: Option<Instant> = 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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<Instant>,
|
||||
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<std::sync::Mutex<UplinkDropLogLimiter>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<SetCapturePowerRequest>
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||
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<RelayClient<Channel>> {
|
||||
|
||||
#[cfg(coverage)]
|
||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||
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))
|
||||
|
||||
@ -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))
|
||||
|
||||
121
client/src/launcher/calibration.rs
Normal file
121
client/src/launcher/calibration.rs
Normal file
@ -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<CalibrationStatus> {
|
||||
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<CalibrationStatus> {
|
||||
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<CalibrationStatus> {
|
||||
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<CalibrationStatus> {
|
||||
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<CalibrationStatus> {
|
||||
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<CalibrationStatus> {
|
||||
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<F, T>(future: F) -> Result<T>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("building launcher calibration runtime")?
|
||||
.block_on(future)
|
||||
}
|
||||
|
||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||
let channel = relay_transport::endpoint(server_addr)?
|
||||
.tcp_nodelay(true)
|
||||
.connect()
|
||||
.await
|
||||
.context("connecting launcher to relay host")?;
|
||||
Ok(RelayClient::new(channel))
|
||||
}
|
||||
@ -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,10 +32,7 @@ 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(),
|
||||
)
|
||||
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);
|
||||
@ -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);
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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<String>,
|
||||
pub selected_mouse: Option<String>,
|
||||
pub status: String,
|
||||
|
||||
@ -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<String, serde_json::Error> {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
292
client/src/launcher/diagnostics/snapshot_report_text.rs
Normal file
292
client/src/launcher/diagnostics/snapshot_report_text.rs
Normal file
@ -0,0 +1,292 @@
|
||||
impl SnapshotReport {
|
||||
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<CapturePowerStatus> {
|
||||
with_runtime(async move {
|
||||
@ -64,8 +65,7 @@ where
|
||||
}
|
||||
|
||||
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
|
||||
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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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};
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
48
client/src/launcher/state/launcher_status_line.rs
Normal file
48
client/src/launcher/state/launcher_status_line.rs
Normal file
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<String>) -> 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<String>,
|
||||
@ -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<String>,
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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<lesavka_common::lesavka::Empty>,
|
||||
) -> Result<Response<lesavka_common::lesavka::CalibrationState>, Status> {
|
||||
Ok(Response::new(
|
||||
lesavka_common::lesavka::CalibrationState::default(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn calibrate(
|
||||
&self,
|
||||
_request: Request<lesavka_common::lesavka::CalibrationRequest>,
|
||||
) -> Result<Response<lesavka_common::lesavka::CalibrationState>, Status> {
|
||||
self.get_calibration(Request::new(lesavka_common::lesavka::Empty {}))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<Response<CapturePowerState>, Status> {
|
||||
Ok(Response::new(CapturePowerState::default()))
|
||||
}
|
||||
|
||||
async fn get_calibration(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
Ok(Response::new(CalibrationState::default()))
|
||||
}
|
||||
|
||||
async fn calibrate(
|
||||
&self,
|
||||
_request: Request<CalibrationRequest>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
Ok(Response::new(CalibrationState::default()))
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(relay: UtilityRelay) -> (tokio::runtime::Runtime, String) {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -18,6 +18,9 @@ struct ActivationContext {
|
||||
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
||||
power_rx: std::sync::mpsc::Receiver<PowerMessage>,
|
||||
power_request_in_flight: Rc<Cell<bool>>,
|
||||
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
||||
calibration_rx: std::sync::mpsc::Receiver<CalibrationMessage>,
|
||||
calibration_request_in_flight: Rc<Cell<bool>>,
|
||||
relay_tx: std::sync::mpsc::Sender<RelayMessage>,
|
||||
relay_rx: std::sync::mpsc::Receiver<RelayMessage>,
|
||||
relay_request_in_flight: Rc<Cell<bool>>,
|
||||
@ -27,6 +30,7 @@ struct ActivationContext {
|
||||
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
|
||||
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
|
||||
next_power_probe: Rc<Cell<Instant>>,
|
||||
next_calibration_probe: Rc<Cell<Instant>>,
|
||||
next_diagnostics_probe: Rc<Cell<Instant>>,
|
||||
next_diagnostics_sample: Rc<Cell<Instant>>,
|
||||
preview_session_active: Rc<Cell<bool>>,
|
||||
|
||||
@ -109,6 +109,9 @@
|
||||
|
||||
let (power_tx, power_rx) = std::sync::mpsc::channel::<PowerMessage>();
|
||||
let power_request_in_flight = Rc::new(Cell::new(false));
|
||||
let (calibration_tx, calibration_rx) =
|
||||
std::sync::mpsc::channel::<CalibrationMessage>();
|
||||
let calibration_request_in_flight = Rc::new(Cell::new(false));
|
||||
let (relay_tx, relay_rx) = std::sync::mpsc::channel::<RelayMessage>();
|
||||
let relay_request_in_flight = Rc::new(Cell::new(false));
|
||||
let (caps_tx, caps_rx) = std::sync::mpsc::channel::<CapsMessage>();
|
||||
@ -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,
|
||||
|
||||
@ -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<PowerMessage>,
|
||||
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<CalibrationMessage>,
|
||||
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<F>(
|
||||
calibration_tx: std::sync::mpsc::Sender<CalibrationMessage>,
|
||||
server_addr: String,
|
||||
action: F,
|
||||
) where
|
||||
F: FnOnce(&str) -> anyhow::Result<CalibrationStatus> + 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<CapsMessage>,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
471
client/src/launcher/ui/eye_capture_bindings.rs
Normal file
471
client/src/launcher/ui/eye_capture_bindings.rs
Normal file
@ -0,0 +1,471 @@
|
||||
{
|
||||
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
||||
|
||||
#[derive(Default)]
|
||||
struct EyeRecordState {
|
||||
save_dir_override: Option<PathBuf>,
|
||||
timer: Option<glib::SourceId>,
|
||||
frame_dir: Option<PathBuf>,
|
||||
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
|
||||
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
|
||||
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<PathBuf, String> {
|
||||
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<gtk::gdk::Texture, String> {
|
||||
let paintable = picture
|
||||
.paintable()
|
||||
.ok_or_else(|| "no live frame is available yet".to_string())?;
|
||||
paintable
|
||||
.downcast::<gtk::gdk::Texture>()
|
||||
.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<RecordFrameTask>,
|
||||
frame_dir: PathBuf,
|
||||
output_path: PathBuf,
|
||||
encode_fps: u32,
|
||||
encode_bitrate_kbit: u32,
|
||||
) -> Result<PathBuf, String> {
|
||||
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::<RecordFrameTask>();
|
||||
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
|
||||
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
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,12 @@ enum PowerMessage {
|
||||
Command(std::result::Result<CapturePowerStatus, String>),
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
enum CalibrationMessage {
|
||||
Refresh(std::result::Result<CalibrationStatus, String>),
|
||||
Command(std::result::Result<CalibrationStatus, String>),
|
||||
}
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
enum RelayMessage {
|
||||
Spawned(std::result::Result<RelayChild, String>),
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -1,238 +1,4 @@
|
||||
{
|
||||
const DEFAULT_EYE_RECORD_FPS: u32 = 30;
|
||||
|
||||
#[derive(Default)]
|
||||
struct EyeRecordState {
|
||||
save_dir_override: Option<PathBuf>,
|
||||
timer: Option<glib::SourceId>,
|
||||
frame_dir: Option<PathBuf>,
|
||||
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
|
||||
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
|
||||
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<PathBuf, String> {
|
||||
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<gtk::gdk::Texture, String> {
|
||||
let paintable = picture
|
||||
.paintable()
|
||||
.ok_or_else(|| "no live frame is available yet".to_string())?;
|
||||
paintable
|
||||
.downcast::<gtk::gdk::Texture>()
|
||||
.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<RecordFrameTask>,
|
||||
frame_dir: PathBuf,
|
||||
output_path: PathBuf,
|
||||
encode_fps: u32,
|
||||
encode_bitrate_kbit: u32,
|
||||
) -> Result<PathBuf, String> {
|
||||
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::<RecordFrameTask>();
|
||||
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
|
||||
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 |_| {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
257
client/src/relay_transport.rs
Normal file
257
client/src/relay_transport.rs
Normal file
@ -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<Endpoint> {
|
||||
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<Channel> {
|
||||
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<ClientTlsConfig> {
|
||||
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<PathBuf> {
|
||||
std::env::var_os(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn default_pki_path(file_name: &str) -> Option<PathBuf> {
|
||||
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"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -148,8 +148,7 @@ async fn run_sync_probe(config: ProbeConfig) -> Result<()> {
|
||||
|
||||
#[cfg(not(coverage))]
|
||||
async fn connect(server_addr: &str) -> Result<Channel> {
|
||||
Channel::from_shared(server_addr.to_string())
|
||||
.context("invalid relay server address")?
|
||||
crate::relay_transport::endpoint(server_addr)?
|
||||
.tcp_nodelay(true)
|
||||
.connect()
|
||||
.await
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
74
scripts/ci/performance_gate.sh
Executable file
74
scripts/ci/performance_gate.sh
Executable file
@ -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}"
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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:"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -114,6 +114,7 @@ fn open_with_retry(path: &str) -> Result<std::fs::File> {
|
||||
}
|
||||
|
||||
#[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()
|
||||
|
||||
467
server/src/calibration.rs
Normal file
467
server/src/calibration.rs
Normal file
@ -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<UpstreamMediaRuntime>,
|
||||
state: Mutex<CalibrationSnapshot>,
|
||||
}
|
||||
|
||||
impl CalibrationStore {
|
||||
pub fn load(runtime: Arc<UpstreamMediaRuntime>) -> 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<ProtoCalibrationState> {
|
||||
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<String> {
|
||||
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::<i64>().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<i64> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i64>().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");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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<AtomicBool>,
|
||||
camera_rt: Arc<CameraRuntime>,
|
||||
upstream_media_rt: Arc<UpstreamMediaRuntime>,
|
||||
calibration: Arc<CalibrationStore>,
|
||||
capture_power: CapturePowerManager,
|
||||
eye_hubs: Arc<Mutex<HashMap<EyeHubKey, Arc<EyeHub>>>>,
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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())),
|
||||
})
|
||||
|
||||
@ -457,6 +457,20 @@ impl Relay for Handler {
|
||||
) -> Result<Response<CapturePowerState>, Status> {
|
||||
self.set_capture_power_reply(req).await
|
||||
}
|
||||
|
||||
async fn get_calibration(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
self.get_calibration_reply().await
|
||||
}
|
||||
|
||||
async fn calibrate(
|
||||
&self,
|
||||
req: Request<CalibrationRequest>,
|
||||
) -> Result<Response<CalibrationState>, 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");
|
||||
|
||||
@ -284,4 +284,18 @@ impl Relay for Handler {
|
||||
) -> Result<Response<CapturePowerState>, Status> {
|
||||
self.set_capture_power_reply(req).await
|
||||
}
|
||||
|
||||
async fn get_calibration(
|
||||
&self,
|
||||
_req: Request<Empty>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
self.get_calibration_reply().await
|
||||
}
|
||||
|
||||
async fn calibrate(
|
||||
&self,
|
||||
req: Request<CalibrationRequest>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
self.calibrate_reply(req).await
|
||||
}
|
||||
}
|
||||
|
||||
30
server/src/main/relay_service_tests.rs
Normal file
30
server/src/main/relay_service_tests.rs
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
@ -102,4 +102,17 @@ impl Handler {
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_calibration_reply(&self) -> Result<Response<CalibrationState>, Status> {
|
||||
Ok(Response::new(self.calibration.current()))
|
||||
}
|
||||
|
||||
async fn calibrate_reply(
|
||||
&self,
|
||||
req: Request<CalibrationRequest>,
|
||||
) -> Result<Response<CalibrationState>, Status> {
|
||||
self.calibration
|
||||
.apply(req.into_inner())
|
||||
.map(Response::new)
|
||||
.map_err(|e| Status::internal(format!("{e:#}")))
|
||||
}
|
||||
}
|
||||
|
||||
211
server/src/security.rs
Normal file
211
server/src/security.rs
Normal file
@ -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<Option<ServerTlsConfig>> {
|
||||
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<PathBuf> {
|
||||
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"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<Semaphore>,
|
||||
pairing_state_notify: Arc<Notify>,
|
||||
audio_progress_notify: Arc<Notify>,
|
||||
camera_playout_offset_us: AtomicI64,
|
||||
microphone_playout_offset_us: AtomicI64,
|
||||
state: Mutex<UpstreamClockState>,
|
||||
}
|
||||
|
||||
@ -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<OwnedSemaphorePermit> {
|
||||
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<u64> {
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
|
||||
142
server/src/upstream_media_runtime/lease_lifecycle.rs
Normal file
142
server/src/upstream_media_runtime/lease_lifecycle.rs
Normal file
@ -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<OwnedSemaphorePermit> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<T>(f: impl FnOnce() -> T) -> T {
|
||||
@ -10,8 +11,9 @@ fn with_info_tracing<T>(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
|
||||
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);
|
||||
.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(|| {
|
||||
|
||||
@ -79,6 +79,14 @@ mod app_support {
|
||||
}
|
||||
}
|
||||
|
||||
mod relay_transport {
|
||||
pub fn endpoint(server_addr: &str) -> anyhow::Result<tonic::transport::Endpoint> {
|
||||
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;"));
|
||||
}
|
||||
}
|
||||
|
||||
35
testing/tests/client_install_script_contract.rs
Normal file
35
testing/tests/client_install_script_contract.rs
Normal file
@ -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"
|
||||
);
|
||||
}
|
||||
@ -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);"));
|
||||
}
|
||||
|
||||
@ -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!(
|
||||
|
||||
54
testing/tests/client_log_noise_contract.rs
Normal file
54
testing/tests/client_log_noise_contract.rs
Normal file
@ -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"
|
||||
);
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
42
testing/tests/performance_gate_script_contract.rs
Normal file
42
testing/tests/performance_gate_script_contract.rs
Normal file
@ -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"
|
||||
);
|
||||
}
|
||||
@ -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!(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,8 +141,10 @@ 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())
|
||||
let echoed_mouse = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
mouse_stream.message(),
|
||||
)
|
||||
.await
|
||||
.expect("mouse response timeout")
|
||||
.expect("mouse grpc")
|
||||
@ -236,7 +241,10 @@ 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"), || {
|
||||
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;
|
||||
@ -268,7 +276,8 @@ mod server_upstream_media_pairing {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<tonic::transport::Channel>,
|
||||
) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user