508 lines
20 KiB
Rust
Raw Normal View History

use anyhow::Result;
#[cfg(not(coverage))]
use {
super::devices::DeviceCatalog,
super::diagnostics::{
DiagnosticsLog, PerformanceSample, SnapshotReport, quality_probe_command,
},
super::runtime_env_vars,
super::state::{InputRouting, LauncherState, ViewMode},
gtk::prelude::*,
std::cell::RefCell,
std::process::{Child, Command},
std::rc::Rc,
std::time::{SystemTime, UNIX_EPOCH},
};
#[cfg(not(coverage))]
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
let app = gtk::Application::builder()
.application_id("dev.lesavka.launcher")
.build();
let catalog = Rc::new(DeviceCatalog::discover());
let state = Rc::new(RefCell::new(LauncherState::new()));
state.borrow_mut().apply_catalog_defaults(&catalog);
let diagnostics = Rc::new(RefCell::new(DiagnosticsLog::new(120)));
let child_proc = Rc::new(RefCell::new(None::<Child>));
let server_addr = Rc::new(server_addr);
{
let child_proc = Rc::clone(&child_proc);
app.connect_shutdown(move |_| {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
});
}
{
let catalog = Rc::clone(&catalog);
let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics);
let child_proc = Rc::clone(&child_proc);
let server_addr = Rc::clone(&server_addr);
app.connect_activate(move |app| {
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Lesavka Launcher")
.default_width(680)
.default_height(520)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
root.set_margin_start(14);
root.set_margin_end(14);
root.set_margin_top(14);
root.set_margin_bottom(14);
let heading = gtk::Label::new(Some("Lesavka Session Launcher"));
heading.add_css_class("title-2");
heading.set_halign(gtk::Align::Start);
root.append(&heading);
let status_label = gtk::Label::new(Some("Idle"));
status_label.set_halign(gtk::Align::Start);
status_label.set_selectable(true);
root.append(&status_label);
let server_label = gtk::Label::new(Some(&format!("Server: {}", server_addr.as_ref())));
server_label.set_halign(gtk::Align::Start);
server_label.set_selectable(true);
root.append(&server_label);
let controls = gtk::Grid::new();
controls.set_row_spacing(8);
controls.set_column_spacing(8);
root.append(&controls);
let routing_label = gtk::Label::new(Some("Remote input capture"));
routing_label.set_halign(gtk::Align::Start);
controls.attach(&routing_label, 0, 0, 1, 1);
let routing_switch = gtk::Switch::new();
routing_switch.set_active(matches!(state.borrow().routing, InputRouting::Remote));
controls.attach(&routing_switch, 1, 0, 1, 1);
let view_label = gtk::Label::new(Some("View mode"));
view_label.set_halign(gtk::Align::Start);
controls.attach(&view_label, 0, 1, 1, 1);
let view_combo = gtk::ComboBoxText::new();
view_combo.append(Some("unified"), "unified");
view_combo.append(Some("breakout"), "breakout");
view_combo.set_active(Some(match state.borrow().view_mode {
ViewMode::Unified => 0,
ViewMode::Breakout => 1,
}));
controls.attach(&view_combo, 1, 1, 1, 1);
let camera_label = gtk::Label::new(Some("Camera"));
camera_label.set_halign(gtk::Align::Start);
controls.attach(&camera_label, 0, 2, 1, 1);
let camera_combo = gtk::ComboBoxText::new();
camera_combo.append(Some("auto"), "auto");
for camera in &catalog.cameras {
camera_combo.append(Some(camera), camera);
}
set_combo_active_text(&camera_combo, state.borrow().devices.camera.as_deref());
controls.attach(&camera_combo, 1, 2, 1, 1);
let microphone_label = gtk::Label::new(Some("Microphone"));
microphone_label.set_halign(gtk::Align::Start);
controls.attach(&microphone_label, 0, 3, 1, 1);
let microphone_combo = gtk::ComboBoxText::new();
microphone_combo.append(Some("auto"), "auto");
for microphone in &catalog.microphones {
microphone_combo.append(Some(microphone), microphone);
}
set_combo_active_text(
&microphone_combo,
state.borrow().devices.microphone.as_deref(),
);
controls.attach(&microphone_combo, 1, 3, 1, 1);
let speaker_label = gtk::Label::new(Some("Speaker"));
speaker_label.set_halign(gtk::Align::Start);
controls.attach(&speaker_label, 0, 4, 1, 1);
let speaker_combo = gtk::ComboBoxText::new();
speaker_combo.append(Some("auto"), "auto");
for speaker in &catalog.speakers {
speaker_combo.append(Some(speaker), speaker);
}
set_combo_active_text(&speaker_combo, state.borrow().devices.speaker.as_deref());
controls.attach(&speaker_combo, 1, 4, 1, 1);
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
root.append(&button_row);
let start_button = gtk::Button::with_label("Start Session");
let stop_button = gtk::Button::with_label("Stop Session");
let view_toggle_button = gtk::Button::with_label("");
let input_toggle_button = gtk::Button::with_label("");
let snapshot_button = gtk::Button::with_label("Save Snapshot");
button_row.append(&start_button);
button_row.append(&stop_button);
button_row.append(&view_toggle_button);
button_row.append(&input_toggle_button);
button_row.append(&snapshot_button);
sync_toggle_button_labels(&state.borrow(), &view_toggle_button, &input_toggle_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 renders both streams side-by-side in one window. Use Pop Out Windows to split back into full windows. Quick input toggle key defaults to Scroll Lock.",
));
note.set_wrap(true);
note.set_halign(gtk::Align::Start);
root.append(&note);
{
let state = Rc::clone(&state);
let diagnostics = Rc::clone(&diagnostics);
let child_proc = Rc::clone(&child_proc);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let view_combo = view_combo.clone();
let camera_combo = camera_combo.clone();
let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone();
let server_addr = Rc::clone(&server_addr);
let view_toggle_button = view_toggle_button.clone();
let input_toggle_button = input_toggle_button.clone();
start_button.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let routing = if routing_switch.is_active() {
InputRouting::Remote
} else {
InputRouting::Local
};
state.set_routing(routing);
state.set_view_mode(if view_combo.active() == Some(0) {
ViewMode::Unified
} else {
ViewMode::Breakout
});
state.select_camera(selected_combo_value(&camera_combo));
state.select_microphone(selected_combo_value(&microphone_combo));
state.select_speaker(selected_combo_value(&speaker_combo));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
status_label.set_text("Session already running");
return;
}
let spawn_result = {
let mut state = state.borrow_mut();
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
};
match spawn_result {
Ok(()) => {
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 |_| {
stop_child_process(&child_proc);
let _ = state.borrow_mut().stop_remote();
status_label.set_text("Stopped");
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let diagnostics = Rc::clone(&diagnostics);
let status_label = status_label.clone();
let view_combo = view_combo.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let server_addr = Rc::clone(&server_addr);
let view_toggle_button_handle = view_toggle_button.clone();
view_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_view_mode(state.view_mode);
state.set_view_mode(next);
let _ = view_combo.set_active_id(Some(state.view_mode.as_env()));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = {
let mut state = state.borrow_mut();
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
};
match spawn_result {
Ok(()) => {
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!(
"View switched live: {}",
state.borrow().status_line()
));
}
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("View switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
}
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let diagnostics = Rc::clone(&diagnostics);
let status_label = status_label.clone();
let routing_switch = routing_switch.clone();
let input_toggle_button = input_toggle_button.clone();
let view_toggle_button = view_toggle_button.clone();
let server_addr = Rc::clone(&server_addr);
let input_toggle_button_handle = input_toggle_button.clone();
input_toggle_button_handle.connect_clicked(move |_| {
{
let mut state = state.borrow_mut();
let next = next_input_routing(state.routing);
state.set_routing(next);
routing_switch.set_active(matches!(state.routing, InputRouting::Remote));
}
sync_toggle_button_labels(
&state.borrow(),
&view_toggle_button,
&input_toggle_button,
);
if child_proc.borrow().is_some() {
let spawn_result = {
let mut state = state.borrow_mut();
launch_or_restart_client(&child_proc, server_addr.as_ref(), &mut state)
};
match spawn_result {
Ok(()) => {
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!(
"Input mode switched live: {}",
state.borrow().status_line()
));
}
Err(err) => {
let _ = state.borrow_mut().stop_remote();
status_label.set_text(&format!("Input switch failed: {err}"));
}
}
} else {
status_label.set_text(&format!("Input ready: {}", state.borrow().status_line()));
}
});
}
{
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_with_args::<&str>(&[]);
Ok(())
}
#[cfg(coverage)]
pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
Ok(())
}
#[cfg(not(coverage))]
fn selected_combo_value(combo: &gtk::ComboBoxText) -> Option<String> {
combo.active_text().and_then(|value| {
let value = value.to_string();
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
None
} else {
Some(trimmed.to_string())
}
})
}
#[cfg(not(coverage))]
fn set_combo_active_text(combo: &gtk::ComboBoxText, wanted: Option<&str>) {
let wanted = wanted.unwrap_or("auto");
if !combo.set_active_id(Some(wanted)) {
let _ = combo.set_active_id(Some("auto"));
}
}
#[cfg(not(coverage))]
fn spawn_client_process(server_addr: &str, state: &LauncherState) -> Result<Child> {
let exe = std::env::current_exe()?;
let mut command = Command::new(exe);
command.env("LESAVKA_LAUNCHER_CHILD", "1");
command.env("LESAVKA_SERVER_ADDR", server_addr);
for (key, value) in runtime_env_vars(state) {
command.env(key, value);
}
Ok(command.spawn()?)
}
#[cfg(not(coverage))]
/// Stops and reaps the launcher child process when one is running.
fn stop_child_process(child_proc: &Rc<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
#[cfg(not(coverage))]
/// Restarts the launcher child process with the current launcher state.
fn launch_or_restart_client(
child_proc: &Rc<RefCell<Option<Child>>>,
server_addr: &str,
state: &mut LauncherState,
) -> Result<()> {
stop_child_process(child_proc);
let _ = state.start_remote();
let child = spawn_client_process(server_addr, state)?;
*child_proc.borrow_mut() = Some(child);
Ok(())
}
#[cfg(not(coverage))]
/// Flips between unified preview and breakout windows.
fn next_view_mode(mode: ViewMode) -> ViewMode {
match mode {
ViewMode::Unified => ViewMode::Breakout,
ViewMode::Breakout => ViewMode::Unified,
}
}
#[cfg(not(coverage))]
/// Flips between remote input capture and local input passthrough.
fn next_input_routing(routing: InputRouting) -> InputRouting {
match routing {
InputRouting::Remote => InputRouting::Local,
InputRouting::Local => InputRouting::Remote,
}
}
#[cfg(not(coverage))]
/// Keeps toggle buttons aligned with the launcher's current state.
fn sync_toggle_button_labels(
state: &LauncherState,
view_toggle_button: &gtk::Button,
input_toggle_button: &gtk::Button,
) {
view_toggle_button.set_label(match state.view_mode {
ViewMode::Unified => "Pop Out Windows",
ViewMode::Breakout => "Merge Windows",
});
input_toggle_button.set_label(match state.routing {
InputRouting::Remote => "Use Local Inputs",
InputRouting::Local => "Use Remote Inputs",
});
}
#[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());
}
}