2026-04-13 23:11:35 -03:00
|
|
|
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(
|
2026-04-14 02:34:14 -03:00
|
|
|
"Unified mode renders both streams side-by-side in one window. Breakout mode keeps dedicated per-eye windows.",
|
2026-04-13 23:11:35 -03:00
|
|
|
));
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|