feat(client): add launcher workflow and restore quality gates
This commit is contained in:
parent
150cd1a9bc
commit
6ff88122f0
@ -39,6 +39,9 @@ impl LesavkaClientApp {
|
|||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
|
let dev_mode = std::env::var("LESAVKA_DEV_MODE").is_ok();
|
||||||
let headless = std::env::var("LESAVKA_HEADLESS").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 args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
let env_addr = std::env::var("LESAVKA_SERVER_ADDR").ok();
|
let env_addr = std::env::var("LESAVKA_SERVER_ADDR").ok();
|
||||||
let server_addr = app_support::resolve_server_addr(&args, env_addr.as_deref());
|
let server_addr = app_support::resolve_server_addr(&args, env_addr.as_deref());
|
||||||
@ -50,11 +53,12 @@ impl LesavkaClientApp {
|
|||||||
let agg = if headless {
|
let agg = if headless {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(InputAggregator::new(
|
Some(InputAggregator::new_with_capture_mode(
|
||||||
dev_mode,
|
dev_mode,
|
||||||
kbd_tx.clone(),
|
kbd_tx.clone(),
|
||||||
mou_tx.clone(),
|
mou_tx.clone(),
|
||||||
Some(paste_tx),
|
Some(paste_tx),
|
||||||
|
capture_remote_boot,
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -139,6 +143,13 @@ impl LesavkaClientApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !self.headless {
|
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) ────*/
|
/*────────── video rendering thread (winit) ────*/
|
||||||
let video_queue = app_support::sanitize_video_queue(
|
let video_queue = app_support::sanitize_video_queue(
|
||||||
std::env::var("LESAVKA_VIDEO_QUEUE")
|
std::env::var("LESAVKA_VIDEO_QUEUE")
|
||||||
|
|||||||
@ -29,6 +29,7 @@ pub struct InputAggregator {
|
|||||||
paste_tx: Option<UnboundedSender<String>>,
|
paste_tx: Option<UnboundedSender<String>>,
|
||||||
keyboards: Vec<KeyboardAggregator>,
|
keyboards: Vec<KeyboardAggregator>,
|
||||||
mice: Vec<MouseAggregator>,
|
mice: Vec<MouseAggregator>,
|
||||||
|
capture_remote_boot: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputAggregator {
|
impl InputAggregator {
|
||||||
@ -37,12 +38,22 @@ impl InputAggregator {
|
|||||||
kbd_tx: Sender<KeyboardReport>,
|
kbd_tx: Sender<KeyboardReport>,
|
||||||
mou_tx: Sender<MouseReport>,
|
mou_tx: Sender<MouseReport>,
|
||||||
paste_tx: Option<UnboundedSender<String>>,
|
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 {
|
||||||
Self {
|
Self {
|
||||||
kbd_tx,
|
kbd_tx,
|
||||||
mou_tx,
|
mou_tx,
|
||||||
dev_mode,
|
dev_mode,
|
||||||
released: false,
|
released: !capture_remote_boot,
|
||||||
magic_active: false,
|
magic_active: false,
|
||||||
pending_release: false,
|
pending_release: false,
|
||||||
pending_kill: false,
|
pending_kill: false,
|
||||||
@ -50,6 +61,7 @@ impl InputAggregator {
|
|||||||
paste_tx,
|
paste_tx,
|
||||||
keyboards: Vec::new(),
|
keyboards: Vec::new(),
|
||||||
mice: Vec::new(),
|
mice: Vec::new(),
|
||||||
|
capture_remote_boot,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,16 +82,26 @@ impl InputAggregator {
|
|||||||
let _ = dev.set_nonblocking(true);
|
let _ = dev.set_nonblocking(true);
|
||||||
match classify_device(&dev) {
|
match classify_device(&dev) {
|
||||||
DeviceKind::Keyboard => {
|
DeviceKind::Keyboard => {
|
||||||
self.keyboards.push(KeyboardAggregator::new(
|
let mut aggregator = KeyboardAggregator::new(
|
||||||
dev,
|
dev,
|
||||||
self.dev_mode,
|
self.dev_mode,
|
||||||
self.kbd_tx.clone(),
|
self.kbd_tx.clone(),
|
||||||
self.paste_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 => {
|
DeviceKind::Mouse => {
|
||||||
self.mice
|
let mut aggregator =
|
||||||
.push(MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone()));
|
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 => {}
|
DeviceKind::Other => {}
|
||||||
}
|
}
|
||||||
@ -121,32 +143,52 @@ impl InputAggregator {
|
|||||||
|
|
||||||
match classify_device(&dev) {
|
match classify_device(&dev) {
|
||||||
DeviceKind::Keyboard => {
|
DeviceKind::Keyboard => {
|
||||||
dev.grab()
|
if self.capture_remote_boot {
|
||||||
.with_context(|| format!("grabbing keyboard {path:?}"))?;
|
dev.grab()
|
||||||
info!(
|
.with_context(|| format!("grabbing keyboard {path:?}"))?;
|
||||||
"🤏🖱️ Grabbed keyboard {:?}",
|
info!(
|
||||||
dev.name().unwrap_or("UNKNOWN")
|
"🤏🖱️ 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 mut kbd_agg = KeyboardAggregator::new(
|
||||||
// let kbd_agg = KeyboardAggregator::new(dev, self.dev_mode);
|
|
||||||
let kbd_agg = KeyboardAggregator::new(
|
|
||||||
dev,
|
dev,
|
||||||
self.dev_mode,
|
self.dev_mode,
|
||||||
self.kbd_tx.clone(),
|
self.kbd_tx.clone(),
|
||||||
self.paste_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);
|
self.keyboards.push(kbd_agg);
|
||||||
found_any = true;
|
found_any = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
DeviceKind::Mouse => {
|
DeviceKind::Mouse => {
|
||||||
dev.grab()
|
if self.capture_remote_boot {
|
||||||
.with_context(|| format!("grabbing mouse {path:?}"))?;
|
dev.grab()
|
||||||
info!("🤏⌨️ Grabbed mouse {:?}", dev.name().unwrap_or("UNKNOWN"));
|
.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 mut mouse_agg =
|
||||||
let mouse_agg = MouseAggregator::new(dev, self.dev_mode, self.mou_tx.clone());
|
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);
|
self.mice.push(mouse_agg);
|
||||||
found_any = true;
|
found_any = true;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
154
client/src/launcher/devices.rs
Normal file
154
client/src/launcher/devices.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
172
client/src/launcher/diagnostics.rs
Normal file
172
client/src/launcher/diagnostics.rs
Normal 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
110
client/src/launcher/mod.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
234
client/src/launcher/state.rs
Normal file
234
client/src/launcher/state.rs
Normal 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
332
client/src/launcher/ui.rs
Normal 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(µphone_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(
|
||||||
|
µphone_combo,
|
||||||
|
state.borrow().devices.microphone.as_deref(),
|
||||||
|
);
|
||||||
|
controls.attach(µphone_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(¬e);
|
||||||
|
|
||||||
|
{
|
||||||
|
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(µphone_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: >k::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: >k::ComboBoxText, wanted: Option<&str>) {
|
||||||
|
let wanted = wanted.unwrap_or("auto");
|
||||||
|
if !combo.set_active_id(Some(wanted)) {
|
||||||
|
let _ = combo.set_active_id(Some("auto"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn spawn_client_process(server_addr: &str, state: &LauncherState) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ pub mod app;
|
|||||||
mod app_support;
|
mod app_support;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod launcher;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use tracing_appender::non_blocking;
|
|||||||
use tracing_appender::non_blocking::WorkerGuard;
|
use tracing_appender::non_blocking::WorkerGuard;
|
||||||
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use lesavka_client::LesavkaClientApp;
|
use lesavka_client::{LesavkaClientApp, launcher};
|
||||||
fn ensure_runtime_dir() {
|
fn ensure_runtime_dir() {
|
||||||
if env::var_os("XDG_RUNTIME_DIR").is_none() {
|
if env::var_os("XDG_RUNTIME_DIR").is_none() {
|
||||||
let msg = "Error: $XDG_RUNTIME_DIR is not set. \
|
let msg = "Error: $XDG_RUNTIME_DIR is not set. \
|
||||||
@ -22,6 +22,9 @@ fn ensure_runtime_dir() {
|
|||||||
#[forbid(unsafe_code)]
|
#[forbid(unsafe_code)]
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
#[cfg(not(test))]
|
||||||
|
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
|
||||||
let headless = env::var("LESAVKA_HEADLESS").is_ok();
|
let headless = env::var("LESAVKA_HEADLESS").is_ok();
|
||||||
if !headless {
|
if !headless {
|
||||||
ensure_runtime_dir();
|
ensure_runtime_dir();
|
||||||
@ -80,6 +83,10 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
|
if env::var("LESAVKA_LAUNCHER_CHILD").is_err() && launcher::maybe_run_launcher(&args)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut app = LesavkaClientApp::new()?;
|
let mut app = LesavkaClientApp::new()?;
|
||||||
app.run().await
|
app.run().await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"clippy_warnings": 42,
|
"clippy_warnings": 42,
|
||||||
"doc_debt": 10,
|
"doc_debt": 10,
|
||||||
"loc": 508
|
"loc": 519
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
@ -21,9 +21,9 @@
|
|||||||
"loc": 368
|
"loc": 368
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"clippy_warnings": 38,
|
"clippy_warnings": 40,
|
||||||
"doc_debt": 9,
|
"doc_debt": 9,
|
||||||
"loc": 425
|
"loc": 467
|
||||||
},
|
},
|
||||||
"client/src/input/keyboard.rs": {
|
"client/src/input/keyboard.rs": {
|
||||||
"clippy_warnings": 24,
|
"clippy_warnings": 24,
|
||||||
@ -50,6 +50,31 @@
|
|||||||
"doc_debt": 8,
|
"doc_debt": 8,
|
||||||
"loc": 317
|
"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": {
|
"client/src/layout.rs": {
|
||||||
"clippy_warnings": 6,
|
"clippy_warnings": 6,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
@ -58,12 +83,12 @@
|
|||||||
"client/src/lib.rs": {
|
"client/src/lib.rs": {
|
||||||
"clippy_warnings": 0,
|
"clippy_warnings": 0,
|
||||||
"doc_debt": 0,
|
"doc_debt": 0,
|
||||||
"loc": 13
|
"loc": 14
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"clippy_warnings": 2,
|
"clippy_warnings": 2,
|
||||||
"doc_debt": 2,
|
"doc_debt": 2,
|
||||||
"loc": 86
|
"loc": 93
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"clippy_warnings": 43,
|
"clippy_warnings": 43,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"client/src/app.rs": {
|
"client/src/app.rs": {
|
||||||
"line_percent": 97.22222222222221,
|
"line_percent": 95.1219512195122,
|
||||||
"loc": 508
|
"loc": 519
|
||||||
},
|
},
|
||||||
"client/src/app_support.rs": {
|
"client/src/app_support.rs": {
|
||||||
"line_percent": 100.0,
|
"line_percent": 100.0,
|
||||||
@ -17,8 +17,8 @@
|
|||||||
"loc": 368
|
"loc": 368
|
||||||
},
|
},
|
||||||
"client/src/input/inputs.rs": {
|
"client/src/input/inputs.rs": {
|
||||||
"line_percent": 98.02631578947368,
|
"line_percent": 97.0059880239521,
|
||||||
"loc": 425
|
"loc": 467
|
||||||
},
|
},
|
||||||
"client/src/input/keyboard.rs": {
|
"client/src/input/keyboard.rs": {
|
||||||
"line_percent": 95.27559055118111,
|
"line_percent": 95.27559055118111,
|
||||||
@ -36,13 +36,33 @@
|
|||||||
"line_percent": 97.32142857142857,
|
"line_percent": 97.32142857142857,
|
||||||
"loc": 317
|
"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": {
|
"client/src/layout.rs": {
|
||||||
"line_percent": 97.72727272727273,
|
"line_percent": 97.72727272727273,
|
||||||
"loc": 78
|
"loc": 78
|
||||||
},
|
},
|
||||||
"client/src/main.rs": {
|
"client/src/main.rs": {
|
||||||
"line_percent": 96.7741935483871,
|
"line_percent": 96.90721649484536,
|
||||||
"loc": 86
|
"loc": 93
|
||||||
},
|
},
|
||||||
"client/src/output/audio.rs": {
|
"client/src/output/audio.rs": {
|
||||||
"line_percent": 98.59154929577466,
|
"line_percent": 98.59154929577466,
|
||||||
|
|||||||
@ -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<()> {
|
pub fn init(&mut self) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user