use anyhow::{Result, anyhow}; #[cfg(not(coverage))] use { super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, super::runtime_env_vars, super::state::{InputRouting, LauncherState, ViewMode}, crate::paste, gtk::prelude::*, lesavka_common::lesavka::relay_client::RelayClient, std::cell::RefCell, std::process::{Child, Command}, std::rc::Rc, tokio::runtime::Builder as RuntimeBuilder, tonic::{Request, transport::Channel}, }; #[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 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 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_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let server_label = gtk::Label::new(Some("Server")); server_label.set_halign(gtk::Align::Start); let server_entry = gtk::Entry::new(); server_entry.set_hexpand(true); server_entry.set_text(server_addr.as_ref()); server_row.append(&server_label); server_row.append(&server_entry); root.append(&server_row); 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 toggle_key_label = gtk::Label::new(Some("Input swap key")); toggle_key_label.set_halign(gtk::Align::Start); controls.attach(&toggle_key_label, 0, 5, 1, 1); let toggle_key_combo = gtk::ComboBoxText::new(); toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); toggle_key_combo.append(Some("sysrq"), "SysRq / PrtSc"); toggle_key_combo.append(Some("pause"), "Pause"); toggle_key_combo.append(Some("f12"), "F12"); toggle_key_combo.append(Some("f11"), "F11"); toggle_key_combo.append(Some("f10"), "F10"); toggle_key_combo.append(Some("off"), "Disabled"); let _ = toggle_key_combo.set_active_id(Some("pause")); controls.attach(&toggle_key_combo, 1, 5, 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("End Relay"); let view_toggle_button = gtk::Button::with_label(""); let input_toggle_button = gtk::Button::with_label(""); let clipboard_button = gtk::Button::with_label("Send Clipboard"); 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(&clipboard_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. Input swap key defaults to Pause and can be changed.", )); note.set_wrap(true); note.set_halign(gtk::Align::Start); root.append(¬e); { let state = Rc::clone(&state); 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 toggle_key_combo = toggle_key_combo.clone(); let server_entry = server_entry.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 = relaunch_with_settings( &child_proc, &state, &server_entry, server_addr.as_ref(), &toggle_key_combo, ); match spawn_result { Ok(()) => 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("Relay ended"); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); 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 toggle_key_combo = toggle_key_combo.clone(); let server_entry = server_entry.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 = relaunch_with_settings( &child_proc, &state, &server_entry, server_addr.as_ref(), &toggle_key_combo, ); match spawn_result { Ok(()) => 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 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 toggle_key_combo = toggle_key_combo.clone(); let server_entry = server_entry.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 = relaunch_with_settings( &child_proc, &state, &server_entry, server_addr.as_ref(), &toggle_key_combo, ); match spawn_result { Ok(()) => 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 child_proc = Rc::clone(&child_proc); let status_label = status_label.clone(); let server_entry = server_entry.clone(); let server_addr = Rc::clone(&server_addr); clipboard_button.connect_clicked(move |_| { if child_proc.borrow().is_none() { status_label.set_text("Start Session before sending clipboard"); return; } let server_addr = selected_server_addr(&server_entry, server_addr.as_ref()); match send_clipboard_to_remote(&server_addr) { Ok(()) => status_label.set_text("Clipboard delivered to remote"), Err(err) => status_label.set_text(&format!("Clipboard send 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 selected_toggle_key(combo: >k::ComboBoxText) -> String { combo .active_id() .map(|value| value.to_string()) .unwrap_or_else(|| "pause".to_string()) } #[cfg(not(coverage))] fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { let current = entry.text(); let trimmed = current.trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } #[cfg(not(coverage))] /// Applies the current server/key launcher controls and relaunches the child session. fn relaunch_with_settings( child_proc: &Rc>>, state: &Rc>, server_entry: >k::Entry, server_fallback: &str, toggle_key_combo: >k::ComboBoxText, ) -> Result<()> { let server_addr = selected_server_addr(server_entry, server_fallback); let input_toggle_key = selected_toggle_key(toggle_key_combo); let mut state = state.borrow_mut(); launch_or_restart_client(child_proc, &server_addr, &mut state, &input_toggle_key) } #[cfg(not(coverage))] /// Reads local clipboard text and sends it to the remote server's paste RPC. fn send_clipboard_to_remote(server_addr: &str) -> Result<()> { let text = read_clipboard_text().ok_or_else(|| anyhow!("clipboard is empty or unavailable"))?; let req = paste::build_paste_request(&text)?; let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; rt.block_on(async { let channel = Channel::from_shared(server_addr.to_string())? .connect() .await?; let mut cli = RelayClient::new(channel); let reply = cli.paste_text(Request::new(req)).await?; if reply.get_ref().ok { Ok(()) } else { Err(anyhow!("server rejected paste: {}", reply.get_ref().error)) } }) } #[cfg(not(coverage))] fn read_clipboard_text() -> Option { if let Ok(out) = Command::new("sh") .arg("-lc") .arg(std::env::var("LESAVKA_CLIPBOARD_CMD").unwrap_or_else( |_| "wl-paste --no-newline --type text/plain || xclip -selection clipboard -o || xsel -b -o".to_string(), )) .output() && out.status.success() { let text = String::from_utf8_lossy(&out.stdout).to_string(); if !text.is_empty() { return Some(text); } } None } #[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, input_toggle_key: &str, ) -> 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); command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); 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, input_toggle_key: &str, ) -> Result<()> { stop_child_process(child_proc); let _ = state.start_remote(); let child = spawn_client_process(server_addr, state, input_toggle_key)?; *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(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()); } }