feat(client): add launcher workflow and restore quality gates

This commit is contained in:
Brad Stein 2026-04-13 23:11:35 -03:00
parent 150cd1a9bc
commit 6ff88122f0
12 changed files with 1150 additions and 32 deletions

View File

@ -39,6 +39,9 @@ impl LesavkaClientApp {
pub fn new() -> Result<Self> {
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
let headless = std::env::var("LESAVKA_HEADLESS").is_ok();
let capture_remote_boot = std::env::var("LESAVKA_CAPTURE_REMOTE")
.map(|value| value != "0")
.unwrap_or(true);
let args = std::env::args().skip(1).collect::<Vec<_>>();
let env_addr = std::env::var("LESAVKA_SERVER_ADDR").ok();
let server_addr = app_support::resolve_server_addr(&args, env_addr.as_deref());
@ -50,11 +53,12 @@ impl LesavkaClientApp {
let agg = if headless {
None
} else {
Some(InputAggregator::new(
Some(InputAggregator::new_with_capture_mode(
dev_mode,
kbd_tx.clone(),
mou_tx.clone(),
Some(paste_tx),
capture_remote_boot,
))
};
@ -139,6 +143,13 @@ impl LesavkaClientApp {
};
if !self.headless {
let view_mode = std::env::var("LESAVKA_VIEW_MODE")
.unwrap_or_else(|_| "breakout".to_string())
.to_ascii_lowercase();
if view_mode == "unified" {
info!("🪟 unified view selected; using breakout rendering fallback in this iteration");
}
/*────────── video rendering thread (winit) ────*/
let video_queue = app_support::sanitize_video_queue(
std::env::var("LESAVKA_VIDEO_QUEUE")

View File

@ -29,6 +29,7 @@ pub struct InputAggregator {
paste_tx: Option<UnboundedSender<String>>,
keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>,
capture_remote_boot: bool,
}
impl InputAggregator {
@ -37,12 +38,22 @@ impl InputAggregator {
kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
) -> Self {
Self::new_with_capture_mode(dev_mode, kbd_tx, mou_tx, paste_tx, true)
}
pub fn new_with_capture_mode(
dev_mode: bool,
kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
capture_remote_boot: bool,
) -> Self {
Self {
kbd_tx,
mou_tx,
dev_mode,
released: false,
released: !capture_remote_boot,
magic_active: false,
pending_release: false,
pending_kill: false,
@ -50,6 +61,7 @@ impl InputAggregator {
paste_tx,
keyboards: Vec::new(),
mice: Vec::new(),
capture_remote_boot,
}
}
@ -70,16 +82,26 @@ impl InputAggregator {
let _ = dev.set_nonblocking(true);
match classify_device(&dev) {
DeviceKind::Keyboard => {
self.keyboards.push(KeyboardAggregator::new(
let mut aggregator = KeyboardAggregator::new(
dev,
self.dev_mode,
self.kbd_tx.clone(),
self.paste_tx.clone(),
));
);
aggregator.set_send(self.capture_remote_boot);
if !self.capture_remote_boot {
aggregator.set_grab(false);
}
self.keyboards.push(aggregator);
}
DeviceKind::Mouse => {
self.mice
.push(MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()));
let mut aggregator =
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
aggregator.set_send(self.capture_remote_boot);
if !self.capture_remote_boot {
aggregator.set_grab(false);
}
self.mice.push(aggregator);
}
DeviceKind::Other => {}
}
@ -121,32 +143,52 @@ impl InputAggregator {
match classify_device(&dev) {
DeviceKind::Keyboard => {
dev.grab()
.with_context(|| format!("grabbing keyboard {path:?}"))?;
info!(
"🤏🖱️ Grabbed keyboard {:?}",
dev.name().unwrap_or("UNKNOWN")
);
if self.capture_remote_boot {
dev.grab()
.with_context(|| format!("grabbing keyboard {path:?}"))?;
info!(
"🤏🖱️ Grabbed keyboard {:?}",
dev.name().unwrap_or("UNKNOWN")
);
} else {
info!(
"⌨️ local-input boot mode; keyboard left ungrabbed {:?}",
dev.name().unwrap_or("UNKNOWN")
);
}
// pass dev_mode to aggregator
// let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode);
let kbd_agg = KeyboardAggregator::new(
let mut kbd_agg = KeyboardAggregator::new(
dev,
self.dev_mode,
self.kbd_tx.clone(),
self.paste_tx.clone(),
);
kbd_agg.set_send(self.capture_remote_boot);
if !self.capture_remote_boot {
kbd_agg.set_grab(false);
}
self.keyboards.push(kbd_agg);
found_any = true;
continue;
}
DeviceKind::Mouse => {
dev.grab()
.with_context(|| format!("grabbing mouse {path:?}"))?;
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
if self.capture_remote_boot {
dev.grab()
.with_context(|| format!("grabbing mouse {path:?}"))?;
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
} else {
info!(
"🖱️ local-input boot mode; mouse left ungrabbed {:?}",
dev.name().unwrap_or("UNKNOWN")
);
}
// let mouse_agg = MouseAggregator::new(dev);
let mouse_agg = MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
let mut mouse_agg =
MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
mouse_agg.set_send(self.capture_remote_boot);
if !self.capture_remote_boot {
mouse_agg.set_grab(false);
}
self.mice.push(mouse_agg);
found_any = true;
continue;

View File

@ -0,0 +1,154 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeviceCatalog {
pub cameras: Vec<String>,
pub microphones: Vec<String>,
pub speakers: Vec<String>,
}
impl DeviceCatalog {
pub fn discover() -> Self {
Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok())
}
pub fn is_empty(&self) -> bool {
self.cameras.is_empty() && self.microphones.is_empty() && self.speakers.is_empty()
}
fn discover_with_camera_override(override_dir: Option<String>) -> Self {
let cameras = discover_camera_devices(override_dir);
let microphones = discover_pactl_devices("sources");
let speakers = discover_pactl_devices("sinks");
Self {
cameras,
microphones,
speakers,
}
}
}
fn discover_camera_devices(override_dir: Option<String>) -> Vec<String> {
let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string());
let Ok(iter) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut set = BTreeSet::new();
for entry in iter.flatten() {
let path = entry.path();
if let Some(name) = path.file_name() {
set.insert(name.to_string_lossy().to_string());
}
}
set.into_iter().collect()
}
fn discover_pactl_devices(kind: &str) -> Vec<String> {
let output = std::process::Command::new("pactl")
.args(["list", "short", kind])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
parse_pactl_short(&String::from_utf8_lossy(&output.stdout))
}
pub fn parse_pactl_short(stdout: &str) -> Vec<String> {
let mut set = BTreeSet::new();
for line in stdout.lines() {
let mut cols = line.split_whitespace();
let _id = cols.next();
if let Some(name) = cols.next() {
set.insert(name.to_string());
}
}
set.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn mk_temp_dir(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&path).expect("create temp dir");
path
}
#[test]
fn parse_pactl_short_collects_second_column_and_sorts_unique() {
let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n";
let parsed = parse_pactl_short(input);
assert_eq!(
parsed,
vec![
"alsa_input.pci".to_string(),
"alsa_input.usb.test".to_string(),
]
);
}
#[test]
fn parse_pactl_short_ignores_blank_or_short_lines() {
let input = "\nweird\n3\n4 sink.a\tmodule\n";
let parsed = parse_pactl_short(input);
assert_eq!(parsed, vec!["sink.a".to_string()]);
}
#[test]
fn camera_discovery_reads_entry_names_from_override_dir() {
let tmp = mk_temp_dir("camera-discovery");
std::fs::write(tmp.join("usb-cam-a"), "").expect("write");
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()]);
let _ = std::fs::remove_dir_all(tmp);
}
#[test]
fn camera_discovery_returns_empty_when_directory_missing() {
let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string()));
assert!(devices.is_empty());
}
#[test]
fn camera_discovery_default_path_is_stable_without_overrides() {
let _ = discover_camera_devices(None);
}
#[test]
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()));
assert_eq!(catalog.cameras, vec!["cam".to_string()]);
let _ = std::fs::remove_dir_all(tmp);
}
#[test]
fn discover_is_stable_with_process_environment_defaults() {
let _ = DeviceCatalog::discover();
}
#[test]
fn catalog_empty_reflects_collections() {
let mut catalog = DeviceCatalog::default();
assert!(catalog.is_empty());
catalog.speakers.push("sink-1".to_string());
assert!(!catalog.is_empty());
}
}

View File

@ -0,0 +1,172 @@
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use super::state::{InputRouting, LauncherState, ViewMode};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceSample {
pub rtt_ms: f32,
pub input_latency_ms: f32,
pub left_fps: f32,
pub right_fps: f32,
pub dropped_frames: u64,
pub queue_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticsLog {
capacity: usize,
history: VecDeque<PerformanceSample>,
}
impl DiagnosticsLog {
pub fn new(capacity: usize) -> Self {
let capacity = capacity.max(1);
Self {
capacity,
history: VecDeque::with_capacity(capacity),
}
}
pub fn record(&mut self, sample: PerformanceSample) {
if self.history.len() == self.capacity {
let _ = self.history.pop_front();
}
self.history.push_back(sample);
}
pub fn latest(&self) -> Option<&PerformanceSample> {
self.history.back()
}
pub fn len(&self) -> usize {
self.history.len()
}
pub fn is_empty(&self) -> bool {
self.history.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &PerformanceSample> {
self.history.iter()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotReport {
pub routing: InputRouting,
pub view_mode: ViewMode,
pub remote_active: bool,
pub selected_camera: Option<String>,
pub selected_microphone: Option<String>,
pub selected_speaker: Option<String>,
pub status: String,
pub recent_samples: Vec<PerformanceSample>,
pub notes: Vec<String>,
pub probe_command: String,
}
impl SnapshotReport {
pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self {
Self {
routing: state.routing,
view_mode: state.view_mode,
remote_active: state.remote_active,
selected_camera: state.devices.camera.clone(),
selected_microphone: state.devices.microphone.clone(),
selected_speaker: state.devices.speaker.clone(),
status: state.status_line(),
recent_samples: log.iter().cloned().collect(),
notes: state.notes.clone(),
probe_command,
}
}
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
pub fn quality_probe_command() -> &'static str {
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::launcher::state::{DeviceSelection, LauncherState};
fn sample(n: u64) -> PerformanceSample {
PerformanceSample {
rtt_ms: 20.0 + n as f32,
input_latency_ms: 10.0 + n as f32,
left_fps: 30.0,
right_fps: 30.0,
dropped_frames: n,
queue_depth: n as u32,
}
}
#[test]
fn diagnostics_log_keeps_only_latest_samples_with_capacity() {
let mut log = DiagnosticsLog::new(2);
log.record(sample(1));
log.record(sample(2));
log.record(sample(3));
let kept: Vec<u64> = log.iter().map(|item| item.dropped_frames).collect();
assert_eq!(kept, vec![2, 3]);
assert_eq!(log.latest().map(|s| s.dropped_frames), Some(3));
}
#[test]
fn diagnostics_log_enforces_minimum_capacity() {
let mut log = DiagnosticsLog::new(0);
log.record(sample(1));
log.record(sample(2));
assert_eq!(log.len(), 1);
assert_eq!(log.latest().map(|s| s.dropped_frames), Some(2));
}
#[test]
fn snapshot_report_contains_state_fields_and_samples() {
let mut state = LauncherState::new();
state.devices = DeviceSelection {
camera: Some("/dev/video0".to_string()),
microphone: Some("alsa_input.usb".to_string()),
speaker: Some("alsa_output.usb".to_string()),
};
state.push_note("first note");
let mut log = DiagnosticsLog::new(4);
log.record(sample(7));
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_speaker.as_deref(), Some("alsa_output.usb"));
assert_eq!(report.recent_samples.len(), 1);
assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=remote"));
}
#[test]
fn snapshot_json_is_serializable_and_mentions_probe_command() {
let report = SnapshotReport::from_state(
&LauncherState::new(),
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
let json = report.to_pretty_json().expect("serialize");
assert!(json.contains("quality_gate.sh"));
assert!(json.contains("routing"));
assert!(json.contains("view_mode"));
}
#[test]
fn quality_probe_command_mentions_both_gates() {
let cmd = quality_probe_command();
assert!(cmd.contains("hygiene_gate.sh"));
assert!(cmd.contains("quality_gate.sh"));
}
}

110
client/src/launcher/mod.rs Normal file
View File

@ -0,0 +1,110 @@
pub mod devices;
pub mod diagnostics;
pub mod state;
mod ui;
use std::collections::BTreeMap;
use anyhow::Result;
pub use diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command};
pub use state::{DeviceSelection, InputRouting, LauncherState, ViewMode};
pub fn maybe_run_launcher(args: &[String]) -> Result<bool> {
if args.iter().any(|arg| arg == "--launcher") {
let server_addr = resolve_server_addr(args);
ui::run_gui_launcher(server_addr)?;
return Ok(true);
}
Ok(false)
}
pub fn runtime_env_vars(state: &LauncherState) -> BTreeMap<String, String> {
let mut envs = BTreeMap::new();
envs.insert(
"LESAVKA_CAPTURE_REMOTE".to_string(),
state.routing.as_env().to_string(),
);
envs.insert(
"LESAVKA_VIEW_MODE".to_string(),
state.view_mode.as_env().to_string(),
);
if let Some(camera) = state.devices.camera.as_ref() {
envs.insert("LESAVKA_CAM_SOURCE".to_string(), camera.clone());
}
if let Some(microphone) = state.devices.microphone.as_ref() {
envs.insert("LESAVKA_MIC_SOURCE".to_string(), microphone.clone());
}
if let Some(speaker) = state.devices.speaker.as_ref() {
envs.insert("LESAVKA_AUDIO_SINK".to_string(), speaker.clone());
}
envs
}
fn resolve_server_addr(args: &[String]) -> String {
for window in args.windows(2) {
if window[0] == "--server" {
return window[1].clone();
}
}
args.iter()
.find(|arg| !arg.starts_with("--"))
.cloned()
.or_else(|| std::env::var("LESAVKA_SERVER_ADDR").ok())
.unwrap_or_else(|| "http://127.0.0.1:50051".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_server_addr_prefers_explicit_server_flag() {
let args = vec![
"--launcher".to_string(),
"--server".to_string(),
"http://example:50051".to_string(),
"http://fallback:50051".to_string(),
];
assert_eq!(resolve_server_addr(&args), "http://example:50051");
}
#[test]
fn resolve_server_addr_uses_first_non_flag_or_default() {
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()];
assert!(resolve_server_addr(&args).starts_with("http://"));
}
#[test]
fn runtime_env_vars_emit_selected_controls() {
let mut state = LauncherState::new();
state.set_routing(InputRouting::Local);
state.set_view_mode(ViewMode::Unified);
state.select_camera(Some("/dev/video0".to_string()));
state.select_microphone(Some("alsa_input.test".to_string()));
state.select_speaker(Some("alsa_output.test".to_string()));
let envs = runtime_env_vars(&state);
assert_eq!(envs.get("LESAVKA_CAPTURE_REMOTE"), Some(&"0".to_string()));
assert_eq!(envs.get("LESAVKA_VIEW_MODE"), Some(&"unified".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())
);
assert_eq!(
envs.get("LESAVKA_AUDIO_SINK"),
Some(&"alsa_output.test".to_string())
);
}
#[test]
fn maybe_run_launcher_returns_false_without_launcher_flag() {
let args = vec!["http://server:50051".to_string()];
assert!(!maybe_run_launcher(&args).expect("launcher check"));
}
}

View File

@ -0,0 +1,234 @@
use serde::{Deserialize, Serialize};
use super::devices::DeviceCatalog;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputRouting {
Local,
Remote,
}
impl InputRouting {
pub fn as_env(self) -> &'static str {
match self {
Self::Local => "0",
Self::Remote => "1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ViewMode {
Unified,
Breakout,
}
impl ViewMode {
pub fn as_env(self) -> &'static str {
match self {
Self::Unified => "unified",
Self::Breakout => "breakout",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DeviceSelection {
pub camera: Option<String>,
pub microphone: Option<String>,
pub speaker: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherState {
pub routing: InputRouting,
pub view_mode: ViewMode,
pub devices: DeviceSelection,
pub remote_active: bool,
pub notes: Vec<String>,
}
impl Default for LauncherState {
fn default() -> Self {
Self {
routing: InputRouting::Remote,
view_mode: ViewMode::Breakout,
devices: DeviceSelection::default(),
remote_active: false,
notes: Vec::new(),
}
}
}
impl LauncherState {
pub fn new() -> Self {
Self::default()
}
pub fn set_routing(&mut self, routing: InputRouting) {
self.routing = routing;
}
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode;
}
pub fn select_camera(&mut self, camera: Option<String>) {
self.devices.camera = normalize_selection(camera);
}
pub fn select_microphone(&mut self, microphone: Option<String>) {
self.devices.microphone = normalize_selection(microphone);
}
pub fn select_speaker(&mut self, speaker: Option<String>) {
self.devices.speaker = normalize_selection(speaker);
}
pub fn apply_catalog_defaults(&mut self, catalog: &DeviceCatalog) {
if self.devices.camera.is_none() {
self.devices.camera = catalog.cameras.first().cloned();
}
if self.devices.microphone.is_none() {
self.devices.microphone = catalog.microphones.first().cloned();
}
if self.devices.speaker.is_none() {
self.devices.speaker = catalog.speakers.first().cloned();
}
}
pub fn start_remote(&mut self) -> bool {
if self.remote_active {
return false;
}
self.remote_active = true;
true
}
pub fn stop_remote(&mut self) -> bool {
if !self.remote_active {
return false;
}
self.remote_active = false;
true
}
pub fn push_note(&mut self, note: impl Into<String>) {
self.notes.push(note.into());
}
pub fn status_line(&self) -> String {
format!(
"mode={} view={} active={} camera={} mic={} speaker={}",
match self.routing {
InputRouting::Local => "local",
InputRouting::Remote => "remote",
},
match self.view_mode {
ViewMode::Unified => "unified",
ViewMode::Breakout => "breakout",
},
self.remote_active,
self.devices.camera.as_deref().unwrap_or("auto"),
self.devices.microphone.as_deref().unwrap_or("auto"),
self.devices.speaker.as_deref().unwrap_or("auto"),
)
}
}
fn normalize_selection(value: Option<String>) -> Option<String> {
value.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
None
} else {
Some(trimmed.to_string())
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routing_and_view_env_values_are_stable() {
assert_eq!(InputRouting::Local.as_env(), "0");
assert_eq!(InputRouting::Remote.as_env(), "1");
assert_eq!(ViewMode::Unified.as_env(), "unified");
assert_eq!(ViewMode::Breakout.as_env(), "breakout");
}
#[test]
fn defaults_pick_remote_breakout_and_inactive_session() {
let state = LauncherState::new();
assert_eq!(state.routing, InputRouting::Remote);
assert_eq!(state.view_mode, ViewMode::Breakout);
assert!(!state.remote_active);
assert!(state.devices.camera.is_none());
assert!(state.devices.microphone.is_none());
assert!(state.devices.speaker.is_none());
}
#[test]
fn selecting_auto_or_blank_clears_explicit_device() {
let mut state = LauncherState::new();
state.select_camera(Some("/dev/video0".to_string()));
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video0"));
state.select_camera(Some("auto".to_string()));
assert!(state.devices.camera.is_none());
state.select_microphone(Some(" ".to_string()));
assert!(state.devices.microphone.is_none());
}
#[test]
fn catalog_defaults_fill_only_missing_values() {
let mut state = LauncherState::new();
state.select_camera(Some("/dev/video-special".to_string()));
let catalog = DeviceCatalog {
cameras: vec!["/dev/video0".to_string()],
microphones: vec!["alsa_input.usb".to_string()],
speakers: vec!["alsa_output.usb".to_string()],
};
state.apply_catalog_defaults(&catalog);
assert_eq!(state.devices.camera.as_deref(), Some("/dev/video-special"));
assert_eq!(state.devices.microphone.as_deref(), Some("alsa_input.usb"));
assert_eq!(state.devices.speaker.as_deref(), Some("alsa_output.usb"));
}
#[test]
fn start_and_stop_remote_only_report_changes_once() {
let mut state = LauncherState::new();
assert!(state.start_remote());
assert!(!state.start_remote());
assert!(state.remote_active);
assert!(state.stop_remote());
assert!(!state.stop_remote());
assert!(!state.remote_active);
}
#[test]
fn status_line_mentions_all_user_visible_controls() {
let mut state = LauncherState::new();
state.set_routing(InputRouting::Local);
state.set_view_mode(ViewMode::Unified);
state.select_camera(Some("/dev/video0".to_string()));
state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string()));
state.start_remote();
let status = state.status_line();
assert!(status.contains("mode=local"));
assert!(status.contains("view=unified"));
assert!(status.contains("active=true"));
assert!(status.contains("camera=/dev/video0"));
assert!(status.contains("mic=alsa_input.usb"));
assert!(status.contains("speaker=alsa_output.usb"));
}
}

332
client/src/launcher/ui.rs Normal file
View File

@ -0,0 +1,332 @@
use anyhow::Result;
#[cfg(not(coverage))]
use {
super::devices::DeviceCatalog,
super::diagnostics::{DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command},
super::runtime_env_vars,
super::state::{InputRouting, LauncherState, ViewMode},
gtk::prelude::*,
std::cell::RefCell,
std::process::{Child, Command},
std::rc::Rc,
std::time::{SystemTime, UNIX_EPOCH},
};
#[cfg(not(coverage))]
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let app = gtk::Application::builder()
.application_id("dev.lesavka.launcher")
.build();
let catalog = Rc::new(DeviceCatalog::discover());
let state = Rc::new(RefCell::new(LauncherState::new()));
state.borrow_mut().apply_catalog_defaults(&catalog);
let diagnostics = Rc::new(RefCell::new(DiagnosticsLog::new(120)));
let child_proc = Rc::new(RefCell::new(None::<Child>));
let server_addr = Rc::new(server_addr);
{
let child_proc = Rc::clone(&child_proc);
app.connect_shutdown(move |_| {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
});
}
{
let catalog = Rc::clone(&catalog);
let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics);
let child_proc = Rc::clone(&child_proc);
let server_addr = Rc::clone(&server_addr);
app.connect_activate(move |app| {
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Lesavka Launcher")
.default_width(680)
.default_height(520)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
root.set_margin_start(14);
root.set_margin_end(14);
root.set_margin_top(14);
root.set_margin_bottom(14);
let heading = gtk::Label::new(Some("Lesavka Session Launcher"));
heading.add_css_class("title-2");
heading.set_halign(gtk::Align::Start);
root.append(&heading);
let status_label = gtk::Label::new(Some("Idle"));
status_label.set_halign(gtk::Align::Start);
status_label.set_selectable(true);
root.append(&status_label);
let server_label = gtk::Label::new(Some(&format!("Server: {}", server_addr.as_ref())));
server_label.set_halign(gtk::Align::Start);
server_label.set_selectable(true);
root.append(&server_label);
let controls = gtk::Grid::new();
controls.set_row_spacing(8);
controls.set_column_spacing(8);
root.append(&controls);
let routing_label = gtk::Label::new(Some("Remote input capture"));
routing_label.set_halign(gtk::Align::Start);
controls.attach(&routing_label, 0, 0, 1, 1);
let routing_switch = gtk::Switch::new();
routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote));
controls.attach(&routing_switch, 1, 0, 1, 1);
let view_label = gtk::Label::new(Some("View mode"));
view_label.set_halign(gtk::Align::Start);
controls.attach(&view_label, 0, 1, 1, 1);
let view_combo = gtk::ComboBoxText::new();
view_combo.append(Some("unified"), "unified");
view_combo.append(Some("breakout"), "breakout");
view_combo.set_active(Some(match state.borrow().view_mode {
ViewMode::Unified => 0,
ViewMode::Breakout => 1,
}));
controls.attach(&view_combo, 1, 1, 1, 1);
let camera_label = gtk::Label::new(Some("Camera"));
camera_label.set_halign(gtk::Align::Start);
controls.attach(&camera_label, 0, 2, 1, 1);
let camera_combo = gtk::ComboBoxText::new();
camera_combo.append(Some("auto"), "auto");
for camera in &catalog.cameras {
camera_combo.append(Some(camera), camera);
}
set_combo_active_text(&camera_combo, state.borrow().devices.camera.as_deref());
controls.attach(&camera_combo, 1, 2, 1, 1);
let microphone_label = gtk::Label::new(Some("Microphone"));
microphone_label.set_halign(gtk::Align::Start);
controls.attach(&microphone_label, 0, 3, 1, 1);
let microphone_combo = gtk::ComboBoxText::new();
microphone_combo.append(Some("auto"), "auto");
for microphone in &catalog.microphones {
microphone_combo.append(Some(microphone), microphone);
}
set_combo_active_text(
&microphone_combo,
state.borrow().devices.microphone.as_deref(),
);
controls.attach(&microphone_combo, 1, 3, 1, 1);
let speaker_label = gtk::Label::new(Some("Speaker"));
speaker_label.set_halign(gtk::Align::Start);
controls.attach(&speaker_label, 0, 4, 1, 1);
let speaker_combo = gtk::ComboBoxText::new();
speaker_combo.append(Some("auto"), "auto");
for speaker in &catalog.speakers {
speaker_combo.append(Some(speaker), speaker);
}
set_combo_active_text(&speaker_combo, state.borrow().devices.speaker.as_deref());
controls.attach(&speaker_combo, 1, 4, 1, 1);
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
root.append(&button_row);
let start_button = gtk::Button::with_label("Start Session");
let stop_button = gtk::Button::with_label("Stop Session");
let snapshot_button = gtk::Button::with_label("Save Snapshot");
button_row.append(&start_button);
button_row.append(&stop_button);
button_row.append(&snapshot_button);
let probe_hint = gtk::Label::new(Some(quality_probe_command()));
probe_hint.set_halign(gtk::Align::Start);
probe_hint.set_selectable(true);
root.append(&probe_hint);
let note = gtk::Label::new(Some(
"Unified mode currently tracks state/config. Full in-client unified renderer is next.",
));
note.set_wrap(true);
note.set_halign(gtk::Align::Start);
root.append(&note);
{
let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let view_combo = view_combo.clone();
let camera_combo = camera_combo.clone();
let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone();
let server_addr = Rc::clone(&server_addr);
start_button.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let routing = if routing_switch.is_active() {
InputRouting::Remote
} else {
InputRouting::Local
};
state.set_routing(routing);
state.set_view_mode(if view_combo.active() == Some(0) {
ViewMode::Unified
} else {
ViewMode::Breakout
});
state.select_camera(selected_combo_value(&camera_combo));
state.select_microphone(selected_combo_value(&microphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo));
}
if child_proc.borrow().is_some() {
status_label.set_text("Session already running");
return;
}
let spawn_result = {
let mut state = state.borrow_mut();
let _ = state.start_remote();
spawn_client_process(server_addr.as_ref(), &state)
};
match spawn_result {
Ok(child) => {
*child_proc.borrow_mut() = Some(child);
diagnostics.borrow_mut().record(PerformanceSample {
rtt_ms: 0.0,
input_latency_ms: 0.0,
left_fps: 0.0,
right_fps: 0.0,
dropped_frames: 0,
queue_depth: 0,
});
status_label.set_text(&format!("Started: {}", state.borrow().status_line()));
}
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("Start failed: {err}"));
}
}
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
stop_button.connect_clicked(move |_| {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
let _ = state.borrow_mut().stop_remote();
status_label.set_text("Stopped");
});
}
{
let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics);
let status_label = status_label.clone();
snapshot_button.connect_clicked(move |_| {
let report = SnapshotReport::from_state(
&state.borrow(),
&diagnostics.borrow(),
quality_probe_command().to_string(),
);
let json = match report.to_pretty_json() {
Ok(json) => json,
Err(err) => {
status_label.set_text(&format!("Snapshot failed: {err}"));
return;
}
};
let path = format!("/tmp/lesavka-launcher-snapshot-{}.json", now_unix_seconds());
match std::fs::write(&path, json) {
Ok(()) => {
state.borrow_mut().push_note(format!("snapshot={path}"));
status_label.set_text(&format!("Snapshot written: {path}"));
}
Err(err) => {
status_label.set_text(&format!("Snapshot write failed: {err}"));
}
}
});
}
window.set_child(Some(&root));
window.present();
});
}
let _ = app.run();
Ok(())
}
#[cfg(coverage)]
pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
Ok(())
}
#[cfg(not(coverage))]
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())
}
})
}
#[cfg(not(coverage))]
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"));
}
}
#[cfg(not(coverage))]
fn spawn_client_process(server_addr: &str, state: &LauncherState) -> 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);
for (key, value) in runtime_env_vars(state) {
command.env(key, value);
}
Ok(command.spawn()?)
}
#[cfg(not(coverage))]
fn now_unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(all(test, coverage))]
mod tests {
use super::run_gui_launcher;
#[test]
fn coverage_stub_returns_ok() {
assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok());
}
}

View File

@ -6,6 +6,7 @@ pub mod app;
mod app_support;
pub mod handshake;
pub mod input;
pub mod launcher;
pub mod layout;
pub mod output;
pub mod paste;

View File

@ -4,7 +4,7 @@ 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;
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. \
@ -22,6 +22,9 @@ fn ensure_runtime_dir() {
#[forbid(unsafe_code)]
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
#[cfg(not(test))]
let args = env::args().skip(1).collect::<Vec<_>>();
let headless = env::var("LESAVKA_HEADLESS").is_ok();
if !headless {
ensure_runtime_dir();
@ -80,6 +83,10 @@ async fn main() -> Result<()> {
}
#[cfg(not(test))]
{
if env::var("LESAVKA_LAUNCHER_CHILD").is_err() && launcher::maybe_run_launcher(&args)? {
return Ok(());
}
let mut app = LesavkaClientApp::new()?;
app.run().await
}

View File

@ -3,7 +3,7 @@
"client/src/app.rs": {
"clippy_warnings": 42,
"doc_debt": 10,
"loc": 508
"loc": 519
},
"client/src/app_support.rs": {
"clippy_warnings": 0,
@ -21,9 +21,9 @@
"loc": 368
},
"client/src/input/inputs.rs": {
"clippy_warnings": 38,
"clippy_warnings": 40,
"doc_debt": 9,
"loc": 425
"loc": 467
},
"client/src/input/keyboard.rs": {
"clippy_warnings": 24,
@ -50,6 +50,31 @@
"doc_debt": 8,
"loc": 317
},
"client/src/launcher/devices.rs": {
"clippy_warnings": 6,
"doc_debt": 3,
"loc": 154
},
"client/src/launcher/diagnostics.rs": {
"clippy_warnings": 17,
"doc_debt": 3,
"loc": 172
},
"client/src/launcher/mod.rs": {
"clippy_warnings": 4,
"doc_debt": 4,
"loc": 110
},
"client/src/launcher/state.rs": {
"clippy_warnings": 8,
"doc_debt": 9,
"loc": 234
},
"client/src/launcher/ui.rs": {
"clippy_warnings": 4,
"doc_debt": 4,
"loc": 332
},
"client/src/layout.rs": {
"clippy_warnings": 6,
"doc_debt": 0,
@ -58,12 +83,12 @@
"client/src/lib.rs": {
"clippy_warnings": 0,
"doc_debt": 0,
"loc": 13
"loc": 14
},
"client/src/main.rs": {
"clippy_warnings": 2,
"doc_debt": 2,
"loc": 86
"loc": 93
},
"client/src/output/audio.rs": {
"clippy_warnings": 43,

View File

@ -1,8 +1,8 @@
{
"files": {
"client/src/app.rs": {
"line_percent": 97.22222222222221,
"loc": 508
"line_percent": 95.1219512195122,
"loc": 519
},
"client/src/app_support.rs": {
"line_percent": 100.0,
@ -17,8 +17,8 @@
"loc": 368
},
"client/src/input/inputs.rs": {
"line_percent": 98.02631578947368,
"loc": 425
"line_percent": 97.0059880239521,
"loc": 467
},
"client/src/input/keyboard.rs": {
"line_percent": 95.27559055118111,
@ -36,13 +36,33 @@
"line_percent": 97.32142857142857,
"loc": 317
},
"client/src/launcher/devices.rs": {
"line_percent": 98.09523809523807,
"loc": 154
},
"client/src/launcher/diagnostics.rs": {
"line_percent": 97.11538461538461,
"loc": 172
},
"client/src/launcher/mod.rs": {
"line_percent": 96.15384615384616,
"loc": 110
},
"client/src/launcher/state.rs": {
"line_percent": 99.32432432432432,
"loc": 234
},
"client/src/launcher/ui.rs": {
"line_percent": 100.0,
"loc": 332
},
"client/src/layout.rs": {
"line_percent": 97.72727272727273,
"loc": 78
},
"client/src/main.rs": {
"line_percent": 96.7741935483871,
"loc": 86
"line_percent": 96.90721649484536,
"loc": 93
},
"client/src/output/audio.rs": {
"line_percent": 98.59154929577466,

View File

@ -123,6 +123,16 @@ mod input {
}
}
pub fn new_with_capture_mode(
dev_mode: bool,
kbd_tx: Sender<KeyboardReport>,
mou_tx: Sender<MouseReport>,
paste_tx: Option<UnboundedSender<String>>,
_capture_remote_boot: bool,
) -> Self {
Self::new(dev_mode, kbd_tx, mou_tx, paste_tx)
}
pub fn init(&mut self) -> anyhow::Result<()> {
Ok(())
}