997 lines
39 KiB
Rust
Raw Normal View History

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<PreviewBinding>,
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::<Child>));
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();
2026-04-14 14:38:03 -03:00
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);
2026-04-14 14:38:03 -03:00
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(&gtk::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(&gtk::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(&gtk::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(
&microphone_combo,
state.borrow().devices.microphone.as_deref(),
);
inputs_grid.attach(&gtk::Label::new(Some("Microphone")), 0, 3, 1, 1);
inputs_grid.attach(&microphone_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(&gtk::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)),
2026-04-14 14:38:03 -03:00
Err(err) => {
let msg = format!("Preview unavailable: {err}");
status_label.set_text(&msg);
None
2026-04-14 14:38:03 -03:00
}
};
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());
});
2026-04-14 14:38:03 -03:00
}
{
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(&microphone_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::<String>();
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
});
}
2026-04-14 14:38:03 -03:00
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: &gtk::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: &gtk::Application,
preview: &LauncherPreview,
state: &Rc<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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: &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 selected_toggle_key(combo: &gtk::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: &gtk::ComboBoxText) -> String {
combo.active_text()
.map(|value| value.to_string())
.unwrap_or_else(|| "Pause".to_string())
}
#[cfg(not(coverage))]
fn selected_server_addr(entry: &gtk::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<InputRouting> {
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: &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,
input_toggle_key: &str,
input_control_path: &Path,
input_state_path: &Path,
) -> 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);
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<RefCell<Option<Child>>>) {
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<RefCell<Option<Child>>>) -> 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());
}
}