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::)); 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 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(¬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); 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(µphone_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: >k::ComboBoxText) -> Option { 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 { 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>>) { 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>>, 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: >k::Button, input_toggle_button: >k::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()); } }