From e15afc2ebd03a07499b4301e8b37b83da9110747 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 15 Apr 2026 01:20:51 -0300 Subject: [PATCH] feat(launcher): stage devices with preview and capture power modes --- client/src/launcher/device_test.rs | 359 ++++++++++++++++++++-- client/src/launcher/power.rs | 14 +- client/src/launcher/ui.rs | 126 ++++++-- client/src/launcher/ui_components.rs | 108 ++++++- client/src/launcher/ui_runtime.rs | 62 ++-- common/proto/lesavka.proto | 7 + scripts/ci/hygiene_gate_baseline.json | 91 +++--- scripts/ci/quality_gate_baseline.json | 8 +- server/src/capture_power.rs | 55 +++- server/src/main.rs | 17 +- testing/tests/server_main_rpc_contract.rs | 89 ++++-- 11 files changed, 760 insertions(+), 176 deletions(-) diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs index de64fef..08e16da 100644 --- a/client/src/launcher/device_test.rs +++ b/client/src/launcher/device_test.rs @@ -1,7 +1,18 @@ -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow}; +use gst::prelude::*; +use gstreamer as gst; +use gstreamer_app as gst_app; +use gtk::{gdk, glib}; use shell_escape::escape; use std::borrow::Cow; use std::process::{Child, Command}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const CAMERA_PREVIEW_WIDTH: i32 = 360; +const CAMERA_PREVIEW_HEIGHT: i32 = 202; +const CAMERA_PREVIEW_IDLE: &str = "Select a camera and click Start Preview."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceTestKind { @@ -10,25 +21,69 @@ pub enum DeviceTestKind { Speaker, } -#[derive(Default)] pub struct DeviceTestController { - camera: Option, + camera: Option, + selected_camera: Option, microphone: Option, speaker: Option, } +impl Default for DeviceTestController { + fn default() -> Self { + Self { + camera: None, + selected_camera: None, + microphone: None, + speaker: None, + } + } +} + impl DeviceTestController { pub fn new() -> Self { Self::default() } - pub fn is_running(&mut self, kind: DeviceTestKind) -> bool { - self.cleanup_finished(); - self.slot(kind).is_some() + pub fn bind_camera_preview( + &mut self, + camera_picture: >k::Picture, + camera_status: >k::Label, + ) -> Result<()> { + if let Some(camera) = self.camera.as_mut() { + camera.stop(); + } + let mut preview = LocalCameraPreview::new(camera_picture, camera_status); + preview.set_selected(self.selected_camera.as_deref())?; + self.camera = Some(preview); + Ok(()) } - pub fn toggle_camera(&mut self, camera: Option<&str>) -> Result { - self.toggle(DeviceTestKind::Camera, build_camera_test(camera)) + pub fn is_running(&mut self, kind: DeviceTestKind) -> bool { + self.cleanup_finished(); + match kind { + DeviceTestKind::Camera => self + .camera + .as_ref() + .is_some_and(LocalCameraPreview::is_running), + DeviceTestKind::Microphone => self.microphone.is_some(), + DeviceTestKind::Speaker => self.speaker.is_some(), + } + } + + pub fn set_camera_selection(&mut self, camera: Option<&str>) -> Result<()> { + self.selected_camera = normalize_camera_selection(camera); + if let Some(preview) = self.camera.as_mut() { + preview.set_selected(self.selected_camera.as_deref())?; + } + Ok(()) + } + + pub fn toggle_camera(&mut self) -> Result { + let preview = self + .camera + .as_mut() + .ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?; + preview.toggle() } pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result { @@ -43,11 +98,10 @@ impl DeviceTestController { } pub fn stop_all(&mut self) { - for kind in [ - DeviceTestKind::Camera, - DeviceTestKind::Microphone, - DeviceTestKind::Speaker, - ] { + if let Some(camera) = self.camera.as_mut() { + camera.stop(); + } + for kind in [DeviceTestKind::Microphone, DeviceTestKind::Speaker] { self.stop(kind); } } @@ -73,11 +127,7 @@ impl DeviceTestController { } fn cleanup_finished(&mut self) { - for kind in [ - DeviceTestKind::Camera, - DeviceTestKind::Microphone, - DeviceTestKind::Speaker, - ] { + for kind in [DeviceTestKind::Microphone, DeviceTestKind::Speaker] { let finished = self .slot_mut(kind) .as_mut() @@ -92,7 +142,7 @@ impl DeviceTestController { fn slot(&self, kind: DeviceTestKind) -> &Option { match kind { - DeviceTestKind::Camera => &self.camera, + DeviceTestKind::Camera => panic!("camera preview is not an external child process"), DeviceTestKind::Microphone => &self.microphone, DeviceTestKind::Speaker => &self.speaker, } @@ -100,28 +150,252 @@ impl DeviceTestController { fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option { match kind { - DeviceTestKind::Camera => &mut self.camera, + DeviceTestKind::Camera => panic!("camera preview is not an external child process"), DeviceTestKind::Microphone => &mut self.microphone, DeviceTestKind::Speaker => &mut self.speaker, } } } -fn build_camera_test(camera: Option<&str>) -> Result { - let Some(camera) = camera.filter(|value| !value.trim().is_empty()) else { - bail!("select a camera before running the local camera test"); - }; - let device = format!("/dev/v4l/by-id/{camera}"); - Ok(shell_command(format!( - "gst-launch-1.0 -q v4l2src device={} ! videoconvert ! queue ! autovideosink sync=false", - quote(device) - ))) +struct LocalCameraPreview { + latest: Arc>>, + status_text: Arc>, + generation: Arc, + running: Arc, + selected_device: Option, +} + +struct PreviewFrame { + width: i32, + height: i32, + stride: usize, + rgba: Vec, +} + +impl LocalCameraPreview { + fn new(picture: >k::Picture, status_label: >k::Label) -> Self { + let latest = Arc::new(Mutex::new(None::)); + let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string())); + let generation = Arc::new(AtomicU64::new(0)); + let running = Arc::new(AtomicBool::new(false)); + + { + let picture = picture.clone(); + let status_label = status_label.clone(); + let latest = Arc::clone(&latest); + let status_text = Arc::clone(&status_text); + glib::timeout_add_local(Duration::from_millis(120), move || { + let next = latest.lock().ok().and_then(|mut slot| slot.take()); + if let Some(frame) = next { + let bytes = glib::Bytes::from_owned(frame.rgba); + let texture = gdk::MemoryTexture::new( + frame.width, + frame.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + frame.stride, + ); + picture.set_paintable(Some(&texture)); + } + if let Ok(text) = status_text.lock() { + status_label.set_text(text.as_str()); + } + glib::ControlFlow::Continue + }); + } + + Self { + latest, + status_text, + generation, + running, + selected_device: None, + } + } + + fn is_running(&self) -> bool { + self.running.load(Ordering::Acquire) + } + + fn set_selected(&mut self, camera: Option<&str>) -> Result<()> { + self.selected_device = normalize_camera_selection(camera); + + if self.is_running() { + self.stop(); + self.start()?; + return Ok(()); + } + + self.set_status(match self.selected_device.as_deref() { + Some(camera) => format!("Selected {camera}. Click Start Preview to verify it here."), + None => CAMERA_PREVIEW_IDLE.to_string(), + }); + Ok(()) + } + + fn toggle(&mut self) -> Result { + if self.is_running() { + self.stop(); + return Ok(false); + } + self.start()?; + Ok(true) + } + + fn start(&mut self) -> Result<()> { + gst::init().context("initialising in-launcher camera preview")?; + let selected = self + .selected_device + .clone() + .ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?; + let device = resolve_camera_device(&selected); + let latest = Arc::clone(&self.latest); + let status_text = Arc::clone(&self.status_text); + let generation = Arc::clone(&self.generation); + let running = Arc::clone(&self.running); + let token = generation.fetch_add(1, Ordering::AcqRel) + 1; + running.store(true, Ordering::Release); + self.set_status(format!("Starting preview for {selected}...")); + + std::thread::spawn(move || { + if let Err(err) = run_camera_preview_feed( + selected, + device, + token, + latest, + status_text.clone(), + generation.clone(), + running.clone(), + ) { + if generation.load(Ordering::Acquire) == token { + running.store(false, Ordering::Release); + if let Ok(mut status) = status_text.lock() { + *status = format!("Camera preview failed: {err}"); + } + } + } + }); + Ok(()) + } + + fn stop(&mut self) { + self.running.store(false, Ordering::Release); + self.generation.fetch_add(1, Ordering::AcqRel); + if let Ok(mut latest) = self.latest.lock() { + *latest = None; + } + self.set_status(match self.selected_device.as_deref() { + Some(camera) => format!("Preview stopped. {camera} is still selected."), + None => CAMERA_PREVIEW_IDLE.to_string(), + }); + } + + fn set_status(&self, text: String) { + if let Ok(mut status) = self.status_text.lock() { + *status = text; + } + } +} + +fn normalize_camera_selection(camera: Option<&str>) -> Option { + camera + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto")) + .map(ToOwned::to_owned) +} + +fn resolve_camera_device(camera: &str) -> String { + if camera.starts_with("/dev/") { + camera.to_string() + } else { + format!("/dev/v4l/by-id/{camera}") + } +} + +fn run_camera_preview_feed( + selected: String, + device: String, + token: u64, + latest: Arc>>, + status_text: Arc>, + generation: Arc, + running: Arc, +) -> Result<()> { + let (pipeline, appsink) = build_camera_preview_pipeline(&device)?; + pipeline + .set_state(gst::State::Playing) + .context("starting in-launcher camera preview pipeline")?; + + if let Ok(mut status) = status_text.lock() { + *status = format!("Previewing {selected} locally."); + } + + while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token { + if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) { + if let Some(frame) = sample_to_frame(&sample) { + if let Ok(mut slot) = latest.lock() { + *slot = Some(frame); + } + } + } + } + + let _ = pipeline.set_state(gst::State::Null); + Ok(()) +} + +fn build_camera_preview_pipeline(device: &str) -> Result<(gst::Pipeline, gst_app::AppSink)> { + let device = gst_quote(device); + let desc = format!( + "v4l2src device=\"{device}\" do-timestamp=true ! \ + video/x-raw,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},framerate=30/1 ! \ + videoconvert ! videoscale ! \ + video/x-raw,format=RGBA,width={CAMERA_PREVIEW_WIDTH},height={CAMERA_PREVIEW_HEIGHT},pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" + ); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("camera preview pipeline"); + let appsink = pipeline + .by_name("sink") + .context("missing in-launcher camera preview appsink")? + .downcast::() + .expect("camera preview appsink"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", &"RGBA") + .field("width", &CAMERA_PREVIEW_WIDTH) + .field("height", &CAMERA_PREVIEW_HEIGHT) + .build(), + )); + Ok((pipeline, appsink)) +} + +fn sample_to_frame(sample: &gst::Sample) -> Option { + let caps = sample.caps()?; + let structure = caps.structure(0)?; + let width = structure.get::("width").ok()?; + let height = structure.get::("height").ok()?; + let buffer = sample.buffer()?; + let map = buffer.map_readable().ok()?; + let rgba = map.as_slice().to_vec(); + let stride = rgba.len() / height.max(1) as usize; + Some(PreviewFrame { + width, + height, + stride, + rgba, + }) +} + +fn gst_quote(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") } fn build_microphone_test(source: Option<&str>, sink: Option<&str>) -> Result { let source = source .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| anyhow::anyhow!("select a microphone before starting a monitor test"))?; + .ok_or_else(|| anyhow!("select a microphone before starting a monitor test"))?; let sink = sink.filter(|value| !value.trim().is_empty()); let sink_prop = sink .map(|value| format!("device={}", quote(value))) @@ -153,3 +427,28 @@ fn shell_command(command: String) -> Command { fn quote(value: impl Into) -> String { escape(Cow::Owned(value.into())).into_owned() } + +#[cfg(test)] +mod tests { + use super::{normalize_camera_selection, resolve_camera_device}; + + #[test] + fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() { + assert_eq!(resolve_camera_device("/dev/video0"), "/dev/video0"); + assert_eq!( + resolve_camera_device("usb-Logitech_C920-video-index0"), + "/dev/v4l/by-id/usb-Logitech_C920-video-index0" + ); + } + + #[test] + fn normalize_camera_selection_drops_auto_and_blank_values() { + assert_eq!(normalize_camera_selection(None), None); + assert_eq!(normalize_camera_selection(Some("")), None); + assert_eq!(normalize_camera_selection(Some("auto")), None); + assert_eq!( + normalize_camera_selection(Some("usb-Logitech_C920-video-index0")), + Some("usb-Logitech_C920-video-index0".to_string()) + ); + } +} diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs index 448baf1..832b86d 100644 --- a/client/src/launcher/power.rs +++ b/client/src/launcher/power.rs @@ -1,5 +1,7 @@ use anyhow::{Context, Result}; -use lesavka_common::lesavka::{Empty, SetCapturePowerRequest, relay_client::RelayClient}; +use lesavka_common::lesavka::{ + CapturePowerCommand, Empty, SetCapturePowerRequest, relay_client::RelayClient, +}; use tonic::{Request, transport::Channel}; use super::state::CapturePowerStatus; @@ -16,11 +18,17 @@ pub fn fetch_capture_power(server_addr: &str) -> Result { }) } -pub fn set_capture_power(server_addr: &str, enabled: bool) -> Result { +pub fn set_capture_power_mode( + server_addr: &str, + command: CapturePowerCommand, +) -> Result { with_runtime(async move { let mut client = connect(server_addr).await?; let reply = client - .set_capture_power(Request::new(SetCapturePowerRequest { enabled })) + .set_capture_power(Request::new(SetCapturePowerRequest { + enabled: matches!(command, CapturePowerCommand::ForceOn), + command: command as i32, + })) .await .context("setting capture power state")? .into_inner(); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 61c71c0..7541a0e 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -7,7 +7,7 @@ use { super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, super::launcher_focus_signal_path, - super::power::{fetch_capture_power, set_capture_power}, + super::power::{fetch_capture_power, set_capture_power_mode}, super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, super::ui_components::build_launcher_view, super::ui_runtime::{ @@ -19,6 +19,7 @@ use { }, gtk::glib, gtk::prelude::*, + lesavka_common::lesavka::CapturePowerCommand, std::cell::{Cell, RefCell}, std::process::Child, std::rc::Rc, @@ -85,6 +86,25 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let preview = view.preview.clone(); let popouts = Rc::clone(&view.popouts); + { + let mut tests = tests.borrow_mut(); + if let Err(err) = tests.bind_camera_preview( + &view.device_stage.camera_preview, + &view.device_stage.camera_status, + ) { + widgets + .status_label + .set_text(&format!("Camera preview setup failed: {err}")); + } + if let Err(err) = + tests.set_camera_selection(state.borrow().devices.camera.as_deref()) + { + widgets + .status_label + .set_text(&format!("Camera staging setup failed: {err}")); + } + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); refresh_test_buttons(&widgets, &mut tests.borrow_mut()); @@ -96,13 +116,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let state = Rc::clone(&state); let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); let camera_combo = camera_combo.clone(); let camera_combo_read = camera_combo.clone(); camera_combo.connect_changed(move |_| { - state - .borrow_mut() - .select_camera(selected_combo_value(&camera_combo_read)); + let selected = selected_combo_value(&camera_combo_read); + state.borrow_mut().select_camera(selected.clone()); + if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) { + widgets + .status_label + .set_text(&format!("Camera preview update failed: {err}")); + } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); }); } @@ -315,15 +341,18 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let camera_test_button = widgets.camera_test_button.clone(); let widgets_handle = widgets.clone(); camera_test_button.connect_clicked(move |_| { - let result = tests - .borrow_mut() - .toggle_camera(selected_combo_value(&camera_combo).as_deref()); + let selected = selected_combo_value(&camera_combo); + let result = { + let mut tests = tests.borrow_mut(); + let _ = tests.set_camera_selection(selected.as_deref()); + tests.toggle_camera() + }; update_test_action_result( &widgets_handle, &mut tests.borrow_mut(), result, - "Camera test started.", - "Camera test stopped.", + "Camera preview started inside the launcher.", + "Camera preview stopped.", ); }); } @@ -373,27 +402,78 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { { let widgets = widgets.clone(); - let state = Rc::clone(&state); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); let power_tx = power_tx.clone(); let power_request_in_flight = Rc::clone(&power_request_in_flight); - widgets.power_button.connect_clicked(move |_| { + let power_auto_button = widgets.power_auto_button.clone(); + let widgets_handle = widgets.clone(); + power_auto_button.connect_clicked(move |_| { if power_request_in_flight.replace(true) { return; } - let target = !state.borrow().capture_power.enabled; let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets.status_label.set_text(if target { - "Powering up remote capture feeds..." - } else { - "Powering down remote capture feeds..." - }); + widgets_handle + .status_label + .set_text("Returning capture feeds to automatic mode..."); let tx = power_tx.clone(); std::thread::spawn(move || { let result = - set_capture_power(&server_addr, target).map_err(|err| err.to_string()); + set_capture_power_mode(&server_addr, CapturePowerCommand::Auto) + .map_err(|err| err.to_string()); + let _ = tx.send(PowerMessage::Command(result)); + }); + }); + } + + { + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let power_tx = power_tx.clone(); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let power_on_button = widgets.power_on_button.clone(); + let widgets_handle = widgets.clone(); + power_on_button.connect_clicked(move |_| { + if power_request_in_flight.replace(true) { + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_handle + .status_label + .set_text("Forcing capture feeds on for staging..."); + let tx = power_tx.clone(); + std::thread::spawn(move || { + let result = + set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOn) + .map_err(|err| err.to_string()); + let _ = tx.send(PowerMessage::Command(result)); + }); + }); + } + + { + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); + let power_tx = power_tx.clone(); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let power_off_button = widgets.power_off_button.clone(); + let widgets_handle = widgets.clone(); + power_off_button.connect_clicked(move |_| { + if power_request_in_flight.replace(true) { + return; + } + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets_handle + .status_label + .set_text("Forcing capture feeds off for staging..."); + let tx = power_tx.clone(); + std::thread::spawn(move || { + let result = + set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOff) + .map_err(|err| err.to_string()); let _ = tx.send(PowerMessage::Command(result)); }); }); @@ -510,12 +590,12 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } PowerMessage::Command(Ok(power)) => { - let enabled = power.enabled; + let mode = power.mode.clone(); state.borrow_mut().set_capture_power(power); - widgets.status_label.set_text(if enabled { - "Capture feeds powered up." - } else { - "Capture feeds powered down." + widgets.status_label.set_text(match mode.as_str() { + "forced-on" => "Capture feeds forced on.", + "forced-off" => "Capture feeds forced off.", + _ => "Capture feeds returned to automatic mode.", }); } PowerMessage::Command(Err(err)) => { diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 609f112..c1b08fe 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -42,7 +42,9 @@ pub struct LauncherWidgets { pub display_panes: [DisplayPaneWidgets; 2], pub start_button: gtk::Button, pub stop_button: gtk::Button, - pub power_button: gtk::Button, + pub power_auto_button: gtk::Button, + pub power_on_button: gtk::Button, + pub power_off_button: gtk::Button, pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, pub probe_button: gtk::Button, @@ -52,12 +54,19 @@ pub struct LauncherWidgets { pub speaker_test_button: gtk::Button, } +#[derive(Clone)] +pub struct DeviceStageWidgets { + pub camera_preview: gtk::Picture, + pub camera_status: gtk::Label, +} + pub struct LauncherView { pub window: gtk::ApplicationWindow, pub server_entry: gtk::Entry, pub camera_combo: gtk::ComboBoxText, pub microphone_combo: gtk::ComboBoxText, pub speaker_combo: gtk::ComboBoxText, + pub device_stage: DeviceStageWidgets, pub widgets: LauncherWidgets, pub preview: Option>, pub popouts: Rc; 2]>>, @@ -131,7 +140,7 @@ pub fn build_launcher_view( stage.set_vexpand(true); content.append(&stage); - let (connection_panel, connection_body) = build_panel("Connection"); + let (connection_panel, connection_body) = build_panel("Session"); let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let server_entry = gtk::Entry::new(); server_entry.add_css_class("server-entry"); @@ -149,18 +158,39 @@ pub fn build_launcher_view( server_row.append(&stop_button); connection_body.append(&server_row); + let power_intro = gtk::Label::new(Some( + "Capture power can stay automatic or be forced on/off while you stage a session.", + )); + power_intro.add_css_class("dim-label"); + power_intro.set_wrap(true); + power_intro.set_xalign(0.0); + connection_body.append(&power_intro); + let power_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - let power_button = gtk::Button::with_label("Power Up Feeds"); - power_button.set_tooltip_text(Some( - "Turns the relay.service-backed capture power on or off from the launcher.", + let power_auto_button = gtk::Button::with_label("Auto"); + power_auto_button.add_css_class("pill-toggle"); + power_auto_button.set_tooltip_text(Some( + "Automatic mode follows the active remote preview and relay stream leases.", + )); + let power_on_button = gtk::Button::with_label("Force On"); + power_on_button.add_css_class("pill-toggle"); + power_on_button.set_tooltip_text(Some( + "Keep the capture feeds powered even when no preview or session stream is active.", + )); + let power_off_button = gtk::Button::with_label("Force Off"); + power_off_button.add_css_class("pill-toggle"); + power_off_button.set_tooltip_text(Some( + "Hold the capture feeds down even if previews or clients ask for them.", )); let power_detail = gtk::Label::new(Some("Capture power status is loading...")); power_detail.add_css_class("dim-label"); power_detail.set_wrap(true); power_detail.set_xalign(0.0); - power_row.append(&power_button); - power_row.append(&power_detail); + power_row.append(&power_auto_button); + power_row.append(&power_on_button); + power_row.append(&power_off_button); connection_body.append(&power_row); + connection_body.append(&power_detail); sidebar.append(&connection_panel); let (routing_panel, routing_body) = build_panel("Input Routing"); @@ -193,7 +223,14 @@ pub fn build_launcher_view( routing_body.append(&swap_row); sidebar.append(&routing_panel); - let (devices_panel, devices_body) = build_panel("Devices"); + let (devices_panel, devices_body) = build_panel("Device Staging"); + let devices_intro = gtk::Label::new(Some( + "Lock in the exact local camera, microphone, and speaker you want before you launch the relay.", + )); + devices_intro.add_css_class("dim-label"); + devices_intro.set_wrap(true); + devices_intro.set_xalign(0.0); + devices_body.append(&devices_intro); let devices_grid = gtk::Grid::new(); devices_grid.set_row_spacing(8); devices_grid.set_column_spacing(8); @@ -205,7 +242,7 @@ pub fn build_launcher_view( camera_combo.append(Some(camera), camera); } super::ui_runtime::set_combo_active_text(&camera_combo, state.devices.camera.as_deref()); - let camera_test_button = gtk::Button::with_label("Test"); + let camera_test_button = gtk::Button::with_label("Start Preview"); camera_test_button.set_tooltip_text(Some( "Open a local preview for the selected webcam so you can confirm the right source.", )); @@ -226,7 +263,7 @@ pub fn build_launcher_view( µphone_combo, state.devices.microphone.as_deref(), ); - let microphone_test_button = gtk::Button::with_label("Test"); + let microphone_test_button = gtk::Button::with_label("Monitor Mic"); microphone_test_button.set_tooltip_text(Some( "Monitor the selected microphone through the selected speaker until you stop the test.", )); @@ -244,7 +281,7 @@ pub fn build_launcher_view( speaker_combo.append(Some(speaker), speaker); } super::ui_runtime::set_combo_active_text(&speaker_combo, state.devices.speaker.as_deref()); - let speaker_test_button = gtk::Button::with_label("Test"); + let speaker_test_button = gtk::Button::with_label("Play Tone"); speaker_test_button.set_tooltip_text(Some( "Play a short continuous tone through the selected speaker until you stop the test.", )); @@ -255,6 +292,32 @@ pub fn build_launcher_view( &speaker_combo, &speaker_test_button, ); + + let preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 8); + preview_shell.add_css_class("camera-preview-shell"); + let preview_heading = gtk::Label::new(Some("Selected Camera Preview")); + preview_heading.add_css_class("panel-title"); + preview_heading.set_halign(gtk::Align::Start); + let preview_note = gtk::Label::new(Some( + "Use this to verify the chosen webcam in-place. Audio device tests still stay local.", + )); + preview_note.add_css_class("dim-label"); + preview_note.set_wrap(true); + preview_note.set_xalign(0.0); + let camera_preview = gtk::Picture::new(); + camera_preview.set_can_shrink(true); + camera_preview.set_hexpand(true); + camera_preview.set_size_request(360, 202); + camera_preview.add_css_class("camera-preview-frame"); + let camera_status = gtk::Label::new(Some("Select a camera and click Start Preview.")); + camera_status.add_css_class("dim-label"); + camera_status.set_wrap(true); + camera_status.set_xalign(0.0); + preview_shell.append(&preview_heading); + preview_shell.append(&preview_note); + preview_shell.append(&camera_preview); + preview_shell.append(&camera_status); + devices_body.append(&preview_shell); sidebar.append(&devices_panel); let (actions_panel, actions_body) = build_panel("Remote Actions"); @@ -327,7 +390,9 @@ pub fn build_launcher_view( display_panes: [left_pane.clone(), right_pane.clone()], start_button: start_button.clone(), stop_button: stop_button.clone(), - power_button: power_button.clone(), + power_auto_button: power_auto_button.clone(), + power_on_button: power_on_button.clone(), + power_off_button: power_off_button.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), probe_button: probe_button.clone(), @@ -346,6 +411,10 @@ pub fn build_launcher_view( camera_combo, microphone_combo, speaker_combo, + device_stage: DeviceStageWidgets { + camera_preview, + camera_status, + }, widgets, preview, popouts, @@ -399,12 +468,27 @@ pub fn install_css(window: >k::ApplicationWindow) { border-radius: 16px; padding: 24px; } + box.camera-preview-shell { + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 14px; + } + picture.camera-preview-frame { + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 14px; + } label.status-line { opacity: 0.88; } entry.server-entry { min-height: 38px; } + button.pill-toggle { + min-height: 36px; + padding: 0 14px; + } "#, ); if let Some(display) = gtk::gdk::Display::default() { diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index b4ce126..5a8a493 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -61,14 +61,16 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi InputRouting::Remote => "Route Inputs To Local", InputRouting::Local => "Route Inputs To Remote", }); - widgets.power_button.set_label( - match (state.capture_power.available, state.capture_power.enabled) { - (false, _) => "Capture Power Unavailable", - (true, true) => "Power Down Feeds", - (true, false) => "Power Up Feeds", - }, + let power_available = state.capture_power.available; + widgets + .power_auto_button + .set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto")); + widgets.power_on_button.set_sensitive( + power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"), + ); + widgets.power_off_button.set_sensitive( + power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"), ); - widgets.power_button.set_sensitive(true); for monitor_id in 0..2 { refresh_display_pane( @@ -82,23 +84,23 @@ pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestCon widgets .camera_test_button .set_label(if tests.is_running(DeviceTestKind::Camera) { - "Stop" + "Stop Preview" } else { - "Test" + "Start Preview" }); widgets .microphone_test_button .set_label(if tests.is_running(DeviceTestKind::Microphone) { - "Stop" + "Stop Monitor" } else { - "Test" + "Monitor Mic" }); widgets .speaker_test_button .set_label(if tests.is_running(DeviceTestKind::Speaker) { - "Stop" + "Stop Tone" } else { - "Test" + "Play Tone" }); } @@ -275,21 +277,37 @@ pub fn capture_power_label(power: &CapturePowerStatus) -> String { if !power.available { return "Unavailable".to_string(); } - format!( - "{} ({})", - if power.enabled { "On" } else { "Off" }, - power.mode - ) + match power.mode.as_str() { + "forced-on" => "Forced On".to_string(), + "forced-off" => "Forced Off".to_string(), + _ => { + if power.enabled { + "Auto • Live".to_string() + } else { + "Auto • Standby".to_string() + } + } + } } pub fn capture_power_detail(power: &CapturePowerStatus) -> String { if !power.available { return format!("{} is unavailable: {}", power.unit, power.detail); } - format!( - "{} • {} • leases {}", - power.unit, power.detail, power.active_leases - ) + match power.mode.as_str() { + "forced-on" => format!( + "{} • manual override holding feeds up • {} • leases {}", + power.unit, power.detail, power.active_leases + ), + "forced-off" => format!( + "{} • manual override holding feeds down • {} • leases {}", + power.unit, power.detail, power.active_leases + ), + _ => format!( + "{} • automatic mode follows live previews and session demand • {} • leases {}", + power.unit, power.detail, power.active_leases + ), + } } pub fn capitalize(value: &str) -> String { diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 5cb8951..17f510f 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -25,8 +25,15 @@ message CapturePowerState { uint32 active_leases = 5; string mode = 6; } +enum CapturePowerCommand { + CAPTURE_POWER_COMMAND_UNSPECIFIED = 0; + CAPTURE_POWER_COMMAND_AUTO = 1; + CAPTURE_POWER_COMMAND_FORCE_ON = 2; + CAPTURE_POWER_COMMAND_FORCE_OFF = 3; +} message SetCapturePowerRequest { bool enabled = 1; + CapturePowerCommand command = 2; } message HandshakeSet { diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 41130f7..7871389 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -7,13 +7,13 @@ }, "client/src/app_support.rs": { "loc": 128, - "clippy_warnings": 0, - "doc_debt": 3 + "doc_debt": 3, + "clippy_warnings": 0 }, "client/src/handshake.rs": { "loc": 194, - "clippy_warnings": 0, - "doc_debt": 3 + "doc_debt": 3, + "clippy_warnings": 0 }, "client/src/input/camera.rs": { "loc": 372, @@ -42,8 +42,8 @@ }, "client/src/input/mod.rs": { "loc": 8, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "client/src/input/mouse.rs": { "loc": 317, @@ -56,9 +56,9 @@ "doc_debt": 1 }, "client/src/launcher/device_test.rs": { - "loc": 155, - "clippy_warnings": 8, - "doc_debt": 7 + "loc": 454, + "clippy_warnings": 36, + "doc_debt": 20 }, "client/src/launcher/devices.rs": { "loc": 158, @@ -76,9 +76,9 @@ "doc_debt": 4 }, "client/src/launcher/power.rs": { - "loc": 61, - "clippy_warnings": 0, - "doc_debt": 0 + "loc": 69, + "doc_debt": 1, + "clippy_warnings": 0 }, "client/src/launcher/preview.rs": { "loc": 293, @@ -91,17 +91,17 @@ "doc_debt": 15 }, "client/src/launcher/ui.rs": { - "loc": 574, + "loc": 654, "clippy_warnings": 4, "doc_debt": 1 }, "client/src/launcher/ui_components.rs": { - "loc": 531, + "loc": 615, "clippy_warnings": 8, "doc_debt": 4 }, "client/src/launcher/ui_runtime.rs": { - "loc": 439, + "loc": 457, "clippy_warnings": 10, "doc_debt": 18 }, @@ -112,8 +112,8 @@ }, "client/src/lib.rs": { "loc": 14, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "client/src/main.rs": { "loc": 96, @@ -127,8 +127,8 @@ }, "client/src/output/display.rs": { "loc": 81, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "client/src/output/layout.rs": { "loc": 155, @@ -137,8 +137,8 @@ }, "client/src/output/mod.rs": { "loc": 6, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "client/src/output/video.rs": { "loc": 547, @@ -152,28 +152,28 @@ }, "common/src/bin/cli.rs": { "loc": 3, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "common/src/cli.rs": { "loc": 22, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "common/src/hid.rs": { "loc": 134, - "clippy_warnings": 0, - "doc_debt": 2 + "doc_debt": 2, + "clippy_warnings": 0 }, "common/src/lib.rs": { "loc": 22, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "common/src/paste.rs": { "loc": 95, - "clippy_warnings": 0, - "doc_debt": 2 + "doc_debt": 2, + "clippy_warnings": 0 }, "server/src/audio.rs": { "loc": 386, @@ -181,14 +181,13 @@ "doc_debt": 7 }, "server/src/bin/lesavka-uvc.real.inc": { - "loc": 0, "clippy_warnings": 31, "doc_debt": 0 }, "server/src/bin/lesavka-uvc.rs": { "loc": 710, - "clippy_warnings": 0, - "doc_debt": 17 + "doc_debt": 17, + "clippy_warnings": 0 }, "server/src/camera.rs": { "loc": 392, @@ -201,8 +200,8 @@ "doc_debt": 5 }, "server/src/capture_power.rs": { - "loc": 295, - "clippy_warnings": 8, + "loc": 338, + "clippy_warnings": 10, "doc_debt": 7 }, "server/src/gadget.rs": { @@ -217,13 +216,13 @@ }, "server/src/lib.rs": { "loc": 14, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 }, "server/src/main.rs": { - "loc": 565, + "loc": 572, "clippy_warnings": 10, - "doc_debt": 12 + "doc_debt": 13 }, "server/src/paste.rs": { "loc": 207, @@ -237,13 +236,13 @@ }, "server/src/uvc_control/model.rs": { "loc": 510, - "clippy_warnings": 0, - "doc_debt": 11 + "doc_debt": 11, + "clippy_warnings": 0 }, "server/src/uvc_control/protocol.rs": { "loc": 403, - "clippy_warnings": 0, - "doc_debt": 11 + "doc_debt": 11, + "clippy_warnings": 0 }, "server/src/uvc_runtime.rs": { "loc": 241, @@ -267,8 +266,8 @@ }, "testing/src/lib.rs": { "loc": 10, - "clippy_warnings": 0, - "doc_debt": 0 + "doc_debt": 0, + "clippy_warnings": 0 } } } diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 3a6bc65..ddc7392 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -57,7 +57,7 @@ "line_percent": 98.29059829059828 }, "client/src/launcher/ui.rs": { - "loc": 574, + "loc": 654, "line_percent": 100.0 }, "client/src/layout.rs": { @@ -125,7 +125,7 @@ "line_percent": 100.0 }, "server/src/capture_power.rs": { - "loc": 295, + "loc": 338, "line_percent": 100.0 }, "server/src/gadget.rs": { @@ -137,8 +137,8 @@ "line_percent": 100.0 }, "server/src/main.rs": { - "loc": 565, - "line_percent": 95.17241379310344 + "loc": 572, + "line_percent": 95.33333333333334 }, "server/src/paste.rs": { "loc": 207, diff --git a/server/src/capture_power.rs b/server/src/capture_power.rs index a82e4d9..1c603ed 100644 --- a/server/src/capture_power.rs +++ b/server/src/capture_power.rs @@ -87,6 +87,18 @@ impl CapturePowerManager { self.snapshot().await } + pub async fn set_auto(&self) -> Result { + let unit = self.unit.to_string(); + let desired = { + let mut inner = self.inner.lock().await; + inner.manual_override = None; + desired_state(&inner) + }; + + sync_unit_state(unit.as_str(), desired).await?; + self.snapshot().await + } + pub async fn snapshot(&self) -> Result { let (active_leases, manual_override) = { let inner = self.inner.lock().await; @@ -109,23 +121,34 @@ impl CapturePowerManager { } async fn release_one(&self) { - let (desired, unit, leases) = { + let (desired, unit, leases, manual_override) = { let mut inner = self.inner.lock().await; inner.active_leases = inner.active_leases.saturating_sub(1); - if inner.active_leases == 0 { - inner.manual_override = None; - } ( desired_state(&inner), self.unit.to_string(), inner.active_leases, + inner.manual_override, ) }; if let Err(err) = sync_unit_state(unit.as_str(), desired).await { - warn!(unit = %unit, leases, desired, ?err, "capture power sync failed on release"); + warn!( + unit = %unit, + leases, + desired, + ?manual_override, + ?err, + "capture power sync failed on release" + ); } else { - info!(unit = %unit, leases, desired, "capture power synced"); + info!( + unit = %unit, + leases, + desired, + ?manual_override, + "capture power synced" + ); } } } @@ -254,6 +277,17 @@ impl CapturePowerManager { }) } + pub async fn set_auto(&self) -> anyhow::Result { + Ok(CapturePowerState { + available: true, + enabled: false, + unit: "relay.service".to_string(), + detail: "inactive/dead".to_string(), + active_leases: 0, + mode: "auto".to_string(), + }) + } + pub async fn snapshot(&self) -> anyhow::Result { Ok(CapturePowerState { available: true, @@ -292,4 +326,13 @@ mod tests { assert!(!off.enabled); assert_eq!(off.mode, "forced-off"); } + + #[tokio::test] + async fn coverage_stub_returns_to_auto_mode() { + let manager = CapturePowerManager::new(); + let state = manager.set_auto().await.expect("auto"); + assert!(state.available); + assert!(!state.enabled); + assert_eq!(state.mode, "auto"); + } } diff --git a/server/src/main.rs b/server/src/main.rs index 4dbe7ff..ca6efb9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,8 +13,8 @@ use tonic_reflection::server::Builder as ReflBuilder; use tracing::{debug, error, info, warn}; use lesavka_common::lesavka::{ - AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, - PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, + AudioPacket, CapturePowerCommand, CapturePowerState, Empty, KeyboardReport, MonitorRequest, + MouseReport, PasteReply, PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, relay_server::{Relay, RelayServer}, }; @@ -170,9 +170,16 @@ impl Handler { &self, req: Request, ) -> Result, Status> { - self.capture_power - .set_manual(req.into_inner().enabled) - .await + let req = req.into_inner(); + let result = match CapturePowerCommand::try_from(req.command) + .unwrap_or(CapturePowerCommand::Unspecified) + { + CapturePowerCommand::Auto => self.capture_power.set_auto().await, + CapturePowerCommand::ForceOn => self.capture_power.set_manual(true).await, + CapturePowerCommand::ForceOff => self.capture_power.set_manual(false).await, + CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await, + }; + result .map(Response::new) .map_err(|e| Status::internal(format!("{e:#}"))) } diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 966a02d..a5fbac0 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -114,25 +114,29 @@ mod server_main_rpc { fn capture_video_returns_guarded_stream_when_coverage_source_is_overridden() { let (_dir, handler) = build_handler_for_tests(); let rt = tokio::runtime::Runtime::new().expect("runtime"); - with_var("LESAVKA_TEST_VIDEO_SOURCE", Some("/dev/lesavka_l_eye"), || { - let mut stream = rt - .block_on(async { - handler - .capture_video(tonic::Request::new(MonitorRequest { - id: 0, - max_bitrate: 3_000, - })) - .await - }) - .expect("coverage video stream should succeed") - .into_inner(); - let packet = rt - .block_on(async { stream.next().await }) - .expect("stream item") - .expect("packet"); - assert_eq!(packet.id, 0); - assert!(!packet.data.is_empty()); - }); + with_var( + "LESAVKA_TEST_VIDEO_SOURCE", + Some("/dev/lesavka_l_eye"), + || { + let mut stream = rt + .block_on(async { + handler + .capture_video(tonic::Request::new(MonitorRequest { + id: 0, + max_bitrate: 3_000, + })) + .await + }) + .expect("coverage video stream should succeed") + .into_inner(); + let packet = rt + .block_on(async { stream.next().await }) + .expect("stream item") + .expect("packet"); + assert_eq!(packet.id, 0); + assert!(!packet.data.is_empty()); + }, + ); } #[test] @@ -209,7 +213,11 @@ mod server_main_rpc { let rt = tokio::runtime::Runtime::new().expect("runtime"); let snapshot = rt - .block_on(async { handler.get_capture_power(tonic::Request::new(Empty {})).await }) + .block_on(async { + handler + .get_capture_power(tonic::Request::new(Empty {})) + .await + }) .expect("capture power snapshot") .into_inner(); assert!(snapshot.available); @@ -221,6 +229,7 @@ mod server_main_rpc { handler .set_capture_power(tonic::Request::new(SetCapturePowerRequest { enabled: true, + command: CapturePowerCommand::ForceOn as i32, })) .await }) @@ -235,6 +244,7 @@ mod server_main_rpc { handler .set_capture_power(tonic::Request::new(SetCapturePowerRequest { enabled: false, + command: CapturePowerCommand::ForceOff as i32, })) .await }) @@ -243,6 +253,36 @@ mod server_main_rpc { assert!(forced_off.available); assert!(!forced_off.enabled); assert_eq!(forced_off.mode, "forced-off"); + + let auto = rt + .block_on(async { + handler + .set_capture_power(tonic::Request::new(SetCapturePowerRequest { + enabled: false, + command: CapturePowerCommand::Auto as i32, + })) + .await + }) + .expect("return capture power to auto") + .into_inner(); + assert!(auto.available); + assert!(!auto.enabled); + assert_eq!(auto.mode, "auto"); + + let legacy_fallback = rt + .block_on(async { + handler + .set_capture_power(tonic::Request::new(SetCapturePowerRequest { + enabled: true, + command: CapturePowerCommand::Unspecified as i32, + })) + .await + }) + .expect("legacy bool fallback") + .into_inner(); + assert!(legacy_fallback.available); + assert!(legacy_fallback.enabled); + assert_eq!(legacy_fallback.mode, "forced-on"); } #[test] @@ -260,8 +300,7 @@ mod server_main_rpc { "configured\n", ) .expect("write state"); - std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n") - .expect("write udc"); + std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n").expect("write udc"); let kb = tokio::fs::File::from_std( std::fs::OpenOptions::new() @@ -290,9 +329,9 @@ mod server_main_rpc { kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), gadget: UsbGadget::new("lesavka"), - did_cycle: std::sync::Arc::new( - std::sync::atomic::AtomicBool::new(false), - ), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + false, + )), camera_rt: std::sync::Arc::new(CameraRuntime::new()), capture_power: CapturePowerManager::new(), };