wip(launcher): recover relay and preview lane onto master

This commit is contained in:
Brad Stein 2026-04-14 23:03:18 -03:00
parent 2f7cc44976
commit df6dfefce6
49 changed files with 2884 additions and 1508 deletions

View File

@ -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"),

View File

@ -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);
}

View File

@ -252,10 +252,9 @@ impl CameraCapture {
#[cfg(coverage)]
fn find_device(substr: &str) -> Option<String> {
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);
}

View File

@ -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);

View File

@ -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::<Vec<InputEvent>>()) else {
let Ok(events) = self
.dev
.fetch_events()
.map(|it| it.collect::<Vec<InputEvent>>())
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<String> {
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<String> {
}
}
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()
{

View File

@ -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<String> {
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<Vec<KeyboardReport>> {
}
Ok(raw_reports
.into_iter()
.map(|data| KeyboardReport { data: data.to_vec() })
.map(|data| KeyboardReport {
data: data.to_vec(),
})
.collect())
}

View File

@ -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<Child>,
microphone: Option<Child>,
speaker: Option<Child>,
}
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<bool> {
self.toggle(DeviceTestKind::Camera, build_camera_test(camera))
}
pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result<bool> {
self.toggle(
DeviceTestKind::Microphone,
build_microphone_test(source, sink),
)
}
pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result<bool> {
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<Command>) -> Result<bool> {
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<Child> {
match kind {
DeviceTestKind::Camera => &self.camera,
DeviceTestKind::Microphone => &self.microphone,
DeviceTestKind::Speaker => &self.speaker,
}
}
fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option<Child> {
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<Command> {
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<Command> {
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<Command> {
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>) -> String {
escape(Cow::Owned(value.into())).into_owned()
}

View File

@ -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);
}

View File

@ -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()]);

View File

@ -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())

View File

@ -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<CapturePowerStatus> {
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<CapturePowerStatus> {
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<F>(future: F) -> Result<CapturePowerStatus>
where
F: std::future::Future<Output = Result<CapturePowerStatus>>,
{
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("building launcher power runtime")?
.block_on(future)
}
async fn connect(server_addr: &str) -> Result<RelayClient<Channel>> {
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,
}
}

View File

@ -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<String>,
@ -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<String>,
}
@ -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"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<PreviewBinding>,
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<Rc<LauncherPreview>>,
pub popouts: Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
}
pub fn build_launcher_view(
app: &gtk::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(
&microphone_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",
&microphone_combo,
&microphone_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: &gtk::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: &gtk::Grid,
row: i32,
label: &str,
combo: &gtk::ComboBoxText,
test_button: &gtk::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(),
}
}

View File

@ -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<bool>,
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: &gtk::Application,
preview: &LauncherPreview,
state: &Rc<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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: &gtk::ComboBoxText) -> Option<String> {
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: &gtk::ComboBoxText) -> String {
combo
.active_id()
.map(|value| value.to_string())
.unwrap_or_else(|| "pause".to_string())
}
pub fn selected_toggle_key_label(combo: &gtk::ComboBoxText) -> String {
combo
.active_text()
.map(|value| value.to_string())
.unwrap_or_else(|| "Pause".to_string())
}
pub fn selected_server_addr(entry: &gtk::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<InputRouting> {
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: &gtk::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<Child> {
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<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<Child>>>) -> 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,
}
}

View File

@ -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<WorkerGuard> = 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()

View File

@ -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<String> {
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();

View File

@ -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})"
),
}
}
});

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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)."

View File

@ -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);
}

View File

@ -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))]

295
server/src/capture_power.rs Normal file
View File

@ -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<bool>,
}
#[cfg(not(coverage))]
#[derive(Debug, Clone)]
pub struct CapturePowerManager {
unit: Arc<str>,
inner: Arc<Mutex<CapturePowerInner>>,
}
#[cfg(not(coverage))]
#[derive(Clone)]
pub struct CapturePowerLease {
manager: CapturePowerManager,
released: Arc<AtomicBool>,
}
#[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::<str>::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<CapturePowerState> {
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<CapturePowerState> {
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<UnitSnapshot> {
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<UnitSnapshot> {
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<CapturePowerState> {
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<CapturePowerState> {
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");
}
}

View File

@ -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;

View File

@ -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<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + 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<AtomicBool>,
camera_rt: Arc<CameraRuntime>,
capture_power: CapturePowerManager,
}
impl Handler {
#[cfg(coverage)]
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
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<Self> {
#[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<Response<VideoStream>, 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<PasteRequest>,
) -> Result<Response<PasteReply>, 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<Response<ResetUsbReply>, 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<Response<CapturePowerState>, 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<SetCapturePowerRequest>,
) -> Result<Response<CapturePowerState>, Status> {
self.capture_power
.set_manual(req.into_inner().enabled)
.await
.map(Response::new)
.map_err(|e| Status::internal(format!("{e:#}")))
}
}
struct GuardedVideoStream<S> {
inner: S,
_lease: lesavka_server::capture_power::CapturePowerLease,
}
impl<S> Stream for GuardedVideoStream<S>
where
S: Stream<Item = Result<VideoPacket, Status>> + Unpin,
{
type Item = Result<VideoPacket, Status>;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
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<Result<KeyboardReport, Status>>;
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
type CaptureVideoStream = VideoStream;
type CaptureAudioStream = AudioStream;
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
@ -272,25 +369,7 @@ impl Relay for Handler {
&self,
req: Request<MonitorRequest>,
) -> Result<Response<Self::CaptureVideoStream>, 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<PasteRequest>) -> Result<Response<PasteReply>, 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<Empty>) -> Result<Response<ResetUsbReply>, 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<Empty>,
) -> Result<Response<CapturePowerState>, Status> {
self.get_capture_power_reply().await
}
async fn set_capture_power(
&self,
req: Request<SetCapturePowerRequest>,
) -> Result<Response<CapturePowerState>, Status> {
self.set_capture_power_reply(req).await
}
}
@ -350,8 +419,8 @@ impl Relay for Handler {
impl Relay for Handler {
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
type CaptureVideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
type CaptureAudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
type CaptureVideoStream = VideoStream;
type CaptureAudioStream = AudioStream;
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
type StreamCameraStream = ReceiverStream<Result<Empty, Status>>;
@ -406,59 +475,47 @@ impl Relay for Handler {
&self,
_req: Request<tonic::Streaming<VideoPacket>>,
) -> Result<Response<Self::StreamCameraStream>, 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<MonitorRequest>,
) -> Result<Response<Self::CaptureVideoStream>, 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<MonitorRequest>,
) -> Result<Response<Self::CaptureAudioStream>, 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<PasteRequest>) -> Result<Response<PasteReply>, 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<Empty>) -> Result<Response<ResetUsbReply>, 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<Empty>,
) -> Result<Response<CapturePowerState>, Status> {
self.get_capture_power_reply().await
}
async fn set_capture_power(
&self,
req: Request<SetCapturePowerRequest>,
) -> Result<Response<CapturePowerState>, Status> {
self.set_capture_power_reply(req).await
}
}

View File

@ -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"));
});
});

View File

@ -26,7 +26,10 @@ pub fn pick_uvc_device() -> anyhow::Result<String> {
}
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<String> {
.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() {

View File

@ -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!(

View File

@ -33,7 +33,10 @@ fn find_binary(name: &str) -> Option<PathBuf> {
.find(|path| path.exists() && path.is_file())
}
fn wait_for_exit(mut child: std::process::Child, timeout: Duration) -> Option<std::process::ExitStatus> {
fn wait_for_exit(
mut child: std::process::Child,
timeout: Duration,
) -> Option<std::process::ExitStatus> {
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() {
);
}
}

View File

@ -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));

View File

@ -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::<String>();
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]);
}
}

View File

@ -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<evdev::Device> {
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<evdev::Device> {
let mut keys = evdev::AttributeSet::<evdev::KeyCode>::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<KeyboardReport>,
) {
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::<String>();
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]);
}
}

View File

@ -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"
);
});
}
}

View File

@ -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]

View File

@ -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]);
}
}

View File

@ -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"
);
});
},
);
}
}

View File

@ -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;

View File

@ -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"
);
});
});

View File

@ -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::<AudioPacket>(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();
});
}
}

View File

@ -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::<AudioPacket>(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();
});
}
}

View File

@ -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(),
},
)
}

View File

@ -413,5 +413,4 @@ mod uvc_binary {
handle_setup(-1, 0, &mut state, &mut pending, interfaces, req, false);
assert!(pending.is_none());
}
}

View File

@ -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"
);
});
});
}

View File

@ -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"
);
}

View File

@ -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;
});
});
});

View File

@ -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"
);
},
);
}

View File

@ -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,