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> {
|
||||
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")
|
||||
|
||||
@ -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;
|
||||
|
||||
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;
|
||||
pub mod handshake;
|
||||
pub mod input;
|
||||
pub mod launcher;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
pub mod paste;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user