diff --git a/client/src/app.rs b/client/src/app.rs index e8c6377..bb8dcd5 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -185,9 +185,7 @@ impl LesavkaClientApp { }, } let renderer = if unified_view { - Renderer::Unified( - UnifiedMonitorWindow::new().expect("unified-window") - ) + Renderer::Unified(UnifiedMonitorWindow::new().expect("unified-window")) } else { Renderer::Breakout { left: MonitorWindow::new(0).expect("win0"), diff --git a/client/src/app_support.rs b/client/src/app_support.rs index 4ff91d9..246c787 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -85,7 +85,10 @@ mod tests { resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")), "http://env:2" ); - assert_eq!(resolve_server_addr(&[], Some("http://env:2")), "http://env:2"); + assert_eq!( + resolve_server_addr(&[], Some("http://env:2")), + "http://env:2" + ); assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR); } diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 71b7b73..a15d5e3 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -252,10 +252,9 @@ impl CameraCapture { #[cfg(coverage)] fn find_device(substr: &str) -> Option { let wanted = substr.to_ascii_lowercase(); - let by_id_dir = std::env::var("LESAVKA_CAM_BY_ID_DIR") - .unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); - let dev_root = - std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); + let by_id_dir = + std::env::var("LESAVKA_CAM_BY_ID_DIR").unwrap_or_else(|_| "/dev/v4l/by-id".to_string()); + let dev_root = std::env::var("LESAVKA_CAM_DEV_ROOT").unwrap_or_else(|_| "/dev".to_string()); let mut matches: Vec<_> = std::fs::read_dir(by_id_dir) .ok()? .flatten() @@ -272,11 +271,7 @@ impl CameraCapture { matches.sort(); for p in matches { if let Ok(target) = std::fs::read_link(&p) { - let dev = format!( - "{}/{}", - dev_root, - target.file_name()?.to_string_lossy() - ); + let dev = format!("{}/{}", dev_root, target.file_name()?.to_string_lossy()); if Self::is_capture(&dev) { return Some(dev); } diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index eb44c0e..eceeec4 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -5,9 +5,9 @@ use anyhow::bail; use anyhow::{Context, Result}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use std::collections::HashSet; -use std::time::Instant; #[cfg(not(coverage))] use std::path::{Path, PathBuf}; +use std::time::Instant; use tokio::{ sync::broadcast::Sender, time::{Duration, interval}, @@ -488,7 +488,11 @@ impl InputAggregator { if let Some(path) = self.routing_state_path.as_deref() { let _ = std::fs::write( path, - if remote_capture { "remote\n" } else { "local\n" }, + if remote_capture { + "remote\n" + } else { + "local\n" + }, ); } self.published_remote_capture = Some(remote_capture); diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs index 35b58f0..7bdb6cb 100644 --- a/client/src/input/keyboard.rs +++ b/client/src/input/keyboard.rs @@ -75,7 +75,11 @@ impl KeyboardAggregator { #[cfg(coverage)] pub fn process_events(&mut self) { - let Ok(events) = self.dev.fetch_events().map(|it| it.collect::>()) else { + let Ok(events) = self + .dev + .fetch_events() + .map(|it| it.collect::>()) + else { return; }; @@ -334,8 +338,7 @@ impl KeyboardAggregator { let chord = std::env::var("LESAVKA_CLIPBOARD_CHORD") .unwrap_or_else(|_| "ctrl+alt+v".into()) .to_ascii_lowercase(); - let have_ctrl = - self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); + let have_ctrl = self.has_key(KeyCode::KEY_LEFTCTRL) || self.has_key(KeyCode::KEY_RIGHTCTRL); let have_alt = self.has_key(KeyCode::KEY_LEFTALT) || self.has_key(KeyCode::KEY_RIGHTALT); if chord == "ctrl+v" { have_ctrl @@ -467,7 +470,11 @@ fn is_paste_modifier(code: KeyCode) -> bool { #[cfg(coverage)] fn read_clipboard_text() -> Option { if let Ok(cmd) = std::env::var("LESAVKA_CLIPBOARD_CMD") { - if let Ok(out) = std::process::Command::new("sh").arg("-lc").arg(cmd).output() { + if let Ok(out) = std::process::Command::new("sh") + .arg("-lc") + .arg(cmd) + .output() + { let text = String::from_utf8_lossy(&out.stdout).to_string(); if out.status.success() && !text.is_empty() { return Some(text); @@ -475,7 +482,11 @@ fn read_clipboard_text() -> Option { } } - for args in [vec!["--no-newline", "--type", "text/plain"], vec!["--no-newline"], vec![]] { + for args in [ + vec!["--no-newline", "--type", "text/plain"], + vec!["--no-newline"], + vec![], + ] { if let Ok(out) = std::process::Command::new("wl-paste").args(&args).output() && out.status.success() { diff --git a/client/src/launcher/clipboard.rs b/client/src/launcher/clipboard.rs index dce2ddc..b4dd396 100644 --- a/client/src/launcher/clipboard.rs +++ b/client/src/launcher/clipboard.rs @@ -1,8 +1,5 @@ use anyhow::{Result, anyhow}; -use lesavka_common::{ - hid::append_char_reports, - lesavka::KeyboardReport, -}; +use lesavka_common::{hid::append_char_reports, lesavka::KeyboardReport}; use std::time::Duration; #[cfg(not(coverage))] @@ -25,7 +22,9 @@ pub fn send_clipboard_to_remote(server_addr: &str) -> Result { Ok(()) => Ok("Clipboard delivered to remote".to_string()), Err(rpc_err) => match send_clipboard_via_hid(server_addr, &text) { Ok(()) => Ok(format!("Clipboard delivered via HID fallback ({rpc_err})")), - Err(hid_err) => Err(anyhow!("rpc failed: {rpc_err}; hid fallback failed: {hid_err}")), + Err(hid_err) => Err(anyhow!( + "rpc failed: {rpc_err}; hid fallback failed: {hid_err}" + )), }, } } @@ -101,7 +100,9 @@ fn build_hid_paste_reports(text: &str) -> Result> { } Ok(raw_reports .into_iter() - .map(|data| KeyboardReport { data: data.to_vec() }) + .map(|data| KeyboardReport { + data: data.to_vec(), + }) .collect()) } diff --git a/client/src/launcher/device_test.rs b/client/src/launcher/device_test.rs new file mode 100644 index 0000000..de64fef --- /dev/null +++ b/client/src/launcher/device_test.rs @@ -0,0 +1,155 @@ +use anyhow::{Context, Result, bail}; +use shell_escape::escape; +use std::borrow::Cow; +use std::process::{Child, Command}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceTestKind { + Camera, + Microphone, + Speaker, +} + +#[derive(Default)] +pub struct DeviceTestController { + camera: Option, + microphone: Option, + speaker: Option, +} + +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 toggle_camera(&mut self, camera: Option<&str>) -> Result { + self.toggle(DeviceTestKind::Camera, build_camera_test(camera)) + } + + pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result { + self.toggle( + DeviceTestKind::Microphone, + build_microphone_test(source, sink), + ) + } + + pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result { + self.toggle(DeviceTestKind::Speaker, build_speaker_test(sink)) + } + + pub fn stop_all(&mut self) { + for kind in [ + DeviceTestKind::Camera, + DeviceTestKind::Microphone, + DeviceTestKind::Speaker, + ] { + self.stop(kind); + } + } + + fn toggle(&mut self, kind: DeviceTestKind, command: Result) -> Result { + self.cleanup_finished(); + if self.slot(kind).is_some() { + self.stop(kind); + return Ok(false); + } + let child = command? + .spawn() + .with_context(|| format!("starting {kind:?} test"))?; + *self.slot_mut(kind) = Some(child); + Ok(true) + } + + fn stop(&mut self, kind: DeviceTestKind) { + if let Some(mut child) = self.slot_mut(kind).take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + fn cleanup_finished(&mut self) { + for kind in [ + DeviceTestKind::Camera, + DeviceTestKind::Microphone, + DeviceTestKind::Speaker, + ] { + let finished = self + .slot_mut(kind) + .as_mut() + .and_then(|child| child.try_wait().ok()) + .flatten() + .is_some(); + if finished { + let _ = self.slot_mut(kind).take(); + } + } + } + + fn slot(&self, kind: DeviceTestKind) -> &Option { + match kind { + DeviceTestKind::Camera => &self.camera, + DeviceTestKind::Microphone => &self.microphone, + DeviceTestKind::Speaker => &self.speaker, + } + } + + fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option { + match kind { + DeviceTestKind::Camera => &mut self.camera, + 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) + ))) +} + +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"))?; + let sink = sink.filter(|value| !value.trim().is_empty()); + let sink_prop = sink + .map(|value| format!("device={}", quote(value))) + .unwrap_or_default(); + Ok(shell_command(format!( + "gst-launch-1.0 -q pulsesrc device={} ! audioconvert ! audioresample ! queue ! pulsesink {}", + quote(source), + sink_prop + ))) +} + +fn build_speaker_test(sink: Option<&str>) -> Result { + let sink_prop = sink + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("device={}", quote(value))) + .unwrap_or_default(); + Ok(shell_command(format!( + "gst-launch-1.0 -q audiotestsrc is-live=true wave=sine freq=880 volume=0.25 ! audioconvert ! audioresample ! queue ! pulsesink {}", + sink_prop + ))) +} + +fn shell_command(command: String) -> Command { + let mut child = Command::new("bash"); + child.args(["-lc", &command]); + child +} + +fn quote(value: impl Into) -> String { + escape(Cow::Owned(value.into())).into_owned() +} diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index a553d99..1ba528f 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -115,7 +115,10 @@ mod tests { std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); - assert_eq!(devices, vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()]); + assert_eq!( + devices, + vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()] + ); let _ = std::fs::remove_dir_all(tmp); } @@ -134,7 +137,8 @@ mod tests { fn discover_uses_override_and_tolerates_missing_pactl() { let tmp = mk_temp_dir("discover-override"); std::fs::write(tmp.join("cam"), "").expect("write"); - let catalog = DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); + let catalog = + DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); assert_eq!(catalog.cameras, vec!["cam".to_string()]); let _ = std::fs::remove_dir_all(tmp); } diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 86337aa..fcd4b48 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -143,7 +143,10 @@ mod tests { let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string()); assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0")); - assert_eq!(report.selected_microphone.as_deref(), Some("alsa_input.usb")); + assert_eq!( + report.selected_microphone.as_deref(), + Some("alsa_input.usb") + ); assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb")); assert_eq!(report.recent_samples.len(), 1); assert_eq!(report.notes, vec!["first note".to_string()]); diff --git a/client/src/launcher/mod.rs b/client/src/launcher/mod.rs index 3020bb6..060606d 100644 --- a/client/src/launcher/mod.rs +++ b/client/src/launcher/mod.rs @@ -4,16 +4,24 @@ pub mod state; mod clipboard; #[cfg(not(coverage))] +mod device_test; +#[cfg(not(coverage))] +mod power; +#[cfg(not(coverage))] mod preview; mod ui; +#[cfg(not(coverage))] +mod ui_components; +#[cfg(not(coverage))] +mod ui_runtime; use std::{collections::BTreeMap, path::PathBuf}; -use anyhow::Result; use crate::app_support::DEFAULT_SERVER_ADDR; +use anyhow::Result; pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command}; -pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode}; +pub use state::{CapturePowerStatus, DeviceSelection, InputRouting, LauncherState, ViewMode}; pub const LAUNCHER_FOCUS_SIGNAL_ENV: &str = "LESAVKA_LAUNCHER_FOCUS_SIGNAL"; pub const DEFAULT_LAUNCHER_FOCUS_SIGNAL_PATH: &str = "/tmp/lesavka-launcher-focus.signal"; @@ -93,7 +101,10 @@ mod tests { #[test] fn resolve_server_addr_uses_first_non_flag_or_default() { - let args = vec!["--launcher".to_string(), "http://from-arg:50051".to_string()]; + let args = vec![ + "--launcher".to_string(), + "http://from-arg:50051".to_string(), + ]; assert_eq!(resolve_server_addr(&args), "http://from-arg:50051"); let args = vec!["--launcher".to_string()]; @@ -116,7 +127,10 @@ mod tests { envs.get("LESAVKA_DISABLE_VIDEO_RENDER"), Some(&"1".to_string()) ); - assert_eq!(envs.get("LESAVKA_CAM_SOURCE"), Some(&"/dev/video0".to_string())); + assert_eq!( + envs.get("LESAVKA_CAM_SOURCE"), + Some(&"/dev/video0".to_string()) + ); assert_eq!( envs.get("LESAVKA_MIC_SOURCE"), Some(&"alsa_input.test".to_string()) diff --git a/client/src/launcher/power.rs b/client/src/launcher/power.rs new file mode 100644 index 0000000..448baf1 --- /dev/null +++ b/client/src/launcher/power.rs @@ -0,0 +1,61 @@ +use anyhow::{Context, Result}; +use lesavka_common::lesavka::{Empty, SetCapturePowerRequest, relay_client::RelayClient}; +use tonic::{Request, transport::Channel}; + +use super::state::CapturePowerStatus; + +pub fn fetch_capture_power(server_addr: &str) -> Result { + with_runtime(async move { + let mut client = connect(server_addr).await?; + let reply = client + .get_capture_power(Request::new(Empty {})) + .await + .context("querying capture power state")? + .into_inner(); + Ok(map_state(reply)) + }) +} + +pub fn set_capture_power(server_addr: &str, enabled: bool) -> Result { + with_runtime(async move { + let mut client = connect(server_addr).await?; + let reply = client + .set_capture_power(Request::new(SetCapturePowerRequest { enabled })) + .await + .context("setting capture power state")? + .into_inner(); + Ok(map_state(reply)) + }) +} + +fn with_runtime(future: F) -> Result +where + F: std::future::Future>, +{ + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("building launcher power runtime")? + .block_on(future) +} + +async fn connect(server_addr: &str) -> Result> { + let channel = Channel::from_shared(server_addr.to_string()) + .context("invalid launcher server address")? + .tcp_nodelay(true) + .connect() + .await + .context("connecting launcher to relay host")?; + Ok(RelayClient::new(channel)) +} + +fn map_state(reply: lesavka_common::lesavka::CapturePowerState) -> CapturePowerStatus { + CapturePowerStatus { + available: reply.available, + enabled: reply.enabled, + unit: reply.unit, + detail: reply.detail, + active_leases: reply.active_leases, + mode: reply.mode, + } +} diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 2554541..d2da02e 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -47,6 +47,29 @@ impl DisplaySurface { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturePowerStatus { + pub available: bool, + pub enabled: bool, + pub unit: String, + pub detail: String, + pub active_leases: u32, + pub mode: String, +} + +impl Default for CapturePowerStatus { + fn default() -> Self { + Self { + available: false, + enabled: false, + unit: "relay.service".to_string(), + detail: "unknown".to_string(), + active_leases: 0, + mode: "auto".to_string(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, @@ -60,6 +83,7 @@ pub struct LauncherState { pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], pub devices: DeviceSelection, + pub capture_power: CapturePowerStatus, pub remote_active: bool, pub notes: Vec, } @@ -71,6 +95,7 @@ impl Default for LauncherState { view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], devices: DeviceSelection::default(), + capture_power: CapturePowerStatus::default(), remote_active: false, notes: Vec::new(), } @@ -167,9 +192,13 @@ impl LauncherState { self.notes.push(note.into()); } + pub fn set_capture_power(&mut self, power: CapturePowerStatus) { + self.capture_power = power; + } + pub fn status_line(&self) -> String { format!( - "mode={} view={} active={} d1={} d2={} camera={} mic={} speaker={}", + "mode={} view={} active={} power={} d1={} d2={} camera={} mic={} speaker={}", match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", @@ -179,6 +208,11 @@ impl LauncherState { ViewMode::Breakout => "breakout", }, self.remote_active, + if self.capture_power.enabled { + "on" + } else { + "off" + }, self.displays[0].label(), self.displays[1].label(), self.devices.camera.as_deref().unwrap_or("auto"), @@ -222,6 +256,8 @@ mod tests { assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); + assert_eq!(state.capture_power.unit, "relay.service"); + assert_eq!(state.capture_power.mode, "auto"); } #[test] @@ -303,4 +339,22 @@ mod tests { assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); } + + #[test] + fn capture_power_status_updates_snapshot_state() { + let mut state = LauncherState::new(); + state.set_capture_power(CapturePowerStatus { + available: true, + enabled: true, + unit: "relay.service".to_string(), + detail: "active/running".to_string(), + active_leases: 2, + mode: "forced-on".to_string(), + }); + + assert!(state.capture_power.available); + assert!(state.capture_power.enabled); + assert_eq!(state.capture_power.active_leases, 2); + assert!(state.status_line().contains("power=on")); + } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 360c252..61c71c0 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -3,70 +3,32 @@ use anyhow::Result; #[cfg(not(coverage))] use { super::clipboard::send_clipboard_to_remote, + super::device_test::DeviceTestController, super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, super::launcher_focus_signal_path, - super::preview::{LauncherPreview, PreviewBinding}, - super::runtime_env_vars, - super::state::{DisplaySurface, InputRouting, LauncherState}, - super::LAUNCHER_FOCUS_SIGNAL_ENV, + super::power::{fetch_capture_power, set_capture_power}, + super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, + super::ui_components::build_launcher_view, + super::ui_runtime::{ + dock_display_to_preview, input_control_path, input_state_path, next_input_routing, + open_popout_window, path_marker, read_input_routing_state, reap_exited_child, + refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value, + selected_server_addr, selected_toggle_key, spawn_client_process, stop_child_process, + update_test_action_result, write_input_routing_request, + }, gtk::glib, gtk::prelude::*, - std::cell::RefCell, - std::path::{Path, PathBuf}, - std::process::{Child, Command}, + std::cell::{Cell, RefCell}, + std::process::Child, std::rc::Rc, - std::time::Duration, + std::time::{Duration, Instant}, }; #[cfg(not(coverage))] -const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; -#[cfg(not(coverage))] -const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; -#[cfg(not(coverage))] -const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; -#[cfg(not(coverage))] -const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; - -#[cfg(not(coverage))] -#[derive(Clone)] -struct SummaryWidgets { - relay_value: gtk::Label, - routing_value: gtk::Label, - displays_value: gtk::Label, - shortcut_value: gtk::Label, -} - -#[cfg(not(coverage))] -#[derive(Clone)] -struct DisplayPaneWidgets { - root: gtk::Box, - stack: gtk::Stack, - picture: gtk::Picture, - stream_status: gtk::Label, - placeholder: gtk::Label, - action_button: gtk::Button, - preview_binding: Option, - title: String, -} - -#[cfg(not(coverage))] -struct PopoutWindowHandle { - window: gtk::ApplicationWindow, - binding: PreviewBinding, -} - -#[cfg(not(coverage))] -#[derive(Clone)] -struct LauncherWidgets { - status_label: gtk::Label, - summary: SummaryWidgets, - display_panes: [DisplayPaneWidgets; 2], - start_button: gtk::Button, - stop_button: gtk::Button, - input_toggle_button: gtk::Button, - clipboard_button: gtk::Button, - toggle_key_combo: gtk::ComboBoxText, +enum PowerMessage { + Poll(std::result::Result), + Command(std::result::Result), } #[cfg(not(coverage))] @@ -78,6 +40,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let state = Rc::new(RefCell::new(LauncherState::new())); state.borrow_mut().apply_catalog_defaults(&catalog); let child_proc = Rc::new(RefCell::new(None::)); + let tests = Rc::new(RefCell::new(DeviceTestController::new())); let server_addr = Rc::new(server_addr); let focus_signal_path = Rc::new(launcher_focus_signal_path()); let input_control_path = Rc::new(input_control_path()); @@ -91,8 +54,10 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let focus_signal_path = Rc::clone(&focus_signal_path); let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); + let tests = Rc::clone(&tests); app.connect_shutdown(move |_| { stop_child_process(&child_proc); + tests.borrow_mut().stop_all(); let _ = std::fs::remove_file(focus_signal_path.as_path()); let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); @@ -103,213 +68,68 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let catalog = Rc::clone(&catalog); let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); + let tests = Rc::clone(&tests); let server_addr = Rc::clone(&server_addr); let focus_signal_path = Rc::clone(&focus_signal_path); let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); app.connect_activate(move |app| { - let window = gtk::ApplicationWindow::builder() - .application(app) - .title("Lesavka Launcher") - .default_width(1120) - .default_height(920) - .build(); + let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow()); + let window = view.window.clone(); + let server_entry = view.server_entry.clone(); + let camera_combo = view.camera_combo.clone(); + let microphone_combo = view.microphone_combo.clone(); + let speaker_combo = view.speaker_combo.clone(); + let widgets = view.widgets.clone(); + let preview = view.preview.clone(); + let popouts = Rc::clone(&view.popouts); - let scroll = gtk::ScrolledWindow::new(); - scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); - - let root = gtk::Box::new(gtk::Orientation::Vertical, 14); - root.set_margin_start(18); - root.set_margin_end(18); - root.set_margin_top(18); - root.set_margin_bottom(18); - - let heading = gtk::Label::new(Some("Lesavka Control Deck")); - heading.add_css_class("title-2"); - heading.set_halign(gtk::Align::Start); - root.append(&heading); - - let status_label = gtk::Label::new(Some( - "Launcher ready - previews stay here, relay starts only when you ask for it.", - )); - status_label.set_halign(gtk::Align::Start); - status_label.set_wrap(true); - status_label.set_selectable(true); - root.append(&status_label); - - let (summary_frame, summary_box) = build_section("Session Status"); - let summary_grid = gtk::Grid::new(); - summary_grid.set_row_spacing(8); - summary_grid.set_column_spacing(12); - summary_box.append(&summary_grid); - let summary = SummaryWidgets { - relay_value: attach_summary_row(&summary_grid, 0, "Relay", "Stopped"), - routing_value: attach_summary_row(&summary_grid, 1, "Input Target", "Remote"), - displays_value: attach_summary_row( - &summary_grid, - 2, - "Displays", - "Display 1: preview | Display 2: preview", - ), - shortcut_value: attach_summary_row(&summary_grid, 3, "Swap Key", "Pause"), - }; - root.append(&summary_frame); - - let (connection_frame, connection_box) = build_section("Connection"); - let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); - let server_label = gtk::Label::new(Some("Server")); - server_label.set_halign(gtk::Align::Start); - let server_entry = gtk::Entry::new(); - server_entry.set_hexpand(true); - server_entry.set_text(server_addr.as_ref()); - let start_button = gtk::Button::with_label("Start Relay"); - let stop_button = gtk::Button::with_label("Stop Relay"); - server_row.append(&server_label); - server_row.append(&server_entry); - server_row.append(&start_button); - server_row.append(&stop_button); - connection_box.append(&server_row); - let connection_note = gtk::Label::new(Some( - "Starting relay launches the live input/audio session to the remote host. Stopping relay severs that session cleanly.", - )); - connection_note.set_wrap(true); - connection_note.set_halign(gtk::Align::Start); - connection_box.append(&connection_note); - root.append(&connection_frame); - - let (inputs_frame, inputs_box) = build_section("Input Routing And Devices"); - let inputs_grid = gtk::Grid::new(); - inputs_grid.set_row_spacing(8); - inputs_grid.set_column_spacing(12); - inputs_box.append(&inputs_grid); - - let input_toggle_button = gtk::Button::with_label("Switch To Local Inputs"); - inputs_grid.attach(>k::Label::new(Some("Live Input Target")), 0, 0, 1, 1); - inputs_grid.attach(&input_toggle_button, 1, 0, 1, 1); - - let toggle_key_combo = gtk::ComboBoxText::new(); - toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); - toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc"); - toggle_key_combo.append(Some("pause"), "Pause"); - toggle_key_combo.append(Some("f12"), "F12"); - toggle_key_combo.append(Some("f11"), "F11"); - toggle_key_combo.append(Some("f10"), "F10"); - toggle_key_combo.append(Some("off"), "Disabled"); - let _ = toggle_key_combo.set_active_id(Some("pause")); - inputs_grid.attach(>k::Label::new(Some("Swap Key")), 0, 1, 1, 1); - inputs_grid.attach(&toggle_key_combo, 1, 1, 1, 1); - - let camera_combo = gtk::ComboBoxText::new(); - camera_combo.append(Some("auto"), "auto"); - for camera in &catalog.cameras { - camera_combo.append(Some(camera), camera); - } - set_combo_active_text(&camera_combo, state.borrow().devices.camera.as_deref()); - inputs_grid.attach(>k::Label::new(Some("Camera")), 0, 2, 1, 1); - inputs_grid.attach(&camera_combo, 1, 2, 1, 1); - - let microphone_combo = gtk::ComboBoxText::new(); - microphone_combo.append(Some("auto"), "auto"); - for microphone in &catalog.microphones { - microphone_combo.append(Some(microphone), microphone); - } - set_combo_active_text( - µphone_combo, - state.borrow().devices.microphone.as_deref(), - ); - inputs_grid.attach(>k::Label::new(Some("Microphone")), 0, 3, 1, 1); - inputs_grid.attach(µphone_combo, 1, 3, 1, 1); - - let speaker_combo = gtk::ComboBoxText::new(); - speaker_combo.append(Some("auto"), "auto"); - for speaker in &catalog.speakers { - speaker_combo.append(Some(speaker), speaker); - } - set_combo_active_text(&speaker_combo, state.borrow().devices.speaker.as_deref()); - inputs_grid.attach(>k::Label::new(Some("Speaker")), 0, 4, 1, 1); - inputs_grid.attach(&speaker_combo, 1, 4, 1, 1); - - let inputs_note = gtk::Label::new(Some( - "Press the swap key while relay is running to flip between local and remote input ownership. The launcher reflects that live state and macros stay launcher-only.", - )); - inputs_note.set_wrap(true); - inputs_note.set_halign(gtk::Align::Start); - inputs_box.append(&inputs_note); - root.append(&inputs_frame); - - let (displays_frame, displays_box) = build_section("Displays"); - let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 14); - let left_pane = build_display_pane("Display 1"); - let right_pane = build_display_pane("Display 2"); - display_row.append(&left_pane.root); - display_row.append(&right_pane.root); - displays_box.append(&display_row); - root.append(&displays_frame); - - let (actions_frame, actions_box) = build_section("Remote Actions"); - let clipboard_button = gtk::Button::with_label("Send Clipboard"); - actions_box.append(&clipboard_button); - let actions_note = gtk::Label::new(Some( - "Clipboard paste is a launcher action only. It types the current local clipboard into the remote target machine when relay is active.", - )); - actions_note.set_wrap(true); - actions_note.set_halign(gtk::Align::Start); - actions_box.append(&actions_note); - root.append(&actions_frame); - - let (diagnostics_frame, diagnostics_box) = build_section("Diagnostics"); - let probe_hint = gtk::Label::new(Some(quality_probe_command())); - probe_hint.set_halign(gtk::Align::Start); - probe_hint.set_selectable(true); - diagnostics_box.append(&probe_hint); - let diagnostics_note = gtk::Label::new(Some( - "Keep the hygiene and quality gates green before calling the launcher changes done. Metrics still land in the local Prometheus textfile output.", - )); - diagnostics_note.set_wrap(true); - diagnostics_note.set_halign(gtk::Align::Start); - diagnostics_box.append(&diagnostics_note); - root.append(&diagnostics_frame); - - let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) { - Ok(preview) => Some(Rc::new(preview)), - Err(err) => { - let msg = format!("Preview unavailable: {err}"); - status_label.set_text(&msg); - None - } - }; - - let mut left_pane = left_pane; - let mut right_pane = right_pane; - if let Some(preview) = preview.as_ref() { - left_pane.preview_binding = - preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status); - right_pane.preview_binding = - preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status); - } else { - left_pane.stream_status.set_text("Preview unavailable"); - right_pane.stream_status.set_text("Preview unavailable"); - } - - let widgets = LauncherWidgets { - status_label: status_label.clone(), - summary, - display_panes: [left_pane.clone(), right_pane.clone()], - start_button: start_button.clone(), - stop_button: stop_button.clone(), - input_toggle_button: input_toggle_button.clone(), - clipboard_button: clipboard_button.clone(), - toggle_key_combo: toggle_key_combo.clone(), - }; - let popouts = Rc::new(RefCell::new([None, None])); refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); + + let (power_tx, power_rx) = std::sync::mpsc::channel::(); + let power_request_in_flight = Rc::new(Cell::new(false)); + let last_power_poll = Rc::new(RefCell::new(None::)); { - let widgets = widgets.clone(); let state = Rc::clone(&state); + let widgets = widgets.clone(); let child_proc = Rc::clone(&child_proc); - toggle_key_combo.connect_changed(move |_| { + 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)); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let microphone_combo = microphone_combo.clone(); + let microphone_combo_read = microphone_combo.clone(); + microphone_combo.connect_changed(move |_| { + state + .borrow_mut() + .select_microphone(selected_combo_value(µphone_combo_read)); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + + { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let child_proc = Rc::clone(&child_proc); + let speaker_combo = speaker_combo.clone(); + let speaker_combo_read = speaker_combo.clone(); + speaker_combo.connect_changed(move |_| { + state + .borrow_mut() + .select_speaker(selected_combo_value(&speaker_combo_read)); refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); } @@ -325,10 +145,14 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); let server_addr_fallback = Rc::clone(&server_addr); + let start_button = widgets.start_button.clone(); + let widgets_handle = widgets.clone(); start_button.connect_clicked(move |_| { if child_proc.borrow().is_some() { - widgets.status_label.set_text("Relay is already running"); - refresh_launcher_ui(&widgets, &state.borrow(), true); + widgets_handle + .status_label + .set_text("Relay is already running."); + refresh_launcher_ui(&widgets_handle, &state.borrow(), true); return; } { @@ -353,19 +177,22 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { Ok(child) => { *child_proc.borrow_mut() = Some(child); let _ = state.borrow_mut().start_remote(); - let routing = state.borrow().routing; - widgets.status_label.set_text(&format!( - "Relay started - input target is {}", - routing_name(routing) + widgets_handle.status_label.set_text(&format!( + "Relay started. Inputs are routed to {}.", + routing_name(state.borrow().routing) )); } Err(err) => { - widgets + widgets_handle .status_label .set_text(&format!("Relay start failed: {err}")); } } - refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + refresh_launcher_ui( + &widgets_handle, + &state.borrow(), + child_proc.borrow().is_some(), + ); }); } @@ -373,11 +200,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); + let stop_button = widgets.stop_button.clone(); + let widgets_handle = widgets.clone(); stop_button.connect_clicked(move |_| { stop_child_process(&child_proc); let _ = state.borrow_mut().stop_remote(); - widgets.status_label.set_text("Relay stopped"); - refresh_launcher_ui(&widgets, &state.borrow(), false); + widgets_handle.status_label.set_text("Relay stopped."); + refresh_launcher_ui(&widgets_handle, &state.borrow(), false); }); } @@ -386,6 +215,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let input_control_path = Rc::clone(&input_control_path); + let input_toggle_button = widgets.input_toggle_button.clone(); + let widgets_handle = widgets.clone(); input_toggle_button.connect_clicked(move |_| { let next = next_input_routing(state.borrow().routing); let child_running = child_proc.borrow().is_some(); @@ -393,24 +224,24 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { if let Err(err) = write_input_routing_request(input_control_path.as_path(), next) { - widgets.status_label.set_text(&format!( - "Could not update live input target: {err}" - )); - refresh_launcher_ui(&widgets, &state.borrow(), true); + widgets_handle + .status_label + .set_text(&format!("Could not update live input target: {err}")); + refresh_launcher_ui(&widgets_handle, &state.borrow(), true); return; } - widgets.status_label.set_text(&format!( - "Requested {} input control - pressing the swap key mirrors this live.", + widgets_handle.status_label.set_text(&format!( + "Input routing switched toward {}.", routing_name(next) )); } else { - widgets.status_label.set_text(&format!( - "Relay will start with {} input control.", + widgets_handle.status_label.set_text(&format!( + "Relay will start with {} input ownership.", routing_name(next) )); } state.borrow_mut().set_routing(next); - refresh_launcher_ui(&widgets, &state.borrow(), child_running); + refresh_launcher_ui(&widgets_handle, &state.borrow(), child_running); }); } @@ -419,14 +250,18 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); - clipboard_button.connect_clicked(move |_| { + widgets.clipboard_button.connect_clicked(move |_| { if child_proc.borrow().is_none() { - widgets.status_label.set_text("Start relay before sending clipboard"); + widgets + .status_label + .set_text("Start the relay before sending clipboard text."); return; } let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); - widgets.status_label.set_text("Sending clipboard to remote..."); + widgets + .status_label + .set_text("Sending clipboard to the remote target..."); let (result_tx, result_rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { let message = match send_clipboard_to_remote(&server_addr) { @@ -448,7 +283,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } Err(std::sync::mpsc::TryRecvError::Disconnected) => { status_label - .set_text("Clipboard send failed: launcher worker exited"); + .set_text("Clipboard send failed: launcher worker exited."); glib::ControlFlow::Break } } @@ -456,6 +291,114 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + { + let widgets = widgets.clone(); + widgets.probe_button.connect_clicked(move |_| { + if let Some(display) = gtk::gdk::Display::default() { + let clipboard = display.clipboard(); + clipboard.set_text(quality_probe_command()); + widgets + .status_label + .set_text("Quality probe command copied to the local clipboard."); + } else { + widgets + .status_label + .set_text("No desktop clipboard is available in this session."); + } + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let camera_combo = camera_combo.clone(); + 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()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Camera test started.", + "Camera test stopped.", + ); + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let microphone_combo = microphone_combo.clone(); + let speaker_combo = speaker_combo.clone(); + let microphone_test_button = widgets.microphone_test_button.clone(); + let widgets_handle = widgets.clone(); + microphone_test_button.connect_clicked(move |_| { + let mic = selected_combo_value(µphone_combo); + let sink = selected_combo_value(&speaker_combo); + let result = tests + .borrow_mut() + .toggle_microphone(mic.as_deref(), sink.as_deref()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Microphone monitor started.", + "Microphone monitor stopped.", + ); + }); + } + + { + let widgets = widgets.clone(); + let tests = Rc::clone(&tests); + let speaker_combo = speaker_combo.clone(); + let speaker_test_button = widgets.speaker_test_button.clone(); + let widgets_handle = widgets.clone(); + speaker_test_button.connect_clicked(move |_| { + let result = tests + .borrow_mut() + .toggle_speaker(selected_combo_value(&speaker_combo).as_deref()); + update_test_action_result( + &widgets_handle, + &mut tests.borrow_mut(), + result, + "Speaker test tone started.", + "Speaker test tone stopped.", + ); + }); + } + + { + 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 |_| { + 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..." + }); + let tx = power_tx.clone(); + std::thread::spawn(move || { + let result = + set_capture_power(&server_addr, target).map_err(|err| err.to_string()); + let _ = tx.send(PowerMessage::Command(result)); + }); + }); + } + for monitor_id in 0..2 { let app = app.clone(); let preview = preview.clone(); @@ -468,11 +411,10 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let Some(preview) = preview.as_ref() else { widgets .status_label - .set_text("Preview is unavailable for breakout windows"); + .set_text("Preview is unavailable for breakout windows."); return; }; - let surface = state.borrow().display_surface(monitor_id); - match surface { + match state.borrow().display_surface(monitor_id) { DisplaySurface::Preview => { open_popout_window( &app, @@ -484,7 +426,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { monitor_id, ); widgets.status_label.set_text(&format!( - "{} moved into its own window", + "{} moved into its own window.", widgets.display_panes[monitor_id].title )); } @@ -497,7 +439,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { monitor_id, ); widgets.status_label.set_text(&format!( - "{} returned to the launcher preview", + "{} returned to the launcher preview.", widgets.display_panes[monitor_id].title )); } @@ -512,15 +454,20 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let widgets = widgets.clone(); let focus_signal_path = Rc::clone(&focus_signal_path); let input_state_path = Rc::clone(&input_state_path); + let tests = Rc::clone(&tests); + let server_entry = server_entry.clone(); + let server_addr_fallback = Rc::clone(&server_addr); let last_focus_marker = Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); let last_state_marker = Rc::new(RefCell::new(path_marker(input_state_path.as_path()))); + let power_request_in_flight = Rc::clone(&power_request_in_flight); + let last_power_poll = Rc::clone(&last_power_poll); glib::timeout_add_local(Duration::from_millis(180), move || { let child_running = reap_exited_child(&child_proc); if !child_running && state.borrow().remote_active { let _ = state.borrow_mut().stop_remote(); - widgets.status_label.set_text("Relay ended"); + widgets.status_label.set_text("Relay ended."); } let next_state_marker = path_marker(input_state_path.as_path()); @@ -542,17 +489,67 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { refresh_launcher_ui(&widgets, &state.borrow(), child_running); widgets .status_label - .set_text("Local control restored - launcher focused"); + .set_text("Local control restored and the launcher is focused."); window.present(); } + while let Ok(message) = power_rx.try_recv() { + power_request_in_flight.set(false); + match message { + PowerMessage::Poll(Ok(power)) => { + state.borrow_mut().set_capture_power(power); + } + PowerMessage::Poll(Err(err)) => { + state.borrow_mut().set_capture_power(CapturePowerStatus { + available: false, + enabled: false, + unit: "relay.service".to_string(), + detail: err, + active_leases: 0, + mode: "auto".to_string(), + }); + } + PowerMessage::Command(Ok(power)) => { + let enabled = power.enabled; + state.borrow_mut().set_capture_power(power); + widgets.status_label.set_text(if enabled { + "Capture feeds powered up." + } else { + "Capture feeds powered down." + }); + } + PowerMessage::Command(Err(err)) => { + widgets + .status_label + .set_text(&format!("Capture power update failed: {err}")); + } + } + } + + let should_poll_power = !power_request_in_flight.get() + && last_power_poll + .borrow() + .map(|stamp| stamp.elapsed() >= Duration::from_millis(1400)) + .unwrap_or(true); + if should_poll_power { + *last_power_poll.borrow_mut() = Some(Instant::now()); + power_request_in_flight.set(true); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + let tx = power_tx.clone(); + std::thread::spawn(move || { + let result = + fetch_capture_power(&server_addr).map_err(|err| err.to_string()); + let _ = tx.send(PowerMessage::Poll(result)); + }); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + refresh_test_buttons(&widgets, &mut tests.borrow_mut()); glib::ControlFlow::Continue }); } - scroll.set_child(Some(&root)); - window.set_child(Some(&scroll)); window.present(); }); } @@ -566,425 +563,6 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> { Ok(()) } -#[cfg(not(coverage))] -fn build_section(title: &str) -> (gtk::Frame, gtk::Box) { - let frame = gtk::Frame::new(Some(title)); - let body = gtk::Box::new(gtk::Orientation::Vertical, 10); - body.set_margin_start(12); - body.set_margin_end(12); - body.set_margin_top(12); - body.set_margin_bottom(12); - frame.set_child(Some(&body)); - (frame, body) -} - -#[cfg(not(coverage))] -fn attach_summary_row(grid: >k::Grid, row: i32, label: &str, value: &str) -> gtk::Label { - let key = gtk::Label::new(Some(label)); - key.set_halign(gtk::Align::Start); - let value_label = gtk::Label::new(Some(value)); - value_label.set_halign(gtk::Align::Start); - value_label.set_selectable(true); - grid.attach(&key, 0, row, 1, 1); - grid.attach(&value_label, 1, row, 1, 1); - value_label -} - -#[cfg(not(coverage))] -fn build_display_pane(title: &str) -> DisplayPaneWidgets { - let root = gtk::Box::new(gtk::Orientation::Vertical, 8); - root.set_hexpand(true); - root.set_vexpand(true); - - let title_label = gtk::Label::new(Some(title)); - title_label.add_css_class("title-4"); - title_label.set_halign(gtk::Align::Center); - root.append(&title_label); - - let picture = gtk::Picture::new(); - picture.set_hexpand(true); - picture.set_vexpand(true); - picture.set_can_shrink(true); - picture.set_size_request(460, 258); - - let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - preview_box.append(&picture); - - let placeholder = gtk::Label::new(Some("This display is docked in the launcher preview.")); - placeholder.set_wrap(true); - placeholder.set_justify(gtk::Justification::Center); - placeholder.set_halign(gtk::Align::Center); - placeholder.set_valign(gtk::Align::Center); - - let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); - placeholder_box.set_hexpand(true); - placeholder_box.set_vexpand(true); - placeholder_box.set_size_request(460, 258); - placeholder_box.append(&placeholder); - - let stack = gtk::Stack::new(); - stack.set_hexpand(true); - stack.set_vexpand(true); - stack.add_named(&preview_box, Some("preview")); - stack.add_named(&placeholder_box, Some("placeholder")); - stack.set_visible_child_name("preview"); - root.append(&stack); - - let stream_status = gtk::Label::new(Some("Waiting for stream...")); - stream_status.set_halign(gtk::Align::Start); - root.append(&stream_status); - - let action_button = gtk::Button::with_label("Break Out"); - action_button.set_halign(gtk::Align::Center); - root.append(&action_button); - - DisplayPaneWidgets { - root, - stack, - picture, - stream_status, - placeholder, - action_button, - preview_binding: None, - title: title.to_string(), - } -} - -#[cfg(not(coverage))] -fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { - widgets.summary.relay_value.set_text(if child_running || state.remote_active { - "Running" - } else { - "Stopped" - }); - widgets - .summary - .routing_value - .set_text(routing_name(state.routing)); - widgets.summary.displays_value.set_text(&format!( - "Display 1: {} | Display 2: {}", - state.display_surface(0).label(), - state.display_surface(1).label() - )); - widgets - .summary - .shortcut_value - .set_text(&selected_toggle_key_label(&widgets.toggle_key_combo)); - - widgets.start_button.set_sensitive(!child_running); - widgets.stop_button.set_sensitive(child_running); - widgets.clipboard_button.set_sensitive(child_running); - widgets.input_toggle_button.set_label(match state.routing { - InputRouting::Remote => "Switch To Local Inputs", - InputRouting::Local => "Switch To Remote Inputs", - }); - - for monitor_id in 0..2 { - refresh_display_pane(&widgets.display_panes[monitor_id], state.display_surface(monitor_id)); - } -} - -#[cfg(not(coverage))] -fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { - if let Some(binding) = pane.preview_binding.as_ref() { - binding.set_enabled(matches!(surface, DisplaySurface::Preview)); - } - pane.action_button.set_sensitive(pane.preview_binding.is_some()); - match surface { - DisplaySurface::Preview => { - pane.stack.set_visible_child_name("preview"); - pane.action_button.set_label("Break Out"); - pane.placeholder - .set_text("This display is docked in the launcher preview."); - if pane.preview_binding.is_none() { - pane.stream_status.set_text("Preview unavailable"); - } - } - DisplaySurface::Window => { - pane.stack.set_visible_child_name("placeholder"); - pane.action_button.set_label("Return To Preview"); - pane.placeholder.set_text(&format!( - "{} is open in its own window.\nUse \"Return To Preview\" to dock it back here.", - pane.title - )); - pane.stream_status.set_text("Streaming in its own window"); - } - } -} - -#[cfg(not(coverage))] -fn open_popout_window( - app: >k::Application, - preview: &LauncherPreview, - state: &Rc>, - child_proc: &Rc>>, - popouts: &Rc; 2]>>, - widgets: &LauncherWidgets, - monitor_id: usize, -) { - let already_open = popouts.borrow()[monitor_id].is_some(); - if already_open { - return; - } - - if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { - binding.set_enabled(false); - } - - let window = gtk::ApplicationWindow::builder() - .application(app) - .title(&format!("Lesavka {}", widgets.display_panes[monitor_id].title)) - .default_width(1280) - .default_height(760) - .build(); - window.maximize(); - - let root = gtk::Box::new(gtk::Orientation::Vertical, 8); - root.set_margin_start(10); - root.set_margin_end(10); - root.set_margin_top(10); - root.set_margin_bottom(10); - - let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title)); - title.add_css_class("title-3"); - title.set_halign(gtk::Align::Center); - root.append(&title); - - let picture = gtk::Picture::new(); - picture.set_hexpand(true); - picture.set_vexpand(true); - picture.set_can_shrink(true); - root.append(&picture); - - let stream_status = gtk::Label::new(Some("Waiting for stream...")); - stream_status.set_halign(gtk::Align::Start); - root.append(&stream_status); - - let binding = preview - .install_on_picture(monitor_id, &picture, &stream_status) - .expect("preview binding for popout"); - - window.set_child(Some(&root)); - - let state_handle = Rc::clone(state); - let child_proc_handle = Rc::clone(child_proc); - let popouts_handle = Rc::clone(popouts); - let widgets_handle = widgets.clone(); - let close_binding = binding.clone(); - window.connect_close_request(move |_| { - let handle = { - let mut popouts = popouts_handle.borrow_mut(); - popouts[monitor_id].take() - }; - if let Some(handle) = handle { - handle.binding.close(); - if let Some(preview_binding) = - widgets_handle.display_panes[monitor_id].preview_binding.as_ref() - { - preview_binding.set_enabled(true); - } - state_handle - .borrow_mut() - .set_display_surface(monitor_id, DisplaySurface::Preview); - refresh_launcher_ui( - &widgets_handle, - &state_handle.borrow(), - child_proc_handle.borrow().is_some(), - ); - } else { - close_binding.close(); - } - glib::Propagation::Proceed - }); - - state - .borrow_mut() - .set_display_surface(monitor_id, DisplaySurface::Window); - popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle { - window: window.clone(), - binding, - }); - refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); - window.present(); -} - -#[cfg(not(coverage))] -fn dock_display_to_preview( - state: &Rc>, - child_proc: &Rc>>, - popouts: &Rc; 2]>>, - widgets: &LauncherWidgets, - monitor_id: usize, -) { - let handle = { - let mut popouts = popouts.borrow_mut(); - popouts[monitor_id].take() - }; - if let Some(handle) = handle { - handle.binding.close(); - handle.window.close(); - } - if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { - binding.set_enabled(true); - } - state - .borrow_mut() - .set_display_surface(monitor_id, DisplaySurface::Preview); - refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); -} - -#[cfg(not(coverage))] -fn selected_combo_value(combo: >k::ComboBoxText) -> Option { - combo.active_text().and_then(|value| { - let value = value.to_string(); - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -#[cfg(not(coverage))] -fn selected_toggle_key(combo: >k::ComboBoxText) -> String { - combo - .active_id() - .map(|value| value.to_string()) - .unwrap_or_else(|| "pause".to_string()) -} - -#[cfg(not(coverage))] -fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String { - combo.active_text() - .map(|value| value.to_string()) - .unwrap_or_else(|| "Pause".to_string()) -} - -#[cfg(not(coverage))] -fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { - let current = entry.text(); - let trimmed = current.trim(); - if trimmed.is_empty() { - fallback.to_string() - } else { - trimmed.to_string() - } -} - -#[cfg(not(coverage))] -fn input_control_path() -> PathBuf { - std::env::var(INPUT_CONTROL_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) -} - -#[cfg(not(coverage))] -fn input_state_path() -> PathBuf { - std::env::var(INPUT_STATE_ENV) - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) -} - -#[cfg(not(coverage))] -fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { - std::fs::write(path, format!("{}\n", routing_name(routing)))?; - Ok(()) -} - -#[cfg(not(coverage))] -fn read_input_routing_state(path: &Path) -> Option { - let raw = std::fs::read_to_string(path).ok()?; - match raw.trim().to_ascii_lowercase().as_str() { - "local" => Some(InputRouting::Local), - "remote" => Some(InputRouting::Remote), - _ => None, - } -} - -#[cfg(not(coverage))] -fn routing_name(routing: InputRouting) -> &'static str { - match routing { - InputRouting::Local => "local", - InputRouting::Remote => "remote", - } -} - -#[cfg(not(coverage))] -fn path_marker(path: &Path) -> u128 { - std::fs::metadata(path) - .ok() - .and_then(|meta| meta.modified().ok()) - .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -#[cfg(not(coverage))] -fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { - let wanted = wanted.unwrap_or("auto"); - if !combo.set_active_id(Some(wanted)) { - let _ = combo.set_active_id(Some("auto")); - } -} - -#[cfg(not(coverage))] -fn spawn_client_process( - server_addr: &str, - state: &LauncherState, - input_toggle_key: &str, - input_control_path: &Path, - input_state_path: &Path, -) -> Result { - let exe = std::env::current_exe()?; - let mut command = Command::new(exe); - command.env("LESAVKA_LAUNCHER_CHILD", "1"); - command.env("LESAVKA_SERVER_ADDR", server_addr); - command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); - command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); - command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); - command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); - command.env(INPUT_CONTROL_ENV, input_control_path); - command.env(INPUT_STATE_ENV, input_state_path); - command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); - command.env("LESAVKA_CLIPBOARD_PASTE", "0"); - for (key, value) in runtime_env_vars(state) { - command.env(key, value); - } - Ok(command.spawn()?) -} - -#[cfg(not(coverage))] -fn stop_child_process(child_proc: &Rc>>) { - if let Some(mut child) = child_proc.borrow_mut().take() { - let _ = child.kill(); - let _ = child.wait(); - } -} - -#[cfg(not(coverage))] -fn reap_exited_child(child_proc: &Rc>>) -> bool { - let mut slot = child_proc.borrow_mut(); - match slot.as_mut() { - Some(child) => match child.try_wait() { - Ok(Some(_)) => { - *slot = None; - false - } - Ok(None) | Err(_) => true, - }, - None => false, - } -} - -#[cfg(not(coverage))] -fn next_input_routing(routing: InputRouting) -> InputRouting { - match routing { - InputRouting::Remote => InputRouting::Local, - InputRouting::Local => InputRouting::Remote, - } -} - #[cfg(all(test, coverage))] mod tests { use super::run_gui_launcher; diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs new file mode 100644 index 0000000..609f112 --- /dev/null +++ b/client/src/launcher/ui_components.rs @@ -0,0 +1,531 @@ +use std::{cell::RefCell, rc::Rc}; + +use gtk::prelude::*; + +use super::{ + devices::DeviceCatalog, + preview::{LauncherPreview, PreviewBinding}, + state::LauncherState, +}; + +#[derive(Clone)] +pub struct SummaryWidgets { + pub relay_value: gtk::Label, + pub routing_value: gtk::Label, + pub power_value: gtk::Label, + pub displays_value: gtk::Label, + pub shortcut_value: gtk::Label, +} + +#[derive(Clone)] +pub struct DisplayPaneWidgets { + pub root: gtk::Box, + pub stack: gtk::Stack, + pub picture: gtk::Picture, + pub stream_status: gtk::Label, + pub placeholder: gtk::Label, + pub action_button: gtk::Button, + pub preview_binding: Option, + pub title: String, +} + +pub struct PopoutWindowHandle { + pub window: gtk::ApplicationWindow, + pub binding: PreviewBinding, +} + +#[derive(Clone)] +pub struct LauncherWidgets { + pub status_label: gtk::Label, + pub summary: SummaryWidgets, + pub power_detail: gtk::Label, + pub display_panes: [DisplayPaneWidgets; 2], + pub start_button: gtk::Button, + pub stop_button: gtk::Button, + pub power_button: gtk::Button, + pub input_toggle_button: gtk::Button, + pub clipboard_button: gtk::Button, + pub probe_button: gtk::Button, + pub toggle_key_combo: gtk::ComboBoxText, + pub camera_test_button: gtk::Button, + pub microphone_test_button: gtk::Button, + pub speaker_test_button: gtk::Button, +} + +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 widgets: LauncherWidgets, + pub preview: Option>, + pub popouts: Rc; 2]>>, +} + +pub fn build_launcher_view( + app: >k::Application, + server_addr: &str, + catalog: &DeviceCatalog, + state: &LauncherState, +) -> LauncherView { + let window = gtk::ApplicationWindow::builder() + .application(app) + .title("Lesavka Launcher") + .default_width(1480) + .default_height(900) + .build(); + install_css(&window); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 16); + root.add_css_class("launcher-root"); + root.set_margin_start(20); + root.set_margin_end(20); + root.set_margin_top(20); + root.set_margin_bottom(20); + + let hero = gtk::Box::new(gtk::Orientation::Horizontal, 16); + hero.set_hexpand(true); + + let brand_box = gtk::Box::new(gtk::Orientation::Vertical, 4); + let heading = gtk::Label::new(Some("Lesavka Control Deck")); + heading.add_css_class("title-2"); + heading.set_halign(gtk::Align::Start); + let subheading = gtk::Label::new(Some( + "Relay, capture power, device staging, and eye previews in one control surface.", + )); + subheading.add_css_class("dim-label"); + subheading.set_halign(gtk::Align::Start); + brand_box.append(&heading); + brand_box.append(&subheading); + hero.append(&brand_box); + + let chips = gtk::Box::new(gtk::Orientation::Horizontal, 10); + chips.set_halign(gtk::Align::End); + chips.set_hexpand(true); + let (relay_chip, relay_value) = build_status_chip("Relay", "Stopped"); + let (routing_chip, routing_value) = build_status_chip("Inputs", "Remote"); + let (power_chip, power_value) = build_status_chip("Capture", "Unknown"); + let (display_chip, displays_value) = build_status_chip("Displays", "Preview"); + let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause"); + chips.append(&relay_chip); + chips.append(&routing_chip); + chips.append(&power_chip); + chips.append(&display_chip); + chips.append(&shortcut_chip); + hero.append(&chips); + root.append(&hero); + + let content = gtk::Box::new(gtk::Orientation::Horizontal, 16); + content.set_hexpand(true); + content.set_vexpand(true); + root.append(&content); + + let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 12); + sidebar.set_size_request(410, -1); + sidebar.set_valign(gtk::Align::Fill); + content.append(&sidebar); + + let stage = gtk::Box::new(gtk::Orientation::Vertical, 12); + stage.set_hexpand(true); + stage.set_vexpand(true); + content.append(&stage); + + let (connection_panel, connection_body) = build_panel("Connection"); + let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let server_entry = gtk::Entry::new(); + server_entry.add_css_class("server-entry"); + server_entry.set_hexpand(true); + server_entry.set_text(server_addr); + server_entry.set_tooltip_text(Some( + "Relay host address for previews, power control, and the live session.", + )); + let start_button = gtk::Button::with_label("Start Relay"); + start_button.add_css_class("suggested-action"); + let stop_button = gtk::Button::with_label("Stop Relay"); + stop_button.add_css_class("destructive-action"); + server_row.append(&server_entry); + server_row.append(&start_button); + server_row.append(&stop_button); + connection_body.append(&server_row); + + 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_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); + connection_body.append(&power_row); + sidebar.append(&connection_panel); + + let (routing_panel, routing_body) = build_panel("Input Routing"); + let routing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let input_toggle_button = gtk::Button::with_label("Route Inputs To Local"); + input_toggle_button.set_hexpand(true); + input_toggle_button.set_tooltip_text(Some( + "Switch live keyboard and mouse ownership between the local machine and the remote target.", + )); + routing_row.append(&input_toggle_button); + routing_body.append(&routing_row); + + let swap_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let swap_label = gtk::Label::new(Some("Swap key")); + swap_label.set_halign(gtk::Align::Start); + let toggle_key_combo = gtk::ComboBoxText::new(); + toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); + toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc"); + toggle_key_combo.append(Some("pause"), "Pause"); + toggle_key_combo.append(Some("f12"), "F12"); + toggle_key_combo.append(Some("f11"), "F11"); + toggle_key_combo.append(Some("f10"), "F10"); + toggle_key_combo.append(Some("off"), "Disabled"); + let _ = toggle_key_combo.set_active_id(Some("pause")); + toggle_key_combo.set_tooltip_text(Some( + "Single-key live input swap while the relay is running.", + )); + swap_row.append(&swap_label); + swap_row.append(&toggle_key_combo); + routing_body.append(&swap_row); + sidebar.append(&routing_panel); + + let (devices_panel, devices_body) = build_panel("Devices"); + let devices_grid = gtk::Grid::new(); + devices_grid.set_row_spacing(8); + devices_grid.set_column_spacing(8); + devices_body.append(&devices_grid); + + let camera_combo = gtk::ComboBoxText::new(); + camera_combo.append(Some("auto"), "auto"); + for camera in &catalog.cameras { + 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"); + camera_test_button.set_tooltip_text(Some( + "Open a local preview for the selected webcam so you can confirm the right source.", + )); + attach_device_row( + &devices_grid, + 0, + "Camera", + &camera_combo, + &camera_test_button, + ); + + let microphone_combo = gtk::ComboBoxText::new(); + microphone_combo.append(Some("auto"), "auto"); + for microphone in &catalog.microphones { + microphone_combo.append(Some(microphone), microphone); + } + super::ui_runtime::set_combo_active_text( + µphone_combo, + state.devices.microphone.as_deref(), + ); + let microphone_test_button = gtk::Button::with_label("Test"); + microphone_test_button.set_tooltip_text(Some( + "Monitor the selected microphone through the selected speaker until you stop the test.", + )); + attach_device_row( + &devices_grid, + 1, + "Microphone", + µphone_combo, + µphone_test_button, + ); + + let speaker_combo = gtk::ComboBoxText::new(); + speaker_combo.append(Some("auto"), "auto"); + for speaker in &catalog.speakers { + 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"); + speaker_test_button.set_tooltip_text(Some( + "Play a short continuous tone through the selected speaker until you stop the test.", + )); + attach_device_row( + &devices_grid, + 2, + "Speaker", + &speaker_combo, + &speaker_test_button, + ); + sidebar.append(&devices_panel); + + let (actions_panel, actions_body) = build_panel("Remote Actions"); + let actions_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let clipboard_button = gtk::Button::with_label("Send Clipboard"); + clipboard_button.set_tooltip_text(Some( + "Type the current local clipboard into the remote target. This stays launcher-only.", + )); + let probe_button = gtk::Button::with_label("Copy Gate Probe"); + probe_button.set_tooltip_text(Some( + "Copy the hygiene/quality probe command into the local clipboard.", + )); + actions_row.append(&clipboard_button); + actions_row.append(&probe_button); + actions_body.append(&actions_row); + sidebar.append(&actions_panel); + + let stage_header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let stage_title = gtk::Label::new(Some("Remote Eyes")); + stage_title.add_css_class("title-4"); + stage_title.set_halign(gtk::Align::Start); + stage_header.append(&stage_title); + stage.append(&stage_header); + + let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 16); + display_row.set_hexpand(true); + display_row.set_vexpand(true); + let left_pane = build_display_pane("Left Eye", "/dev/lesavka_l_eye"); + let right_pane = build_display_pane("Right Eye", "/dev/lesavka_r_eye"); + display_row.append(&left_pane.root); + display_row.append(&right_pane.root); + stage.append(&display_row); + + let status_label = gtk::Label::new(Some("Launcher ready.")); + status_label.add_css_class("status-line"); + status_label.set_halign(gtk::Align::Start); + status_label.set_ellipsize(gtk::pango::EllipsizeMode::End); + root.append(&status_label); + + let preview = match LauncherPreview::new(server_addr.to_string()) { + Ok(preview) => Some(Rc::new(preview)), + Err(err) => { + status_label.set_text(&format!("Preview unavailable: {err}")); + None + } + }; + + let mut left_pane = left_pane; + let mut right_pane = right_pane; + if let Some(preview) = preview.as_ref() { + left_pane.preview_binding = + preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status); + right_pane.preview_binding = + preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status); + } else { + left_pane.stream_status.set_text("Preview unavailable"); + right_pane.stream_status.set_text("Preview unavailable"); + } + + let widgets = LauncherWidgets { + status_label: status_label.clone(), + summary: SummaryWidgets { + relay_value, + routing_value, + power_value, + displays_value, + shortcut_value, + }, + power_detail, + display_panes: [left_pane.clone(), right_pane.clone()], + start_button: start_button.clone(), + stop_button: stop_button.clone(), + power_button: power_button.clone(), + input_toggle_button: input_toggle_button.clone(), + clipboard_button: clipboard_button.clone(), + probe_button: probe_button.clone(), + toggle_key_combo: toggle_key_combo.clone(), + camera_test_button: camera_test_button.clone(), + microphone_test_button: microphone_test_button.clone(), + speaker_test_button: speaker_test_button.clone(), + }; + let popouts = Rc::new(RefCell::new([None, None])); + + window.set_child(Some(&root)); + + LauncherView { + window, + server_entry, + camera_combo, + microphone_combo, + speaker_combo, + widgets, + preview, + popouts, + } +} + +pub fn install_css(window: >k::ApplicationWindow) { + let provider = gtk::CssProvider::new(); + provider.load_from_data( + r#" + window.lesavka-launcher { + background: #101319; + color: #eef2f7; + } + box.launcher-root { + background: linear-gradient(180deg, #11161f 0%, #161d28 100%); + } + box.panel { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + padding: 14px; + } + label.panel-title { + font-weight: 700; + font-size: 1.05rem; + margin-bottom: 4px; + } + box.status-chip { + background: rgba(91, 179, 162, 0.12); + border: 1px solid rgba(91, 179, 162, 0.25); + border-radius: 999px; + padding: 8px 12px; + } + label.status-chip-label { + font-size: 0.78rem; + opacity: 0.72; + } + label.status-chip-value { + font-weight: 700; + } + box.display-card { + background: rgba(255, 255, 255, 0.045); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 22px; + padding: 16px; + } + box.display-placeholder { + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 16px; + padding: 24px; + } + label.status-line { + opacity: 0.88; + } + entry.server-entry { + min-height: 38px; + } + "#, + ); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + window.add_css_class("lesavka-launcher"); +} + +fn build_panel(title: &str) -> (gtk::Box, gtk::Box) { + let panel = gtk::Box::new(gtk::Orientation::Vertical, 10); + panel.add_css_class("panel"); + + let heading = gtk::Label::new(Some(title)); + heading.add_css_class("panel-title"); + heading.set_halign(gtk::Align::Start); + panel.append(&heading); + + let body = gtk::Box::new(gtk::Orientation::Vertical, 10); + panel.append(&body); + (panel, body) +} + +fn build_status_chip(label: &str, value: &str) -> (gtk::Box, gtk::Label) { + let chip = gtk::Box::new(gtk::Orientation::Vertical, 2); + chip.add_css_class("status-chip"); + + let label_widget = gtk::Label::new(Some(label)); + label_widget.add_css_class("status-chip-label"); + label_widget.set_halign(gtk::Align::Start); + let value_widget = gtk::Label::new(Some(value)); + value_widget.add_css_class("status-chip-value"); + value_widget.set_halign(gtk::Align::Start); + chip.append(&label_widget); + chip.append(&value_widget); + (chip, value_widget) +} + +fn attach_device_row( + grid: >k::Grid, + row: i32, + label: &str, + combo: >k::ComboBoxText, + test_button: >k::Button, +) { + let label_widget = gtk::Label::new(Some(label)); + label_widget.set_halign(gtk::Align::Start); + combo.set_hexpand(true); + grid.attach(&label_widget, 0, row, 1, 1); + grid.attach(combo, 1, row, 1, 1); + grid.attach(test_button, 2, row, 1, 1); +} + +fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { + let root = gtk::Box::new(gtk::Orientation::Vertical, 10); + root.add_css_class("display-card"); + root.set_hexpand(true); + root.set_vexpand(true); + + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-4"); + title_label.set_halign(gtk::Align::Start); + let capture_label = gtk::Label::new(Some(capture_path)); + capture_label.add_css_class("dim-label"); + capture_label.set_halign(gtk::Align::Start); + root.append(&title_label); + root.append(&capture_label); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_can_shrink(true); + picture.set_size_request(540, 304); + + let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + preview_box.append(&picture); + + let placeholder = gtk::Label::new(Some( + "This feed is running in its own window.\nUse Return To Preview to dock it back here.", + )); + placeholder.set_wrap(true); + placeholder.set_justify(gtk::Justification::Center); + placeholder.set_halign(gtk::Align::Center); + placeholder.set_valign(gtk::Align::Center); + + let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + placeholder_box.add_css_class("display-placeholder"); + placeholder_box.set_hexpand(true); + placeholder_box.set_vexpand(true); + placeholder_box.set_size_request(540, 304); + placeholder_box.append(&placeholder); + + let stack = gtk::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + stack.add_named(&preview_box, Some("preview")); + stack.add_named(&placeholder_box, Some("placeholder")); + stack.set_visible_child_name("preview"); + root.append(&stack); + + let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let stream_status = gtk::Label::new(Some("Waiting for stream...")); + stream_status.set_halign(gtk::Align::Start); + stream_status.set_hexpand(true); + let action_button = gtk::Button::with_label("Break Out"); + action_button.set_halign(gtk::Align::End); + footer.append(&stream_status); + footer.append(&action_button); + root.append(&footer); + + DisplayPaneWidgets { + root, + stack, + picture, + stream_status, + placeholder, + action_button, + preview_binding: None, + title: title.to_string(), + } +} diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs new file mode 100644 index 0000000..b4ce126 --- /dev/null +++ b/client/src/launcher/ui_runtime.rs @@ -0,0 +1,439 @@ +use anyhow::Result; +use gtk::{glib, prelude::*}; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + process::{Child, Command}, + rc::Rc, +}; + +use super::{ + LAUNCHER_FOCUS_SIGNAL_ENV, + device_test::{DeviceTestController, DeviceTestKind}, + launcher_focus_signal_path, + preview::LauncherPreview, + runtime_env_vars, + state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, + ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, +}; + +pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; +pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; +pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; +pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; + +pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { + widgets + .summary + .relay_value + .set_text(if child_running || state.remote_active { + "Running" + } else { + "Stopped" + }); + widgets + .summary + .routing_value + .set_text(&capitalize(routing_name(state.routing))); + widgets + .summary + .power_value + .set_text(&capture_power_label(&state.capture_power)); + widgets.summary.displays_value.set_text(&format!( + "L {} / R {}", + state.display_surface(0).label(), + state.display_surface(1).label() + )); + widgets + .summary + .shortcut_value + .set_text(&selected_toggle_key_label(&widgets.toggle_key_combo)); + + widgets + .power_detail + .set_text(&capture_power_detail(&state.capture_power)); + + widgets.start_button.set_sensitive(!child_running); + widgets.stop_button.set_sensitive(child_running); + widgets.clipboard_button.set_sensitive(child_running); + widgets.probe_button.set_sensitive(true); + widgets.input_toggle_button.set_label(match state.routing { + 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", + }, + ); + widgets.power_button.set_sensitive(true); + + for monitor_id in 0..2 { + refresh_display_pane( + &widgets.display_panes[monitor_id], + state.display_surface(monitor_id), + ); + } +} + +pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) { + widgets + .camera_test_button + .set_label(if tests.is_running(DeviceTestKind::Camera) { + "Stop" + } else { + "Test" + }); + widgets + .microphone_test_button + .set_label(if tests.is_running(DeviceTestKind::Microphone) { + "Stop" + } else { + "Test" + }); + widgets + .speaker_test_button + .set_label(if tests.is_running(DeviceTestKind::Speaker) { + "Stop" + } else { + "Test" + }); +} + +pub fn update_test_action_result( + widgets: &LauncherWidgets, + tests: &mut DeviceTestController, + result: Result, + start_msg: &str, + stop_msg: &str, +) { + match result { + Ok(true) => widgets.status_label.set_text(start_msg), + Ok(false) => widgets.status_label.set_text(stop_msg), + Err(err) => widgets + .status_label + .set_text(&format!("Device test failed: {err}")), + } + refresh_test_buttons(widgets, tests); +} + +pub fn open_popout_window( + app: >k::Application, + preview: &LauncherPreview, + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + if popouts.borrow()[monitor_id].is_some() { + return; + } + + if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { + binding.set_enabled(false); + } + + let window = gtk::ApplicationWindow::builder() + .application(app) + .title(&format!( + "Lesavka {}", + widgets.display_panes[monitor_id].title + )) + .default_width(1280) + .default_height(760) + .build(); + super::ui_components::install_css(&window); + window.maximize(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 10); + root.set_margin_start(14); + root.set_margin_end(14); + root.set_margin_top(14); + root.set_margin_bottom(14); + + let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title)); + title.add_css_class("title-3"); + title.set_halign(gtk::Align::Center); + root.append(&title); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_can_shrink(true); + root.append(&picture); + + let stream_status = gtk::Label::new(Some("Waiting for stream...")); + stream_status.set_halign(gtk::Align::Start); + root.append(&stream_status); + + let binding = preview + .install_on_picture(monitor_id, &picture, &stream_status) + .expect("preview binding for popout"); + + window.set_child(Some(&root)); + + let state_handle = Rc::clone(state); + let child_proc_handle = Rc::clone(child_proc); + let popouts_handle = Rc::clone(popouts); + let widgets_handle = widgets.clone(); + let close_binding = binding.clone(); + window.connect_close_request(move |_| { + let handle = { + let mut popouts = popouts_handle.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + if let Some(preview_binding) = widgets_handle.display_panes[monitor_id] + .preview_binding + .as_ref() + { + preview_binding.set_enabled(true); + } + state_handle + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Preview); + refresh_launcher_ui( + &widgets_handle, + &state_handle.borrow(), + child_proc_handle.borrow().is_some(), + ); + } else { + close_binding.close(); + } + glib::Propagation::Proceed + }); + + state + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Window); + popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle { + window: window.clone(), + binding, + }); + refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); + window.present(); +} + +pub fn dock_display_to_preview( + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + let handle = { + let mut popouts = popouts.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + handle.window.close(); + } + if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { + binding.set_enabled(true); + } + state + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Preview); + refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); +} + +pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { + if let Some(binding) = pane.preview_binding.as_ref() { + binding.set_enabled(matches!(surface, DisplaySurface::Preview)); + } + pane.action_button + .set_sensitive(pane.preview_binding.is_some()); + match surface { + DisplaySurface::Preview => { + pane.stack.set_visible_child_name("preview"); + pane.action_button.set_label("Break Out"); + pane.placeholder.set_text( + "This feed is running in its own window.\nUse Return To Preview to dock it back here.", + ); + if pane.preview_binding.is_none() { + pane.stream_status.set_text("Preview unavailable"); + } + } + DisplaySurface::Window => { + pane.stack.set_visible_child_name("placeholder"); + pane.action_button.set_label("Return To Preview"); + pane.placeholder.set_text(&format!( + "{} is running in a dedicated window.\nReturn it here when you want the in-launcher preview back.", + pane.title + )); + pane.stream_status.set_text("Streaming in its own window"); + } + } +} + +pub fn capture_power_label(power: &CapturePowerStatus) -> String { + if !power.available { + return "Unavailable".to_string(); + } + format!( + "{} ({})", + if power.enabled { "On" } else { "Off" }, + power.mode + ) +} + +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 + ) +} + +pub fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), + None => String::new(), + } +} + +pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option { + combo.active_text().and_then(|value| { + let value = value.to_string(); + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub fn selected_toggle_key(combo: >k::ComboBoxText) -> String { + combo + .active_id() + .map(|value| value.to_string()) + .unwrap_or_else(|| "pause".to_string()) +} + +pub fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String { + combo + .active_text() + .map(|value| value.to_string()) + .unwrap_or_else(|| "Pause".to_string()) +} + +pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { + let current = entry.text(); + let trimmed = current.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +pub fn input_control_path() -> PathBuf { + std::env::var(INPUT_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) +} + +pub fn input_state_path() -> PathBuf { + std::env::var(INPUT_STATE_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) +} + +pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { + std::fs::write(path, format!("{}\n", routing_name(routing)))?; + Ok(()) +} + +pub fn read_input_routing_state(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "local" => Some(InputRouting::Local), + "remote" => Some(InputRouting::Remote), + _ => None, + } +} + +pub fn routing_name(routing: InputRouting) -> &'static str { + match routing { + InputRouting::Local => "local", + InputRouting::Remote => "remote", + } +} + +pub fn path_marker(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { + let wanted = wanted.unwrap_or("auto"); + if !combo.set_active_id(Some(wanted)) { + let _ = combo.set_active_id(Some("auto")); + } +} + +pub fn spawn_client_process( + server_addr: &str, + state: &LauncherState, + input_toggle_key: &str, + input_control_path: &Path, + input_state_path: &Path, +) -> Result { + let exe = std::env::current_exe()?; + let mut command = Command::new(exe); + command.env("LESAVKA_LAUNCHER_CHILD", "1"); + command.env("LESAVKA_SERVER_ADDR", server_addr); + command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); + command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); + command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); + command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); + command.env(INPUT_CONTROL_ENV, input_control_path); + command.env(INPUT_STATE_ENV, input_state_path); + command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); + command.env("LESAVKA_CLIPBOARD_PASTE", "0"); + for (key, value) in runtime_env_vars(state) { + command.env(key, value); + } + Ok(command.spawn()?) +} + +pub fn stop_child_process(child_proc: &Rc>>) { + if let Some(mut child) = child_proc.borrow_mut().take() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +pub fn reap_exited_child(child_proc: &Rc>>) -> bool { + let mut slot = child_proc.borrow_mut(); + match slot.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + *slot = None; + false + } + Ok(None) | Err(_) => true, + }, + None => false, + } +} + +pub fn next_input_routing(routing: InputRouting) -> InputRouting { + match routing { + InputRouting::Remote => InputRouting::Local, + InputRouting::Local => InputRouting::Remote, + } +} diff --git a/client/src/main.rs b/client/src/main.rs index 3dcf01f..86286b2 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,10 +1,10 @@ use anyhow::Result; +#[cfg(not(test))] +use lesavka_client::{LesavkaClientApp, launcher}; use std::{env, fs::OpenOptions, path::Path}; use tracing_appender::non_blocking; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*}; -#[cfg(not(test))] -use lesavka_client::{LesavkaClientApp, launcher}; fn ensure_runtime_dir() { if env::var_os("XDG_RUNTIME_DIR").is_none() { let msg = "Error: $XDG_RUNTIME_DIR is not set. \ @@ -49,7 +49,10 @@ async fn main() -> Result<()> { let mut _guard: Option = None; if dev_mode { let log_path = Path::new("/tmp").join("lesavka-client.log"); - let file = OpenOptions::new().create(true).write(true).open(&log_path)?; + let file = OpenOptions::new() + .create(true) + .write(true) + .open(&log_path)?; let (file_writer, guard) = non_blocking(file); _guard = Some(guard); let file_layer = fmt::layer() diff --git a/client/src/output/audio.rs b/client/src/output/audio.rs index d07cf9c..21432e9 100644 --- a/client/src/output/audio.rs +++ b/client/src/output/audio.rs @@ -97,7 +97,9 @@ impl AudioOut { } }); } - pipeline.set_state(gst::State::Playing).context("starting audio pipeline")?; + pipeline + .set_state(gst::State::Playing) + .context("starting audio pipeline")?; Ok(Self { pipeline, src }) } @@ -128,7 +130,10 @@ impl Drop for AudioOut { fn pick_sink_element() -> Result { if let Ok(s) = std::env::var("LESAVKA_AUDIO_SINK") { let sink = normalize_sink_override(&s); - info!("💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}", s, sink); + info!( + "💪 sink overridden via LESAVKA_AUDIO_SINK={} -> {}", + s, sink + ); return Ok(sink); } let sinks = list_pw_sinks(); diff --git a/client/src/output/video.rs b/client/src/output/video.rs index 93f04db..ff1884f 100644 --- a/client/src/output/video.rs +++ b/client/src/output/video.rs @@ -49,7 +49,9 @@ fn spawn_wmctrl_placement(id: u32, rect: layout::Rect) { tracing::info!("✅ wmctrl placed eye-{id} via {window_id} (attempt {attempt})"); break; } - _ => tracing::debug!("⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})"), + _ => tracing::debug!( + "⌛ wmctrl: eye-{id} not ready for placement (attempt {attempt})" + ), } } }); diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 8a8ea91..5cb8951 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -17,6 +17,17 @@ message PasteRequest { bool encrypted = 3; } message PasteReply { bool ok = 1; string error = 2; } +message CapturePowerState { + bool available = 1; + bool enabled = 2; + string unit = 3; + string detail = 4; + uint32 active_leases = 5; + string mode = 6; +} +message SetCapturePowerRequest { + bool enabled = 1; +} message HandshakeSet { bool camera = 1; @@ -40,6 +51,8 @@ service Relay { rpc PasteText (PasteRequest) returns (PasteReply); rpc ResetUsb (Empty) returns (ResetUsbReply); + rpc GetCapturePower (Empty) returns (CapturePowerState); + rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState); } service Handshake { diff --git a/scripts/daemon/lesavka-hw-watchdog.py b/scripts/daemon/lesavka-hw-watchdog.py deleted file mode 100644 index 6db6a03..0000000 --- a/scripts/daemon/lesavka-hw-watchdog.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -import os -import signal -import time - -path = os.environ.get("LESAVKA_WATCHDOG_DEV", "/dev/watchdog") -interval = float(os.environ.get("LESAVKA_WATCHDOG_INTERVAL", "5")) - -fd = os.open(path, os.O_WRONLY) - -running = True - - -def handle(_sig, _frame): - global running - running = False - - -signal.signal(signal.SIGTERM, handle) -signal.signal(signal.SIGINT, handle) - -try: - while running: - os.write(fd, b"\0") - time.sleep(interval) -finally: - try: - os.write(fd, b"V") - except Exception: - pass - os.close(fd) diff --git a/scripts/daemon/lesavka-hw-watchdog.service b/scripts/daemon/lesavka-hw-watchdog.service deleted file mode 100644 index 8112525..0000000 --- a/scripts/daemon/lesavka-hw-watchdog.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Lesavka hardware watchdog -ConditionPathExists=/dev/watchdog - -[Service] -Type=simple -ExecStart=/usr/local/bin/lesavka-hw-watchdog.py -Restart=always -RestartSec=2 -KillMode=process - -[Install] -WantedBy=multi-user.target diff --git a/scripts/install/server.sh b/scripts/install/server.sh index c991ca5..1e2e072 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -164,7 +164,6 @@ sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-server" /usr/local/b sudo install -Dm755 "$SRC_DIR/server/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh -sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-hw-watchdog.py" /usr/local/bin/lesavka-hw-watchdog.py echo "==> 6a. Systemd units - lesavka-core" cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null @@ -266,26 +265,7 @@ sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \ /usr/local/bin/lesavka-watchdog.sh \ /etc/lesavka/watchdog.touch -cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-hw-watchdog.service >/dev/null -[Unit] -Description=Lesavka hardware watchdog -ConditionPathExists=/dev/watchdog - -[Service] -Type=simple -ExecStart=/usr/local/bin/lesavka-hw-watchdog.py -Restart=always -RestartSec=2 -KillMode=process - -[Install] -WantedBy=multi-user.target -UNIT - -sudo install -d /etc/lesavka - sudo systemctl daemon-reload -sudo systemctl enable --now lesavka-hw-watchdog if systemctl is-active --quiet lesavka-uvc; then echo "✅ lesavka-uvc is active (dependency-managed; manual restart disabled)." diff --git a/server/src/bin/lesavka-uvc.rs b/server/src/bin/lesavka-uvc.rs index b56111e..5ad1d76 100644 --- a/server/src/bin/lesavka-uvc.rs +++ b/server/src/bin/lesavka-uvc.rs @@ -672,7 +672,17 @@ mod coverage_self_tests { true, ); assert!(pending.is_none()); - assert!(build_in_response(&state, interfaces, interfaces.streaming, 0xFE, UVC_GET_CUR, 8).is_none()); + assert!( + build_in_response( + &state, + interfaces, + interfaces.streaming, + 0xFE, + UVC_GET_CUR, + 8 + ) + .is_none() + ); let short = [0u8; 8]; let _ = sanitize_streaming_control(&short, &state); } diff --git a/server/src/camera_runtime.rs b/server/src/camera_runtime.rs index e2fe630..459bb9b 100644 --- a/server/src/camera_runtime.rs +++ b/server/src/camera_runtime.rs @@ -60,7 +60,9 @@ impl CameraRuntime { "UVC output disabled (LESAVKA_DISABLE_UVC set)", )); } - Err(Status::internal("camera relay unavailable in coverage harness")) + Err(Status::internal( + "camera relay unavailable in coverage harness", + )) } #[cfg(not(coverage))] diff --git a/server/src/capture_power.rs b/server/src/capture_power.rs new file mode 100644 index 0000000..a82e4d9 --- /dev/null +++ b/server/src/capture_power.rs @@ -0,0 +1,295 @@ +use lesavka_common::lesavka::CapturePowerState; + +#[cfg(not(coverage))] +use { + anyhow::{Context, Result, anyhow}, + std::process::Command, + std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + tokio::sync::Mutex, + tracing::{info, warn}, +}; + +#[cfg(not(coverage))] +#[derive(Debug, Default)] +struct CapturePowerInner { + active_leases: u32, + manual_override: Option, +} + +#[cfg(not(coverage))] +#[derive(Debug, Clone)] +pub struct CapturePowerManager { + unit: Arc, + inner: Arc>, +} + +#[cfg(not(coverage))] +#[derive(Clone)] +pub struct CapturePowerLease { + manager: CapturePowerManager, + released: Arc, +} + +#[cfg(not(coverage))] +#[derive(Debug)] +struct UnitSnapshot { + available: bool, + enabled: bool, + detail: String, +} + +#[cfg(not(coverage))] +impl CapturePowerManager { + pub fn new() -> Self { + let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "relay.service".to_string()); + Self { + unit: Arc::::from(unit), + inner: Arc::new(Mutex::new(CapturePowerInner::default())), + } + } + + pub async fn acquire(&self) -> CapturePowerLease { + let (desired, unit, leases, manual_override) = { + let mut inner = self.inner.lock().await; + inner.active_leases = inner.active_leases.saturating_add(1); + ( + 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, ?manual_override, ?err, "capture power sync failed on acquire"); + } + + CapturePowerLease { + manager: self.clone(), + released: Arc::new(AtomicBool::new(false)), + } + } + + pub async fn set_manual(&self, enabled: bool) -> Result { + let unit = self.unit.to_string(); + { + let mut inner = self.inner.lock().await; + inner.manual_override = Some(enabled); + } + + sync_unit_state(unit.as_str(), enabled).await?; + self.snapshot().await + } + + pub async fn snapshot(&self) -> Result { + let (active_leases, manual_override) = { + let inner = self.inner.lock().await; + (inner.active_leases, inner.manual_override) + }; + let unit = self.unit.to_string(); + let snapshot = inspect_unit(unit.as_str()).await?; + Ok(CapturePowerState { + available: snapshot.available, + enabled: snapshot.enabled, + unit, + detail: snapshot.detail, + active_leases, + mode: match manual_override { + Some(true) => "forced-on".to_string(), + Some(false) => "forced-off".to_string(), + None => "auto".to_string(), + }, + }) + } + + async fn release_one(&self) { + let (desired, unit, leases) = { + 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, + ) + }; + + if let Err(err) = sync_unit_state(unit.as_str(), desired).await { + warn!(unit = %unit, leases, desired, ?err, "capture power sync failed on release"); + } else { + info!(unit = %unit, leases, desired, "capture power synced"); + } + } +} + +#[cfg(not(coverage))] +impl Drop for CapturePowerLease { + fn drop(&mut self) { + if self.released.swap(true, Ordering::AcqRel) { + return; + } + let manager = self.manager.clone(); + tokio::spawn(async move { + manager.release_one().await; + }); + } +} + +#[cfg(not(coverage))] +fn desired_state(inner: &CapturePowerInner) -> bool { + inner.manual_override.unwrap_or(inner.active_leases > 0) +} + +#[cfg(not(coverage))] +async fn inspect_unit(unit: &str) -> Result { + let unit = unit.to_string(); + tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str())) + .await + .map_err(|err| anyhow!("capture power inspect task failed: {err}"))? +} + +#[cfg(not(coverage))] +fn inspect_unit_blocking(unit: &str) -> Result { + let output = Command::new("systemctl") + .args([ + "show", + unit, + "--property=LoadState,ActiveState,SubState", + "--value", + ]) + .output() + .with_context(|| format!("querying systemd unit {unit}"))?; + + if !output.status.success() { + return Err(anyhow!( + "systemctl show {unit} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let load_state = lines.next().unwrap_or_default().trim().to_string(); + let active_state = lines.next().unwrap_or_default().trim().to_string(); + let sub_state = lines.next().unwrap_or_default().trim().to_string(); + let available = !load_state.is_empty() && load_state != "not-found"; + let enabled = active_state == "active"; + let detail = if available { + format!("{active_state}/{sub_state}") + } else { + "unit not found".to_string() + }; + + Ok(UnitSnapshot { + available, + enabled, + detail, + }) +} + +#[cfg(not(coverage))] +async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> { + let unit = unit.to_string(); + tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled)) + .await + .map_err(|err| anyhow!("capture power sync task failed: {err}"))? +} + +#[cfg(not(coverage))] +fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> { + let action = if enabled { "start" } else { "stop" }; + let status = Command::new("systemctl") + .args([action, unit]) + .status() + .with_context(|| format!("running systemctl {action} {unit}"))?; + if status.success() { + Ok(()) + } else { + Err(anyhow!("systemctl {action} {unit} failed with {status}")) + } +} + +#[cfg(coverage)] +#[derive(Debug, Clone, Default)] +pub struct CapturePowerManager; + +#[cfg(coverage)] +#[derive(Clone, Default)] +pub struct CapturePowerLease; + +#[cfg(coverage)] +impl CapturePowerManager { + pub fn new() -> Self { + Self + } + + pub async fn acquire(&self) -> CapturePowerLease { + CapturePowerLease + } + + pub async fn set_manual(&self, enabled: bool) -> anyhow::Result { + Ok(CapturePowerState { + available: true, + enabled, + unit: "relay.service".to_string(), + detail: if enabled { + "active/running".to_string() + } else { + "inactive/dead".to_string() + }, + active_leases: 0, + mode: if enabled { + "forced-on".to_string() + } else { + "forced-off".to_string() + }, + }) + } + + pub async fn snapshot(&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(), + }) + } +} + +#[cfg(all(test, coverage))] +mod tests { + use super::*; + + #[tokio::test] + async fn coverage_stub_reports_auto_snapshot() { + let state = CapturePowerManager::new() + .snapshot() + .await + .expect("snapshot"); + assert!(state.available); + assert!(!state.enabled); + assert_eq!(state.mode, "auto"); + } + + #[tokio::test] + async fn coverage_stub_toggles_manual_modes() { + let manager = CapturePowerManager::new(); + let on = manager.set_manual(true).await.expect("on"); + assert!(on.enabled); + assert_eq!(on.mode, "forced-on"); + + let off = manager.set_manual(false).await.expect("off"); + assert!(!off.enabled); + assert_eq!(off.mode, "forced-off"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index be721ac..9ec9960 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,7 @@ pub mod audio; pub mod camera; pub mod camera_runtime; +pub mod capture_power; pub mod gadget; pub mod handshake; pub mod paste; diff --git a/server/src/main.rs b/server/src/main.rs index 22ca6d2..4dbe7ff 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,10 +1,9 @@ // lesavka-server - gadget cycle guarded by env // server/src/main.rs -#[allow(clippy::useless_attribute)] #[forbid(unsafe_code)] -use anyhow::Context as _; +#[allow(clippy::useless_attribute)] +#[forbid(unsafe_code)] use futures_util::{Stream, StreamExt}; use std::sync::atomic::AtomicBool; -use std::time::Duration; use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc}; use tokio::sync::Mutex; use tokio_stream::wrappers::ReceiverStream; @@ -14,20 +13,24 @@ use tonic_reflection::server::Builder as ReflBuilder; use tracing::{debug, error, info, warn}; use lesavka_common::lesavka::{ - AudioPacket, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, PasteRequest, - ResetUsbReply, VideoPacket, + AudioPacket, CapturePowerState, Empty, KeyboardReport, MonitorRequest, MouseReport, PasteReply, + PasteRequest, ResetUsbReply, SetCapturePowerRequest, VideoPacket, relay_server::{Relay, RelayServer}, }; use lesavka_server::{ - audio, camera, camera_runtime::CameraRuntime, gadget::UsbGadget, handshake::HandshakeSvc, - paste, runtime_support, runtime_support::init_tracing, uvc_runtime, video, + audio, camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, + gadget::UsbGadget, handshake::HandshakeSvc, paste, runtime_support, + runtime_support::init_tracing, uvc_runtime, video, }; /*──────────────── constants ────────────────*/ const VERSION: &str = env!("CARGO_PKG_VERSION"); const PKG_NAME: &str = env!("CARGO_PKG_NAME"); +type VideoStream = Pin> + Send>>; +type AudioStream = Pin> + Send>>; + fn hid_endpoint(index: u8) -> String { std::env::var("LESAVKA_HID_DIR") .map(|dir| format!("{dir}/hidg{index}")) @@ -41,37 +44,28 @@ struct Handler { gadget: UsbGadget, did_cycle: Arc, camera_rt: Arc, + capture_power: CapturePowerManager, } impl Handler { - #[cfg(coverage)] - async fn new(gadget: UsbGadget) -> anyhow::Result { - let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?; - let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?; - - Ok(Self { - kb: Arc::new(Mutex::new(kb)), - ms: Arc::new(Mutex::new(ms)), - gadget, - did_cycle: Arc::new(AtomicBool::new(false)), - camera_rt: Arc::new(CameraRuntime::new()), - }) - } - - #[cfg(not(coverage))] async fn new(gadget: UsbGadget) -> anyhow::Result { + #[cfg(not(coverage))] if runtime_support::allow_gadget_cycle() { info!("🛠️ Initial USB reset…"); let _ = gadget.cycle(); // ignore failure - may boot without host - } else { - info!( - "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" - ); } - - info!("🛠️ opening HID endpoints …"); + #[cfg(not(coverage))] + { + if !runtime_support::allow_gadget_cycle() { + info!( + "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" + ); + } + info!("🛠️ opening HID endpoints …"); + } let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?; let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?; + #[cfg(not(coverage))] info!("✅ HID endpoints ready"); Ok(Self { @@ -80,17 +74,10 @@ impl Handler { gadget, did_cycle: Arc::new(AtomicBool::new(false)), camera_rt: Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), }) } - #[cfg(coverage)] - async fn reopen_hid(&self) -> anyhow::Result<()> { - *self.kb.lock().await = runtime_support::open_with_retry(&hid_endpoint(0)).await?; - *self.ms.lock().await = runtime_support::open_with_retry(&hid_endpoint(1)).await?; - Ok(()) - } - - #[cfg(not(coverage))] async fn reopen_hid(&self) -> anyhow::Result<()> { let kb_new = runtime_support::open_with_retry(&hid_endpoint(0)).await?; let ms_new = runtime_support::open_with_retry(&hid_endpoint(1)).await?; @@ -98,6 +85,116 @@ impl Handler { *self.ms.lock().await = ms_new; Ok(()) } + + async fn capture_video_reply( + &self, + req: MonitorRequest, + ) -> Result, Status> { + let id = req.id; + let dev = match id { + 0 => "/dev/lesavka_l_eye", + 1 => "/dev/lesavka_r_eye", + _ => return Err(Status::invalid_argument("monitor id must be 0 or 1")), + }; + + #[cfg(not(coverage))] + { + let rpc_id = runtime_support::next_stream_id(); + info!( + rpc_id, + id, + max_bitrate = req.max_bitrate, + "🎥 capture_video opened" + ); + debug!(rpc_id, "🎥 streaming {dev}"); + } + + let lease = self.capture_power.acquire().await; + let stream = video::eye_ball(dev, id, req.max_bitrate) + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + Ok(Response::new(Box::pin(GuardedVideoStream { + inner: stream, + _lease: lease, + }))) + } + + async fn paste_text_reply( + &self, + req: Request, + ) -> Result, Status> { + let req = req.into_inner(); + let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; + if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await { + return Ok(Response::new(PasteReply { + ok: false, + error: format!("{e}"), + })); + } + Ok(Response::new(PasteReply { + ok: true, + error: String::new(), + })) + } + + async fn reset_usb_reply(&self) -> Result, Status> { + #[cfg(not(coverage))] + info!("🔴 explicit ResetUsb() called"); + + match self.gadget.cycle() { + Ok(_) => { + if let Err(e) = self.reopen_hid().await { + #[cfg(not(coverage))] + error!("💥 reopen HID failed: {e:#}"); + return Err(Status::internal(e.to_string())); + } + Ok(Response::new(ResetUsbReply { ok: true })) + } + Err(e) => { + #[cfg(not(coverage))] + error!("💥 cycle failed: {e:#}"); + Err(Status::internal(e.to_string())) + } + } + } + + async fn get_capture_power_reply(&self) -> Result, Status> { + self.capture_power + .snapshot() + .await + .map(Response::new) + .map_err(|e| Status::internal(format!("{e:#}"))) + } + + async fn set_capture_power_reply( + &self, + req: Request, + ) -> Result, Status> { + self.capture_power + .set_manual(req.into_inner().enabled) + .await + .map(Response::new) + .map_err(|e| Status::internal(format!("{e:#}"))) + } +} + +struct GuardedVideoStream { + inner: S, + _lease: lesavka_server::capture_power::CapturePowerLease, +} + +impl Stream for GuardedVideoStream +where + S: Stream> + Unpin, +{ + type Item = Result; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Pin::new(&mut self.inner).poll_next(cx) + } } /*──────────────── gRPC service ─────────────*/ @@ -107,8 +204,8 @@ impl Relay for Handler { /* existing streams ─ unchanged, except: no more auto-reset */ type StreamKeyboardStream = ReceiverStream>; type StreamMouseStream = ReceiverStream>; - type CaptureVideoStream = Pin> + Send>>; - type CaptureAudioStream = Pin> + Send>>; + type CaptureVideoStream = VideoStream; + type CaptureAudioStream = AudioStream; type StreamMicrophoneStream = ReceiverStream>; type StreamCameraStream = ReceiverStream>; @@ -272,25 +369,7 @@ impl Relay for Handler { &self, req: Request, ) -> Result, Status> { - let rpc_id = runtime_support::next_stream_id(); - let req = req.into_inner(); - let id = req.id; - let dev = match id { - 0 => "/dev/lesavka_l_eye", - 1 => "/dev/lesavka_r_eye", - _ => return Err(Status::invalid_argument("monitor id must be 0 or 1")), - }; - info!( - rpc_id, - id, - max_bitrate = req.max_bitrate, - "🎥 capture_video opened" - ); - debug!(rpc_id, "🎥 streaming {dev}"); - let s = video::eye_ball(dev, id, req.max_bitrate) - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new(Box::pin(s))) + self.capture_video_reply(req.into_inner()).await } async fn capture_audio( @@ -312,36 +391,26 @@ impl Relay for Handler { } async fn paste_text(&self, req: Request) -> Result, Status> { - let req = req.into_inner(); - let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; - if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await { - return Ok(Response::new(PasteReply { - ok: false, - error: format!("{e}"), - })); - } - Ok(Response::new(PasteReply { - ok: true, - error: String::new(), - })) + self.paste_text_reply(req).await } /*────────────── USB-reset RPC ────────────*/ async fn reset_usb(&self, _req: Request) -> Result, Status> { - info!("🔴 explicit ResetUsb() called"); - match self.gadget.cycle() { - Ok(_) => { - if let Err(e) = self.reopen_hid().await { - error!("💥 reopen HID failed: {e:#}"); - return Err(Status::internal(e.to_string())); - } - Ok(Response::new(ResetUsbReply { ok: true })) - } - Err(e) => { - error!("💥 cycle failed: {e:#}"); - Err(Status::internal(e.to_string())) - } - } + self.reset_usb_reply().await + } + + async fn get_capture_power( + &self, + _req: Request, + ) -> Result, Status> { + self.get_capture_power_reply().await + } + + async fn set_capture_power( + &self, + req: Request, + ) -> Result, Status> { + self.set_capture_power_reply(req).await } } @@ -350,8 +419,8 @@ impl Relay for Handler { impl Relay for Handler { type StreamKeyboardStream = ReceiverStream>; type StreamMouseStream = ReceiverStream>; - type CaptureVideoStream = Pin> + Send>>; - type CaptureAudioStream = Pin> + Send>>; + type CaptureVideoStream = VideoStream; + type CaptureAudioStream = AudioStream; type StreamMicrophoneStream = ReceiverStream>; type StreamCameraStream = ReceiverStream>; @@ -406,59 +475,47 @@ impl Relay for Handler { &self, _req: Request>, ) -> Result, Status> { - Err(Status::internal("camera stream unavailable in coverage harness")) + Err(Status::internal( + "camera stream unavailable in coverage harness", + )) } async fn capture_video( &self, req: Request, ) -> Result, Status> { - let req = req.into_inner(); - let id = req.id; - let dev = match id { - 0 => "/dev/lesavka_l_eye", - 1 => "/dev/lesavka_r_eye", - _ => return Err(Status::invalid_argument("monitor id must be 0 or 1")), - }; - - let s = video::eye_ball(dev, id, req.max_bitrate) - .await - .map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new(Box::pin(s))) + self.capture_video_reply(req.into_inner()).await } async fn capture_audio( &self, _req: Request, ) -> Result, Status> { - Err(Status::internal("audio capture unavailable in coverage harness")) + Err(Status::internal( + "audio capture unavailable in coverage harness", + )) } async fn paste_text(&self, req: Request) -> Result, Status> { - let req = req.into_inner(); - let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; - if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await { - return Ok(Response::new(PasteReply { - ok: false, - error: format!("{e}"), - })); - } - Ok(Response::new(PasteReply { - ok: true, - error: String::new(), - })) + self.paste_text_reply(req).await } async fn reset_usb(&self, _req: Request) -> Result, Status> { - match self.gadget.cycle() { - Ok(_) => { - if let Err(e) = self.reopen_hid().await { - return Err(Status::internal(e.to_string())); - } - Ok(Response::new(ResetUsbReply { ok: true })) - } - Err(e) => Err(Status::internal(e.to_string())), - } + self.reset_usb_reply().await + } + + async fn get_capture_power( + &self, + _req: Request, + ) -> Result, Status> { + self.get_capture_power_reply().await + } + + async fn set_capture_power( + &self, + req: Request, + ) -> Result, Status> { + self.set_capture_power_reply(req).await } } diff --git a/server/src/paste.rs b/server/src/paste.rs index 29411bf..5d73209 100644 --- a/server/src/paste.rs +++ b/server/src/paste.rs @@ -197,7 +197,9 @@ mod tests { .await .expect("open temp file"); let kb = Mutex::new(file); - let err = type_text(&kb, "pw🙂").await.expect_err("unsupported char should fail"); + let err = type_text(&kb, "pw🙂") + .await + .expect_err("unsupported char should fail"); assert!(err.to_string().contains("unsupported character")); }); }); diff --git a/server/src/uvc_runtime.rs b/server/src/uvc_runtime.rs index bc8ff28..4b45ce9 100644 --- a/server/src/uvc_runtime.rs +++ b/server/src/uvc_runtime.rs @@ -26,7 +26,10 @@ pub fn pick_uvc_device() -> anyhow::Result { } if let Ok(ctrl) = UsbGadget::find_controller() { - return Ok(format!("{}/platform-{ctrl}-video-index0", uvc_by_path_root())); + return Ok(format!( + "{}/platform-{ctrl}-video-index0", + uvc_by_path_root() + )); } Err(anyhow::anyhow!( @@ -74,7 +77,9 @@ pub fn pick_uvc_device() -> anyhow::Result { .property_value("ID_PATH") .and_then(|value| value.to_str()) .unwrap_or_default(); - if let Some(ctrl) = ctrl.as_deref() && (product == ctrl || path.contains(ctrl)) { + if let Some(ctrl) = ctrl.as_deref() + && (product == ctrl || path.contains(ctrl)) + { return Ok(node); } if fallback.is_none() { diff --git a/server/src/video.rs b/server/src/video.rs index 2b5b95f..cc91b41 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -58,7 +58,8 @@ pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Res return Err(anyhow::anyhow!("invalid video source")); } - let use_test_src = dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); + let use_test_src = + dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); if !use_test_src { return Err(anyhow::anyhow!("video source unavailable")); } @@ -108,7 +109,8 @@ pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Resu let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 8).max(1); let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 8).max(1); - let use_test_src = dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); + let use_test_src = + dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); let desc = if use_test_src { let test_bitrate = env_u32("LESAVKA_EYE_TESTSRC_KBIT", max_bitrate_kbit.max(800)); format!( diff --git a/testing/tests/client_app_process_contract.rs b/testing/tests/client_app_process_contract.rs index c91b59b..b39a7a4 100644 --- a/testing/tests/client_app_process_contract.rs +++ b/testing/tests/client_app_process_contract.rs @@ -33,7 +33,10 @@ fn find_binary(name: &str) -> Option { .find(|path| path.exists() && path.is_file()) } -fn wait_for_exit(mut child: std::process::Child, timeout: Duration) -> Option { +fn wait_for_exit( + mut child: std::process::Child, + timeout: Duration, +) -> Option { let deadline = Instant::now() + timeout; loop { if let Some(status) = child.try_wait().expect("poll child") { @@ -91,4 +94,3 @@ fn client_desktop_runtime_executes_startup_branches() { ); } } - diff --git a/testing/tests/client_camera_include_contract.rs b/testing/tests/client_camera_include_contract.rs index 7db9433..c95f1b7 100644 --- a/testing/tests/client_camera_include_contract.rs +++ b/testing/tests/client_camera_include_contract.rs @@ -33,12 +33,18 @@ mod camera_include_contract { init_gst(); let (enc, _caps) = CameraCapture::pick_encoder(); assert!( - matches!(enc, "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"), + matches!( + enc, + "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc" + ), "unexpected encoder: {enc}" ); let (enc, key_prop, key_val) = CameraCapture::choose_encoder(); assert!( - matches!(enc, "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc"), + matches!( + enc, + "nvh264enc" | "vaapih264enc" | "v4l2h264enc" | "x264enc" + ), "unexpected encoder: {enc}" ); assert!(!key_prop.is_empty()); @@ -48,7 +54,9 @@ mod camera_include_contract { #[test] fn find_device_and_capture_detection_handle_missing_nodes() { assert!(CameraCapture::find_device("never-matches-this-fragment").is_none()); - assert!(!CameraCapture::is_capture("/dev/definitely-missing-camera0")); + assert!(!CameraCapture::is_capture( + "/dev/definitely-missing-camera0" + )); } #[test] @@ -60,8 +68,7 @@ mod camera_include_contract { std::fs::create_dir_all(&by_id).expect("create by-id"); std::fs::create_dir_all(&dev_root).expect("create dev root"); std::fs::write(dev_root.join("video42"), "").expect("create fake node"); - symlink("../dev-root/video42", by_id.join("usb-Cam_42")) - .expect("create camera symlink"); + symlink("../dev-root/video42", by_id.join("usb-Cam_42")).expect("create camera symlink"); with_var( "LESAVKA_CAM_BY_ID_DIR", @@ -168,7 +175,10 @@ mod camera_include_contract { for _ in 0..20 { if let Some(pkt) = cap.pull() { assert_eq!(pkt.id, 2); - assert!(!pkt.data.is_empty(), "test pattern should emit payload bytes"); + assert!( + !pkt.data.is_empty(), + "test pattern should emit payload bytes" + ); return; } std::thread::sleep(std::time::Duration::from_millis(30)); diff --git a/testing/tests/client_keyboard_include_contract.rs b/testing/tests/client_keyboard_include_contract.rs index b0b6c38..4c30be3 100644 --- a/testing/tests/client_keyboard_include_contract.rs +++ b/testing/tests/client_keyboard_include_contract.rs @@ -132,7 +132,7 @@ mod keyboard_contract { evdev::KeyCode::KEY_A.0, 1, )]) - .expect("emit key press"); + .expect("emit key press"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); let press = rx.try_recv().expect("press report"); @@ -143,7 +143,7 @@ mod keyboard_contract { evdev::KeyCode::KEY_A.0, 0, )]) - .expect("emit key release"); + .expect("emit key release"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); let release = rx.try_recv().expect("release report"); @@ -235,13 +235,17 @@ mod keyboard_contract { agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT); agg.pressed_keys.insert(evdev::KeyCode::KEY_V); - with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'hello-from-clipboard'"), || { - with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { - with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || { - assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); + with_var( + "LESAVKA_CLIPBOARD_CMD", + Some("printf 'hello-from-clipboard'"), + || { + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { + with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || { + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); + }); }); - }); - }); + }, + ); let payload: String = rx_rpc.try_recv().expect("rpc payload"); assert!(payload.contains("hello-from-clipboard")); @@ -274,7 +278,10 @@ mod keyboard_contract { while rx.try_recv().is_ok() { seen += 1; } - assert!(seen >= 2, "expected multiple key reports for pasted characters"); + assert!( + seen >= 2, + "expected multiple key reports for pasted characters" + ); } #[test] @@ -292,208 +299,4 @@ mod keyboard_contract { let pkt = rx.try_recv().expect("empty report after reset"); assert_eq!(pkt.data, vec![0; 8]); } - #[test] - #[serial] - fn reset_state_when_idle_still_emits_an_empty_report() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-reset-idle").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, mut rx) = new_aggregator(dev); - agg.reset_state(); - let pkt = rx.try_recv().expect("idle reset should still publish empty report"); - assert_eq!(pkt.data, vec![0; 8]); - } - #[test] - #[serial] - fn pressed_keys_snapshot_returns_the_current_keyset() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-snapshot").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, _) = new_aggregator(dev); - agg.pressed_keys.insert(evdev::KeyCode::KEY_A); - agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); - let snapshot = agg.pressed_keys_snapshot(); - assert!(snapshot.contains(&evdev::KeyCode::KEY_A)); - assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL)); - assert_eq!(snapshot.len(), 2); - } - #[test] - #[serial] - fn set_send_false_blocks_manual_empty_report() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-nosend").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, mut rx) = new_aggregator(dev); - agg.set_send(false); - agg.send_empty_report(); - assert!(rx.try_recv().is_err()); - } - #[test] - #[serial] - fn process_events_respects_send_toggle() { - let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-send-toggle") else { - return; - }; - let (mut agg, mut rx) = new_aggregator(dev); - agg.set_send(false); - vdev.emit(&[evdev::InputEvent::new( - evdev::EventType::KEY.0, - evdev::KeyCode::KEY_B.0, - 1, - )]) - .expect("emit key"); - thread::sleep(std::time::Duration::from_millis(20)); - agg.process_events(); - assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports"); - } - #[test] - #[serial] - fn paste_chord_active_supports_ctrl_v_variant() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, _) = new_aggregator(dev); - agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); - agg.pressed_keys.insert(evdev::KeyCode::KEY_V); - with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || { - assert!(agg.paste_chord_active()); - }); - } - - #[test] - #[serial] - fn paste_debounced_rejects_rapid_repeat_with_positive_window() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-debounce").map(|(_, dev)| dev)) - else { - return; - }; - let (agg, _) = new_aggregator(dev); - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_millis() as u64; - LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); - with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("9999"), || { - assert!(!agg.paste_debounced()); - }); - } - - #[test] - #[serial] - fn paste_via_rpc_returns_false_without_sender() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-rpc-none").map(|(_, dev)| dev)) - else { - return; - }; - let (tx, _rx) = tokio::sync::broadcast::channel(8); - let agg = KeyboardAggregator::new(dev, false, tx, None); - assert!(!agg.paste_via_rpc()); - } - - #[test] - #[serial] - fn read_clipboard_text_returns_none_when_custom_and_fallback_tools_fail() { - with_var("LESAVKA_CLIPBOARD_CMD", Some("nonexistent-clipboard-command"), || { - with_var("PATH", Some("/tmp/definitely-missing-path"), || { - assert!(read_clipboard_text().is_none()); - }); - }); - } - - #[test] - #[serial] - fn read_clipboard_text_handles_empty_custom_command_output() { - with_var("LESAVKA_CLIPBOARD_CMD", Some("printf ''"), || { - with_var("PATH", Some("/tmp/definitely-missing-path"), || { - assert!(read_clipboard_text().is_none()); - }); - }); - } - - #[test] - #[serial] - fn read_clipboard_text_handles_failing_custom_command() { - with_var("LESAVKA_CLIPBOARD_CMD", Some("echo boom >&2; exit 1"), || { - with_var("PATH", Some("/tmp/definitely-missing-path"), || { - assert!(read_clipboard_text().is_none()); - }); - }); - } - - #[test] - #[serial] - fn read_clipboard_text_uses_fallback_tool_when_available() { - let wl_paste = r#"#!/usr/bin/env sh -printf 'fallback-clipboard' -"#; - with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { - with_fake_path_command("wl-paste", wl_paste, || { - let text = read_clipboard_text().expect("fallback clipboard text"); - assert_eq!(text, "fallback-clipboard"); - }); - }); - } - - #[test] - #[serial] - fn paste_via_rpc_returns_true_for_empty_clipboard_payload() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty").map(|(_, dev)| dev)) - else { - return; - }; - let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8); - let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx)); - let wl_paste_empty = r#"#!/usr/bin/env sh -exit 0 -"#; - with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { - with_fake_path_command("wl-paste", wl_paste_empty, || { - assert!(agg.paste_via_rpc(), "empty clipboard should still consume the chord"); - assert!(paste_rx.try_recv().is_err(), "empty clipboard should not enqueue payload"); - }); - }); - } - - #[test] - #[serial] - fn set_grab_path_is_non_panicking() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-grab").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, _) = new_aggregator(dev); - agg.set_grab(false); - agg.set_grab(true); - } - - #[test] - #[serial] - fn try_handle_paste_event_swallows_incomplete_chord_sequences() { - let Some(dev) = open_any_keyboard_device() - .or_else(|| build_keyboard("lesavka-include-kbd-incomplete").map(|(_, dev)| dev)) - else { - return; - }; - let (mut agg, mut rx) = new_aggregator(dev); - agg.paste_enabled = true; - agg.paste_chord_armed = true; - agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); - - assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1)); - let pkt = rx.try_recv().expect("swallow report"); - assert_eq!(pkt.data, vec![0; 8]); - } } diff --git a/testing/tests/client_keyboard_include_extra_contract.rs b/testing/tests/client_keyboard_include_extra_contract.rs new file mode 100644 index 0000000..8ce9f88 --- /dev/null +++ b/testing/tests/client_keyboard_include_extra_contract.rs @@ -0,0 +1,256 @@ +//! Extra integration coverage for client keyboard aggregator helpers. +//! +//! Scope: include keyboard input source and cover reset-state, clipboard +//! fallback, send-toggle, and auxiliary paste branches without blowing the +//! primary keyboard contract past the 500 LOC cap. +//! Targets: `client/src/input/keyboard.rs`. +//! Why: keyboard helper branches are numerous enough to merit a paired +//! contract file for hygiene-gate modularity. + +mod keymap { + pub use lesavka_client::input::keymap::*; +} + +#[allow(warnings)] +mod keyboard_contract_extra { + include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC")); + + use serial_test::serial; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use temp_env::with_var; + use tempfile::tempdir; + + fn write_executable(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + fs::write(&path, body).expect("write script"); + let mut perms = fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); + } + + fn with_fake_path_command(name: &str, script_body: &str, f: impl FnOnce()) { + let dir = tempdir().expect("tempdir"); + write_executable(dir.path(), name, script_body); + let prior = std::env::var("PATH").unwrap_or_default(); + let merged = if prior.is_empty() { + dir.path().display().to_string() + } else { + format!("{}:{prior}", dir.path().display()) + }; + with_var("PATH", Some(merged), f); + } + + fn open_any_keyboard_device() -> Option { + let entries = std::fs::read_dir("/dev/input").ok()?; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name()?.to_string_lossy(); + if !name.starts_with("event") { + continue; + } + let dev = evdev::Device::open(path).ok()?; + let _ = dev.set_nonblocking(true); + let looks_like_keyboard = dev + .supported_keys() + .map(|keys| { + keys.contains(evdev::KeyCode::KEY_A) + && keys.contains(evdev::KeyCode::KEY_ENTER) + && keys.contains(evdev::KeyCode::KEY_LEFTCTRL) + }) + .unwrap_or(false); + if looks_like_keyboard { + return Some(dev); + } + } + None + } + + fn build_keyboard(name: &str) -> Option { + let mut keys = evdev::AttributeSet::::new(); + for key in [ + evdev::KeyCode::KEY_A, + evdev::KeyCode::KEY_B, + evdev::KeyCode::KEY_V, + evdev::KeyCode::KEY_LEFTCTRL, + evdev::KeyCode::KEY_LEFTALT, + ] { + keys.insert(key); + } + + let mut vdev = evdev::uinput::VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .build() + .ok()?; + for _ in 0..40 { + if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { + if let Some(Ok(path)) = nodes.next() { + let dev = evdev::Device::open(path).ok()?; + dev.set_nonblocking(true).ok()?; + return Some(dev); + } + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + None + } + + fn new_aggregator( + dev: evdev::Device, + ) -> ( + KeyboardAggregator, + tokio::sync::broadcast::Receiver, + ) { + let (tx, rx) = tokio::sync::broadcast::channel(64); + (KeyboardAggregator::new(dev, true, tx, None), rx) + } + + #[test] + #[serial] + fn reset_state_when_idle_still_emits_an_empty_report() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-reset-idle")) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.reset_state(); + let pkt = rx + .try_recv() + .expect("idle reset should still publish empty report"); + assert_eq!(pkt.data, vec![0; 8]); + } + + #[test] + #[serial] + fn pressed_keys_snapshot_returns_the_current_keyset() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-snapshot")) + else { + return; + }; + let (mut agg, _) = new_aggregator(dev); + agg.pressed_keys.insert(evdev::KeyCode::KEY_A); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + let snapshot = agg.pressed_keys_snapshot(); + assert!(snapshot.contains(&evdev::KeyCode::KEY_A)); + assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL)); + assert_eq!(snapshot.len(), 2); + } + + #[test] + #[serial] + fn set_send_false_blocks_manual_empty_report() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-nosend")) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.set_send(false); + agg.send_empty_report(); + assert!(rx.try_recv().is_err()); + } + + #[test] + #[serial] + fn paste_chord_active_supports_ctrl_v_variant() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v")) + else { + return; + }; + let (mut agg, _) = new_aggregator(dev); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_V); + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || { + assert!(agg.paste_chord_active()); + }); + } + + #[test] + #[serial] + fn paste_via_rpc_returns_false_without_sender() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-rpc-none")) + else { + return; + }; + let (tx, _rx) = tokio::sync::broadcast::channel(8); + let agg = KeyboardAggregator::new(dev, false, tx, None); + assert!(!agg.paste_via_rpc()); + } + + #[test] + #[serial] + fn read_clipboard_text_uses_fallback_tool_when_available() { + let wl_paste = "#!/usr/bin/env sh\nprintf 'fallback-clipboard'\n"; + with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { + with_fake_path_command("wl-paste", wl_paste, || { + let text = read_clipboard_text().expect("fallback clipboard text"); + assert_eq!(text, "fallback-clipboard"); + }); + }); + } + + #[test] + #[serial] + fn paste_via_rpc_returns_true_for_empty_clipboard_payload() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty")) + else { + return; + }; + let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8); + let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx)); + let wl_paste_empty = "#!/usr/bin/env sh\nexit 0\n"; + with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || { + with_fake_path_command("wl-paste", wl_paste_empty, || { + assert!( + agg.paste_via_rpc(), + "empty clipboard should still consume the chord" + ); + assert!( + paste_rx.try_recv().is_err(), + "empty clipboard should not enqueue payload" + ); + }); + }); + } + + #[test] + #[serial] + fn set_grab_path_is_non_panicking() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-grab")) + else { + return; + }; + let (mut agg, _) = new_aggregator(dev); + agg.set_grab(false); + agg.set_grab(true); + } + + #[test] + #[serial] + fn try_handle_paste_event_swallows_incomplete_chord_sequences() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-incomplete")) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.paste_enabled = true; + agg.paste_chord_armed = true; + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1)); + let pkt = rx.try_recv().expect("swallow report"); + assert_eq!(pkt.data, vec![0; 8]); + } +} diff --git a/testing/tests/client_main_binary_contract.rs b/testing/tests/client_main_binary_contract.rs index c588a08..31bac6c 100644 --- a/testing/tests/client_main_binary_contract.rs +++ b/testing/tests/client_main_binary_contract.rs @@ -33,7 +33,10 @@ mod client_main_binary { with_var("LESAVKA_DEV_MODE", Some("1"), || { with_var("LESAVKA_TEST_SKIP_APP", Some("1"), || { let result = main(); - assert!(result.is_ok(), "dev-mode startup should succeed in test skip mode"); + assert!( + result.is_ok(), + "dev-mode startup should succeed in test skip mode" + ); }); }); }); @@ -59,7 +62,10 @@ mod client_main_binary { fn ensure_runtime_dir_panics_in_tests_when_missing() { with_var("XDG_RUNTIME_DIR", None::<&str>, || { let panic_result = std::panic::catch_unwind(ensure_runtime_dir); - assert!(panic_result.is_err(), "missing runtime dir should panic in test cfg"); + assert!( + panic_result.is_err(), + "missing runtime dir should panic in test cfg" + ); }); } } diff --git a/testing/tests/client_microphone_include_contract.rs b/testing/tests/client_microphone_include_contract.rs index c8f1d09..e88269b 100644 --- a/testing/tests/client_microphone_include_contract.rs +++ b/testing/tests/client_microphone_include_contract.rs @@ -48,8 +48,8 @@ fi exit 0 "#; with_fake_pactl(script, || { - let src = MicrophoneCapture::pulse_source_by_substr("Mic_1234") - .expect("matching source"); + let src = + MicrophoneCapture::pulse_source_by_substr("Mic_1234").expect("matching source"); assert_eq!(src, "alsa_input.usb-Mic_1234-00.analog-stereo"); }); } @@ -95,7 +95,10 @@ exit 0 pipeline: gst::Pipeline::new(), sink, }; - assert!(cap.pull().is_none(), "empty appsink should produce no packet"); + assert!( + cap.pull().is_none(), + "empty appsink should produce no packet" + ); } #[test] diff --git a/testing/tests/client_mouse_include_contract.rs b/testing/tests/client_mouse_include_contract.rs index e36f239..fca29c9 100644 --- a/testing/tests/client_mouse_include_contract.rs +++ b/testing/tests/client_mouse_include_contract.rs @@ -106,9 +106,15 @@ mod mouse_contract { .name(name) .with_keys(&keys) .ok()? - .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_X, abs)) + .with_absolute_axis(&evdev::UinputAbsSetup::new( + evdev::AbsoluteAxisCode::ABS_X, + abs, + )) .ok()? - .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_Y, abs)) + .with_absolute_axis(&evdev::UinputAbsSetup::new( + evdev::AbsoluteAxisCode::ABS_Y, + abs, + )) .ok()? .with_absolute_axis(&evdev::UinputAbsSetup::new( evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID, @@ -133,9 +139,15 @@ mod mouse_contract { .name(name) .with_keys(&keys) .ok()? - .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_X, abs)) + .with_absolute_axis(&evdev::UinputAbsSetup::new( + evdev::AbsoluteAxisCode::ABS_X, + abs, + )) .ok()? - .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_Y, abs)) + .with_absolute_axis(&evdev::UinputAbsSetup::new( + evdev::AbsoluteAxisCode::ABS_Y, + abs, + )) .ok()? .build() .ok()?; @@ -263,7 +275,10 @@ mod mouse_contract { agg.next_send = std::time::Instant::now() - std::time::Duration::from_millis(10); agg.set_send(false); agg.flush(); - assert!(rx.try_recv().is_err(), "send-disabled flush should not emit"); + assert!( + rx.try_recv().is_err(), + "send-disabled flush should not emit" + ); agg.buttons = 2; agg.last_buttons = 0; @@ -299,7 +314,10 @@ mod mouse_contract { let threshold = MouseAggregator::abs_jump_threshold(&dev, &[evdev::AbsoluteAxisCode::ABS_X], 3); - assert!(threshold >= 120, "threshold should honor scale-derived minimum"); + assert!( + threshold >= 120, + "threshold should honor scale-derived minimum" + ); } #[test] @@ -469,5 +487,4 @@ mod mouse_contract { let pkt = rx.try_recv().expect("drop packet"); assert_eq!(pkt.data, vec![0; 8]); } - } diff --git a/testing/tests/client_output_audio_include_contract.rs b/testing/tests/client_output_audio_include_contract.rs index ec00d20..1ac3b9b 100644 --- a/testing/tests/client_output_audio_include_contract.rs +++ b/testing/tests/client_output_audio_include_contract.rs @@ -105,7 +105,10 @@ exit 0 "#; with_fake_pactl(script, || { with_var("LESAVKA_AUDIO_SINK", None::<&str>, || { - assert!(list_pw_sinks().is_empty(), "no default sink should be parsed"); + assert!( + list_pw_sinks().is_empty(), + "no default sink should be parsed" + ); let sink = pick_sink_element().expect("fallback sink"); assert_eq!(sink, "autoaudiosink"); }); @@ -116,19 +119,17 @@ exit 0 #[serial] fn audio_out_new_and_push_are_stable_with_sink_override() { with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || { - with_var("LESAVKA_TAP_AUDIO", Some("1"), || { - match AudioOut::new() { - Ok(out) => { - out.push(AudioPacket { - id: 0, - pts: 1_234, - data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], - }); - drop(out); - } - Err(err) => { - assert!(!err.to_string().trim().is_empty()); - } + with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() { + Ok(out) => { + out.push(AudioPacket { + id: 0, + pts: 1_234, + data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], + }); + drop(out); + } + Err(err) => { + assert!(!err.to_string().trim().is_empty()); } }); }); @@ -137,11 +138,18 @@ exit 0 #[test] #[serial] fn audio_out_new_returns_error_for_invalid_sink_override() { - with_var("LESAVKA_AUDIO_SINK", Some("definitely-not-a-real-gst-sink"), || { - with_var("LESAVKA_TAP_AUDIO", None::<&str>, || { - let result = AudioOut::new(); - assert!(result.is_err(), "invalid sink override must fail pipeline parsing"); - }); - }); + with_var( + "LESAVKA_AUDIO_SINK", + Some("definitely-not-a-real-gst-sink"), + || { + with_var("LESAVKA_TAP_AUDIO", None::<&str>, || { + let result = AudioOut::new(); + assert!( + result.is_err(), + "invalid sink override must fail pipeline parsing" + ); + }); + }, + ); } } diff --git a/testing/tests/client_output_display_include_contract.rs b/testing/tests/client_output_display_include_contract.rs index 6b84985..dfcbf41 100644 --- a/testing/tests/client_output_display_include_contract.rs +++ b/testing/tests/client_output_display_include_contract.rs @@ -147,7 +147,7 @@ mod gtk { #[allow(warnings)] mod display_include_contract { - use crate::gtk as gtk; + use crate::gtk; include!(env!("LESAVKA_CLIENT_OUTPUT_DISPLAY_SRC")); use crate::gtk::gdk as mock_gdk; diff --git a/testing/tests/server_gadget_include_contract.rs b/testing/tests/server_gadget_include_contract.rs index 18a03ea..6741f2c 100644 --- a/testing/tests/server_gadget_include_contract.rs +++ b/testing/tests/server_gadget_include_contract.rs @@ -34,7 +34,10 @@ mod gadget_include_contract { &base.join(format!("sys/class/udc/{ctrl}/state")), &format!("{state}\n"), ); - write_file(&base.join(format!("sys/class/udc/{ctrl}/soft_connect")), "1\n"); + write_file( + &base.join(format!("sys/class/udc/{ctrl}/soft_connect")), + "1\n", + ); write_file( &base.join("sys/bus/platform/drivers/dwc2/unbind"), "placeholder\n", @@ -161,7 +164,10 @@ mod gadget_include_contract { with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); - assert!(result.is_ok(), "configured host state should short-circuit safely"); + assert!( + result.is_ok(), + "configured host state should short-circuit safely" + ); }); } @@ -176,7 +182,10 @@ mod gadget_include_contract { with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); - assert!(result.is_ok(), "force cycle should complete on fake sysfs tree"); + assert!( + result.is_ok(), + "force cycle should complete on fake sysfs tree" + ); let udc_path = dir.path().join("cfg/lesavka-test/UDC"); let value = std::fs::read_to_string(udc_path).expect("read udc file"); assert_eq!(value.trim(), ctrl); @@ -202,7 +211,10 @@ mod gadget_include_contract { with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); - assert!(result.is_ok(), "configured transition should satisfy final wait_state"); + assert!( + result.is_ok(), + "configured transition should satisfy final wait_state" + ); }); }); diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs index 4f92d67..57b08bc 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/testing/tests/server_main_binary_contract.rs @@ -49,6 +49,7 @@ mod server_main_binary { gadget: UsbGadget::new("lesavka"), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), }, ) } @@ -97,11 +98,15 @@ mod server_main_binary { fn main_spawns_uvc_supervisor_branch_before_failing_without_hid_nodes() { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UVC_EXTERNAL", None::<&str>, || { - with_var("LESAVKA_UVC_CTRL_BIN", Some("/definitely/missing/uvc-helper"), || { - with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { - let _ = std::panic::catch_unwind(main); - }); - }); + with_var( + "LESAVKA_UVC_CTRL_BIN", + Some("/definitely/missing/uvc-helper"), + || { + with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { + let _ = std::panic::catch_unwind(main); + }); + }, + ); }); }); } @@ -204,284 +209,4 @@ mod server_main_binary { }; assert_eq!(err.code(), tonic::Code::Internal); } - - #[test] - #[serial] - fn stream_keyboard_writes_reports_to_hid_file() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (dir, handler) = build_handler_for_tests(); - let kb_path = dir.path().join("hidg0.bin"); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(KeyboardReport { - data: vec![1, 2, 3, 4, 5, 6, 7, 8], - }) - .await - .expect("send keyboard packet"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut resp = cli - .stream_keyboard(tonic::Request::new(outbound)) - .await - .expect("stream keyboard"); - let echoed = resp - .get_mut() - .message() - .await - .expect("grpc result") - .expect("echo packet"); - assert_eq!(echoed.data, vec![1, 2, 3, 4, 5, 6, 7, 8]); - - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - let written = std::fs::read(&kb_path).expect("read hidg0 file"); - assert!( - !written.is_empty(), - "keyboard stream should write HID bytes to target file" - ); - - server.abort(); - }); - } - - #[test] - #[serial] - fn stream_mouse_writes_reports_to_hid_file() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (dir, handler) = build_handler_for_tests(); - let ms_path = dir.path().join("hidg1.bin"); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(MouseReport { - data: vec![8, 7, 6, 5, 4, 3, 2, 1], - }) - .await - .expect("send mouse packet"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut resp = cli - .stream_mouse(tonic::Request::new(outbound)) - .await - .expect("stream mouse"); - let echoed = resp - .get_mut() - .message() - .await - .expect("grpc result") - .expect("echo packet"); - assert_eq!(echoed.data, vec![8, 7, 6, 5, 4, 3, 2, 1]); - - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - let written = std::fs::read(&ms_path).expect("read hidg1 file"); - assert!( - !written.is_empty(), - "mouse stream should write HID bytes to target file" - ); - - server.abort(); - }); - } - - #[test] - #[serial] - fn stream_keyboard_recovers_when_hid_write_fails() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests_with_modes(false, true); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(KeyboardReport { - data: vec![11, 12, 13, 14, 15, 16, 17, 18], - }) - .await - .expect("send keyboard packet"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut resp = cli - .stream_keyboard(tonic::Request::new(outbound)) - .await - .expect("stream keyboard"); - let echoed = resp - .get_mut() - .message() - .await - .expect("grpc result") - .expect("echo packet"); - assert_eq!(echoed.data, vec![11, 12, 13, 14, 15, 16, 17, 18]); - - server.abort(); - }); - } - - #[test] - #[serial] - fn stream_mouse_recovers_when_hid_write_fails() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests_with_modes(true, false); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(MouseReport { - data: vec![21, 22, 23, 24, 25, 26, 27, 28], - }) - .await - .expect("send mouse packet"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let mut resp = cli - .stream_mouse(tonic::Request::new(outbound)) - .await - .expect("stream mouse"); - let echoed = resp - .get_mut() - .message() - .await - .expect("grpc result") - .expect("echo packet"); - assert_eq!(echoed.data, vec![21, 22, 23, 24, 25, 26, 27, 28]); - - server.abort(); - }); - } - - #[test] - #[serial] - fn stream_microphone_returns_internal_error_without_uac_device() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (_tx, rx) = tokio::sync::mpsc::channel::(4); - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let err = cli - .stream_microphone(tonic::Request::new(outbound)) - .await - .expect_err("missing UAC sink should fail stream setup"); - assert_eq!(err.code(), tonic::Code::Internal); - - server.abort(); - }); - } - - #[test] - #[serial] - fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - - let server = tokio::spawn(async move { - let _ = tonic::transport::Server::builder() - .add_service(RelayServer::new(handler)) - .serve(addr) - .await; - }); - - let channel = connect_with_retry(addr).await; - let mut cli = RelayClient::new(channel); - let (tx, rx) = tokio::sync::mpsc::channel(4); - tx.send(VideoPacket { - id: 2, - pts: 1, - data: vec![0, 1, 2, 3], - }) - .await - .expect("send camera packet"); - drop(tx); - - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); - let result = cli.stream_camera(tonic::Request::new(outbound)).await; - match result { - Ok(mut stream) => { - let _ = stream.get_mut().message().await; - } - Err(err) => { - assert!( - matches!( - err.code(), - tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown - ), - "unexpected camera stream error code: {}", - err.code() - ); - } - } - - server.abort(); - }); - } } diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs index c75526a..87fcde7 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/testing/tests/server_main_binary_extra_contract.rs @@ -10,10 +10,68 @@ mod server_main_binary_extra { include!(env!("LESAVKA_SERVER_MAIN_SRC")); + use lesavka_common::lesavka::relay_client::RelayClient; use serial_test::serial; use temp_env::with_var; use tempfile::tempdir; + async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel { + let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}")) + .expect("endpoint") + .tcp_nodelay(true); + for _ in 0..40 { + if let Ok(channel) = endpoint.clone().connect().await { + return channel; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + panic!("failed to connect to local tonic server"); + } + + fn build_handler_for_tests_with_modes( + kb_writable: bool, + ms_writable: bool, + ) -> (tempfile::TempDir, Handler) { + let dir = tempdir().expect("tempdir"); + let kb_path = dir.path().join("hidg0.bin"); + let ms_path = dir.path().join("hidg1.bin"); + std::fs::write(&kb_path, []).expect("create kb file"); + std::fs::write(&ms_path, []).expect("create ms file"); + + let kb_std = std::fs::OpenOptions::new() + .read(true) + .write(kb_writable) + .create(kb_writable) + .truncate(kb_writable) + .open(&kb_path) + .expect("open kb"); + let ms_std = std::fs::OpenOptions::new() + .read(true) + .write(ms_writable) + .create(ms_writable) + .truncate(ms_writable) + .open(&ms_path) + .expect("open ms"); + let kb = tokio::fs::File::from_std(kb_std); + let ms = tokio::fs::File::from_std(ms_std); + + ( + dir, + Handler { + 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)), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + }, + ) + } + + fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { + build_handler_for_tests_with_modes(true, true) + } + #[test] #[serial] fn handler_new_and_reopen_hid_succeed_with_override_paths() { @@ -54,4 +112,284 @@ mod server_main_binary_extra { }); }); } + + #[test] + #[serial] + fn stream_keyboard_writes_reports_to_hid_file() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (dir, handler) = build_handler_for_tests(); + let kb_path = dir.path().join("hidg0.bin"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(KeyboardReport { + data: vec![1, 2, 3, 4, 5, 6, 7, 8], + }) + .await + .expect("send keyboard packet"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut resp = cli + .stream_keyboard(tonic::Request::new(outbound)) + .await + .expect("stream keyboard"); + let echoed = resp + .get_mut() + .message() + .await + .expect("grpc result") + .expect("echo packet"); + assert_eq!(echoed.data, vec![1, 2, 3, 4, 5, 6, 7, 8]); + + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + let written = std::fs::read(&kb_path).expect("read hidg0 file"); + assert!( + !written.is_empty(), + "keyboard stream should write HID bytes to target file" + ); + + server.abort(); + }); + } + + #[test] + #[serial] + fn stream_mouse_writes_reports_to_hid_file() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (dir, handler) = build_handler_for_tests(); + let ms_path = dir.path().join("hidg1.bin"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(MouseReport { + data: vec![8, 7, 6, 5, 4, 3, 2, 1], + }) + .await + .expect("send mouse packet"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut resp = cli + .stream_mouse(tonic::Request::new(outbound)) + .await + .expect("stream mouse"); + let echoed = resp + .get_mut() + .message() + .await + .expect("grpc result") + .expect("echo packet"); + assert_eq!(echoed.data, vec![8, 7, 6, 5, 4, 3, 2, 1]); + + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + let written = std::fs::read(&ms_path).expect("read hidg1 file"); + assert!( + !written.is_empty(), + "mouse stream should write HID bytes to target file" + ); + + server.abort(); + }); + } + + #[test] + #[serial] + fn stream_keyboard_recovers_when_hid_write_fails() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests_with_modes(false, true); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(KeyboardReport { + data: vec![11, 12, 13, 14, 15, 16, 17, 18], + }) + .await + .expect("send keyboard packet"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut resp = cli + .stream_keyboard(tonic::Request::new(outbound)) + .await + .expect("stream keyboard"); + let echoed = resp + .get_mut() + .message() + .await + .expect("grpc result") + .expect("echo packet"); + assert_eq!(echoed.data, vec![11, 12, 13, 14, 15, 16, 17, 18]); + + server.abort(); + }); + } + + #[test] + #[serial] + fn stream_mouse_recovers_when_hid_write_fails() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests_with_modes(true, false); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(MouseReport { + data: vec![21, 22, 23, 24, 25, 26, 27, 28], + }) + .await + .expect("send mouse packet"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut resp = cli + .stream_mouse(tonic::Request::new(outbound)) + .await + .expect("stream mouse"); + let echoed = resp + .get_mut() + .message() + .await + .expect("grpc result") + .expect("echo packet"); + assert_eq!(echoed.data, vec![21, 22, 23, 24, 25, 26, 27, 28]); + + server.abort(); + }); + } + + #[test] + #[serial] + fn stream_microphone_returns_internal_error_without_uac_device() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (_tx, rx) = tokio::sync::mpsc::channel::(4); + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let err = cli + .stream_microphone(tonic::Request::new(outbound)) + .await + .expect_err("missing UAC sink should fail stream setup"); + assert_eq!(err.code(), tonic::Code::Internal); + + server.abort(); + }); + } + + #[test] + #[serial] + fn stream_camera_reports_error_or_terminates_cleanly_without_camera_hardware() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + + let server = tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(handler)) + .serve(addr) + .await; + }); + + let channel = connect_with_retry(addr).await; + let mut cli = RelayClient::new(channel); + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(VideoPacket { + id: 2, + pts: 1, + data: vec![0, 1, 2, 3], + }) + .await + .expect("send camera packet"); + drop(tx); + + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let result = cli.stream_camera(tonic::Request::new(outbound)).await; + match result { + Ok(mut stream) => { + let _ = stream.get_mut().message().await; + } + Err(err) => { + assert!( + matches!( + err.code(), + tonic::Code::Internal | tonic::Code::Unavailable | tonic::Code::Unknown + ), + "unexpected camera stream error code: {}", + err.code() + ); + } + } + + server.abort(); + }); + } } diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index ea10c74..2e65499 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -43,6 +43,7 @@ mod server_main_rpc { gadget: UsbGadget::new("lesavka"), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), }, ) } diff --git a/testing/tests/server_uvc_binary_contract.rs b/testing/tests/server_uvc_binary_contract.rs index eba0e0e..905e1e7 100644 --- a/testing/tests/server_uvc_binary_contract.rs +++ b/testing/tests/server_uvc_binary_contract.rs @@ -413,5 +413,4 @@ mod uvc_binary { handle_setup(-1, 0, &mut state, &mut pending, interfaces, req, false); assert!(pending.is_none()); } - } diff --git a/testing/tests/server_uvc_binary_extra_contract.rs b/testing/tests/server_uvc_binary_extra_contract.rs index 62a408a..c6f2c55 100644 --- a/testing/tests/server_uvc_binary_extra_contract.rs +++ b/testing/tests/server_uvc_binary_extra_contract.rs @@ -239,7 +239,10 @@ mod uvc_binary_extra { with_var("LESAVKA_UVC_DEV", Some("/dev/null"), || { with_var("LESAVKA_UVC_BLOCKING", Some("1"), || { let result = main(); - assert!(result.is_err(), "non-UVC node should fail during event subscribe"); + assert!( + result.is_err(), + "non-UVC node should fail during event subscribe" + ); }); }); } diff --git a/testing/tests/server_uvc_process_contract.rs b/testing/tests/server_uvc_process_contract.rs index d5dffab..b54e096 100644 --- a/testing/tests/server_uvc_process_contract.rs +++ b/testing/tests/server_uvc_process_contract.rs @@ -59,7 +59,10 @@ fn uvc_binary_requires_device_argument_or_env() { .env_remove("LESAVKA_UVC_DEV") .status() .expect("spawn lesavka-uvc"); - assert!(!status.success(), "uvc binary should fail without a device path"); + assert!( + !status.success(), + "uvc binary should fail without a device path" + ); } #[test] @@ -109,4 +112,3 @@ fn uvc_binary_accepts_positional_device_argument() { "uvc binary should fail on non-v4l2 test file" ); } - diff --git a/testing/tests/server_video_include_contract.rs b/testing/tests/server_video_include_contract.rs index 2715321..ad833cd 100644 --- a/testing/tests/server_video_include_contract.rs +++ b/testing/tests/server_video_include_contract.rs @@ -15,8 +15,7 @@ mod video_sinks { mod video_support { pub use lesavka_server::video_support::{ - adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, - should_send_frame, + adjust_effective_fps, contains_idr, default_eye_fps, env_u32, env_usize, should_send_frame, }; } @@ -50,7 +49,11 @@ mod video_include_contract { inner: ReceiverStream::new(rx), }; - let first = stream.next().await.expect("stream item").expect("packet ok"); + let first = stream + .next() + .await + .expect("stream item") + .expect("packet ok"); assert_eq!(first.id, 1); assert_eq!(first.pts, 42); assert_eq!(first.data, vec![1, 2, 3, 4]); @@ -74,11 +77,7 @@ mod video_include_contract { let rt = tokio::runtime::Runtime::new().expect("runtime"); with_var("LESAVKA_EYE_ADAPTIVE", Some("0"), || { with_var("LESAVKA_EYE_QUEUE_BUFFERS", Some("4"), || { - let result = rt.block_on(eye_ball( - "/dev/video0\" ! thiswillneverparse", - 0, - 4_000, - )); + let result = rt.block_on(eye_ball("/dev/video0\" ! thiswillneverparse", 0, 4_000)); assert!(result.is_err(), "malformed pipeline should fail parse"); }); }); @@ -90,11 +89,7 @@ mod video_include_contract { let rt = tokio::runtime::Runtime::new().expect("runtime"); with_var("LESAVKA_EYE_APPSINK_BUFFERS", Some("5"), || { with_var("LESAVKA_EYE_CHAN_CAPACITY", Some("17"), || { - let result = rt.block_on(eye_ball( - "/dev/video1\" ! still-bad", - 1, - 8_000, - )); + let result = rt.block_on(eye_ball("/dev/video1\" ! still-bad", 1, 8_000)); assert!(result.is_err(), "malformed pipeline should fail parse"); }); }); @@ -106,7 +101,10 @@ mod video_include_contract { let panic_result = std::panic::catch_unwind(|| { let _ = rt.block_on(eye_ball("/dev/video0", 2, 1_000)); }); - assert!(panic_result.is_err(), "invalid eye id must panic before setup"); + assert!( + panic_result.is_err(), + "invalid eye id must panic before setup" + ); } #[test] @@ -207,11 +205,8 @@ mod video_include_contract { Err(_) => panic!("testsrc setup timed out"), }; tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let _ = tokio::time::timeout( - std::time::Duration::from_secs(1), - stream.next(), - ) - .await; + let _ = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next()) + .await; }); }); }); diff --git a/testing/tests/server_video_sink_smoke_contract.rs b/testing/tests/server_video_sink_smoke_contract.rs index 35c16ef..e499fb7 100644 --- a/testing/tests/server_video_sink_smoke_contract.rs +++ b/testing/tests/server_video_sink_smoke_contract.rs @@ -116,9 +116,16 @@ fn hdmi_sink_mjpeg_constructor_path_is_stable() { #[test] #[serial] fn hdmi_sink_override_with_invalid_element_returns_error() { - with_var("LESAVKA_HDMI_SINK", Some("definitely-not-a-real-gst-element"), || { - let cfg = hdmi_config(CameraCodec::H264); - let result = HdmiSink::new(&cfg); - assert!(result.is_err(), "invalid sink override should fail construction"); - }); + with_var( + "LESAVKA_HDMI_SINK", + Some("definitely-not-a-real-gst-element"), + || { + let cfg = hdmi_config(CameraCodec::H264); + let result = HdmiSink::new(&cfg); + assert!( + result.is_err(), + "invalid sink override should fail construction" + ); + }, + ); } diff --git a/testing/tests/server_video_sinks_include_contract.rs b/testing/tests/server_video_sinks_include_contract.rs index 18dd199..d8f7857 100644 --- a/testing/tests/server_video_sinks_include_contract.rs +++ b/testing/tests/server_video_sinks_include_contract.rs @@ -50,10 +50,14 @@ mod video_sinks_include_contract { #[serial] fn build_hdmi_sink_invalid_override_surfaces_error() { init_gst(); - with_var("LESAVKA_HDMI_SINK", Some("definitely-not-a-real-gst-element"), || { - let sink = build_hdmi_sink(&cfg(CameraCodec::H264)); - assert!(sink.is_err(), "invalid override must fail"); - }); + with_var( + "LESAVKA_HDMI_SINK", + Some("definitely-not-a-real-gst-element"), + || { + let sink = build_hdmi_sink(&cfg(CameraCodec::H264)); + assert!(sink.is_err(), "invalid override must fail"); + }, + ); } #[test] @@ -84,7 +88,8 @@ mod video_sinks_include_contract { #[test] #[serial] fn camera_sink_dispatch_is_stable_for_uvc_variant() { - if let Ok(sink) = WebcamSink::new("/dev/video-definitely-missing", &cfg(CameraCodec::Mjpeg)) { + if let Ok(sink) = WebcamSink::new("/dev/video-definitely-missing", &cfg(CameraCodec::Mjpeg)) + { let cam_sink = CameraSink::Uvc(sink); cam_sink.push(VideoPacket { id: 9,