From e3cb555c90bf9f8b47a61d4f169f578d17b01e63 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 18 Apr 2026 03:34:54 -0300 Subject: [PATCH] lesavka: cover inline preview capture requests --- client/src/launcher/preview.rs | 253 ++++++++++++++++++++++++++++++++- 1 file changed, 252 insertions(+), 1 deletion(-) diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 85e4469..a779b65 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -348,6 +348,26 @@ impl LauncherPreview { )) } + #[cfg(test)] + pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) { + let feed = match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()), + }; + if let Some(feed) = feed { + feed.session_active.store(true, Ordering::Relaxed); + feed.active_bindings.fetch_add(1, Ordering::AcqRel); + } + } + fn rebuild_feed( &self, feeds: &Arc>, @@ -1518,10 +1538,140 @@ mod tests { use super::{ DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, - PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, + LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, adapt_inline_preview_request, }; + use crate::launcher::state::{CaptureSizePreset, LauncherState}; + use futures::stream; + use lesavka_common::lesavka::relay_server::{Relay, RelayServer}; + use lesavka_common::lesavka::{MonitorRequest, VideoPacket}; + use serial_test::serial; + use std::pin::Pin; + use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; + use tokio::sync::mpsc; + use tokio_stream::wrappers::ReceiverStream; + use tonic::{Request, Response, Status}; + + #[derive(Clone, Default)] + struct ProbeRelay { + requests: Arc>>, + } + + #[tonic::async_trait] + impl Relay for ProbeRelay { + type StreamKeyboardStream = Pin< + Box< + dyn futures::Stream> + + Send, + >, + >; + type StreamMouseStream = Pin< + Box< + dyn futures::Stream> + + Send, + >, + >; + type CaptureVideoStream = + Pin> + Send>>; + type CaptureAudioStream = Pin< + Box< + dyn futures::Stream> + + Send, + >, + >; + type StreamMicrophoneStream = Pin< + Box> + Send>, + >; + type StreamCameraStream = Pin< + Box> + Send>, + >; + + async fn stream_keyboard( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_mouse( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn capture_video( + &self, + request: Request, + ) -> Result, Status> { + self.requests.lock().unwrap().push(request.into_inner()); + let (_tx, rx) = mpsc::channel(1); + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + async fn capture_audio( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_microphone( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn stream_camera( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn paste_text( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::PasteReply { + ok: true, + error: String::new(), + })) + } + + async fn reset_usb( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::ResetUsbReply { + ok: true, + })) + } + + async fn get_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(lesavka_common::lesavka::CapturePowerState { + available: true, + enabled: true, + unit: "relay.service".to_string(), + detail: "active/running".to_string(), + active_leases: 1, + mode: "auto".to_string(), + })) + } + + async fn set_capture_power( + &self, + _request: Request, + ) -> Result, Status> { + self.get_capture_power(Request::new(lesavka_common::lesavka::Empty {})) + .await + } + } #[test] fn inline_preview_profile_uses_lightweight_defaults() { @@ -1609,4 +1759,105 @@ mod tests { assert_eq!(snapshot.server_encoder_label, "x264enc"); assert_eq!(snapshot.decoder_label, "nvh264dec"); } + + #[test] + #[serial] + fn inline_preview_requests_selected_reencode_profile_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let state = LauncherState::default(); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + capture.preset != CaptureSizePreset::Source, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + assert_eq!(request.requested_fps, 24); + assert_eq!(request.max_bitrate, 4_000); + assert!(request.prefer_reencode); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a capture request within timeout"); + } + + #[test] + #[serial] + fn inline_preview_requests_honest_source_profile_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::Source); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + capture.preset != CaptureSizePreset::Source, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + assert_eq!(request.requested_fps, 30); + assert_eq!(request.max_bitrate, 12_000); + assert!(!request.prefer_reencode); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a source capture request within timeout"); + } }