From 277442ef940c53a8b36c37f993fdd9ec946e42e9 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 18 May 2026 17:07:26 -0300 Subject: [PATCH] ci(lesavka): clear hygiene gate regressions --- client/src/launcher/preview.rs | 2 + client/src/launcher/preview/feed_runtime.rs | 312 ---------------- client/src/launcher/preview/feed_worker.rs | 312 ++++++++++++++++ .../launcher/preview/launcher_preview_impl.rs | 334 +++++++++++++++++ client/src/launcher/preview/preview_core.rs | 335 ------------------ client/src/launcher/ui.rs | 4 + .../src/launcher/ui/eye_capture_bindings.rs | 240 +------------ .../eye_capture_bindings/recording_worker.rs | 235 ++++++++++++ .../launcher/ui/utility_button_bindings.rs | 60 ---- .../ui/utility_button_bindings/pki_support.rs | 59 +++ docs/operational-env.md | 2 + scripts/ci/hygiene_gate_baseline.json | 84 +++-- .../server/uvc/server_uvc_runtime_contract.rs | 8 +- 13 files changed, 1013 insertions(+), 974 deletions(-) create mode 100644 client/src/launcher/preview/feed_worker.rs create mode 100644 client/src/launcher/preview/launcher_preview_impl.rs create mode 100644 client/src/launcher/ui/eye_capture_bindings/recording_worker.rs create mode 100644 client/src/launcher/ui/utility_button_bindings/pki_support.rs diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 2ec661b..8f3664c 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -4,6 +4,8 @@ include!("preview/feed_state.rs"); include!("preview/feed_runtime.rs"); include!("preview/status_pipeline.rs"); include!("preview/frame_telemetry.rs"); +include!("preview/feed_worker.rs"); +include!("preview/launcher_preview_impl.rs"); #[cfg(test)] #[path = "tests/preview.rs"] diff --git a/client/src/launcher/preview/feed_runtime.rs b/client/src/launcher/preview/feed_runtime.rs index 0710798..cc5e3d0 100644 --- a/client/src/launcher/preview/feed_runtime.rs +++ b/client/src/launcher/preview/feed_runtime.rs @@ -224,315 +224,3 @@ impl PreviewFrame { } } } - -#[cfg(not(coverage))] -#[allow(clippy::too_many_arguments)] -fn run_preview_feed( - server_addr: Arc>, - monitor_id: u32, - profile: PreviewProfile, - session_active: Arc, - active_bindings: Arc, - running: Arc, - shared: Arc>, - log_sink: Arc>>>, -) -> Result<()> { - let mut startup_error = None; - let mut selected = None; - for decoder_name in preview_decoder_candidates() { - match build_preview_pipeline(profile, &decoder_name) { - Ok((pipeline, appsrc, appsink, decoder_label)) => { - match pipeline - .set_state(gst::State::Playing) - .context("starting launcher preview pipeline") - { - Ok(_) => { - selected = Some((pipeline, appsrc, appsink, decoder_label)); - break; - } - Err(err) => { - let _ = pipeline.set_state(gst::State::Null); - startup_error = Some(err); - } - } - } - Err(err) => { - startup_error = Some(err); - } - } - } - let (pipeline, appsrc, appsink, decoder_name) = selected.ok_or_else(|| { - startup_error.unwrap_or_else(|| anyhow::anyhow!("no usable H.264 decoder")) - })?; - let parser = pipeline.by_name("preview_parse"); - let decoder = pipeline.by_name("decoder"); - if let Ok(mut slot) = shared.lock() { - slot.telemetry.note_decoder(&decoder_name); - } - { - let shared = Arc::clone(&shared); - pipeline.connect_deep_element_added(move |_, _, element| { - if let Some(decoder_label) = preview_decoder_label(element) - && let Ok(mut slot) = shared.lock() - { - slot.telemetry.note_decoder(&decoder_label); - } - }); - } - let sample_worker = { - let shared = Arc::clone(&shared); - let appsink = appsink.clone(); - let parser = parser.clone(); - let decoder = decoder.clone(); - let running = Arc::clone(&running); - std::thread::spawn(move || { - loop { - if !running.load(Ordering::Relaxed) { - break; - } - if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { - if let Some(parser) = parser.as_ref() { - record_preview_caps(&shared, parser, "src", PreviewCapsKind::Stream); - } - if let Some(decoder) = decoder.as_ref() { - record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); - } - if let Some(caps) = sample.caps() { - let caps_label = preview_caps_summary(&caps); - if !caps_label.is_empty() - && let Ok(mut slot) = shared.lock() - { - slot.telemetry.note_rendered_caps(&caps_label); - } - } - if let Some(frame) = sample_to_frame(&sample) - && let Ok(mut slot) = shared.lock() { - slot.push_frame(frame); - } - } - } - }) - }; - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("building preview tokio runtime")?; - - let running_for_loop = Arc::clone(&running); - let _ = rt.block_on(async move { - let mut was_active = false; - let mut retry_delay = Duration::from_millis(750); - loop { - if !running_for_loop.load(Ordering::Relaxed) { - break; - } - let active_now = session_active.load(Ordering::Relaxed) - && active_bindings.load(Ordering::Relaxed) > 0; - if !active_now { - was_active = false; - retry_delay = Duration::from_millis(750); - set_shared_status(&shared, &log_sink, monitor_id, PREVIEW_IDLE_STATUS, true); - tokio::time::sleep(Duration::from_millis(150)).await; - continue; - } - - if !was_active { - was_active = true; - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waking relay preview...", - true, - ); - tokio::time::sleep(Duration::from_millis(350)).await; - } - - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Connecting relay preview...", - true, - ); - let current_addr = match server_addr.lock() { - Ok(value) => value.clone(), - Err(_) => { - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview address is unavailable.", - true, - ); - tokio::time::sleep(Duration::from_millis(750)).await; - continue; - } - }; - - let channel = match crate::relay_transport::endpoint(¤t_addr) { - Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { - Ok(channel) => channel, - Err(err) => { - warn!(monitor_id, ?err, "launcher preview connect failed"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview host is unavailable: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview host is unavailable.", - true, - ); - tokio::time::sleep(retry_delay).await; - continue; - } - }, - Err(err) => { - warn!(monitor_id, ?err, "launcher preview endpoint invalid"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview address is invalid: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview address is invalid.", - true, - ); - tokio::time::sleep(retry_delay).await; - continue; - } - }; - - let mut cli = RelayClient::new(channel); - let req = MonitorRequest { - id: monitor_id, - max_bitrate: profile.max_bitrate_kbit, - requested_width: profile.requested_width.max(0) as u32, - requested_height: profile.requested_height.max(0) as u32, - requested_fps: profile.requested_fps, - source_id: Some(profile.source_monitor_id), - }; - match cli.capture_video(Request::new(req)).await { - Ok(mut stream) => { - retry_delay = Duration::from_millis(750); - debug!(monitor_id, "launcher preview connected"); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waiting for stream...", - true, - ); - loop { - if !session_active.load(Ordering::Relaxed) - || !running_for_loop.load(Ordering::Relaxed) - || active_bindings.load(Ordering::Relaxed) == 0 - { - break; - } - match tokio::time::timeout( - Duration::from_millis(300), - stream.get_mut().message(), - ) - .await - { - Ok(Ok(Some(pkt))) => { - record_preview_packet(&shared, &pkt); - push_preview_packet(&appsrc, pkt); - } - Ok(Ok(None)) => { - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview stream ended.", - true, - ); - retry_delay = Duration::from_millis(1_500); - break; - } - Ok(Err(err)) => { - warn!(monitor_id, ?err, "launcher preview stream error"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview stream error: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview stream error. See session log.", - true, - ); - retry_delay = - preview_retry_delay(retry_delay, Some(&err.to_string())); - break; - } - Err(_) => continue, - } - } - } - Err(err) => { - if preview_startup_condition(&err) { - debug!( - monitor_id, - ?err, - "launcher preview waiting for capture pipeline" - ); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Waiting for capture pipeline: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Waiting for capture pipeline...", - true, - ); - retry_delay = preview_retry_delay(retry_delay, Some(err.message())); - } else { - warn!(monitor_id, ?err, "launcher preview rpc failed"); - log_preview_issue( - &shared, - &log_sink, - monitor_id, - &format!("Preview RPC failed: {err}"), - ); - set_shared_status( - &shared, - &log_sink, - monitor_id, - "Preview RPC failed. See session log.", - true, - ); - retry_delay = preview_retry_delay(retry_delay, Some(err.message())); - } - } - } - tokio::time::sleep(retry_delay).await; - } - #[allow(unreachable_code)] - Ok::<(), anyhow::Error>(()) - }); - - let _ = pipeline.set_state(gst::State::Null); - running.store(false, Ordering::Relaxed); - let _ = sample_worker.join(); - - Ok(()) -} diff --git a/client/src/launcher/preview/feed_worker.rs b/client/src/launcher/preview/feed_worker.rs new file mode 100644 index 0000000..ee130ea --- /dev/null +++ b/client/src/launcher/preview/feed_worker.rs @@ -0,0 +1,312 @@ +#[cfg(not(coverage))] +#[allow(clippy::too_many_arguments)] +fn run_preview_feed( + server_addr: Arc>, + monitor_id: u32, + profile: PreviewProfile, + session_active: Arc, + active_bindings: Arc, + running: Arc, + shared: Arc>, + log_sink: Arc>>>, +) -> Result<()> { + let mut startup_error = None; + let mut selected = None; + for decoder_name in preview_decoder_candidates() { + match build_preview_pipeline(profile, &decoder_name) { + Ok((pipeline, appsrc, appsink, decoder_label)) => { + match pipeline + .set_state(gst::State::Playing) + .context("starting launcher preview pipeline") + { + Ok(_) => { + selected = Some((pipeline, appsrc, appsink, decoder_label)); + break; + } + Err(err) => { + let _ = pipeline.set_state(gst::State::Null); + startup_error = Some(err); + } + } + } + Err(err) => { + startup_error = Some(err); + } + } + } + let (pipeline, appsrc, appsink, decoder_name) = selected.ok_or_else(|| { + startup_error.unwrap_or_else(|| anyhow::anyhow!("no usable H.264 decoder")) + })?; + let parser = pipeline.by_name("preview_parse"); + let decoder = pipeline.by_name("decoder"); + if let Ok(mut slot) = shared.lock() { + slot.telemetry.note_decoder(&decoder_name); + } + { + let shared = Arc::clone(&shared); + pipeline.connect_deep_element_added(move |_, _, element| { + if let Some(decoder_label) = preview_decoder_label(element) + && let Ok(mut slot) = shared.lock() + { + slot.telemetry.note_decoder(&decoder_label); + } + }); + } + let sample_worker = { + let shared = Arc::clone(&shared); + let appsink = appsink.clone(); + let parser = parser.clone(); + let decoder = decoder.clone(); + let running = Arc::clone(&running); + std::thread::spawn(move || { + loop { + if !running.load(Ordering::Relaxed) { + break; + } + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + if let Some(parser) = parser.as_ref() { + record_preview_caps(&shared, parser, "src", PreviewCapsKind::Stream); + } + if let Some(decoder) = decoder.as_ref() { + record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); + } + if let Some(caps) = sample.caps() { + let caps_label = preview_caps_summary(&caps); + if !caps_label.is_empty() + && let Ok(mut slot) = shared.lock() + { + slot.telemetry.note_rendered_caps(&caps_label); + } + } + if let Some(frame) = sample_to_frame(&sample) + && let Ok(mut slot) = shared.lock() + { + slot.push_frame(frame); + } + } + } + }) + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("building preview tokio runtime")?; + + let running_for_loop = Arc::clone(&running); + let _ = rt.block_on(async move { + let mut was_active = false; + let mut retry_delay = Duration::from_millis(750); + loop { + if !running_for_loop.load(Ordering::Relaxed) { + break; + } + let active_now = + session_active.load(Ordering::Relaxed) && active_bindings.load(Ordering::Relaxed) > 0; + if !active_now { + was_active = false; + retry_delay = Duration::from_millis(750); + set_shared_status(&shared, &log_sink, monitor_id, PREVIEW_IDLE_STATUS, true); + tokio::time::sleep(Duration::from_millis(150)).await; + continue; + } + + if !was_active { + was_active = true; + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waking relay preview...", + true, + ); + tokio::time::sleep(Duration::from_millis(350)).await; + } + + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Connecting relay preview...", + true, + ); + let current_addr = match server_addr.lock() { + Ok(value) => value.clone(), + Err(_) => { + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview address is unavailable.", + true, + ); + tokio::time::sleep(Duration::from_millis(750)).await; + continue; + } + }; + + let channel = match crate::relay_transport::endpoint(¤t_addr) { + Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { + Ok(channel) => channel, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview connect failed"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview host is unavailable: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview host is unavailable.", + true, + ); + tokio::time::sleep(retry_delay).await; + continue; + } + }, + Err(err) => { + warn!(monitor_id, ?err, "launcher preview endpoint invalid"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview address is invalid: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview address is invalid.", + true, + ); + tokio::time::sleep(retry_delay).await; + continue; + } + }; + + let mut cli = RelayClient::new(channel); + let req = MonitorRequest { + id: monitor_id, + max_bitrate: profile.max_bitrate_kbit, + requested_width: profile.requested_width.max(0) as u32, + requested_height: profile.requested_height.max(0) as u32, + requested_fps: profile.requested_fps, + source_id: Some(profile.source_monitor_id), + }; + match cli.capture_video(Request::new(req)).await { + Ok(mut stream) => { + retry_delay = Duration::from_millis(750); + debug!(monitor_id, "launcher preview connected"); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waiting for stream...", + true, + ); + loop { + if !session_active.load(Ordering::Relaxed) + || !running_for_loop.load(Ordering::Relaxed) + || active_bindings.load(Ordering::Relaxed) == 0 + { + break; + } + match tokio::time::timeout( + Duration::from_millis(300), + stream.get_mut().message(), + ) + .await + { + Ok(Ok(Some(pkt))) => { + record_preview_packet(&shared, &pkt); + push_preview_packet(&appsrc, pkt); + } + Ok(Ok(None)) => { + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview stream ended.", + true, + ); + retry_delay = Duration::from_millis(1_500); + break; + } + Ok(Err(err)) => { + warn!(monitor_id, ?err, "launcher preview stream error"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview stream error: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview stream error. See session log.", + true, + ); + retry_delay = + preview_retry_delay(retry_delay, Some(&err.to_string())); + break; + } + Err(_) => continue, + } + } + } + Err(err) => { + if preview_startup_condition(&err) { + debug!( + monitor_id, + ?err, + "launcher preview waiting for capture pipeline" + ); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Waiting for capture pipeline: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Waiting for capture pipeline...", + true, + ); + retry_delay = preview_retry_delay(retry_delay, Some(err.message())); + } else { + warn!(monitor_id, ?err, "launcher preview rpc failed"); + log_preview_issue( + &shared, + &log_sink, + monitor_id, + &format!("Preview RPC failed: {err}"), + ); + set_shared_status( + &shared, + &log_sink, + monitor_id, + "Preview RPC failed. See session log.", + true, + ); + retry_delay = preview_retry_delay(retry_delay, Some(err.message())); + } + } + } + tokio::time::sleep(retry_delay).await; + } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }); + + let _ = pipeline.set_state(gst::State::Null); + running.store(false, Ordering::Relaxed); + let _ = sample_worker.join(); + + Ok(()) +} diff --git a/client/src/launcher/preview/launcher_preview_impl.rs b/client/src/launcher/preview/launcher_preview_impl.rs new file mode 100644 index 0000000..a832c43 --- /dev/null +++ b/client/src/launcher/preview/launcher_preview_impl.rs @@ -0,0 +1,334 @@ +#[cfg(not(coverage))] +impl LauncherPreview { + pub fn new(server_addr: String) -> Result { + gst::init().context("initialising preview gstreamer")?; + let server_addr = Arc::new(Mutex::new(server_addr)); + let log_sink = Arc::new(Mutex::new(None)); + let inline_feeds = Arc::new(Mutex::new([ + PreviewFeed::spawn( + Arc::clone(&server_addr), + 0, + PreviewSurface::Inline.profile(), + Arc::clone(&log_sink), + )?, + PreviewFeed::spawn( + Arc::clone(&server_addr), + 1, + PreviewSurface::Inline.profile(), + Arc::clone(&log_sink), + )?, + ])); + let window_feeds = Arc::new(Mutex::new([ + PreviewFeed::spawn( + Arc::clone(&server_addr), + 0, + PreviewSurface::Window.profile(), + Arc::clone(&log_sink), + )?, + PreviewFeed::spawn( + Arc::clone(&server_addr), + 1, + PreviewSurface::Window.profile(), + Arc::clone(&log_sink), + )?, + ])); + Ok(Self { + server_addr: Arc::clone(&server_addr), + log_sink: Arc::clone(&log_sink), + inline_feeds, + window_feeds, + }) + } + + pub fn set_log_sink(&self, tx: std::sync::mpsc::Sender) { + if let Ok(mut slot) = self.log_sink.lock() { + *slot = Some(tx); + } + } + + pub fn set_server_addr(&self, server_addr: String) { + if let Ok(mut slot) = self.server_addr.lock() { + *slot = server_addr; + } + } + + pub fn set_session_active(&self, active: bool) { + if let Ok(feeds) = self.inline_feeds.lock() { + for feed in feeds.iter() { + feed.set_active(active); + } + } + if let Ok(feeds) = self.window_feeds.lock() { + for feed in feeds.iter() { + feed.set_active(active); + } + } + } + + pub fn shutdown_all(&self) { + if let Ok(feeds) = self.inline_feeds.lock() { + for feed in feeds.iter() { + feed.shutdown(); + } + } + if let Ok(feeds) = self.window_feeds.lock() { + for feed in feeds.iter() { + feed.shutdown(); + } + } + } + + pub fn install_on_picture( + &self, + monitor_id: usize, + surface: PreviewSurface, + picture: >k::Picture, + status_label: >k::Label, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.install_on_picture(picture, status_label)), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.install_on_picture(picture, status_label)), + } + } + + pub fn snapshot_metrics( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.snapshot_metrics()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .map(|feed| feed.snapshot_metrics()), + } + } + + pub fn start_recording_tap( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .and_then(|feed| feed.start_recording_tap()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .and_then(|feed| feed.start_recording_tap()), + } + } + + pub fn set_capture_profile( + &self, + monitor_id: usize, + source_monitor_id: usize, + requested_width: i32, + requested_height: i32, + requested_fps: u32, + max_bitrate_kbit: u32, + ) { + let ( + inline_requested_width, + inline_requested_height, + inline_requested_fps, + inline_max_bitrate_kbit, + ) = sanitize_preview_request( + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + ); + self.rebuild_feed( + &self.inline_feeds, + monitor_id, + Some(( + source_monitor_id, + inline_requested_width, + inline_requested_height, + inline_requested_fps, + inline_max_bitrate_kbit, + )), + None, + ); + self.rebuild_feed( + &self.window_feeds, + monitor_id, + Some(( + source_monitor_id, + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + )), + None, + ); + } + + pub fn set_breakout_profile(&self, monitor_id: usize, width: i32, height: i32) { + self.rebuild_feed(&self.window_feeds, monitor_id, None, Some((width, height))); + } + + #[cfg(test)] + pub(crate) fn profile_for_test( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> { + let feed = match surface { + PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), + PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), + }?; + let profile = feed.profile(); + Some(( + profile.source_monitor_id, + profile.display_width, + profile.display_height, + profile.requested_width, + profile.requested_height, + profile.requested_fps, + profile.max_bitrate_kbit, + )) + } + + #[cfg(test)] + pub(crate) fn feed_disabled_for_test( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + let feed = match surface { + PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), + PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), + }?; + Some(feed.is_disabled()) + } + + fn rebuild_feed( + &self, + feeds: &Arc>, + monitor_id: usize, + requested: Option<(usize, i32, i32, u32, u32)>, + display: Option<(i32, i32)>, + ) { + let Ok(mut feeds) = feeds.lock() else { + return; + }; + let Some(existing) = feeds.get(monitor_id).cloned() else { + return; + }; + let was_active = existing.is_active(); + let keep_disabled = existing.is_disabled(); + let mut profile = existing.profile(); + if let Some(( + source_monitor_id, + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + )) = requested + { + profile.source_monitor_id = source_monitor_id as u32; + profile.requested_width = requested_width.max(2); + profile.requested_height = requested_height.max(2); + profile.requested_fps = requested_fps.max(1); + profile.max_bitrate_kbit = max_bitrate_kbit.max(800); + } + if let Some((display_width, display_height)) = display { + profile.display_width = display_width.max(2); + profile.display_height = display_height.max(2); + } + let next_feed = if keep_disabled { + Some(PreviewFeed::spawn_disabled(profile)) + } else { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => Some(feed), + Err(err) => { + warn!(monitor_id, ?err, "could not rebuild preview feed"); + None + } + } + }; + if let Some(feed) = next_feed { + if was_active { + feed.set_active(true); + } + existing.shutdown(); + feeds[monitor_id] = feed; + } + } + + pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) { + self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled); + self.set_feed_enabled(&self.window_feeds, monitor_id, enabled); + } + + fn set_feed_enabled( + &self, + feeds: &Arc>, + monitor_id: usize, + enabled: bool, + ) { + let Ok(mut feeds) = feeds.lock() else { + return; + }; + let Some(existing) = feeds.get(monitor_id).cloned() else { + return; + }; + if existing.is_disabled() != enabled { + return; + } + let was_active = existing.is_active(); + let profile = existing.profile(); + let replacement = if enabled { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => feed, + Err(err) => { + warn!(monitor_id, ?err, "could not enable preview feed"); + return; + } + } + } else { + PreviewFeed::spawn_disabled(profile) + }; + if was_active { + replacement.set_active(true); + } + existing.shutdown(); + feeds[monitor_id] = replacement; + } +} diff --git a/client/src/launcher/preview/preview_core.rs b/client/src/launcher/preview/preview_core.rs index 6640b44..746c155 100644 --- a/client/src/launcher/preview/preview_core.rs +++ b/client/src/launcher/preview/preview_core.rs @@ -198,338 +198,3 @@ impl PreviewSurface { } } } - -#[cfg(not(coverage))] -impl LauncherPreview { - pub fn new(server_addr: String) -> Result { - gst::init().context("initialising preview gstreamer")?; - let server_addr = Arc::new(Mutex::new(server_addr)); - let log_sink = Arc::new(Mutex::new(None)); - let inline_feeds = Arc::new(Mutex::new([ - PreviewFeed::spawn( - Arc::clone(&server_addr), - 0, - PreviewSurface::Inline.profile(), - Arc::clone(&log_sink), - )?, - PreviewFeed::spawn( - Arc::clone(&server_addr), - 1, - PreviewSurface::Inline.profile(), - Arc::clone(&log_sink), - )?, - ])); - let window_feeds = Arc::new(Mutex::new([ - PreviewFeed::spawn( - Arc::clone(&server_addr), - 0, - PreviewSurface::Window.profile(), - Arc::clone(&log_sink), - )?, - PreviewFeed::spawn( - Arc::clone(&server_addr), - 1, - PreviewSurface::Window.profile(), - Arc::clone(&log_sink), - )?, - ])); - Ok(Self { - server_addr: Arc::clone(&server_addr), - log_sink: Arc::clone(&log_sink), - inline_feeds, - window_feeds, - }) - } - - pub fn set_log_sink(&self, tx: std::sync::mpsc::Sender) { - if let Ok(mut slot) = self.log_sink.lock() { - *slot = Some(tx); - } - } - - pub fn set_server_addr(&self, server_addr: String) { - if let Ok(mut slot) = self.server_addr.lock() { - *slot = server_addr; - } - } - - pub fn set_session_active(&self, active: bool) { - if let Ok(feeds) = self.inline_feeds.lock() { - for feed in feeds.iter() { - feed.set_active(active); - } - } - if let Ok(feeds) = self.window_feeds.lock() { - for feed in feeds.iter() { - feed.set_active(active); - } - } - } - - pub fn shutdown_all(&self) { - if let Ok(feeds) = self.inline_feeds.lock() { - for feed in feeds.iter() { - feed.shutdown(); - } - } - if let Ok(feeds) = self.window_feeds.lock() { - for feed in feeds.iter() { - feed.shutdown(); - } - } - } - - pub fn install_on_picture( - &self, - monitor_id: usize, - surface: PreviewSurface, - picture: >k::Picture, - status_label: >k::Label, - ) -> Option { - match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.install_on_picture(picture, status_label)), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.install_on_picture(picture, status_label)), - } - } - - pub fn snapshot_metrics( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option { - match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.snapshot_metrics()), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .map(|feed| feed.snapshot_metrics()), - } - } - - pub fn start_recording_tap( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option { - match surface { - PreviewSurface::Inline => self - .inline_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .and_then(|feed| feed.start_recording_tap()), - PreviewSurface::Window => self - .window_feeds - .lock() - .ok() - .and_then(|feeds| feeds.get(monitor_id).cloned()) - .and_then(|feed| feed.start_recording_tap()), - } - } - - pub fn set_capture_profile( - &self, - monitor_id: usize, - source_monitor_id: usize, - requested_width: i32, - requested_height: i32, - requested_fps: u32, - max_bitrate_kbit: u32, - ) { - let ( - inline_requested_width, - inline_requested_height, - inline_requested_fps, - inline_max_bitrate_kbit, - ) = sanitize_preview_request( - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - ); - self.rebuild_feed( - &self.inline_feeds, - monitor_id, - Some(( - source_monitor_id, - inline_requested_width, - inline_requested_height, - inline_requested_fps, - inline_max_bitrate_kbit, - )), - None, - ); - self.rebuild_feed( - &self.window_feeds, - monitor_id, - Some(( - source_monitor_id, - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - )), - None, - ); - } - - pub fn set_breakout_profile(&self, monitor_id: usize, width: i32, height: i32) { - self.rebuild_feed(&self.window_feeds, monitor_id, None, Some((width, height))); - } - - #[cfg(test)] - pub(crate) fn profile_for_test( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> { - let feed = match surface { - PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), - PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), - }?; - let profile = feed.profile(); - Some(( - profile.source_monitor_id, - profile.display_width, - profile.display_height, - profile.requested_width, - profile.requested_height, - profile.requested_fps, - profile.max_bitrate_kbit, - )) - } - - #[cfg(test)] - pub(crate) fn feed_disabled_for_test( - &self, - monitor_id: usize, - surface: PreviewSurface, - ) -> Option { - let feed = match surface { - PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), - PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), - }?; - Some(feed.is_disabled()) - } - - fn rebuild_feed( - &self, - feeds: &Arc>, - monitor_id: usize, - requested: Option<(usize, i32, i32, u32, u32)>, - display: Option<(i32, i32)>, - ) { - let Ok(mut feeds) = feeds.lock() else { - return; - }; - let Some(existing) = feeds.get(monitor_id).cloned() else { - return; - }; - let was_active = existing.is_active(); - let keep_disabled = existing.is_disabled(); - let mut profile = existing.profile(); - if let Some(( - source_monitor_id, - requested_width, - requested_height, - requested_fps, - max_bitrate_kbit, - )) = requested - { - profile.source_monitor_id = source_monitor_id as u32; - profile.requested_width = requested_width.max(2); - profile.requested_height = requested_height.max(2); - profile.requested_fps = requested_fps.max(1); - profile.max_bitrate_kbit = max_bitrate_kbit.max(800); - } - if let Some((display_width, display_height)) = display { - profile.display_width = display_width.max(2); - profile.display_height = display_height.max(2); - } - let next_feed = if keep_disabled { - Some(PreviewFeed::spawn_disabled(profile)) - } else { - match PreviewFeed::spawn( - Arc::clone(&self.server_addr), - monitor_id as u32, - profile, - Arc::clone(&self.log_sink), - ) { - Ok(feed) => Some(feed), - Err(err) => { - warn!(monitor_id, ?err, "could not rebuild preview feed"); - None - } - } - }; - if let Some(feed) = next_feed { - if was_active { - feed.set_active(true); - } - existing.shutdown(); - feeds[monitor_id] = feed; - } - } - - pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) { - self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled); - self.set_feed_enabled(&self.window_feeds, monitor_id, enabled); - } - - fn set_feed_enabled( - &self, - feeds: &Arc>, - monitor_id: usize, - enabled: bool, - ) { - let Ok(mut feeds) = feeds.lock() else { - return; - }; - let Some(existing) = feeds.get(monitor_id).cloned() else { - return; - }; - if existing.is_disabled() != enabled { - return; - } - let was_active = existing.is_active(); - let profile = existing.profile(); - let replacement = if enabled { - match PreviewFeed::spawn( - Arc::clone(&self.server_addr), - monitor_id as u32, - profile, - Arc::clone(&self.log_sink), - ) { - Ok(feed) => feed, - Err(err) => { - warn!(monitor_id, ?err, "could not enable preview feed"); - return; - } - } - } else { - PreviewFeed::spawn_disabled(profile) - }; - if was_active { - replacement.set_active(true); - } - existing.shutdown(); - feeds[monitor_id] = replacement; - } -} diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 3997ddf..511bcbb 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -69,6 +69,10 @@ include!("ui/activation_context.rs"); include!("ui/startup_window_guard.rs"); #[cfg(not(coverage))] include!("ui/eye_capture_bindings/recording_support.rs"); +#[cfg(not(coverage))] +include!("ui/eye_capture_bindings/recording_worker.rs"); +#[cfg(not(coverage))] +include!("ui/utility_button_bindings/pki_support.rs"); #[cfg(coverage)] include!("ui/session_preview_coverage.rs"); diff --git a/client/src/launcher/ui/eye_capture_bindings.rs b/client/src/launcher/ui/eye_capture_bindings.rs index bb2b2d6..6f09756 100644 --- a/client/src/launcher/ui/eye_capture_bindings.rs +++ b/client/src/launcher/ui/eye_capture_bindings.rs @@ -1,228 +1,4 @@ { - fn spawn_raw_video_encoder( - width: i32, - height: i32, - output_path: &Path, - encode_fps: u32, - encode_bitrate_kbit: u32, - ) -> Result { - let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); - let fps_arg = encode_fps.max(1).to_string(); - let video_size = format!("{}x{}", width.max(1), height.max(1)); - let output_arg = output_path.to_string_lossy().into_owned(); - Command::new("ffmpeg") - .args([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-f", - "rawvideo", - "-pix_fmt", - "rgba", - "-video_size", - &video_size, - "-framerate", - &fps_arg, - "-i", - "-", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-r", - &fps_arg, - "-b:v", - &bitrate_arg, - ]) - .arg(&output_arg) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|err| format!("ffmpeg video encoder is unavailable: {err}")) - } - - fn normalize_recording_frame(frame: PreviewFrameSnapshot) -> Result<(i32, i32, Vec), String> { - let width = frame.width.max(0) as usize; - let height = frame.height.max(0) as usize; - if width == 0 || height == 0 { - return Err("decoded preview frame had zero size".to_string()); - } - let row_bytes = width.saturating_mul(4); - let needed = row_bytes.saturating_mul(height); - if frame.rgba.len() < needed && frame.stride == row_bytes { - return Err("decoded preview frame was shorter than its declared size".to_string()); - } - if frame.stride == row_bytes && frame.rgba.len() >= needed { - return Ok((frame.width, frame.height, frame.rgba[..needed].to_vec())); - } - if frame.stride < row_bytes || frame.rgba.len() < frame.stride.saturating_mul(height) { - return Err("decoded preview frame stride was inconsistent".to_string()); - } - let mut rgba = Vec::with_capacity(needed); - for row in 0..height { - let start = row.saturating_mul(frame.stride); - rgba.extend_from_slice(&frame.rgba[start..start + row_bytes]); - } - Ok((frame.width, frame.height, rgba)) - } - - fn finish_raw_video_encoder( - child: &mut std::process::Child, - frame_dir: &Path, - output_path: &Path, - ) -> Result<(), String> { - let _ = child.stdin.take(); - let status = child - .wait() - .map_err(|err| format!("ffmpeg video encoder wait failed: {err}"))?; - if !status.success() { - return Err(format!( - "ffmpeg failed while encoding {}; temporary data is still in {}", - output_path.display(), - frame_dir.display() - )); - } - Ok(()) - } - - fn mux_recording_audio( - video_path: &Path, - output_path: &Path, - audio_mode: EyeRecordAudioMode, - audio_paths: &[PathBuf], - ) -> Result<(), String> { - let usable_audio_paths = validated_audio_paths(audio_mode, audio_paths)?; - if usable_audio_paths.is_empty() { - return Ok(()); - } - let video_arg = video_path.to_string_lossy().into_owned(); - let output_arg = output_path.to_string_lossy().into_owned(); - let mut command = Command::new("ffmpeg"); - command.args([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - &video_arg, - ]); - for audio_path in &usable_audio_paths { - command.arg("-i").arg(audio_path); - } - if usable_audio_paths.len() > 1 { - command.args([ - "-filter_complex", - "[1:a][2:a]amix=inputs=2:duration=shortest:normalize=0[a]", - "-map", - "0:v:0", - "-map", - "[a]", - ]); - } else { - command.args(["-map", "0:v:0", "-map", "1:a:0"]); - } - command.args(["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest"]); - let mux = command - .arg(&output_arg) - .status() - .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; - if !mux.success() { - return Err(format!( - "ffmpeg failed while adding audio to {}", - output_path.display() - )); - } - Ok(()) - } - - fn run_recording_worker( - frame_tap: PreviewRecordingTap, - control_rx: std::sync::mpsc::Receiver, - frame_dir: PathBuf, - output_path: PathBuf, - encode_fps: u32, - encode_bitrate_kbit: u32, - audio_mode: EyeRecordAudioMode, - audio_paths: Vec, - ) -> Result { - let needs_audio_mux = audio_mode != EyeRecordAudioMode::NoAudio; - let video_output_path = if needs_audio_mux { - frame_dir.join("recording-video.mp4") - } else { - output_path.clone() - }; - let mut encoder: Option = None; - let mut frame_size: Option<(i32, i32)> = None; - let mut captured_frames = 0_u32; - - loop { - match control_rx.try_recv() { - Ok(RecordFrameTask::Finish) | Err(std::sync::mpsc::TryRecvError::Disconnected) => { - break; - } - Err(std::sync::mpsc::TryRecvError::Empty) => {} - } - - match frame_tap.recv_timeout(Duration::from_millis(50)) { - Ok(frame) => { - let (width, height, rgba) = normalize_recording_frame(frame)?; - if let Some((expected_width, expected_height)) = frame_size { - if width != expected_width || height != expected_height { - continue; - } - } else { - frame_size = Some((width, height)); - encoder = Some(spawn_raw_video_encoder( - width, - height, - &video_output_path, - encode_fps, - encode_bitrate_kbit, - )?); - } - let encoder = encoder - .as_mut() - .ok_or_else(|| "recording encoder did not start".to_string())?; - let stdin = encoder - .stdin - .as_mut() - .ok_or_else(|| "recording encoder stdin is closed".to_string())?; - std::io::Write::write_all(stdin, &rgba) - .map_err(|err| format!("recording encoder write failed: {err}"))?; - captured_frames = captured_frames.saturating_add(1); - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, - } - } - - if captured_frames < 2 { - if let Some(mut child) = encoder { - let _ = child.kill(); - let _ = child.wait(); - } - let _ = std::fs::remove_dir_all(&frame_dir); - return Err("need at least two captured frames to build a recording".to_string()); - } - - if let Some(mut child) = encoder { - finish_raw_video_encoder(&mut child, &frame_dir, &video_output_path)?; - } - mux_recording_audio( - &video_output_path, - &output_path, - audio_mode, - &audio_paths, - )?; - if needs_audio_mux { - let _ = std::fs::remove_file(&video_output_path); - } - 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(); @@ -477,15 +253,17 @@ let frame_dir_worker = frame_dir.clone(); let output_path_worker = output_path.clone(); std::thread::spawn(move || { - let result = run_recording_worker( + let result = run_recording_worker( frame_tap, control_rx, - frame_dir_worker, - output_path_worker, - record_fps, - record_bitrate_kbit, - audio_mode, - audio_paths, + EyeRecordingWorkerConfig { + frame_dir: frame_dir_worker, + output_path: output_path_worker, + encode_fps: record_fps, + encode_bitrate_kbit: record_bitrate_kbit, + audio_mode, + audio_paths, + }, ); let _ = result_tx.send(result); }); diff --git a/client/src/launcher/ui/eye_capture_bindings/recording_worker.rs b/client/src/launcher/ui/eye_capture_bindings/recording_worker.rs new file mode 100644 index 0000000..134eed6 --- /dev/null +++ b/client/src/launcher/ui/eye_capture_bindings/recording_worker.rs @@ -0,0 +1,235 @@ +fn spawn_raw_video_encoder( + width: i32, + height: i32, + output_path: &Path, + encode_fps: u32, + encode_bitrate_kbit: u32, +) -> Result { + let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); + let fps_arg = encode_fps.max(1).to_string(); + let video_size = format!("{}x{}", width.max(1), height.max(1)); + let output_arg = output_path.to_string_lossy().into_owned(); + Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "rawvideo", + "-pix_fmt", + "rgba", + "-video_size", + &video_size, + "-framerate", + &fps_arg, + "-i", + "-", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-r", + &fps_arg, + "-b:v", + &bitrate_arg, + ]) + .arg(&output_arg) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|err| format!("ffmpeg video encoder is unavailable: {err}")) +} + +fn normalize_recording_frame(frame: PreviewFrameSnapshot) -> Result<(i32, i32, Vec), String> { + let width = frame.width.max(0) as usize; + let height = frame.height.max(0) as usize; + if width == 0 || height == 0 { + return Err("decoded preview frame had zero size".to_string()); + } + let row_bytes = width.saturating_mul(4); + let needed = row_bytes.saturating_mul(height); + if frame.rgba.len() < needed && frame.stride == row_bytes { + return Err("decoded preview frame was shorter than its declared size".to_string()); + } + if frame.stride == row_bytes && frame.rgba.len() >= needed { + return Ok((frame.width, frame.height, frame.rgba[..needed].to_vec())); + } + if frame.stride < row_bytes || frame.rgba.len() < frame.stride.saturating_mul(height) { + return Err("decoded preview frame stride was inconsistent".to_string()); + } + let mut rgba = Vec::with_capacity(needed); + for row in 0..height { + let start = row.saturating_mul(frame.stride); + rgba.extend_from_slice(&frame.rgba[start..start + row_bytes]); + } + Ok((frame.width, frame.height, rgba)) +} + +fn finish_raw_video_encoder( + child: &mut std::process::Child, + frame_dir: &Path, + output_path: &Path, +) -> Result<(), String> { + let _ = child.stdin.take(); + let status = child + .wait() + .map_err(|err| format!("ffmpeg video encoder wait failed: {err}"))?; + if !status.success() { + return Err(format!( + "ffmpeg failed while encoding {}; temporary data is still in {}", + output_path.display(), + frame_dir.display() + )); + } + Ok(()) +} + +fn mux_recording_audio( + video_path: &Path, + output_path: &Path, + audio_mode: EyeRecordAudioMode, + audio_paths: &[PathBuf], +) -> Result<(), String> { + let usable_audio_paths = validated_audio_paths(audio_mode, audio_paths)?; + if usable_audio_paths.is_empty() { + return Ok(()); + } + let video_arg = video_path.to_string_lossy().into_owned(); + let output_arg = output_path.to_string_lossy().into_owned(); + let mut command = Command::new("ffmpeg"); + command.args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + &video_arg, + ]); + for audio_path in &usable_audio_paths { + command.arg("-i").arg(audio_path); + } + if usable_audio_paths.len() > 1 { + command.args([ + "-filter_complex", + "[1:a][2:a]amix=inputs=2:duration=shortest:normalize=0[a]", + "-map", + "0:v:0", + "-map", + "[a]", + ]); + } else { + command.args(["-map", "0:v:0", "-map", "1:a:0"]); + } + command.args(["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest"]); + let mux = command + .arg(&output_arg) + .status() + .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; + if !mux.success() { + return Err(format!( + "ffmpeg failed while adding audio to {}", + output_path.display() + )); + } + Ok(()) +} + +struct EyeRecordingWorkerConfig { + frame_dir: PathBuf, + output_path: PathBuf, + encode_fps: u32, + encode_bitrate_kbit: u32, + audio_mode: EyeRecordAudioMode, + audio_paths: Vec, +} + +fn run_recording_worker( + frame_tap: PreviewRecordingTap, + control_rx: std::sync::mpsc::Receiver, + config: EyeRecordingWorkerConfig, +) -> Result { + let EyeRecordingWorkerConfig { + frame_dir, + output_path, + encode_fps, + encode_bitrate_kbit, + audio_mode, + audio_paths, + } = config; + let needs_audio_mux = audio_mode != EyeRecordAudioMode::NoAudio; + let video_output_path = if needs_audio_mux { + frame_dir.join("recording-video.mp4") + } else { + output_path.clone() + }; + let mut encoder: Option = None; + let mut frame_size: Option<(i32, i32)> = None; + let mut captured_frames = 0_u32; + + loop { + match control_rx.try_recv() { + Ok(RecordFrameTask::Finish) | Err(std::sync::mpsc::TryRecvError::Disconnected) => { + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + + match frame_tap.recv_timeout(Duration::from_millis(50)) { + Ok(frame) => { + let (width, height, rgba) = normalize_recording_frame(frame)?; + if let Some((expected_width, expected_height)) = frame_size { + if width != expected_width || height != expected_height { + continue; + } + } else { + frame_size = Some((width, height)); + encoder = Some(spawn_raw_video_encoder( + width, + height, + &video_output_path, + encode_fps, + encode_bitrate_kbit, + )?); + } + let encoder = encoder + .as_mut() + .ok_or_else(|| "recording encoder did not start".to_string())?; + let stdin = encoder + .stdin + .as_mut() + .ok_or_else(|| "recording encoder stdin is closed".to_string())?; + std::io::Write::write_all(stdin, &rgba) + .map_err(|err| format!("recording encoder write failed: {err}"))?; + captured_frames = captured_frames.saturating_add(1); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + if captured_frames < 2 { + if let Some(mut child) = encoder { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = std::fs::remove_dir_all(&frame_dir); + return Err("need at least two captured frames to build a recording".to_string()); + } + + if let Some(mut child) = encoder { + finish_raw_video_encoder(&mut child, &frame_dir, &video_output_path)?; + } + mux_recording_audio( + &video_output_path, + &output_path, + audio_mode, + &audio_paths, + )?; + if needs_audio_mux { + let _ = std::fs::remove_file(&video_output_path); + } + let _ = std::fs::remove_dir_all(&frame_dir); + Ok(output_path) +} diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index a295daf..7316739 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -1,64 +1,4 @@ { - fn default_client_pki_dir() -> PathBuf { - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home).join(".config/lesavka/pki"); - } - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".config/lesavka/pki") - } - - fn install_client_pki_bundle(bundle: &Path) -> Result { - let target = default_client_pki_dir(); - let scratch = std::env::temp_dir().join(format!( - "lesavka-client-pki-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - )); - std::fs::create_dir_all(&scratch) - .map_err(|err| format!("could not create extraction folder: {err}"))?; - let extract = Command::new("tar") - .arg("-xzf") - .arg(bundle) - .arg("-C") - .arg(&scratch) - .status() - .map_err(|err| format!("tar is unavailable: {err}"))?; - if !extract.success() { - let _ = std::fs::remove_dir_all(&scratch); - return Err(format!("could not extract {}", bundle.display())); - } - for item in ["ca.crt", "client.crt", "client.key"] { - if !scratch.join(item).is_file() { - let _ = std::fs::remove_dir_all(&scratch); - return Err(format!("bundle is missing {item}")); - } - } - std::fs::create_dir_all(&target) - .map_err(|err| format!("could not create {}: {err}", target.display()))?; - for item in ["ca.crt", "client.crt", "client.key"] { - std::fs::copy(scratch.join(item), target.join(item)) - .map_err(|err| format!("could not install {item}: {err}"))?; - } - tighten_client_key_permissions(&target.join("client.key")); - let _ = std::fs::remove_dir_all(&scratch); - Ok(target) - } - - #[cfg(unix)] - fn tighten_client_key_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt; - if let Ok(mut permissions) = std::fs::metadata(path).map(|metadata| metadata.permissions()) { - permissions.set_mode(0o600); - let _ = std::fs::set_permissions(path, permissions); - } - } - - #[cfg(not(unix))] - fn tighten_client_key_permissions(_path: &Path) {} - { let widgets = widgets.clone(); let window = window.clone(); diff --git a/client/src/launcher/ui/utility_button_bindings/pki_support.rs b/client/src/launcher/ui/utility_button_bindings/pki_support.rs new file mode 100644 index 0000000..98bae06 --- /dev/null +++ b/client/src/launcher/ui/utility_button_bindings/pki_support.rs @@ -0,0 +1,59 @@ +fn default_client_pki_dir() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".config/lesavka/pki"); + } + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".config/lesavka/pki") +} + +fn install_client_pki_bundle(bundle: &Path) -> Result { + let target = default_client_pki_dir(); + let scratch = std::env::temp_dir().join(format!( + "lesavka-client-pki-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + std::fs::create_dir_all(&scratch) + .map_err(|err| format!("could not create extraction folder: {err}"))?; + let extract = Command::new("tar") + .arg("-xzf") + .arg(bundle) + .arg("-C") + .arg(&scratch) + .status() + .map_err(|err| format!("tar is unavailable: {err}"))?; + if !extract.success() { + let _ = std::fs::remove_dir_all(&scratch); + return Err(format!("could not extract {}", bundle.display())); + } + for item in ["ca.crt", "client.crt", "client.key"] { + if !scratch.join(item).is_file() { + let _ = std::fs::remove_dir_all(&scratch); + return Err(format!("bundle is missing {item}")); + } + } + std::fs::create_dir_all(&target) + .map_err(|err| format!("could not create {}: {err}", target.display()))?; + for item in ["ca.crt", "client.crt", "client.key"] { + std::fs::copy(scratch.join(item), target.join(item)) + .map_err(|err| format!("could not install {item}: {err}"))?; + } + tighten_client_key_permissions(&target.join("client.key")); + let _ = std::fs::remove_dir_all(&scratch); + Ok(target) +} + +#[cfg(unix)] +fn tighten_client_key_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Ok(mut permissions) = std::fs::metadata(path).map(|metadata| metadata.permissions()) { + permissions.set_mode(0o600); + let _ = std::fs::set_permissions(path, permissions); + } +} + +#[cfg(not(unix))] +fn tighten_client_key_permissions(_path: &Path) {} diff --git a/docs/operational-env.md b/docs/operational-env.md index 1f379f7..b7bf0c6 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -183,6 +183,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_LAUNCHER_PARENT_PID` | launcher UI/runtime override | | `LESAVKA_LAUNCHER_PARENT_START_TICKS` | launcher UI/runtime override | | `LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL` | launcher UI/runtime override | +| `LESAVKA_LAUNCHER_WAKE_CONTROL` | launcher idle-wake control file override; stores staged relay idle-nudge requests from the UI | | `LESAVKA_LAUNCHER_WINDOW_TITLE` | launcher UI/runtime override | | `LESAVKA_LAB_GATE_PUSHGATEWAY_JOB` | CI metrics destination override for the opt-in bare-metal lab gate profile | | `LESAVKA_LIVE_KEYBOARD_REPORT_DELAY_MS` | input routing/clipboard override | @@ -355,6 +356,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_MJPEG` | server hardware/device override | | `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset; non-bulk UVC is additionally capped by `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | | `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers | +| `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | | `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables | | `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots | diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index c298689..7876132 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -168,12 +168,12 @@ "client/src/input/inputs.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 87 + "loc": 94 }, "client/src/input/inputs/construction_and_scan.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 275 + "loc": 292 }, "client/src/input/inputs/device_classification.rs": { "clippy_warnings": 0, @@ -182,18 +182,18 @@ }, "client/src/input/inputs/routing_state.rs": { "clippy_warnings": 0, - "doc_debt": 11, - "loc": 293 + "doc_debt": 14, + "loc": 379 }, "client/src/input/inputs/run_loop.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 143 + "loc": 149 }, "client/src/input/inputs/runtime_controls.rs": { "clippy_warnings": 0, - "doc_debt": 4, - "loc": 127 + "doc_debt": 5, + "loc": 145 }, "client/src/input/inputs/toggle_keys.rs": { "clippy_warnings": 0, @@ -238,7 +238,7 @@ "client/src/input/mouse.rs": { "clippy_warnings": 0, "doc_debt": 13, - "loc": 411 + "loc": 416 }, "client/src/input/mouse_event_contract_tests.rs": { "clippy_warnings": 0, @@ -318,27 +318,37 @@ "client/src/launcher/preview.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 10 + "loc": 12 }, "client/src/launcher/preview/feed_runtime.rs": { "clippy_warnings": 0, "doc_debt": 7, - "loc": 500 + "loc": 226 }, "client/src/launcher/preview/feed_state.rs": { "clippy_warnings": 0, - "doc_debt": 12, - "loc": 304 + "doc_debt": 13, + "loc": 315 + }, + "client/src/launcher/preview/feed_worker.rs": { + "clippy_warnings": 0, + "doc_debt": 1, + "loc": 312 }, "client/src/launcher/preview/frame_telemetry.rs": { "clippy_warnings": 0, "doc_debt": 9, "loc": 179 }, + "client/src/launcher/preview/launcher_preview_impl.rs": { + "clippy_warnings": 0, + "doc_debt": 13, + "loc": 334 + }, "client/src/launcher/preview/preview_core.rs": { "clippy_warnings": 0, - "doc_debt": 14, - "loc": 500 + "doc_debt": 1, + "loc": 200 }, "client/src/launcher/preview/status_pipeline.rs": { "clippy_warnings": 0, @@ -378,7 +388,7 @@ "client/src/launcher/ui.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 204 + "loc": 213 }, "client/src/launcher/ui/activation_context.rs": { "clippy_warnings": 0, @@ -407,13 +417,18 @@ }, "client/src/launcher/ui/eye_capture_bindings.rs": { "clippy_warnings": 0, - "doc_debt": 2, - "loc": 435 + "doc_debt": 0, + "loc": 294 }, "client/src/launcher/ui/eye_capture_bindings/recording_support.rs": { "clippy_warnings": 0, - "doc_debt": 24, - "loc": 449 + "doc_debt": 23, + "loc": 421 + }, + "client/src/launcher/ui/eye_capture_bindings/recording_worker.rs": { + "clippy_warnings": 0, + "doc_debt": 5, + "loc": 235 }, "client/src/launcher/ui/eye_display_bindings.rs": { "clippy_warnings": 0, @@ -476,24 +491,29 @@ "loc": 53 }, "client/src/launcher/ui/utility_button_bindings.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 481 + }, + "client/src/launcher/ui/utility_button_bindings/pki_support.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 489 + "loc": 59 }, "client/src/launcher/ui_components.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 136 + "loc": 137 }, "client/src/launcher/ui_components/assemble_view.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 216 + "loc": 217 }, "client/src/launcher/ui_components/build_contexts.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 96 + "loc": 97 }, "client/src/launcher/ui_components/build_device_controls.rs": { "clippy_warnings": 0, @@ -503,7 +523,7 @@ "client/src/launcher/ui_components/build_operations_rail.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 332 + "loc": 352 }, "client/src/launcher/ui_components/build_shell.rs": { "clippy_warnings": 0, @@ -523,7 +543,7 @@ "client/src/launcher/ui_components/display_pane.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 247 + "loc": 249 }, "client/src/launcher/ui_components/panel_chips.rs": { "clippy_warnings": 0, @@ -543,7 +563,7 @@ "client/src/launcher/ui_components/types.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 237 + "loc": 238 }, "client/src/launcher/ui_runtime.rs": { "clippy_warnings": 0, @@ -553,12 +573,12 @@ "client/src/launcher/ui_runtime/control_paths.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 302 + "loc": 316 }, "client/src/launcher/ui_runtime/display_popouts.rs": { "clippy_warnings": 0, "doc_debt": 5, - "loc": 273 + "loc": 277 }, "client/src/launcher/ui_runtime/log_filtering.rs": { "clippy_warnings": 0, @@ -568,7 +588,7 @@ "client/src/launcher/ui_runtime/process_logs.rs": { "clippy_warnings": 0, "doc_debt": 7, - "loc": 277 + "loc": 279 }, "client/src/launcher/ui_runtime/report_popouts.rs": { "clippy_warnings": 0, @@ -588,7 +608,7 @@ "client/src/launcher/ui_runtime/status_refresh.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 444 + "loc": 446 }, "client/src/layout.rs": { "clippy_warnings": 0, @@ -918,7 +938,7 @@ "server/src/audio/ear_capture.rs": { "clippy_warnings": 0, "doc_debt": 9, - "loc": 450 + "loc": 451 }, "server/src/audio/ear_capture/source_watchdog.rs": { "clippy_warnings": 0, @@ -1253,7 +1273,7 @@ "server/src/uvc_runtime.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 255 + "loc": 264 }, "server/src/video.rs": { "clippy_warnings": 0, diff --git a/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs b/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs index 4104215..6c96333 100644 --- a/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs +++ b/tests/compatibility/server/uvc/server_uvc_runtime_contract.rs @@ -19,10 +19,10 @@ use tokio::runtime::Runtime; async fn wait_for_marker(marker: &Path, is_ready: impl Fn(&str) -> bool) -> String { let deadline = tokio::time::Instant::now() + Duration::from_secs(2); loop { - if let Ok(contents) = fs::read_to_string(marker) { - if is_ready(&contents) { - return contents; - } + if let Ok(contents) = fs::read_to_string(marker) + && is_ready(&contents) + { + return contents; } assert!( tokio::time::Instant::now() < deadline,