release: ship lesavka 0.16.0

This commit is contained in:
Brad Stein 2026-04-30 08:16:57 -03:00
parent 0e3da31b29
commit 9ec915f91c
97 changed files with 4209 additions and 1337 deletions

139
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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))
}

View File

@ -4,11 +4,9 @@ use std::time::Duration;
#[cfg(not(coverage))]
use {
crate::paste,
async_stream::stream,
lesavka_common::lesavka::relay_client::RelayClient,
tokio::runtime::Builder as RuntimeBuilder,
tonic::{Request, transport::Channel},
crate::paste, crate::relay_transport, async_stream::stream,
lesavka_common::lesavka::relay_client::RelayClient, tokio::runtime::Builder as RuntimeBuilder,
tonic::Request,
};
#[cfg(not(coverage))]
@ -34,12 +32,9 @@ fn send_clipboard_via_rpc(server_addr: &str, text: &str) -> Result<()> {
let timeout = clipboard_transport_timeout();
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
rt.block_on(async {
let channel = tokio::time::timeout(
timeout,
Channel::from_shared(server_addr.to_string())?.connect(),
)
.await
.map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
.await
.map_err(|_| anyhow!("timed out connecting paste RPC after {:?}", timeout))??;
let mut cli = RelayClient::new(channel);
let reply = tokio::time::timeout(timeout, cli.paste_text(Request::new(req)))
.await
@ -62,7 +57,7 @@ fn send_clipboard_via_hid(server_addr: &str, text: &str) -> Result<()> {
let timeout = clipboard_transport_timeout();
let rt = RuntimeBuilder::new_current_thread().enable_all().build()?;
rt.block_on(async {
let channel = tokio::time::timeout(timeout, Channel::from_shared(server_addr.to_string())?.connect())
let channel = tokio::time::timeout(timeout, relay_transport::connect(server_addr))
.await
.map_err(|_| anyhow!("timed out connecting keyboard fallback stream after {:?}", timeout))??;
let mut cli = RelayClient::new(channel);

View File

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

View File

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

View File

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

View 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
)
}

View File

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

View File

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

View File

@ -327,7 +327,7 @@ fn run_preview_feed(
}
};
let channel = match Channel::from_shared(current_addr.clone()) {
let channel = match crate::relay_transport::endpoint(&current_addr) {
Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await {
Ok(channel) => channel,
Err(err) => {

View File

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

View File

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

View File

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

View 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,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: &gtk::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: &gtk::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: &gtk::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
));
});
}
}
}

View File

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

View File

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

View File

@ -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: &gtk::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: &gtk::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: &gtk::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,
&note,
)
});
});
}
{
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 |_| {

View File

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

View File

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

View File

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

View File

@ -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(&gtk::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(&gtk::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(&gtk::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,

View File

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

View File

@ -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<&gtk::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<&gtk::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<&gtk::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");

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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}"

View File

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

View File

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

View File

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

View File

@ -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:"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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));
}
}

View File

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

View File

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

View File

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

View 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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
.wait_for_audio_master(
123_456,
tokio::time::Instant::now()
.checked_sub(Duration::from_millis(1))
.unwrap_or_else(tokio::time::Instant::now),
)
.await);
assert!(
!runtime
.wait_for_audio_master(
123_456,
tokio::time::Instant::now()
.checked_sub(Duration::from_millis(1))
.unwrap_or_else(tokio::time::Instant::now),
)
.await
);
}
#[test]
#[serial(upstream_media_runtime)]
fn timing_trace_paths_emit_overlap_and_dropbeforeoverlap_details() {
temp_env::with_var("LESAVKA_UPSTREAM_TIMING_TRACE", Some("1"), || {
with_info_tracing(|| {

View File

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

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

View File

@ -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(&gtk::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);"));
}

View File

@ -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!(

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

View File

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

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

View File

@ -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!(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,6 +60,9 @@ mod server_upstream_media_pairing {
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()),
upstream_media_rt: std::sync::Arc::new(UpstreamMediaRuntime::new()),
calibration: std::sync::Arc::new(CalibrationStore::load(std::sync::Arc::new(
UpstreamMediaRuntime::new(),
))),
capture_power: CapturePowerManager::new(),
eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
@ -138,12 +141,14 @@ mod server_upstream_media_pairing {
.await
.expect("mouse stream should open")
.into_inner();
let echoed_mouse =
tokio::time::timeout(std::time::Duration::from_secs(1), mouse_stream.message())
.await
.expect("mouse response timeout")
.expect("mouse grpc")
.expect("mouse echo");
let echoed_mouse = tokio::time::timeout(
std::time::Duration::from_secs(1),
mouse_stream.message(),
)
.await
.expect("mouse response timeout")
.expect("mouse grpc")
.expect("mouse echo");
assert_eq!(echoed_mouse.data, vec![8, 7, 6, 5, 4, 3, 2, 1]);
server.abort();
@ -236,39 +241,43 @@ mod server_upstream_media_pairing {
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
with_var("LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US", Some("-500000"), || {
rt.block_on(async {
let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await;
let (tx, rx) = tokio::sync::mpsc::channel(4);
with_var(
"LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US",
Some("-500000"),
|| {
rt.block_on(async {
let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await;
let (tx, rx) = tokio::sync::mpsc::channel(4);
tx.send(AudioPacket {
id: 0,
pts: 12_345,
data: vec![1, 2, 3, 4, 5, 6],
})
.await
.expect("send stale synthetic upstream audio");
drop(tx);
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
let mut response = cli
.stream_microphone(tonic::Request::new(outbound))
tx.send(AudioPacket {
id: 0,
pts: 12_345,
data: vec![1, 2, 3, 4, 5, 6],
})
.await
.expect("microphone stream should open");
let ack = tokio::time::timeout(
std::time::Duration::from_secs(1),
response.get_mut().message(),
)
.await
.expect("microphone ack timeout")
.expect("microphone ack grpc")
.expect("microphone ack item");
assert_eq!(ack, Empty {});
.expect("send stale synthetic upstream audio");
drop(tx);
server.abort();
});
});
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
let mut response = cli
.stream_microphone(tonic::Request::new(outbound))
.await
.expect("microphone stream should open");
let ack = tokio::time::timeout(
std::time::Duration::from_secs(1),
response.get_mut().message(),
)
.await
.expect("microphone ack timeout")
.expect("microphone ack grpc")
.expect("microphone ack item");
assert_eq!(ack, Empty {});
server.abort();
});
},
);
});
});
});
@ -440,121 +449,4 @@ mod server_upstream_media_pairing {
});
});
}
#[test]
#[serial]
fn stream_microphone_drops_stale_packets_when_freshness_budget_is_zero() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("0"), || {
with_var("LESAVKA_UPSTREAM_STALE_DROP_MS", Some("0"), || {
rt.block_on(async {
let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await;
let (tx, rx) = tokio::sync::mpsc::channel(4);
tx.send(AudioPacket {
id: 0,
pts: 12_345,
data: vec![1, 2, 3, 4, 5, 6],
})
.await
.expect("send stale synthetic upstream audio");
drop(tx);
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
let mut response = cli
.stream_microphone(tonic::Request::new(outbound))
.await
.expect("microphone stream should open");
let ack = tokio::time::timeout(
std::time::Duration::from_secs(1),
response.get_mut().message(),
)
.await
.expect("microphone ack timeout")
.expect("microphone ack grpc")
.expect("microphone ack item");
assert_eq!(ack, Empty {});
server.abort();
});
});
});
});
}
#[test]
#[serial]
fn stream_camera_drops_frames_that_never_reach_the_audio_master() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
rt.block_on(async {
let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await;
let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4);
let (video_tx, video_rx) = tokio::sync::mpsc::channel(4);
let mut audio_response = cli
.stream_microphone(tonic::Request::new(
tokio_stream::wrappers::ReceiverStream::new(audio_rx),
))
.await
.expect("microphone stream should open")
.into_inner();
let mut video_response = cli
.stream_camera(tonic::Request::new(
tokio_stream::wrappers::ReceiverStream::new(video_rx),
))
.await
.expect("camera stream should open")
.into_inner();
audio_tx
.send(AudioPacket {
id: 0,
pts: 1_000_000,
data: vec![1, 2, 3, 4],
})
.await
.expect("send first audio packet");
video_tx
.send(VideoPacket {
id: 2,
pts: 1_050_000,
data: vec![0, 0, 0, 1, 0x65, 0x55],
..Default::default()
})
.await
.expect("send unmatched video packet");
drop(audio_tx);
drop(video_tx);
let audio_ack = tokio::time::timeout(
std::time::Duration::from_secs(1),
audio_response.message(),
)
.await
.expect("microphone ack timeout")
.expect("microphone ack grpc")
.expect("microphone ack item");
let video_ack = tokio::time::timeout(
std::time::Duration::from_secs(1),
video_response.message(),
)
.await
.expect("camera ack timeout")
.expect("camera ack grpc")
.expect("camera ack item");
assert_eq!(audio_ack, Empty {});
assert_eq!(video_ack, Empty {});
server.abort();
});
});
});
});
}
}

View File

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