use anyhow::Result; #[cfg(not(coverage))] use { super::clipboard::send_clipboard_to_remote, super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, super::launcher_focus_signal_path, super::preview::{LauncherPreview, PreviewBinding}, super::runtime_env_vars, super::state::{DisplaySurface, InputRouting, LauncherState}, super::LAUNCHER_FOCUS_SIGNAL_ENV, gtk::glib, gtk::prelude::*, std::cell::RefCell, std::path::{Path, PathBuf}, std::process::{Child, Command}, std::rc::Rc, std::time::Duration, }; #[cfg(not(coverage))] const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; #[cfg(not(coverage))] const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; #[cfg(not(coverage))] const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; #[cfg(not(coverage))] const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; #[cfg(not(coverage))] #[derive(Clone)] struct SummaryWidgets { relay_value: gtk::Label, routing_value: gtk::Label, displays_value: gtk::Label, shortcut_value: gtk::Label, } #[cfg(not(coverage))] #[derive(Clone)] struct DisplayPaneWidgets { root: gtk::Box, stack: gtk::Stack, picture: gtk::Picture, stream_status: gtk::Label, placeholder: gtk::Label, action_button: gtk::Button, preview_binding: Option, title: String, } #[cfg(not(coverage))] struct PopoutWindowHandle { window: gtk::ApplicationWindow, binding: PreviewBinding, } #[cfg(not(coverage))] #[derive(Clone)] struct LauncherWidgets { status_label: gtk::Label, summary: SummaryWidgets, display_panes: [DisplayPaneWidgets; 2], start_button: gtk::Button, stop_button: gtk::Button, input_toggle_button: gtk::Button, clipboard_button: gtk::Button, toggle_key_combo: gtk::ComboBoxText, } #[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 focus_signal_path = Rc::new(launcher_focus_signal_path()); let input_control_path = Rc::new(input_control_path()); let input_state_path = Rc::new(input_state_path()); let _ = std::fs::remove_file(focus_signal_path.as_path()); let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); { let child_proc = Rc::clone(&child_proc); let focus_signal_path = Rc::clone(&focus_signal_path); let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); app.connect_shutdown(move |_| { stop_child_process(&child_proc); let _ = std::fs::remove_file(focus_signal_path.as_path()); let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); }); } { let catalog = Rc::clone(&catalog); let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let server_addr = Rc::clone(&server_addr); let focus_signal_path = Rc::clone(&focus_signal_path); let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); app.connect_activate(move |app| { let window = gtk::ApplicationWindow::builder() .application(app) .title("Lesavka Launcher") .default_width(1120) .default_height(920) .build(); let scroll = gtk::ScrolledWindow::new(); scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); let root = gtk::Box::new(gtk::Orientation::Vertical, 14); root.set_margin_start(18); root.set_margin_end(18); root.set_margin_top(18); root.set_margin_bottom(18); let heading = gtk::Label::new(Some("Lesavka Control Deck")); heading.add_css_class("title-2"); heading.set_halign(gtk::Align::Start); root.append(&heading); let status_label = gtk::Label::new(Some( "Launcher ready - previews stay here, relay starts only when you ask for it.", )); status_label.set_halign(gtk::Align::Start); status_label.set_wrap(true); status_label.set_selectable(true); root.append(&status_label); let (summary_frame, summary_box) = build_section("Session Status"); let summary_grid = gtk::Grid::new(); summary_grid.set_row_spacing(8); summary_grid.set_column_spacing(12); summary_box.append(&summary_grid); let summary = SummaryWidgets { relay_value: attach_summary_row(&summary_grid, 0, "Relay", "Stopped"), routing_value: attach_summary_row(&summary_grid, 1, "Input Target", "Remote"), displays_value: attach_summary_row( &summary_grid, 2, "Displays", "Display 1: preview | Display 2: preview", ), shortcut_value: attach_summary_row(&summary_grid, 3, "Swap Key", "Pause"), }; root.append(&summary_frame); let (connection_frame, connection_box) = build_section("Connection"); let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); 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()); let start_button = gtk::Button::with_label("Start Relay"); let stop_button = gtk::Button::with_label("Stop Relay"); server_row.append(&server_label); server_row.append(&server_entry); server_row.append(&start_button); server_row.append(&stop_button); connection_box.append(&server_row); let connection_note = gtk::Label::new(Some( "Starting relay launches the live input/audio session to the remote host. Stopping relay severs that session cleanly.", )); connection_note.set_wrap(true); connection_note.set_halign(gtk::Align::Start); connection_box.append(&connection_note); root.append(&connection_frame); let (inputs_frame, inputs_box) = build_section("Input Routing And Devices"); let inputs_grid = gtk::Grid::new(); inputs_grid.set_row_spacing(8); inputs_grid.set_column_spacing(12); inputs_box.append(&inputs_grid); let input_toggle_button = gtk::Button::with_label("Switch To Local Inputs"); inputs_grid.attach(>k::Label::new(Some("Live Input Target")), 0, 0, 1, 1); inputs_grid.attach(&input_toggle_button, 1, 0, 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")); inputs_grid.attach(>k::Label::new(Some("Swap Key")), 0, 1, 1, 1); inputs_grid.attach(&toggle_key_combo, 1, 1, 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()); inputs_grid.attach(>k::Label::new(Some("Camera")), 0, 2, 1, 1); inputs_grid.attach(&camera_combo, 1, 2, 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(), ); inputs_grid.attach(>k::Label::new(Some("Microphone")), 0, 3, 1, 1); inputs_grid.attach(µphone_combo, 1, 3, 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()); inputs_grid.attach(>k::Label::new(Some("Speaker")), 0, 4, 1, 1); inputs_grid.attach(&speaker_combo, 1, 4, 1, 1); let inputs_note = gtk::Label::new(Some( "Press the swap key while relay is running to flip between local and remote input ownership. The launcher reflects that live state and macros stay launcher-only.", )); inputs_note.set_wrap(true); inputs_note.set_halign(gtk::Align::Start); inputs_box.append(&inputs_note); root.append(&inputs_frame); let (displays_frame, displays_box) = build_section("Displays"); let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 14); let left_pane = build_display_pane("Display 1"); let right_pane = build_display_pane("Display 2"); display_row.append(&left_pane.root); display_row.append(&right_pane.root); displays_box.append(&display_row); root.append(&displays_frame); let (actions_frame, actions_box) = build_section("Remote Actions"); let clipboard_button = gtk::Button::with_label("Send Clipboard"); actions_box.append(&clipboard_button); let actions_note = gtk::Label::new(Some( "Clipboard paste is a launcher action only. It types the current local clipboard into the remote target machine when relay is active.", )); actions_note.set_wrap(true); actions_note.set_halign(gtk::Align::Start); actions_box.append(&actions_note); root.append(&actions_frame); let (diagnostics_frame, diagnostics_box) = build_section("Diagnostics"); let probe_hint = gtk::Label::new(Some(quality_probe_command())); probe_hint.set_halign(gtk::Align::Start); probe_hint.set_selectable(true); diagnostics_box.append(&probe_hint); let diagnostics_note = gtk::Label::new(Some( "Keep the hygiene and quality gates green before calling the launcher changes done. Metrics still land in the local Prometheus textfile output.", )); diagnostics_note.set_wrap(true); diagnostics_note.set_halign(gtk::Align::Start); diagnostics_box.append(&diagnostics_note); root.append(&diagnostics_frame); let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) { Ok(preview) => Some(Rc::new(preview)), Err(err) => { let msg = format!("Preview unavailable: {err}"); status_label.set_text(&msg); None } }; let mut left_pane = left_pane; let mut right_pane = right_pane; if let Some(preview) = preview.as_ref() { left_pane.preview_binding = preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status); right_pane.preview_binding = preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status); } else { left_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable"); } let widgets = LauncherWidgets { status_label: status_label.clone(), summary, display_panes: [left_pane.clone(), right_pane.clone()], start_button: start_button.clone(), stop_button: stop_button.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), toggle_key_combo: toggle_key_combo.clone(), }; let popouts = Rc::new(RefCell::new([None, None])); refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); { let widgets = widgets.clone(); let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); toggle_key_combo.connect_changed(move |_| { refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let server_entry = server_entry.clone(); let camera_combo = camera_combo.clone(); let microphone_combo = microphone_combo.clone(); let speaker_combo = speaker_combo.clone(); let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); let server_addr_fallback = Rc::clone(&server_addr); start_button.connect_clicked(move |_| { if child_proc.borrow().is_some() { widgets.status_label.set_text("Relay is already running"); refresh_launcher_ui(&widgets, &state.borrow(), true); return; } { let mut state = state.borrow_mut(); state.select_camera(selected_combo_value(&camera_combo)); state.select_microphone(selected_combo_value(µphone_combo)); state.select_speaker(selected_combo_value(&speaker_combo)); } let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); let launch_state = state.borrow().clone(); let input_toggle_key = selected_toggle_key(&widgets.toggle_key_combo); match spawn_client_process( &server_addr, &launch_state, &input_toggle_key, input_control_path.as_path(), input_state_path.as_path(), ) { Ok(child) => { *child_proc.borrow_mut() = Some(child); let _ = state.borrow_mut().start_remote(); let routing = state.borrow().routing; widgets.status_label.set_text(&format!( "Relay started - input target is {}", routing_name(routing) )); } Err(err) => { widgets .status_label .set_text(&format!("Relay start failed: {err}")); } } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); stop_button.connect_clicked(move |_| { stop_child_process(&child_proc); let _ = state.borrow_mut().stop_remote(); widgets.status_label.set_text("Relay stopped"); refresh_launcher_ui(&widgets, &state.borrow(), false); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let input_control_path = Rc::clone(&input_control_path); input_toggle_button.connect_clicked(move |_| { let next = next_input_routing(state.borrow().routing); let child_running = child_proc.borrow().is_some(); if child_running { if let Err(err) = write_input_routing_request(input_control_path.as_path(), next) { widgets.status_label.set_text(&format!( "Could not update live input target: {err}" )); refresh_launcher_ui(&widgets, &state.borrow(), true); return; } widgets.status_label.set_text(&format!( "Requested {} input control - pressing the swap key mirrors this live.", routing_name(next) )); } else { widgets.status_label.set_text(&format!( "Relay will start with {} input control.", routing_name(next) )); } state.borrow_mut().set_routing(next); refresh_launcher_ui(&widgets, &state.borrow(), child_running); }); } { let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let server_entry = server_entry.clone(); let server_addr_fallback = Rc::clone(&server_addr); clipboard_button.connect_clicked(move |_| { if child_proc.borrow().is_none() { widgets.status_label.set_text("Start relay before sending clipboard"); return; } let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets.status_label.set_text("Sending clipboard to remote..."); let (result_tx, result_rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { let message = match send_clipboard_to_remote(&server_addr) { Ok(mode) => mode, Err(err) => format!("Clipboard send failed: {err}"), }; let _ = result_tx.send(message); }); let status_label = widgets.status_label.clone(); glib::timeout_add_local(Duration::from_millis(100), move || { match result_rx.try_recv() { Ok(message) => { status_label.set_text(&message); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => { glib::ControlFlow::Continue } Err(std::sync::mpsc::TryRecvError::Disconnected) => { status_label .set_text("Clipboard send failed: launcher worker exited"); glib::ControlFlow::Break } } }); }); } for monitor_id in 0..2 { let app = app.clone(); let preview = preview.clone(); let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let popouts = Rc::clone(&popouts); let widgets = widgets.clone(); let action_button = widgets.display_panes[monitor_id].action_button.clone(); action_button.connect_clicked(move |_| { let Some(preview) = preview.as_ref() else { widgets .status_label .set_text("Preview is unavailable for breakout windows"); return; }; let surface = state.borrow().display_surface(monitor_id); match surface { DisplaySurface::Preview => { open_popout_window( &app, preview, &state, &child_proc, &popouts, &widgets, monitor_id, ); widgets.status_label.set_text(&format!( "{} moved into its own window", widgets.display_panes[monitor_id].title )); } DisplaySurface::Window => { dock_display_to_preview( &state, &child_proc, &popouts, &widgets, monitor_id, ); widgets.status_label.set_text(&format!( "{} returned to the launcher preview", widgets.display_panes[monitor_id].title )); } } }); } { let window = window.clone(); let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); let widgets = widgets.clone(); let focus_signal_path = Rc::clone(&focus_signal_path); let input_state_path = Rc::clone(&input_state_path); let last_focus_marker = Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); let last_state_marker = Rc::new(RefCell::new(path_marker(input_state_path.as_path()))); glib::timeout_add_local(Duration::from_millis(180), move || { let child_running = reap_exited_child(&child_proc); if !child_running && state.borrow().remote_active { let _ = state.borrow_mut().stop_remote(); widgets.status_label.set_text("Relay ended"); } let next_state_marker = path_marker(input_state_path.as_path()); let mut last_state = last_state_marker.borrow_mut(); if next_state_marker > *last_state { *last_state = next_state_marker; if let Some(routing) = read_input_routing_state(input_state_path.as_path()) { state.borrow_mut().set_routing(routing); refresh_launcher_ui(&widgets, &state.borrow(), child_running); } } let next_focus_marker = path_marker(focus_signal_path.as_path()); let mut last_focus = last_focus_marker.borrow_mut(); if next_focus_marker > *last_focus { *last_focus = next_focus_marker; state.borrow_mut().set_routing(InputRouting::Local); refresh_launcher_ui(&widgets, &state.borrow(), child_running); widgets .status_label .set_text("Local control restored - launcher focused"); window.present(); } refresh_launcher_ui(&widgets, &state.borrow(), child_running); glib::ControlFlow::Continue }); } scroll.set_child(Some(&root)); window.set_child(Some(&scroll)); 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 build_section(title: &str) -> (gtk::Frame, gtk::Box) { let frame = gtk::Frame::new(Some(title)); let body = gtk::Box::new(gtk::Orientation::Vertical, 10); body.set_margin_start(12); body.set_margin_end(12); body.set_margin_top(12); body.set_margin_bottom(12); frame.set_child(Some(&body)); (frame, body) } #[cfg(not(coverage))] fn attach_summary_row(grid: >k::Grid, row: i32, label: &str, value: &str) -> gtk::Label { let key = gtk::Label::new(Some(label)); key.set_halign(gtk::Align::Start); let value_label = gtk::Label::new(Some(value)); value_label.set_halign(gtk::Align::Start); value_label.set_selectable(true); grid.attach(&key, 0, row, 1, 1); grid.attach(&value_label, 1, row, 1, 1); value_label } #[cfg(not(coverage))] fn build_display_pane(title: &str) -> DisplayPaneWidgets { let root = gtk::Box::new(gtk::Orientation::Vertical, 8); root.set_hexpand(true); root.set_vexpand(true); let title_label = gtk::Label::new(Some(title)); title_label.add_css_class("title-4"); title_label.set_halign(gtk::Align::Center); root.append(&title_label); let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_can_shrink(true); picture.set_size_request(460, 258); let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 6); preview_box.append(&picture); let placeholder = gtk::Label::new(Some("This display is docked in the launcher preview.")); placeholder.set_wrap(true); placeholder.set_justify(gtk::Justification::Center); placeholder.set_halign(gtk::Align::Center); placeholder.set_valign(gtk::Align::Center); let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); placeholder_box.set_hexpand(true); placeholder_box.set_vexpand(true); placeholder_box.set_size_request(460, 258); placeholder_box.append(&placeholder); let stack = gtk::Stack::new(); stack.set_hexpand(true); stack.set_vexpand(true); stack.add_named(&preview_box, Some("preview")); stack.add_named(&placeholder_box, Some("placeholder")); stack.set_visible_child_name("preview"); root.append(&stack); let stream_status = gtk::Label::new(Some("Waiting for stream...")); stream_status.set_halign(gtk::Align::Start); root.append(&stream_status); let action_button = gtk::Button::with_label("Break Out"); action_button.set_halign(gtk::Align::Center); root.append(&action_button); DisplayPaneWidgets { root, stack, picture, stream_status, placeholder, action_button, preview_binding: None, title: title.to_string(), } } #[cfg(not(coverage))] fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { widgets.summary.relay_value.set_text(if child_running || state.remote_active { "Running" } else { "Stopped" }); widgets .summary .routing_value .set_text(routing_name(state.routing)); widgets.summary.displays_value.set_text(&format!( "Display 1: {} | Display 2: {}", state.display_surface(0).label(), state.display_surface(1).label() )); widgets .summary .shortcut_value .set_text(&selected_toggle_key_label(&widgets.toggle_key_combo)); widgets.start_button.set_sensitive(!child_running); widgets.stop_button.set_sensitive(child_running); widgets.clipboard_button.set_sensitive(child_running); widgets.input_toggle_button.set_label(match state.routing { InputRouting::Remote => "Switch To Local Inputs", InputRouting::Local => "Switch To Remote Inputs", }); for monitor_id in 0..2 { refresh_display_pane(&widgets.display_panes[monitor_id], state.display_surface(monitor_id)); } } #[cfg(not(coverage))] fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { if let Some(binding) = pane.preview_binding.as_ref() { binding.set_enabled(matches!(surface, DisplaySurface::Preview)); } pane.action_button.set_sensitive(pane.preview_binding.is_some()); match surface { DisplaySurface::Preview => { pane.stack.set_visible_child_name("preview"); pane.action_button.set_label("Break Out"); pane.placeholder .set_text("This display is docked in the launcher preview."); if pane.preview_binding.is_none() { pane.stream_status.set_text("Preview unavailable"); } } DisplaySurface::Window => { pane.stack.set_visible_child_name("placeholder"); pane.action_button.set_label("Return To Preview"); pane.placeholder.set_text(&format!( "{} is open in its own window.\nUse \"Return To Preview\" to dock it back here.", pane.title )); pane.stream_status.set_text("Streaming in its own window"); } } } #[cfg(not(coverage))] fn open_popout_window( app: >k::Application, preview: &LauncherPreview, state: &Rc>, child_proc: &Rc>>, popouts: &Rc; 2]>>, widgets: &LauncherWidgets, monitor_id: usize, ) { let already_open = popouts.borrow()[monitor_id].is_some(); if already_open { return; } if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { binding.set_enabled(false); } let window = gtk::ApplicationWindow::builder() .application(app) .title(&format!("Lesavka {}", widgets.display_panes[monitor_id].title)) .default_width(1280) .default_height(760) .build(); window.maximize(); let root = gtk::Box::new(gtk::Orientation::Vertical, 8); root.set_margin_start(10); root.set_margin_end(10); root.set_margin_top(10); root.set_margin_bottom(10); let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title)); title.add_css_class("title-3"); title.set_halign(gtk::Align::Center); root.append(&title); let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_can_shrink(true); root.append(&picture); let stream_status = gtk::Label::new(Some("Waiting for stream...")); stream_status.set_halign(gtk::Align::Start); root.append(&stream_status); let binding = preview .install_on_picture(monitor_id, &picture, &stream_status) .expect("preview binding for popout"); window.set_child(Some(&root)); let state_handle = Rc::clone(state); let child_proc_handle = Rc::clone(child_proc); let popouts_handle = Rc::clone(popouts); let widgets_handle = widgets.clone(); let close_binding = binding.clone(); window.connect_close_request(move |_| { let handle = { let mut popouts = popouts_handle.borrow_mut(); popouts[monitor_id].take() }; if let Some(handle) = handle { handle.binding.close(); if let Some(preview_binding) = widgets_handle.display_panes[monitor_id].preview_binding.as_ref() { preview_binding.set_enabled(true); } state_handle .borrow_mut() .set_display_surface(monitor_id, DisplaySurface::Preview); refresh_launcher_ui( &widgets_handle, &state_handle.borrow(), child_proc_handle.borrow().is_some(), ); } else { close_binding.close(); } glib::Propagation::Proceed }); state .borrow_mut() .set_display_surface(monitor_id, DisplaySurface::Window); popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle { window: window.clone(), binding, }); refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); window.present(); } #[cfg(not(coverage))] fn dock_display_to_preview( state: &Rc>, child_proc: &Rc>>, popouts: &Rc; 2]>>, widgets: &LauncherWidgets, monitor_id: usize, ) { let handle = { let mut popouts = popouts.borrow_mut(); popouts[monitor_id].take() }; if let Some(handle) = handle { handle.binding.close(); handle.window.close(); } if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { binding.set_enabled(true); } state .borrow_mut() .set_display_surface(monitor_id, DisplaySurface::Preview); refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); } #[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_toggle_key_label(combo: >k::ComboBoxText) -> String { combo.active_text() .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))] fn input_control_path() -> PathBuf { std::env::var(INPUT_CONTROL_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) } #[cfg(not(coverage))] fn input_state_path() -> PathBuf { std::env::var(INPUT_STATE_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) } #[cfg(not(coverage))] fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { std::fs::write(path, format!("{}\n", routing_name(routing)))?; Ok(()) } #[cfg(not(coverage))] fn read_input_routing_state(path: &Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; match raw.trim().to_ascii_lowercase().as_str() { "local" => Some(InputRouting::Local), "remote" => Some(InputRouting::Remote), _ => None, } } #[cfg(not(coverage))] fn routing_name(routing: InputRouting) -> &'static str { match routing { InputRouting::Local => "local", InputRouting::Remote => "remote", } } #[cfg(not(coverage))] fn path_marker(path: &Path) -> u128 { std::fs::metadata(path) .ok() .and_then(|meta| meta.modified().ok()) .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default() } #[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, input_control_path: &Path, input_state_path: &Path, ) -> 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"); command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); command.env(INPUT_CONTROL_ENV, input_control_path); command.env(INPUT_STATE_ENV, input_state_path); command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); command.env("LESAVKA_CLIPBOARD_PASTE", "0"); for (key, value) in runtime_env_vars(state) { command.env(key, value); } Ok(command.spawn()?) } #[cfg(not(coverage))] 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))] fn reap_exited_child(child_proc: &Rc>>) -> bool { let mut slot = child_proc.borrow_mut(); match slot.as_mut() { Some(child) => match child.try_wait() { Ok(Some(_)) => { *slot = None; false } Ok(None) | Err(_) => true, }, None => false, } } #[cfg(not(coverage))] fn next_input_routing(routing: InputRouting) -> InputRouting { match routing { InputRouting::Remote => InputRouting::Local, InputRouting::Local => InputRouting::Remote, } } #[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()); } }