release: ship lesavka 0.16.4

This commit is contained in:
Brad Stein 2026-04-30 15:04:00 -03:00
parent bbf799ea42
commit ca649f96e5
31 changed files with 1043 additions and 165 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ dist/
coverage/ coverage/
logs/ logs/
captures/ captures/
tmp/
override.toml override.toml
.cache/sccache/ .cache/sccache/
/unit-graph.json /unit-graph.json

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.3" version = "0.16.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.3" version = "0.16.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.3" version = "0.16.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

@ -68,12 +68,30 @@ impl LesavkaClientApp {
/*──────────────── audio stream ───────────────*/ /*──────────────── audio stream ───────────────*/
#[cfg(not(coverage))] #[cfg(not(coverage))]
async fn audio_loop(ep: Channel, out: AudioOut) { async fn audio_loop(
ep: Channel,
out: AudioOut,
media_controls: crate::live_media_control::LiveMediaControls,
) {
let mut consecutive_source_failures = 0_u32; let mut consecutive_source_failures = 0_u32;
let mut last_usb_recovery_at: Option<Instant> = None; let mut last_usb_recovery_at: Option<Instant> = None;
let mut delay = Duration::from_secs(1); let mut delay = Duration::from_secs(1);
let mut audio_failure_log = AudioFailureLogLimiter::default(); let mut audio_failure_log = AudioFailureLogLimiter::default();
let mut paused = false;
loop { loop {
if !media_controls.refresh().audio {
if !paused {
tracing::info!("🔇 remote audio soft-paused");
paused = true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
if paused {
tracing::info!("🔊 remote audio resumed");
paused = false;
delay = Duration::from_secs(1);
}
let mut cli = RelayClient::new(ep.clone()); let mut cli = RelayClient::new(ep.clone());
let req = MonitorRequest { let req = MonitorRequest {
id: 0, id: 0,
@ -90,6 +108,10 @@ impl LesavkaClientApp {
let mut warned_no_packets = false; let mut warned_no_packets = false;
delay = Duration::from_secs(1); delay = Duration::from_secs(1);
loop { loop {
if !media_controls.refresh().audio {
tracing::info!("🔇 remote audio soft-paused; closing capture stream");
break;
}
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(1), Duration::from_secs(1),
stream.get_mut().message(), stream.get_mut().message(),

View File

@ -58,9 +58,17 @@ impl LesavkaClientApp {
let caps = handshake::negotiate(&self.server_addr).await; let caps = handshake::negotiate(&self.server_addr).await;
tracing::info!("🤝 server capabilities = {:?}", caps); tracing::info!("🤝 server capabilities = {:?}", caps);
let camera_cfg = app_support::camera_config_from_caps(&caps); let camera_cfg = app_support::camera_config_from_caps(&caps);
let media_controls = crate::live_media_control::LiveMediaControls::from_env(
crate::live_media_control::MediaControlState::new(
caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err(),
caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err(),
std::env::var("LESAVKA_AUDIO_DISABLE").is_err(),
),
);
let media_state = media_controls.refresh();
let uplink_telemetry = crate::uplink_telemetry::UplinkTelemetryPublisher::from_env( let uplink_telemetry = crate::uplink_telemetry::UplinkTelemetryPublisher::from_env(
caps.camera && std::env::var("LESAVKA_CAM_DISABLE").is_err(), media_state.camera,
caps.microphone && std::env::var("LESAVKA_MIC_DISABLE").is_err(), media_state.microphone,
); );
/*────────── persistent gRPC channels ──────────*/ /*────────── persistent gRPC channels ──────────*/
@ -207,7 +215,11 @@ impl LesavkaClientApp {
if std::env::var("LESAVKA_AUDIO_DISABLE").is_err() { if std::env::var("LESAVKA_AUDIO_DISABLE").is_err() {
let audio_out = AudioOut::new()?; let audio_out = AudioOut::new()?;
let ep_audio = vid_ep.clone(); let ep_audio = vid_ep.clone();
tokio::spawn(Self::audio_loop(ep_audio, audio_out)); tokio::spawn(Self::audio_loop(
ep_audio,
audio_out,
media_controls.clone(),
));
} else { } else {
info!("🔇 remote audio disabled for this relay session"); info!("🔇 remote audio disabled for this relay session");
} }
@ -229,6 +241,7 @@ impl LesavkaClientApp {
let cam_source = std::env::var("LESAVKA_CAM_SOURCE").ok(); let cam_source = std::env::var("LESAVKA_CAM_SOURCE").ok();
let cam_telemetry = let cam_telemetry =
uplink_telemetry.handle(crate::uplink_telemetry::UpstreamStreamKind::Camera); uplink_telemetry.handle(crate::uplink_telemetry::UpstreamStreamKind::Camera);
let media_controls = media_controls.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
CameraCapture::new(cam_source.as_deref(), camera_cfg) CameraCapture::new(cam_source.as_deref(), camera_cfg)
@ -237,7 +250,12 @@ impl LesavkaClientApp {
match result { match result {
Ok(Ok(cam)) => { Ok(Ok(cam)) => {
let cam = Arc::new(cam); let cam = Arc::new(cam);
tokio::spawn(Self::cam_loop(ep, cam, cam_telemetry.clone())); tokio::spawn(Self::cam_loop(
ep,
cam,
cam_telemetry.clone(),
media_controls.clone(),
));
} }
Ok(Err(err)) => { Ok(Err(err)) => {
cam_telemetry.record_disconnect(format!( cam_telemetry.record_disconnect(format!(
@ -262,12 +280,18 @@ impl LesavkaClientApp {
let ep = vid_ep.clone(); let ep = vid_ep.clone();
let mic_telemetry = let mic_telemetry =
uplink_telemetry.handle(crate::uplink_telemetry::UpstreamStreamKind::Microphone); uplink_telemetry.handle(crate::uplink_telemetry::UpstreamStreamKind::Microphone);
let media_controls = media_controls.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::task::spawn_blocking(MicrophoneCapture::new).await; let result = tokio::task::spawn_blocking(MicrophoneCapture::new).await;
match result { match result {
Ok(Ok(mic)) => { Ok(Ok(mic)) => {
let mic = Arc::new(mic); let mic = Arc::new(mic);
tokio::spawn(Self::voice_loop(ep, mic, mic_telemetry.clone())); tokio::spawn(Self::voice_loop(
ep,
mic,
mic_telemetry.clone(),
media_controls.clone(),
));
} }
Ok(Err(err)) => { Ok(Err(err)) => {
mic_telemetry.record_disconnect(format!( mic_telemetry.record_disconnect(format!(

View File

@ -5,6 +5,7 @@ impl LesavkaClientApp {
ep: Channel, ep: Channel,
mic: Arc<MicrophoneCapture>, mic: Arc<MicrophoneCapture>,
telemetry: crate::uplink_telemetry::UplinkTelemetryHandle, telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
media_controls: crate::live_media_control::LiveMediaControls,
) { ) {
let mut delay = Duration::from_secs(1); let mut delay = Duration::from_secs(1);
static FAIL_CNT: AtomicUsize = AtomicUsize::new(0); static FAIL_CNT: AtomicUsize = AtomicUsize::new(0);
@ -53,8 +54,24 @@ impl LesavkaClientApp {
let telemetry_thread = telemetry.clone(); let telemetry_thread = telemetry.clone();
let queue_thread = queue.clone(); let queue_thread = queue.clone();
let drop_log_thread = Arc::clone(&drop_log); let drop_log_thread = Arc::clone(&drop_log);
let media_controls_thread = media_controls.clone();
let mic_worker = std::thread::spawn(move || { let mic_worker = std::thread::spawn(move || {
let mut paused = false;
while stop_rx.try_recv().is_err() { while stop_rx.try_recv().is_err() {
if !media_controls_thread.refresh().microphone {
if !paused {
telemetry_thread.record_enabled(false);
tracing::info!("🎤 microphone uplink soft-paused");
paused = true;
}
std::thread::sleep(Duration::from_millis(20));
continue;
}
if paused {
telemetry_thread.record_enabled(true);
tracing::info!("🎤 microphone uplink resumed");
paused = false;
}
if let Some(pkt) = mic_clone.pull() { if let Some(pkt) = mic_clone.pull() {
trace!("🎤📤 cli {} bytes → gRPC", pkt.data.len()); trace!("🎤📤 cli {} bytes → gRPC", pkt.data.len());
let enqueue_age = crate::live_capture_clock::packet_age(pkt.pts); let enqueue_age = crate::live_capture_clock::packet_age(pkt.pts);
@ -108,6 +125,7 @@ impl LesavkaClientApp {
ep: Channel, ep: Channel,
cam: Arc<CameraCapture>, cam: Arc<CameraCapture>,
telemetry: crate::uplink_telemetry::UplinkTelemetryHandle, telemetry: crate::uplink_telemetry::UplinkTelemetryHandle,
media_controls: crate::live_media_control::LiveMediaControls,
) { ) {
let mut delay = Duration::from_secs(1); let mut delay = Duration::from_secs(1);
@ -154,10 +172,25 @@ impl LesavkaClientApp {
let telemetry = telemetry.clone(); let telemetry = telemetry.clone();
let queue = queue.clone(); let queue = queue.clone();
let drop_log = Arc::clone(&drop_log); let drop_log = Arc::clone(&drop_log);
let media_controls = media_controls.clone();
move || loop { move || loop {
if stop_rx.try_recv().is_ok() { if stop_rx.try_recv().is_ok() {
break; break;
} }
if !media_controls.refresh().camera {
telemetry.record_enabled(false);
tracing::info!("📸 webcam uplink soft-paused");
while stop_rx.try_recv().is_err()
&& !media_controls.refresh().camera
{
std::thread::sleep(Duration::from_millis(25));
}
if stop_rx.try_recv().is_ok() {
break;
}
telemetry.record_enabled(true);
tracing::info!("📸 webcam uplink resumed");
}
let Some(pkt) = cam.pull() else { let Some(pkt) = cam.pull() else {
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
continue; continue;

View File

@ -303,17 +303,33 @@ fn microphone_pipeline_desc(source_desc: &str, gain: f64, level_tap_enabled: boo
} }
fn buffer_duration_us(buf: &gst::BufferRef, bytes: usize) -> u64 { fn buffer_duration_us(buf: &gst::BufferRef, bytes: usize) -> u64 {
let payload_duration_us = pcm_payload_duration_us(bytes);
buf.duration() buf.duration()
.map(|ts| ts.nseconds() / 1_000) .map(|ts| ts.nseconds() / 1_000)
.unwrap_or_else(|| { .filter(|duration_us| duration_matches_pcm_payload(*duration_us, payload_duration_us))
let bytes_per_frame = MIC_CHANNELS * MIC_SAMPLE_BYTES; .unwrap_or(payload_duration_us)
let frames = bytes / bytes_per_frame.max(1);
((frames as u128 * 1_000_000u128) / MIC_SAMPLE_RATE as u128).min(u64::MAX as u128)
as u64
})
.max(1) .max(1)
} }
fn pcm_payload_duration_us(bytes: usize) -> u64 {
let bytes_per_frame = MIC_CHANNELS * MIC_SAMPLE_BYTES;
let frames = bytes / bytes_per_frame.max(1);
((frames as u128 * 1_000_000u128) / MIC_SAMPLE_RATE as u128).min(u64::MAX as u128) as u64
}
/// Rejects bogus capture timestamps before they can poison mic PTS rebasing.
fn duration_matches_pcm_payload(reported_us: u64, payload_us: u64) -> bool {
if reported_us == 0 {
return false;
}
if payload_us <= 1 {
return true;
}
let lower = (payload_us / 8).max(1);
let upper = payload_us.saturating_mul(8);
reported_us >= lower && reported_us <= upper
}
/// Detect launcher catalog names that should be opened through Pulse directly. /// Detect launcher catalog names that should be opened through Pulse directly.
fn looks_like_pulse_source_name(source: &str) -> bool { fn looks_like_pulse_source_name(source: &str) -> bool {
let source = source.trim(); let source = source.trim();
@ -411,3 +427,53 @@ impl Drop for MicrophoneCapture {
let _ = self.pipeline.set_state(gst::State::Null); let _ = self.pipeline.set_state(gst::State::Null);
} }
} }
#[cfg(test)]
mod tests {
use super::{
MIC_CHANNELS, MIC_SAMPLE_BYTES, MIC_SAMPLE_RATE, buffer_duration_us,
pcm_payload_duration_us,
};
use gstreamer as gst;
fn buffer_with_duration(size: usize, duration: Option<gst::ClockTime>) -> gst::Buffer {
gst::init().ok();
let mut buffer = gst::Buffer::with_size(size).expect("test buffer");
buffer
.get_mut()
.expect("test buffer should be uniquely owned")
.set_duration(duration);
buffer
}
#[test]
fn mic_payload_duration_uses_pcm_frame_count() {
let ten_ms_bytes = (MIC_SAMPLE_RATE as usize / 100) * MIC_CHANNELS * MIC_SAMPLE_BYTES;
assert_eq!(pcm_payload_duration_us(ten_ms_bytes), 10_000);
}
#[test]
fn zero_reported_duration_falls_back_to_pcm_payload_duration() {
let bytes = 1_024 * MIC_CHANNELS * MIC_SAMPLE_BYTES;
let buffer = buffer_with_duration(bytes, Some(gst::ClockTime::ZERO));
assert_eq!(buffer_duration_us(buffer.as_ref(), bytes), 21_333);
}
#[test]
fn implausibly_tiny_reported_duration_falls_back_to_payload_duration() {
let bytes = 1_024 * MIC_CHANNELS * MIC_SAMPLE_BYTES;
let buffer = buffer_with_duration(bytes, Some(gst::ClockTime::from_useconds(1)));
assert_eq!(buffer_duration_us(buffer.as_ref(), bytes), 21_333);
}
#[test]
fn plausible_reported_duration_is_preserved() {
let bytes = 1_024 * MIC_CHANNELS * MIC_SAMPLE_BYTES;
let buffer = buffer_with_duration(bytes, Some(gst::ClockTime::from_useconds(20_000)));
assert_eq!(buffer_duration_us(buffer.as_ref(), bytes), 20_000);
}
}

View File

@ -162,9 +162,7 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
if matches!(state.view_mode, ViewMode::Unified) { if matches!(state.view_mode, ViewMode::Unified) {
envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string()); envs.insert("LESAVKA_DISABLE_VIDEO_RENDER".to_string(), "1".to_string());
} }
if state.channels.camera if let Some(camera) = state.devices.camera.as_ref() {
&& let Some(camera) = state.devices.camera.as_ref()
{
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone()); envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
if let Some(mode) = state.camera_quality { if let Some(mode) = state.camera_quality {
envs.insert("LESAVKA_CAM_WIDTH".to_string(), mode.width.to_string()); envs.insert("LESAVKA_CAM_WIDTH".to_string(), mode.width.to_string());
@ -178,16 +176,12 @@ pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
} else { } else {
envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string()); envs.insert("LESAVKA_CAM_DISABLE".to_string(), "1".to_string());
} }
if state.channels.microphone if let Some(microphone) = state.devices.microphone.as_ref() {
&& let Some(microphone) = state.devices.microphone.as_ref()
{
envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone()); envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone());
} else { } else {
envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string()); envs.insert("LESAVKA_MIC_DISABLE".to_string(), "1".to_string());
} }
if state.channels.audio if let Some(speaker) = state.devices.speaker.as_ref() {
&& let Some(speaker) = state.devices.speaker.as_ref()
{
envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone()); envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone());
} else { } else {
envs.insert("LESAVKA_AUDIO_DISABLE".to_string(), "1".to_string()); envs.insert("LESAVKA_AUDIO_DISABLE".to_string(), "1".to_string());

View File

@ -287,7 +287,7 @@ fn runtime_env_vars_emit_selected_audio_gain() {
} }
#[test] #[test]
fn runtime_env_vars_use_channel_toggles_for_media_inclusion() { fn runtime_env_vars_keep_selected_media_available_for_live_soft_pause() {
let mut state = LauncherState::new(); let mut state = LauncherState::new();
let envs = runtime_env_vars(&state); let envs = runtime_env_vars(&state);
@ -298,6 +298,15 @@ fn runtime_env_vars_use_channel_toggles_for_media_inclusion() {
state.select_camera(Some("/dev/video0".to_string())); state.select_camera(Some("/dev/video0".to_string()));
state.select_microphone(Some("alsa_input.usb".to_string())); state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string())); state.select_speaker(Some("alsa_output.usb".to_string()));
let envs = runtime_env_vars(&state);
assert!(!envs.contains_key("LESAVKA_CAM_DISABLE"));
assert!(!envs.contains_key("LESAVKA_MIC_DISABLE"));
assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE"));
assert_eq!(
envs.get("LESAVKA_CAM_SOURCE"),
Some(&"/dev/video0".to_string())
);
state.set_camera_channel_enabled(true); state.set_camera_channel_enabled(true);
state.set_microphone_channel_enabled(true); state.set_microphone_channel_enabled(true);
let envs = runtime_env_vars(&state); let envs = runtime_env_vars(&state);
@ -307,7 +316,7 @@ fn runtime_env_vars_use_channel_toggles_for_media_inclusion() {
state.set_audio_channel_enabled(false); state.set_audio_channel_enabled(false);
let envs = runtime_env_vars(&state); let envs = runtime_env_vars(&state);
assert_eq!(envs.get("LESAVKA_AUDIO_DISABLE"), Some(&"1".to_string())); assert!(!envs.contains_key("LESAVKA_AUDIO_DISABLE"));
} }
#[test] #[test]

View File

@ -5,6 +5,7 @@ use crate::launcher::{
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize}, state::{BreakoutSizePreset, LauncherState, PreviewSourceSize},
ui_components::build_launcher_view, ui_components::build_launcher_view,
}; };
use crate::uplink_telemetry::UpstreamStreamTelemetry;
use gtk::prelude::*; use gtk::prelude::*;
use serial_test::serial; use serial_test::serial;
use std::{ use std::{
@ -399,6 +400,88 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
assert_eq!(server_version_label(&state), "???"); assert_eq!(server_version_label(&state), "???");
} }
#[test]
fn uac_chip_uses_live_microphone_flow_not_only_server_caps() {
let mut state = LauncherState::new();
state.set_server_available(true);
state.set_server_media_caps(Some(true), Some(true), Some("uvc".to_string()), None);
state.set_microphone_channel_enabled(true);
assert_eq!(
recovery_uac_health(&state, false, None),
(StatusLightState::Live, "Ready".to_string())
);
assert_eq!(
recovery_uac_health(&state, true, None),
(StatusLightState::Caution, "No Flow".to_string())
);
let healthy = UpstreamStreamTelemetry {
enabled: true,
connected: true,
packets_streamed: 24,
latest_delivery_age_ms: 42.0,
latest_enqueue_age_ms: 12.0,
queue_depth: 1,
..UpstreamStreamTelemetry::default()
};
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "Flowing".to_string())
);
state.set_microphone_channel_enabled(false);
assert_eq!(
recovery_uac_health(&state, true, Some(&healthy)),
(StatusLightState::Idle, "Paused".to_string())
);
}
#[test]
fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
let mut state = LauncherState::new();
state.set_server_available(true);
state.set_server_media_caps(
Some(true),
Some(true),
Some("uvc".to_string()),
Some("mjpeg".to_string()),
);
state.set_camera_channel_enabled(true);
assert_eq!(
recovery_uvc_health(&state, false, None),
(StatusLightState::Live, "MJPEG".to_string())
);
assert_eq!(
recovery_uvc_health(&state, true, None),
(StatusLightState::Caution, "No Frames".to_string())
);
let healthy = UpstreamStreamTelemetry {
enabled: true,
connected: true,
packets_streamed: 12,
latest_delivery_age_ms: 48.0,
latest_enqueue_age_ms: 20.0,
queue_depth: 3,
..UpstreamStreamTelemetry::default()
};
assert_eq!(
recovery_uvc_health(&state, true, Some(&healthy)),
(StatusLightState::Live, "MJPEG".to_string())
);
let lagging = UpstreamStreamTelemetry {
latest_delivery_age_ms: 321.0,
..healthy
};
assert_eq!(
recovery_uvc_health(&state, true, Some(&lagging)),
(StatusLightState::Caution, "Lagging".to_string())
);
}
#[test] #[test]
fn capture_power_detail_mentions_detected_eyes_when_powered() { fn capture_power_detail_mentions_detected_eyes_when_powered() {
let power = CapturePowerStatus { let power = CapturePowerStatus {
@ -478,6 +561,22 @@ fn write_mic_gain_request_formats_live_control_file() {
assert!(raw.starts_with("3.250 "), "{raw}"); assert!(raw.starts_with("3.250 "), "{raw}");
} }
#[test]
fn write_media_control_request_formats_soft_pause_state() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("media.control");
let mut state = LauncherState::new();
state.set_camera_channel_enabled(true);
state.set_microphone_channel_enabled(false);
state.set_audio_channel_enabled(true);
write_media_control_request(&path, &state).expect("write media control");
let raw = std::fs::read_to_string(path).expect("read media control");
assert!(raw.contains("camera=1"), "{raw}");
assert!(raw.contains("microphone=0"), "{raw}");
assert!(raw.contains("audio=1"), "{raw}");
}
#[gtk::test] #[gtk::test]
#[serial] #[serial]
fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {

View File

@ -27,14 +27,15 @@ use {
RelayChild, append_session_log_for_level, apply_popout_window_size, RelayChild, append_session_log_for_level, apply_popout_window_size,
attach_child_log_streams, audio_gain_control_path, capture_swap_key, copy_plain_text, attach_child_log_streams, audio_gain_control_path, capture_swap_key, copy_plain_text,
copy_session_log, dock_all_displays_to_preview, dock_display_to_preview, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
input_control_path, input_state_path, input_toggle_control_path, mic_gain_control_path, input_control_path, input_state_path, input_toggle_control_path, media_control_path,
next_input_routing, open_diagnostics_popout, open_popout_window, open_session_log_popout, mic_gain_control_path, next_input_routing, open_diagnostics_popout, open_popout_window,
path_marker, present_popout_windows, read_input_routing_state, reap_exited_child, open_session_log_popout, path_marker, present_popout_windows, read_input_routing_state,
refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value, reap_exited_child, refresh_launcher_ui, refresh_test_buttons, routing_name,
selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
toggle_key_label, update_test_action_result, uplink_camera_preview_path, spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
uplink_mic_level_path, uplink_telemetry_path, write_audio_gain_request, uplink_camera_preview_path, uplink_mic_level_path, uplink_telemetry_path,
write_input_routing_request, write_input_toggle_key_request, write_mic_gain_request, write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request,
write_media_control_request, write_mic_gain_request,
}, },
crate::handshake::{HandshakeProbe, probe}, crate::handshake::{HandshakeProbe, probe},
crate::output::display::enumerate_monitors, crate::output::display::enumerate_monitors,

View File

@ -103,6 +103,38 @@ fn apply_mic_gain_change(
true true
} }
#[cfg(not(coverage))]
/// Apply a live media soft-pause/resume request without restarting USB gadget functions.
fn apply_media_control_change(
state_snapshot: &LauncherState,
widgets: &super::ui_components::LauncherWidgets,
child_proc: &Rc<RefCell<Option<RelayChild>>>,
feed_label: &str,
enabled: bool,
) {
let relay_live = child_proc
.try_borrow()
.map(|child| child.is_some())
.unwrap_or(false);
let action = if enabled { "resumed" } else { "soft-paused" };
if relay_live {
let path = media_control_path();
match write_media_control_request(&path, state_snapshot) {
Ok(()) => widgets
.status_label
.set_text(&format!("{feed_label} {action} for the live relay.")),
Err(err) => widgets.status_label.set_text(&format!(
"{feed_label} will be {action} on the next relay launch, but the live soft-pause control could not be written: {err}"
)),
}
} else {
widgets.status_label.set_text(&format!(
"{feed_label} will start {} on the next relay launch.",
if enabled { "enabled" } else { "paused" }
));
}
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
/// Refresh relay capture-power state in the background so GTK stays responsive. /// Refresh relay capture-power state in the background so GTK stays responsive.
fn request_capture_power_refresh( fn request_capture_power_refresh(

View File

@ -87,16 +87,21 @@
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let toggle = widgets.camera_channel_toggle.clone(); let toggle = widgets.camera_channel_toggle.clone();
toggle.connect_toggled(move |toggle| { toggle.connect_toggled(move |toggle| {
let enabled = toggle.is_active();
if let Ok(mut state) = state.try_borrow_mut() { if let Ok(mut state) = state.try_borrow_mut() {
state.set_camera_channel_enabled(toggle.is_active()); state.set_camera_channel_enabled(enabled);
} }
if !toggle.is_active() { if !enabled {
tests.borrow_mut().stop_camera_preview(); tests.borrow_mut().stop_camera_preview();
widgets
.status_label
.set_text("Camera stream disabled. Webcam preview stopped.");
} }
if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) {
apply_media_control_change(
&state_snapshot,
&widgets,
&child_proc,
"Camera",
enabled,
);
refresh_launcher_ui( refresh_launcher_ui(
&widgets, &widgets,
&state_snapshot, &state_snapshot,
@ -114,18 +119,23 @@
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let toggle = widgets.microphone_channel_toggle.clone(); let toggle = widgets.microphone_channel_toggle.clone();
toggle.connect_toggled(move |toggle| { toggle.connect_toggled(move |toggle| {
let enabled = toggle.is_active();
if let Ok(mut state) = state.try_borrow_mut() { if let Ok(mut state) = state.try_borrow_mut() {
state.set_microphone_channel_enabled(toggle.is_active()); state.set_microphone_channel_enabled(enabled);
} }
if !toggle.is_active() { if !enabled {
let mut tests = tests.borrow_mut(); let mut tests = tests.borrow_mut();
tests.stop_microphone_monitor(); tests.stop_microphone_monitor();
tests.stop_microphone_replay(); tests.stop_microphone_replay();
widgets
.status_label
.set_text("Mic stream disabled. Mic monitor and replay stopped.");
} }
if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) {
apply_media_control_change(
&state_snapshot,
&widgets,
&child_proc,
"Mic",
enabled,
);
refresh_launcher_ui( refresh_launcher_ui(
&widgets, &widgets,
&state_snapshot, &state_snapshot,
@ -143,18 +153,23 @@
let tests = Rc::clone(&tests); let tests = Rc::clone(&tests);
let toggle = widgets.audio_channel_toggle.clone(); let toggle = widgets.audio_channel_toggle.clone();
toggle.connect_toggled(move |toggle| { toggle.connect_toggled(move |toggle| {
let enabled = toggle.is_active();
if let Ok(mut state) = state.try_borrow_mut() { if let Ok(mut state) = state.try_borrow_mut() {
state.set_audio_channel_enabled(toggle.is_active()); state.set_audio_channel_enabled(enabled);
} }
if !toggle.is_active() { if !enabled {
let mut tests = tests.borrow_mut(); let mut tests = tests.borrow_mut();
tests.stop_speaker_test(); tests.stop_speaker_test();
tests.stop_microphone_replay(); tests.stop_microphone_replay();
widgets
.status_label
.set_text("Speaker stream disabled. Local audio playback stopped.");
} }
if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) { if let Ok(state_snapshot) = state.try_borrow().map(|state| state.clone()) {
apply_media_control_change(
&state_snapshot,
&widgets,
&child_proc,
"Speaker",
enabled,
);
refresh_launcher_ui( refresh_launcher_ui(
&widgets, &widgets,
&state_snapshot, &state_snapshot,

View File

@ -88,6 +88,12 @@ pub fn mic_gain_control_path() -> PathBuf {
.unwrap_or_else(|_| PathBuf::from(DEFAULT_MIC_GAIN_CONTROL_PATH)) .unwrap_or_else(|_| PathBuf::from(DEFAULT_MIC_GAIN_CONTROL_PATH))
} }
pub fn media_control_path() -> PathBuf {
std::env::var(MEDIA_CONTROL_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_MEDIA_CONTROL_PATH))
}
pub fn uplink_camera_preview_path() -> PathBuf { pub fn uplink_camera_preview_path() -> PathBuf {
std::env::var(UPLINK_CAMERA_PREVIEW_ENV) std::env::var(UPLINK_CAMERA_PREVIEW_ENV)
.map(PathBuf::from) .map(PathBuf::from)
@ -126,6 +132,18 @@ pub fn write_mic_gain_request(path: &Path, gain_percent: u32) -> Result<()> {
Ok(()) Ok(())
} }
pub fn write_media_control_request(path: &Path, state: &LauncherState) -> Result<()> {
crate::live_media_control::write_media_control_request(
path,
crate::live_media_control::MediaControlState::new(
state.channels.camera,
state.channels.microphone,
state.channels.audio,
),
)?;
Ok(())
}
pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> { pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> {
std::fs::write( std::fs::write(
path, path,

View File

@ -39,6 +39,9 @@ pub fn spawn_client_process(
let mic_gain_path = mic_gain_control_path(); let mic_gain_path = mic_gain_control_path();
let _ = write_mic_gain_request(&mic_gain_path, state.mic_gain_percent); let _ = write_mic_gain_request(&mic_gain_path, state.mic_gain_percent);
command.env(MIC_GAIN_CONTROL_ENV, mic_gain_path); command.env(MIC_GAIN_CONTROL_ENV, mic_gain_path);
let media_control_path = media_control_path();
let _ = write_media_control_request(&media_control_path, state);
command.env(MEDIA_CONTROL_ENV, media_control_path);
let camera_preview_path = uplink_camera_preview_path(); let camera_preview_path = uplink_camera_preview_path();
let _ = std::fs::remove_file(&camera_preview_path); let _ = std::fs::remove_file(&camera_preview_path);
command.env(UPLINK_CAMERA_PREVIEW_ENV, camera_preview_path); command.env(UPLINK_CAMERA_PREVIEW_ENV, camera_preview_path);

View File

@ -139,19 +139,35 @@ fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
} }
/// Summarize whether the UAC microphone/audio function is advertised by the relay. /// Summarize whether the UAC microphone/audio function is advertised by the relay.
fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) { fn recovery_uac_health(
state: &LauncherState,
relay_live: bool,
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
) -> (StatusLightState, String) {
if !state.server_available { if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string()); return (StatusLightState::Idle, "Offline".to_string());
} }
match state.server_microphone { if state.server_microphone == Some(false) {
Some(true) => (StatusLightState::Live, "Ready".to_string()), return (StatusLightState::Warning, "Missing".to_string());
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
None => (StatusLightState::Caution, "Unknown".to_string()),
} }
if state.server_microphone.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if !relay_live {
return (StatusLightState::Live, "Ready".to_string());
}
if !state.channels.microphone {
return (StatusLightState::Idle, "Paused".to_string());
}
media_stream_health(stream, MediaStreamKind::Microphone)
} }
/// Summarize whether the UVC camera function is advertised with the expected codec. /// Summarize whether the UVC camera function is advertised with the expected codec.
fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) { fn recovery_uvc_health(
state: &LauncherState,
relay_live: bool,
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
) -> (StatusLightState, String) {
if !state.server_available { if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string()); return (StatusLightState::Idle, "Offline".to_string());
} }
@ -160,22 +176,88 @@ fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) {
.as_deref() .as_deref()
.map(|value| value.to_ascii_uppercase()) .map(|value| value.to_ascii_uppercase())
.unwrap_or_else(|| "READY".to_string()); .unwrap_or_else(|| "READY".to_string());
match state.server_camera { if state.server_camera == Some(false) {
Some(true) => { return (StatusLightState::Warning, "Missing".to_string());
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
(StatusLightState::Live, codec)
} else {
let value = state
.server_camera_output
.as_deref()
.map(|output| format!("{}/{}", output.to_ascii_uppercase(), codec))
.unwrap_or(codec);
(StatusLightState::Caution, value)
}
}
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
None => (StatusLightState::Caution, "Unknown".to_string()),
} }
if state.server_camera.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if !matches!(state.server_camera_output.as_deref(), Some("uvc")) {
let value = state
.server_camera_output
.as_deref()
.map(|output| format!("{}/{}", output.to_ascii_uppercase(), codec))
.unwrap_or(codec);
return (StatusLightState::Caution, value);
}
if !relay_live {
return (StatusLightState::Live, codec);
}
if !state.channels.camera {
return (StatusLightState::Idle, "Paused".to_string());
}
let (health, label) = media_stream_health(stream, MediaStreamKind::Camera);
if matches!(health, StatusLightState::Live) {
(health, codec)
} else {
(health, label)
}
}
#[derive(Clone, Copy)]
enum MediaStreamKind {
Camera,
Microphone,
}
/// Converts live uplink telemetry into the small, glanceable UAC/UVC chip state.
fn media_stream_health(
stream: Option<&crate::uplink_telemetry::UpstreamStreamTelemetry>,
kind: MediaStreamKind,
) -> (StatusLightState, String) {
let Some(stream) = stream else {
return match kind {
MediaStreamKind::Camera => (StatusLightState::Caution, "No Frames".to_string()),
MediaStreamKind::Microphone => (StatusLightState::Caution, "No Flow".to_string()),
};
};
if !stream.enabled {
return (StatusLightState::Idle, "Paused".to_string());
}
if !stream.last_error.trim().is_empty() {
return (StatusLightState::Warning, "Error".to_string());
}
if !stream.connected {
return match kind {
MediaStreamKind::Camera => (StatusLightState::Caution, "No Frames".to_string()),
MediaStreamKind::Microphone => (StatusLightState::Caution, "No Flow".to_string()),
};
}
if stream.packets_streamed == 0 {
let label = if stream.packets_enqueued > 0 {
"Queued"
} else {
match kind {
MediaStreamKind::Camera => "No Frames",
MediaStreamKind::Microphone => "No Flow",
}
};
return (StatusLightState::Caution, label.to_string());
}
let (delivery_budget_ms, enqueue_budget_ms, queue_pressure, healthy_label) = match kind {
MediaStreamKind::Camera => (250.0, 250.0, 24, "Frames"),
MediaStreamKind::Microphone => (180.0, 120.0, 12, "Flowing"),
};
if stream.latest_delivery_age_ms > delivery_budget_ms
|| stream.latest_enqueue_age_ms > enqueue_budget_ms
{
return (StatusLightState::Caution, "Lagging".to_string());
}
if stream.queue_depth >= queue_pressure {
return (StatusLightState::Caution, "Dropping".to_string());
}
(StatusLightState::Live, healthy_label.to_string())
} }
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState { fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {

View File

@ -29,6 +29,7 @@ pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE";
pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"; pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL";
pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL";
pub const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; pub const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL";
pub const MEDIA_CONTROL_ENV: &str = crate::live_media_control::MEDIA_CONTROL_ENV;
pub const UPLINK_CAMERA_PREVIEW_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW"; pub const UPLINK_CAMERA_PREVIEW_ENV: &str = "LESAVKA_UPLINK_CAMERA_PREVIEW";
pub const UPLINK_MIC_LEVEL_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL"; pub const UPLINK_MIC_LEVEL_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL";
pub use crate::uplink_telemetry::{DEFAULT_UPLINK_TELEMETRY_PATH, UPLINK_TELEMETRY_ENV}; pub use crate::uplink_telemetry::{DEFAULT_UPLINK_TELEMETRY_PATH, UPLINK_TELEMETRY_ENV};
@ -37,6 +38,7 @@ pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state";
pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control"; pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control";
pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control"; pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control";
pub const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control"; pub const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control";
pub const DEFAULT_MEDIA_CONTROL_PATH: &str = crate::live_media_control::DEFAULT_MEDIA_CONTROL_PATH;
pub const DEFAULT_UPLINK_CAMERA_PREVIEW_PATH: &str = "/tmp/lesavka-uplink-camera-preview.rgba"; pub const DEFAULT_UPLINK_CAMERA_PREVIEW_PATH: &str = "/tmp/lesavka-uplink-camera-preview.rgba";
pub const DEFAULT_UPLINK_MIC_LEVEL_PATH: &str = "/tmp/lesavka-uplink-mic-level.value"; pub const DEFAULT_UPLINK_MIC_LEVEL_PATH: &str = "/tmp/lesavka-uplink-mic-level.value";
@ -44,6 +46,7 @@ pub type RelayChild = Child;
pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) {
let relay_live = child_running || state.remote_active; let relay_live = child_running || state.remote_active;
let latest_sample = widgets.diagnostics_log.borrow().latest().cloned();
let server_label = server_version_label(state); let server_label = server_version_label(state);
set_status_light( set_status_light(
&widgets.summary.relay_light, &widgets.summary.relay_light,
@ -86,11 +89,23 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
set_status_light(&widgets.summary.usb_light, usb_state); set_status_light(&widgets.summary.usb_light, usb_state);
widgets.summary.usb_value.set_text(&usb_value); widgets.summary.usb_value.set_text(&usb_value);
widgets.summary.usb_value.set_tooltip_text(Some(&usb_value)); widgets.summary.usb_value.set_tooltip_text(Some(&usb_value));
let (uac_state, uac_value) = recovery_uac_health(state); let (uac_state, uac_value) = recovery_uac_health(
state,
relay_live,
latest_sample
.as_ref()
.map(|sample| &sample.upstream_microphone),
);
set_status_light(&widgets.summary.uac_light, uac_state); set_status_light(&widgets.summary.uac_light, uac_state);
widgets.summary.uac_value.set_text(&uac_value); widgets.summary.uac_value.set_text(&uac_value);
widgets.summary.uac_value.set_tooltip_text(Some(&uac_value)); widgets.summary.uac_value.set_tooltip_text(Some(&uac_value));
let (uvc_state, uvc_value) = recovery_uvc_health(state); let (uvc_state, uvc_value) = recovery_uvc_health(
state,
relay_live,
latest_sample
.as_ref()
.map(|sample| &sample.upstream_camera),
);
set_status_light(&widgets.summary.uvc_light, uvc_state); set_status_light(&widgets.summary.uvc_light, uvc_state);
widgets.summary.uvc_value.set_text(&uvc_value); widgets.summary.uvc_value.set_text(&uvc_value);
widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value)); widgets.summary.uvc_value.set_tooltip_text(Some(&uvc_value));
@ -177,9 +192,29 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.set_sensitive(!relay_live && state.channels.audio); .set_sensitive(!relay_live && state.channels.audio);
widgets.keyboard_combo.set_sensitive(!relay_live); widgets.keyboard_combo.set_sensitive(!relay_live);
widgets.mouse_combo.set_sensitive(!relay_live); widgets.mouse_combo.set_sensitive(!relay_live);
widgets.camera_channel_toggle.set_sensitive(!relay_live); widgets
widgets.microphone_channel_toggle.set_sensitive(!relay_live); .camera_channel_toggle
widgets.audio_channel_toggle.set_sensitive(!relay_live); .set_sensitive(!relay_live || state.devices.camera.is_some());
widgets
.microphone_channel_toggle
.set_sensitive(!relay_live || state.devices.microphone.is_some());
widgets
.audio_channel_toggle
.set_sensitive(!relay_live || state.devices.speaker.is_some());
let media_toggle_tooltip = if relay_live {
"Soft-pause or resume this feed in the running relay without resetting USB. Use Recover for hard device resets."
} else {
"Choose which media feeds are enabled when the next relay session starts."
};
widgets
.camera_channel_toggle
.set_tooltip_text(Some(media_toggle_tooltip));
widgets
.microphone_channel_toggle
.set_tooltip_text(Some(media_toggle_tooltip));
widgets
.audio_channel_toggle
.set_tooltip_text(Some(media_toggle_tooltip));
widgets widgets
.camera_test_button .camera_test_button
.set_sensitive(!relay_live && state.channels.camera); .set_sensitive(!relay_live && state.channels.camera);

View File

@ -13,6 +13,7 @@ pub mod input;
pub mod launcher; pub mod launcher;
pub mod layout; pub mod layout;
pub(crate) mod live_capture_clock; pub(crate) mod live_capture_clock;
pub(crate) mod live_media_control;
pub mod output; pub mod output;
pub mod paste; pub mod paste;
pub mod relay_transport; pub mod relay_transport;

View File

@ -0,0 +1,200 @@
//! Live media feed controls shared by the launcher and relay child.
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
pub const MEDIA_CONTROL_ENV: &str = "LESAVKA_MEDIA_CONTROL";
pub const DEFAULT_MEDIA_CONTROL_PATH: &str = "/tmp/lesavka-media.control";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MediaControlState {
pub camera: bool,
pub microphone: bool,
pub audio: bool,
}
impl MediaControlState {
#[must_use]
pub const fn new(camera: bool, microphone: bool, audio: bool) -> Self {
Self {
camera,
microphone,
audio,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct LiveMediaControls {
path: PathBuf,
inner: Arc<Mutex<LiveMediaControlsInner>>,
}
#[derive(Debug)]
struct LiveMediaControlsInner {
state: MediaControlState,
}
impl LiveMediaControls {
#[must_use]
pub fn from_env(initial: MediaControlState) -> Self {
let path = std::env::var(MEDIA_CONTROL_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_MEDIA_CONTROL_PATH));
let controls = Self {
path,
inner: Arc::new(Mutex::new(LiveMediaControlsInner { state: initial })),
};
let _ = controls.refresh();
controls
}
/// Reloads the launcher-written soft-pause switches, falling back safely on read errors.
pub fn refresh(&self) -> MediaControlState {
let Ok(mut inner) = self.inner.lock() else {
return MediaControlState::new(true, true, true);
};
if let Ok(raw) = fs::read_to_string(&self.path)
&& let Some(state) = parse_media_control_state(&raw)
{
inner.state = state;
}
inner.state
}
}
/// Writes one atomic-ish soft-pause request for the running relay child to poll.
pub(crate) fn write_media_control_request(
path: &Path,
state: MediaControlState,
) -> std::io::Result<()> {
fs::write(
path,
format!(
"camera={} microphone={} audio={} {}\n",
bool_flag(state.camera),
bool_flag(state.microphone),
bool_flag(state.audio),
control_request_nonce()
),
)
}
/// Parses the small launcher control-file grammar used across process boundaries.
fn parse_media_control_state(raw: &str) -> Option<MediaControlState> {
let mut camera = None;
let mut microphone = None;
let mut audio = None;
for token in raw.split_ascii_whitespace() {
let Some((key, value)) = token.split_once('=') else {
continue;
};
match key {
"camera" => camera = Some(parse_bool_flag(value)?),
"microphone" | "mic" => microphone = Some(parse_bool_flag(value)?),
"audio" | "speaker" => audio = Some(parse_bool_flag(value)?),
_ => {}
}
}
Some(MediaControlState {
camera: camera?,
microphone: microphone?,
audio: audio?,
})
}
fn parse_bool_flag(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => Some(true),
"0" | "false" | "off" | "no" => Some(false),
_ => None,
}
}
const fn bool_flag(enabled: bool) -> &'static str {
if enabled { "1" } else { "0" }
}
fn control_request_nonce() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_media_control_state_from_launcher_file() {
assert_eq!(
parse_media_control_state("camera=1 microphone=0 audio=true 123"),
Some(MediaControlState::new(true, false, true))
);
}
#[test]
fn live_media_controls_refresh_after_file_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("media.control");
write_media_control_request(&path, MediaControlState::new(true, true, false))
.expect("write initial controls");
let controls = LiveMediaControls {
path: path.clone(),
inner: Arc::new(Mutex::new(LiveMediaControlsInner {
state: MediaControlState::new(false, false, false),
})),
};
assert_eq!(
controls.refresh(),
MediaControlState::new(true, true, false)
);
std::thread::sleep(std::time::Duration::from_millis(5));
write_media_control_request(&path, MediaControlState::new(false, true, true))
.expect("write updated controls");
assert_eq!(
controls.refresh(),
MediaControlState::new(false, true, true)
);
}
#[test]
fn from_env_default_path_fallback_is_safe() {
let controls = LiveMediaControls::from_env(MediaControlState::new(true, false, true));
let _ = controls.refresh();
}
#[test]
fn parser_tolerates_unknown_tokens_and_rejects_invalid_flags() {
assert_eq!(
parse_media_control_state("camera=on extra=ignored microphone=no audio=off"),
Some(MediaControlState::new(true, false, false))
);
assert_eq!(
parse_media_control_state("camera=maybe microphone=1 audio=1"),
None
);
}
#[test]
fn refresh_falls_back_to_all_enabled_if_lock_is_poisoned() {
let controls = LiveMediaControls {
path: PathBuf::from("/definitely/not/a/real/lesavka-media.control"),
inner: Arc::new(Mutex::new(LiveMediaControlsInner {
state: MediaControlState::new(false, false, false),
})),
};
let inner = Arc::clone(&controls.inner);
let _ = std::panic::catch_unwind(move || {
let _guard = inner.lock().expect("lock");
panic!("poison media controls lock");
});
assert_eq!(controls.refresh(), MediaControlState::new(true, true, true));
}
}

View File

@ -143,6 +143,20 @@ impl UplinkTelemetryPublisher {
} }
impl UplinkTelemetryHandle { impl UplinkTelemetryHandle {
/// Updates whether this feed is intentionally active in the current relay session.
pub fn record_enabled(&self, enabled: bool) {
self.update(true, |stream| {
stream.enabled = enabled;
if !enabled {
stream.connected = false;
stream.queue_depth = 0;
stream.latest_enqueue_age_ms = 0.0;
stream.latest_delivery_age_ms = 0.0;
stream.last_error.clear();
}
});
}
/// Records a fresh gRPC connection attempt for this stream. /// Records a fresh gRPC connection attempt for this stream.
pub fn record_reconnect_attempt(&self) { pub fn record_reconnect_attempt(&self) {
self.update(false, |stream| { self.update(false, |stream| {
@ -298,4 +312,25 @@ mod tests {
assert_eq!(snapshot.camera.delivery_age_peak_ms, 55.0); assert_eq!(snapshot.camera.delivery_age_peak_ms, 55.0);
assert_eq!(snapshot.camera.last_error, "stream ended"); assert_eq!(snapshot.camera.last_error, "stream ended");
} }
/// Soft-paused streams should report disabled without looking stale or crashed.
#[test]
fn handle_enabled_updates_expose_live_soft_pause_state() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let path = temp_dir.path().join("uplink.json");
let publisher = UplinkTelemetryPublisher::new(path.clone(), true, true);
let microphone = publisher.handle(UpstreamStreamKind::Microphone);
microphone.record_connected();
microphone.record_enqueue(4, 12.0, 0.0);
microphone.record_enabled(false);
publisher.flush_now();
let snapshot = load_uplink_telemetry(&path).expect("load snapshot");
assert!(!snapshot.microphone.enabled);
assert!(!snapshot.microphone.connected);
assert_eq!(snapshot.microphone.queue_depth, 0);
assert_eq!(snapshot.microphone.latest_enqueue_age_ms, 0.0);
assert!(snapshot.microphone.last_error.is_empty());
}
} }

View File

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

View File

@ -162,6 +162,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override | | `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override |
| `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override | | `LESAVKA_LIVE_MODIFIER_DELAY_MS` | input routing/clipboard override |
| `LESAVKA_MAX_SPEED` | document near use before promoting to operator config | | `LESAVKA_MAX_SPEED` | document near use before promoting to operator config |
| `LESAVKA_MEDIA_CONTROL` | launcher-to-relay live media control file; used for soft-pausing camera, mic, or speaker streams without hard-resetting USB |
| `LESAVKA_MEDIA_GATE_PUSHGATEWAY_JOB` | CI metrics destination override | | `LESAVKA_MEDIA_GATE_PUSHGATEWAY_JOB` | CI metrics destination override |
| `LESAVKA_MIC_DISABLE` | client media capture/playback override | | `LESAVKA_MIC_DISABLE` | client media capture/playback override |
| `LESAVKA_MIC_DISABLE_PIPEWIRE` | client media capture/playback override | | `LESAVKA_MIC_DISABLE_PIPEWIRE` | client media capture/playback override |
@ -304,6 +305,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_UAC_SANITY_VOLUME` | manual UAC sanity probe override | | `LESAVKA_UAC_SANITY_VOLUME` | manual UAC sanity probe override |
| `LESAVKA_UPLINK_TELEMETRY` | launcher/uplink telemetry path override | | `LESAVKA_UPLINK_TELEMETRY` | launcher/uplink telemetry path override |
| `LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS` | upstream A/V timing override | | `LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_MS` | upstream A/V timing override |
| `LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US` | upstream A/V timing override used by test contracts and server startup grace handling |
| `LESAVKA_UPSTREAM_REANCHOR_LATE_MS` | upstream A/V timing override | | `LESAVKA_UPSTREAM_REANCHOR_LATE_MS` | upstream A/V timing override |
| `LESAVKA_UPSTREAM_SOURCE_LAG_CAP_MS` | upstream A/V timing override | | `LESAVKA_UPSTREAM_SOURCE_LAG_CAP_MS` | upstream A/V timing override |
| `LESAVKA_UVC_CONTROL_READ_ONLY` | UVC helper runtime override | | `LESAVKA_UVC_CONTROL_READ_ONLY` | UVC helper runtime override |

View File

@ -13,7 +13,7 @@
"client/src/app/downlink_media.rs": { "client/src/app/downlink_media.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 209 "loc": 231
}, },
"client/src/app/input_streams.rs": { "client/src/app/input_streams.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -23,12 +23,12 @@
"client/src/app/session_lifecycle.rs": { "client/src/app/session_lifecycle.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 324 "loc": 348
}, },
"client/src/app/uplink_media.rs": { "client/src/app/uplink_media.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 2, "doc_debt": 2,
"loc": 329 "loc": 362
}, },
"client/src/app_support.rs": { "client/src/app_support.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -148,7 +148,7 @@
"client/src/input/microphone.rs": { "client/src/input/microphone.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 12, "doc_debt": 12,
"loc": 413 "loc": 479
}, },
"client/src/input/mod.rs": { "client/src/input/mod.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -293,7 +293,7 @@
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 193 "loc": 194
}, },
"client/src/launcher/ui/activation_context.rs": { "client/src/launcher/ui/activation_context.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -308,7 +308,7 @@
"client/src/launcher/ui/control_requests.rs": { "client/src/launcher/ui/control_requests.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 214 "loc": 246
}, },
"client/src/launcher/ui/device_refresh_binding.rs": { "client/src/launcher/ui/device_refresh_binding.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -338,7 +338,7 @@
"client/src/launcher/ui/media_device_bindings.rs": { "client/src/launcher/ui/media_device_bindings.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 0, "doc_debt": 0,
"loc": 167 "loc": 182
}, },
"client/src/launcher/ui/message_and_network_state.rs": { "client/src/launcher/ui/message_and_network_state.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -458,7 +458,7 @@
"client/src/launcher/ui_runtime/control_paths.rs": { "client/src/launcher/ui_runtime/control_paths.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 9, "doc_debt": 9,
"loc": 257 "loc": 275
}, },
"client/src/launcher/ui_runtime/display_popouts.rs": { "client/src/launcher/ui_runtime/display_popouts.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -473,7 +473,7 @@
"client/src/launcher/ui_runtime/process_logs.rs": { "client/src/launcher/ui_runtime/process_logs.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 5, "doc_debt": 5,
"loc": 216 "loc": 219
}, },
"client/src/launcher/ui_runtime/report_popouts.rs": { "client/src/launcher/ui_runtime/report_popouts.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -483,12 +483,12 @@
"client/src/launcher/ui_runtime/status_details.rs": { "client/src/launcher/ui_runtime/status_details.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 12, "doc_debt": 12,
"loc": 345 "loc": 427
}, },
"client/src/launcher/ui_runtime/status_refresh.rs": { "client/src/launcher/ui_runtime/status_refresh.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 316 "loc": 351
}, },
"client/src/layout.rs": { "client/src/layout.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -498,13 +498,18 @@
"client/src/lib.rs": { "client/src/lib.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 0, "doc_debt": 0,
"loc": 25 "loc": 26
}, },
"client/src/live_capture_clock.rs": { "client/src/live_capture_clock.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 7, "doc_debt": 7,
"loc": 429 "loc": 429
}, },
"client/src/live_media_control.rs": {
"loc": 200,
"clippy_warnings": 0,
"doc_debt": 3
},
"client/src/main.rs": { "client/src/main.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 2, "doc_debt": 2,
@ -648,7 +653,7 @@
"client/src/uplink_telemetry.rs": { "client/src/uplink_telemetry.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 4, "doc_debt": 4,
"loc": 301 "loc": 336
}, },
"client/src/video_support.rs": { "client/src/video_support.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -808,7 +813,7 @@
"server/src/main.rs": { "server/src/main.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 99 "loc": 100
}, },
"server/src/main/entrypoint.rs": { "server/src/main/entrypoint.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -832,8 +837,8 @@
}, },
"server/src/main/relay_service.rs": { "server/src/main/relay_service.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 5, "doc_debt": 3,
"loc": 485 "loc": 498
}, },
"server/src/main/relay_service_coverage.rs": { "server/src/main/relay_service_coverage.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -843,7 +848,12 @@
"server/src/main/relay_service_tests.rs": { "server/src/main/relay_service_tests.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 30 "loc": 60
},
"server/src/main/relay_stream_lifecycle.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 130
}, },
"server/src/main/rpc_helpers.rs": { "server/src/main/rpc_helpers.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,

View File

@ -6,7 +6,7 @@
}, },
"client/src/app/session_lifecycle.rs": { "client/src/app/session_lifecycle.rs": {
"line_percent": 97.56, "line_percent": 97.56,
"loc": 324 "loc": 348
}, },
"client/src/app_support.rs": { "client/src/app_support.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -93,8 +93,8 @@
"loc": 196 "loc": 196
}, },
"client/src/input/microphone.rs": { "client/src/input/microphone.rs": {
"line_percent": 100.0, "line_percent": 99.63,
"loc": 413 "loc": 479
}, },
"client/src/input/mouse.rs": { "client/src/input/mouse.rs": {
"line_percent": 98.85, "line_percent": 98.85,
@ -146,7 +146,7 @@
}, },
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 193 "loc": 194
}, },
"client/src/launcher/ui/session_preview_coverage.rs": { "client/src/launcher/ui/session_preview_coverage.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -160,6 +160,10 @@
"line_percent": 99.08, "line_percent": 99.08,
"loc": 429 "loc": 429
}, },
"client/src/live_media_control.rs": {
"line_percent": 100.0,
"loc": 203
},
"client/src/main.rs": { "client/src/main.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 101 "loc": 101
@ -249,8 +253,8 @@
"loc": 284 "loc": 284
}, },
"client/src/uplink_telemetry.rs": { "client/src/uplink_telemetry.rs": {
"line_percent": 95.76, "line_percent": 96.89,
"loc": 301 "loc": 336
}, },
"client/src/video_support.rs": { "client/src/video_support.rs": {
"line_percent": 97.3, "line_percent": 97.3,
@ -358,7 +362,7 @@
}, },
"server/src/main.rs": { "server/src/main.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 99 "loc": 100
}, },
"server/src/main/entrypoint.rs": { "server/src/main/entrypoint.rs": {
"line_percent": 100.0, "line_percent": 100.0,
@ -384,6 +388,10 @@
"line_percent": 96.53, "line_percent": 96.53,
"loc": 301 "loc": 301
}, },
"server/src/main/relay_stream_lifecycle.rs": {
"line_percent": 100.0,
"loc": 130
},
"server/src/main/rpc_helpers.rs": { "server/src/main/rpc_helpers.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 118 "loc": 118

View File

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

View File

@ -94,6 +94,7 @@ include!("main/rpc_helpers.rs");
include!("main/usb_recovery_helpers.rs"); include!("main/usb_recovery_helpers.rs");
include!("main/eye_hub.rs"); include!("main/eye_hub.rs");
include!("main/relay_stream_lifecycle.rs");
include!("main/relay_service.rs"); include!("main/relay_service.rs");
include!("main/relay_service_coverage.rs"); include!("main/relay_service_coverage.rs");
include!("main/entrypoint.rs"); include!("main/entrypoint.rs");

View File

@ -1,31 +1,6 @@
/*──────────────── gRPC service ─────────────*/
#[cfg(not(coverage))]
fn upstream_stale_drop_budget() -> Duration {
let drop_ms = std::env::var("LESAVKA_UPSTREAM_STALE_DROP_MS")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or(80);
Duration::from_millis(drop_ms)
}
#[cfg(not(coverage))]
fn retain_freshest_video_packet(
pending: &mut std::collections::VecDeque<VideoPacket>,
) -> usize {
if pending.len() <= 1 {
return 0;
}
let newest = pending.pop_back().expect("non-empty pending video queue");
let dropped = pending.len();
pending.clear();
pending.push_back(newest);
dropped
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
#[tonic::async_trait] #[tonic::async_trait]
impl Relay for Handler { impl Relay for Handler {
/* existing streams ─ unchanged, except: no more auto-reset */
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>; type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>; type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
type CaptureVideoStream = VideoStream; type CaptureVideoStream = VideoStream;
@ -145,11 +120,11 @@ impl Relay for Handler {
session_id = lease.session_id, session_id = lease.session_id,
"🎤 stream_microphone stood down before the sink became available" "🎤 stream_microphone stood down before the sink became available"
); );
self.upstream_media_rt.close_microphone(lease.generation);
return Err(Status::aborted( return Err(Status::aborted(
"microphone stream superseded before sink became available", "microphone stream superseded before sink became available",
)); ));
}; };
// 1 ─ build once, early
let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); let uac_dev = std::env::var("LESAVKA_UAC_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into());
info!(%uac_dev, "🎤 stream_microphone using UAC sink"); info!(%uac_dev, "🎤 stream_microphone using UAC sink");
let mut sink = runtime_support::open_voice_with_retry(&uac_dev) let mut sink = runtime_support::open_voice_with_retry(&uac_dev)
@ -159,13 +134,17 @@ impl Relay for Handler {
Status::internal(format!("{e:#}")) Status::internal(format!("{e:#}"))
})?; })?;
// 2 ─ dummy outbound stream (same trick as before)
let (tx, rx) = tokio::sync::mpsc::channel(1); let (tx, rx) = tokio::sync::mpsc::channel(1);
let upstream_media_rt = self.upstream_media_rt.clone(); let upstream_media_rt = self.upstream_media_rt.clone();
// 3 ─ drive the sink in a background task
tokio::spawn(async move { tokio::spawn(async move {
let _microphone_sink_permit = microphone_sink_permit; let _microphone_sink_permit = microphone_sink_permit;
let mut cleanup = UpstreamStreamCleanup::microphone(
upstream_media_rt.clone(),
lease.generation,
rpc_id,
lease.session_id,
);
let mut inbound = req.into_inner(); let mut inbound = req.into_inner();
let mut pending = std::collections::VecDeque::new(); let mut pending = std::collections::VecDeque::new();
let mut inbound_closed = false; let mut inbound_closed = false;
@ -175,6 +154,7 @@ impl Relay for Handler {
loop { loop {
if !upstream_media_rt.is_microphone_active(lease.generation) { if !upstream_media_rt.is_microphone_active(lease.generation) {
info!(rpc_id, session_id = lease.session_id, "🎤 stream_microphone session superseded"); info!(rpc_id, session_id = lease.session_id, "🎤 stream_microphone session superseded");
cleanup.mark_superseded();
break; break;
} }
if !inbound_closed { if !inbound_closed {
@ -183,14 +163,24 @@ impl Relay for Handler {
_ = tokio::time::sleep(Duration::from_millis(50)) => None, _ = tokio::time::sleep(Duration::from_millis(50)) => None,
}; };
if let Some(next_packet) = next_packet { if let Some(next_packet) = next_packet {
match next_packet.transpose()? { match next_packet.transpose() {
Some(pkt) => pending.push_back(pkt), Ok(Some(pkt)) => pending.push_back(pkt),
None => inbound_closed = true, Ok(None) => inbound_closed = true,
Err(err) => {
cleanup.mark_aborted();
warn!(
rpc_id,
session_id = lease.session_id,
"🎤 stream_microphone inbound error before clean EOF: {err}"
);
break;
}
} }
} }
} }
let Some(mut pkt) = pending.pop_front() else { let Some(mut pkt) = pending.pop_front() else {
if inbound_closed { if inbound_closed {
cleanup.mark_closed();
break; break;
} }
continue; continue;
@ -237,9 +227,7 @@ impl Relay for Handler {
sink.push(&pkt); sink.push(&pkt);
} }
sink.finish(); // flush on EOS sink.finish(); // flush on EOS
upstream_media_rt.close_microphone(lease.generation);
let _ = tx.send(Ok(Empty {})).await; let _ = tx.send(Ok(Empty {})).await;
info!(rpc_id, session_id = lease.session_id, "🎤 stream_microphone closed");
Ok::<(), Status>(()) Ok::<(), Status>(())
}); });
@ -265,26 +253,43 @@ impl Relay for Handler {
); );
let upstream_lease = self.upstream_media_rt.activate_camera(); let upstream_lease = self.upstream_media_rt.activate_camera();
let (session_id, relay, _relay_reused) = self.camera_rt.activate(&cfg).await?; let (camera_session_id, relay, _relay_reused) = self.camera_rt.activate(&cfg).await?;
let camera_rt = self.camera_rt.clone(); let camera_rt = self.camera_rt.clone();
let upstream_media_rt = self.upstream_media_rt.clone(); let upstream_media_rt = self.upstream_media_rt.clone();
info!(rpc_id, session_id, "🎥 stream_camera opened"); info!(
rpc_id,
session_id = upstream_lease.session_id,
camera_session_id,
"🎥 stream_camera opened"
);
let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1); let frame_step_us = (1_000_000u64 / u64::from(cfg.fps.max(1))).max(1);
// dummy outbound (same pattern as other streams)
let (tx, rx) = tokio::sync::mpsc::channel(1); let (tx, rx) = tokio::sync::mpsc::channel(1);
tokio::spawn(async move { tokio::spawn(async move {
let mut cleanup = UpstreamStreamCleanup::camera(
upstream_media_rt.clone(),
upstream_lease.generation,
rpc_id,
upstream_lease.session_id,
camera_session_id,
);
let mut s = req.into_inner(); let mut s = req.into_inner();
let mut pending = std::collections::VecDeque::new(); let mut pending = std::collections::VecDeque::new();
let mut inbound_closed = false; let mut inbound_closed = false;
let stale_drop_budget = upstream_stale_drop_budget(); let stale_drop_budget = upstream_stale_drop_budget();
let mut startup_video_settled = false; let mut startup_video_settled = false;
loop { loop {
if !camera_rt.is_active(session_id) if !camera_rt.is_active(camera_session_id)
|| !upstream_media_rt.is_camera_active(upstream_lease.generation) || !upstream_media_rt.is_camera_active(upstream_lease.generation)
{ {
info!(rpc_id, session_id, "🎥 stream_camera session superseded"); info!(
rpc_id,
session_id = upstream_lease.session_id,
camera_session_id,
"🎥 stream_camera session superseded"
);
cleanup.mark_superseded();
break; break;
} }
if !inbound_closed { if !inbound_closed {
@ -293,25 +298,37 @@ impl Relay for Handler {
_ = tokio::time::sleep(Duration::from_millis(50)) => None, _ = tokio::time::sleep(Duration::from_millis(50)) => None,
}; };
if let Some(next_packet) = next_packet { if let Some(next_packet) = next_packet {
match next_packet.transpose()? { match next_packet.transpose() {
Some(pkt) => { Ok(Some(pkt)) => {
pending.push_back(pkt); pending.push_back(pkt);
let coalesced = retain_freshest_video_packet(&mut pending); let coalesced = retain_freshest_video_packet(&mut pending);
if coalesced > 0 { if coalesced > 0 {
tracing::debug!( tracing::debug!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
dropped = coalesced, dropped = coalesced,
"🎥 coalesced stale upstream video backlog down to the freshest frame" "🎥 coalesced stale upstream video backlog down to the freshest frame"
); );
} }
} }
None => inbound_closed = true, Ok(None) => inbound_closed = true,
Err(err) => {
cleanup.mark_aborted();
warn!(
rpc_id,
session_id = upstream_lease.session_id,
camera_session_id,
"🎥 stream_camera inbound error before clean EOF: {err}"
);
break;
}
} }
} }
} }
let Some(mut pkt) = pending.pop_front() else { let Some(mut pkt) = pending.pop_front() else {
if inbound_closed { if inbound_closed {
cleanup.mark_closed();
break; break;
} }
continue; continue;
@ -321,7 +338,8 @@ impl Relay for Handler {
if inbound_closed { if inbound_closed {
tracing::debug!( tracing::debug!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
pts = pkt.pts, pts = pkt.pts,
"🎥 dropping trailing upstream video frame because no paired audio arrived before stream close" "🎥 dropping trailing upstream video frame because no paired audio arrived before stream close"
); );
@ -341,7 +359,8 @@ impl Relay for Handler {
{ {
tracing::warn!( tracing::warn!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
pts = plan.local_pts_us, pts = plan.local_pts_us,
"🎥 upstream video frame dropped because the audio master never caught up inside the pairing window" "🎥 upstream video frame dropped because the audio master never caught up inside the pairing window"
); );
@ -352,7 +371,8 @@ impl Relay for Handler {
if startup_video_settled { if startup_video_settled {
tracing::warn!( tracing::warn!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
late_by_ms = plan.late_by.as_millis(), late_by_ms = plan.late_by.as_millis(),
pts = plan.local_pts_us, pts = plan.local_pts_us,
dropped_pending = coalesced, dropped_pending = coalesced,
@ -361,7 +381,8 @@ impl Relay for Handler {
} else { } else {
tracing::debug!( tracing::debug!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
late_by_ms = plan.late_by.as_millis(), late_by_ms = plan.late_by.as_millis(),
pts = plan.local_pts_us, pts = plan.local_pts_us,
dropped_pending = coalesced, dropped_pending = coalesced,
@ -379,7 +400,8 @@ impl Relay for Handler {
if startup_video_settled { if startup_video_settled {
tracing::warn!( tracing::warn!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
late_by_ms = actual_late_by.as_millis(), late_by_ms = actual_late_by.as_millis(),
pts = plan.local_pts_us, pts = plan.local_pts_us,
dropped_pending = coalesced, dropped_pending = coalesced,
@ -388,7 +410,8 @@ impl Relay for Handler {
} else { } else {
tracing::debug!( tracing::debug!(
rpc_id, rpc_id,
session_id, session_id = upstream_lease.session_id,
camera_session_id,
late_by_ms = actual_late_by.as_millis(), late_by_ms = actual_late_by.as_millis(),
pts = plan.local_pts_us, pts = plan.local_pts_us,
dropped_pending = coalesced, dropped_pending = coalesced,
@ -401,9 +424,7 @@ impl Relay for Handler {
startup_video_settled = true; startup_video_settled = true;
relay.feed(pkt); // ← all logging inside video.rs relay.feed(pkt); // ← all logging inside video.rs
} }
upstream_media_rt.close_camera(upstream_lease.generation);
tx.send(Ok(Empty {})).await.ok(); tx.send(Ok(Empty {})).await.ok();
info!(rpc_id, session_id, "🎥 stream_camera closed");
Ok::<(), Status>(()) Ok::<(), Status>(())
}); });
@ -473,13 +494,5 @@ impl Relay for Handler {
} }
} }
fn remote_audio_status(message: String) -> Status {
if message.contains("remote USB gadget is not attached") {
Status::unavailable(message)
} else {
Status::internal(message)
}
}
#[cfg(test)] #[cfg(test)]
include!("relay_service_tests.rs"); include!("relay_service_tests.rs");

View File

@ -1,8 +1,10 @@
#[cfg(test)] #[cfg(all(test, not(coverage)))]
#[allow(clippy::items_after_test_module)] #[allow(clippy::items_after_test_module)]
mod tests { mod tests {
use super::retain_freshest_video_packet; use super::{UpstreamStreamCleanup, retain_freshest_video_packet};
use lesavka_common::lesavka::VideoPacket; use lesavka_common::lesavka::VideoPacket;
use lesavka_server::upstream_media_runtime::UpstreamMediaRuntime;
use std::sync::Arc;
#[test] #[test]
fn retain_freshest_video_packet_keeps_only_the_latest_frame() { fn retain_freshest_video_packet_keeps_only_the_latest_frame() {
@ -27,4 +29,32 @@ mod tests {
assert_eq!(pending.len(), 1); assert_eq!(pending.len(), 1);
assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300)); assert_eq!(pending.front().map(|pkt| pkt.pts), Some(300));
} }
#[test]
fn upstream_cleanup_guard_closes_its_microphone_generation() {
let runtime = Arc::new(UpstreamMediaRuntime::new());
let lease = runtime.activate_microphone();
{
let _guard =
UpstreamStreamCleanup::microphone(runtime.clone(), lease.generation, 1, lease.session_id);
}
assert!(!runtime.is_microphone_active(lease.generation));
}
#[test]
fn upstream_cleanup_guard_cannot_close_a_newer_camera_generation() {
let runtime = Arc::new(UpstreamMediaRuntime::new());
let first = runtime.activate_camera();
let second = runtime.activate_camera();
{
let _guard =
UpstreamStreamCleanup::camera(runtime.clone(), first.generation, 2, first.session_id, 7);
}
assert!(!runtime.is_camera_active(first.generation));
assert!(runtime.is_camera_active(second.generation));
}
} }

View File

@ -0,0 +1,130 @@
#[cfg(not(coverage))]
fn upstream_stale_drop_budget() -> Duration {
let drop_ms = std::env::var("LESAVKA_UPSTREAM_STALE_DROP_MS")
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.unwrap_or(80);
Duration::from_millis(drop_ms)
}
#[cfg(not(coverage))]
/// Keeps only the newest webcam packet when the host path is already behind.
fn retain_freshest_video_packet(
pending: &mut std::collections::VecDeque<VideoPacket>,
) -> usize {
if pending.len() <= 1 {
return 0;
}
let newest = pending.pop_back().expect("non-empty pending video queue");
let dropped = pending.len();
pending.clear();
pending.push_back(newest);
dropped
}
#[cfg(not(coverage))]
#[derive(Clone, Copy, Debug)]
enum UpstreamStreamCleanupKind {
Microphone,
Camera,
}
#[cfg(not(coverage))]
struct UpstreamStreamCleanup {
runtime: Arc<UpstreamMediaRuntime>,
kind: UpstreamStreamCleanupKind,
generation: u64,
rpc_id: u64,
session_id: u64,
camera_session_id: Option<u64>,
outcome: &'static str,
}
#[cfg(not(coverage))]
impl UpstreamStreamCleanup {
fn microphone(
runtime: Arc<UpstreamMediaRuntime>,
generation: u64,
rpc_id: u64,
session_id: u64,
) -> Self {
Self {
runtime,
kind: UpstreamStreamCleanupKind::Microphone,
generation,
rpc_id,
session_id,
camera_session_id: None,
outcome: "aborted",
}
}
fn camera(
runtime: Arc<UpstreamMediaRuntime>,
generation: u64,
rpc_id: u64,
session_id: u64,
camera_session_id: u64,
) -> Self {
Self {
runtime,
kind: UpstreamStreamCleanupKind::Camera,
generation,
rpc_id,
session_id,
camera_session_id: Some(camera_session_id),
outcome: "aborted",
}
}
fn mark_closed(&mut self) {
self.outcome = "closed";
}
fn mark_superseded(&mut self) {
self.outcome = "superseded";
}
fn mark_aborted(&mut self) {
self.outcome = "aborted";
}
}
#[cfg(not(coverage))]
impl Drop for UpstreamStreamCleanup {
/// Closes only the stream generation owned by this RPC lifecycle guard.
fn drop(&mut self) {
match self.kind {
UpstreamStreamCleanupKind::Microphone => {
self.runtime.close_microphone(self.generation);
info!(
rpc_id = self.rpc_id,
session_id = self.session_id,
generation = self.generation,
outcome = self.outcome,
"🎤 stream_microphone lifecycle ended"
);
}
UpstreamStreamCleanupKind::Camera => {
self.runtime.close_camera(self.generation);
info!(
rpc_id = self.rpc_id,
session_id = self.session_id,
camera_session_id = self.camera_session_id.unwrap_or_default(),
generation = self.generation,
outcome = self.outcome,
"🎥 stream_camera lifecycle ended"
);
}
}
}
}
/// Maps expected remote-audio availability failures onto retryable gRPC status codes.
fn remote_audio_status(message: String) -> Status {
if message.contains("remote USB gadget is not attached") {
Status::unavailable(message)
} else {
Status::internal(message)
}
}

View File

@ -37,6 +37,10 @@ mod uplink_fresh_queue;
#[allow(warnings)] #[allow(warnings)]
mod uplink_telemetry; mod uplink_telemetry;
#[path = "../../client/src/live_media_control.rs"]
#[allow(warnings)]
mod live_media_control;
mod app_support { mod app_support {
use super::handshake::PeerCaps; use super::handshake::PeerCaps;
use std::time::Duration; use std::time::Duration;

View File

@ -93,11 +93,21 @@ fn relay_address_entry_is_locked_while_relay_is_live() {
assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);")); assert!(UI_RUNTIME_SRC.contains(".set_sensitive(!relay_live && state.channels.microphone);"));
assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.keyboard_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);")); assert!(UI_RUNTIME_SRC.contains("widgets.mouse_combo.set_sensitive(!relay_live);"));
assert!(UI_RUNTIME_SRC.contains("widgets.camera_channel_toggle.set_sensitive(!relay_live);"));
assert!( assert!(
UI_RUNTIME_SRC.contains("widgets.microphone_channel_toggle.set_sensitive(!relay_live);") UI_RUNTIME_SRC
.contains(".camera_channel_toggle\n .set_sensitive(!relay_live || state.devices.camera.is_some());")
); );
assert!(UI_RUNTIME_SRC.contains("widgets.audio_channel_toggle.set_sensitive(!relay_live);")); assert!(
UI_RUNTIME_SRC.contains(
".microphone_channel_toggle\n .set_sensitive(!relay_live || state.devices.microphone.is_some());"
)
);
assert!(
UI_RUNTIME_SRC.contains(
".audio_channel_toggle\n .set_sensitive(!relay_live || state.devices.speaker.is_some());"
)
);
assert!(UI_RUNTIME_SRC.contains("Soft-pause or resume this feed in the running relay"));
assert!(UI_RUNTIME_SRC.contains("\"Connect\"")); assert!(UI_RUNTIME_SRC.contains("\"Connect\""));
assert!(UI_RUNTIME_SRC.contains("\"Disconnect\"")); assert!(UI_RUNTIME_SRC.contains("\"Disconnect\""));
} }