799 lines
33 KiB
Rust
799 lines
33 KiB
Rust
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,
|
|
super::runtime_env_vars,
|
|
super::state::{InputRouting, LauncherState, ViewMode},
|
|
super::LAUNCHER_FOCUS_SIGNAL_ENV,
|
|
gtk::prelude::*,
|
|
gtk::glib,
|
|
std::cell::RefCell,
|
|
std::path::Path,
|
|
std::process::{Child, Command},
|
|
std::rc::Rc,
|
|
std::time::Duration,
|
|
};
|
|
|
|
#[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 _ = std::fs::remove_file(focus_signal_path.as_path());
|
|
|
|
{
|
|
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);
|
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
|
|
|
app.connect_activate(move |app| {
|
|
let window = gtk::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("Lesavka Launcher")
|
|
.default_width(980)
|
|
.default_height(860)
|
|
.build();
|
|
|
|
let scroll = gtk::ScrolledWindow::new();
|
|
scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
|
|
|
|
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 - preview warming up"));
|
|
status_label.set_halign(gtk::Align::Start);
|
|
status_label.set_selectable(true);
|
|
root.append(&status_label);
|
|
|
|
let preview_frame = gtk::Frame::new(Some("Live Preview"));
|
|
let preview_row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
|
let (left_preview, left_picture, left_status) = build_preview_pane("Display 1");
|
|
let (right_preview, right_picture, right_status) = build_preview_pane("Display 2");
|
|
preview_row.append(&left_preview);
|
|
preview_row.append(&right_preview);
|
|
preview_frame.set_child(Some(&preview_row));
|
|
root.append(&preview_frame);
|
|
|
|
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("Start in remote control"));
|
|
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("Preview 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 Relay");
|
|
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(
|
|
"The live preview stays in this launcher by default so you can watch both displays before handing control over. Start Relay now starts in remote control by default, Pop Out Windows pauses the preview and moves you back to the external video windows, and pressing the swap key returns local control and re-focuses this launcher.",
|
|
));
|
|
note.set_wrap(true);
|
|
note.set_halign(gtk::Align::Start);
|
|
root.append(¬e);
|
|
|
|
let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) {
|
|
Ok(preview) => {
|
|
let preview = Rc::new(preview);
|
|
preview.install_on_picture(0, &left_picture, &left_status);
|
|
preview.install_on_picture(1, &right_picture, &right_status);
|
|
Some(preview)
|
|
}
|
|
Err(err) => {
|
|
let msg = format!("Preview unavailable: {err}");
|
|
left_status.set_text(&msg);
|
|
right_status.set_text(&msg);
|
|
None
|
|
}
|
|
};
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
|
|
{
|
|
let window = window.clone();
|
|
let preview_frame = preview_frame.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
|
let routing_switch = routing_switch.clone();
|
|
let view_toggle_button = view_toggle_button.clone();
|
|
let input_toggle_button = input_toggle_button.clone();
|
|
let last_focus_marker =
|
|
Rc::new(RefCell::new(focus_signal_marker(focus_signal_path.as_path())));
|
|
let last_focus_marker_handle = Rc::clone(&last_focus_marker);
|
|
let status_label = status_label.clone();
|
|
let state = Rc::clone(&state);
|
|
let preview = preview.clone();
|
|
glib::timeout_add_local(Duration::from_millis(200), move || {
|
|
let next_marker = focus_signal_marker(focus_signal_path.as_path());
|
|
let mut last_marker = last_focus_marker_handle.borrow_mut();
|
|
if next_marker > *last_marker {
|
|
*last_marker = next_marker;
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_routing(InputRouting::Local);
|
|
routing_switch.set_active(false);
|
|
sync_toggle_button_labels(
|
|
&state,
|
|
&view_toggle_button,
|
|
&input_toggle_button,
|
|
);
|
|
}
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text("Local control restored - launcher focused");
|
|
window.present();
|
|
}
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let window = window.clone();
|
|
let preview_frame = preview_frame.clone();
|
|
let preview = preview.clone();
|
|
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,
|
|
preview.as_deref(),
|
|
);
|
|
|
|
match spawn_result {
|
|
Ok(()) => {
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
|
queue_breakout_window_surface(&window);
|
|
}
|
|
status_label.set_text(&format!("Started: {}", state.borrow().status_line()));
|
|
}
|
|
Err(err) => {
|
|
let _ = state.borrow_mut().stop_remote();
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text(&format!("Start failed: {err}"));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let window = window.clone();
|
|
let preview_frame = preview_frame.clone();
|
|
let preview = preview.clone();
|
|
let status_label = status_label.clone();
|
|
stop_button.connect_clicked(move |_| {
|
|
stop_child_process(&child_proc);
|
|
let _ = state.borrow_mut().stop_remote();
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text("Relay ended");
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let window = window.clone();
|
|
let preview_frame = preview_frame.clone();
|
|
let preview = preview.clone();
|
|
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,
|
|
preview.as_deref(),
|
|
);
|
|
match spawn_result {
|
|
Ok(()) => {
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
|
queue_breakout_window_surface(&window);
|
|
}
|
|
status_label
|
|
.set_text(&format!("View switched live: {}", state.borrow().status_line()));
|
|
}
|
|
Err(err) => {
|
|
let _ = state.borrow_mut().stop_remote();
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text(&format!("View switch failed: {err}"));
|
|
}
|
|
}
|
|
} else {
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text(&format!("View ready: {}", state.borrow().status_line()));
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let window = window.clone();
|
|
let preview_frame = preview_frame.clone();
|
|
let preview = preview.clone();
|
|
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,
|
|
preview.as_deref(),
|
|
);
|
|
match spawn_result {
|
|
Ok(()) => {
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
if matches!(state.borrow().view_mode, ViewMode::Breakout) {
|
|
queue_breakout_window_surface(&window);
|
|
}
|
|
status_label.set_text(&format!(
|
|
"Input mode switched live: {}",
|
|
state.borrow().status_line()
|
|
));
|
|
}
|
|
Err(err) => {
|
|
let _ = state.borrow_mut().stop_remote();
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
status_label.set_text(&format!("Input switch failed: {err}"));
|
|
}
|
|
}
|
|
} else {
|
|
sync_preview_runtime_state(
|
|
&window,
|
|
&preview_frame,
|
|
preview.as_deref(),
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
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 Relay before sending clipboard");
|
|
return;
|
|
}
|
|
let server_addr = selected_server_addr(&server_entry, server_addr.as_ref());
|
|
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 = 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
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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 selected_combo_value(combo: >k::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: >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<RefCell<Option<Child>>>,
|
|
state: &Rc<RefCell<LauncherState>>,
|
|
server_entry: >k::Entry,
|
|
server_fallback: &str,
|
|
toggle_key_combo: >k::ComboBoxText,
|
|
preview: Option<&LauncherPreview>,
|
|
) -> 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();
|
|
if matches!(state.view_mode, ViewMode::Breakout) {
|
|
if let Some(preview) = preview {
|
|
preview.set_enabled(false);
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
}
|
|
} else if let Some(preview) = preview {
|
|
preview.set_enabled(true);
|
|
}
|
|
launch_or_restart_client(child_proc, &server_addr, &mut state, &input_toggle_key)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
#[cfg(not(coverage))]
|
|
fn focus_signal_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 sync_preview_runtime_state(
|
|
window: >k::ApplicationWindow,
|
|
preview_frame: >k::Frame,
|
|
preview: Option<&LauncherPreview>,
|
|
state: &LauncherState,
|
|
child_running: bool,
|
|
) {
|
|
let breakout_active = child_running && matches!(state.view_mode, ViewMode::Breakout);
|
|
preview_frame.set_visible(!breakout_active);
|
|
if let Some(preview) = preview {
|
|
preview.set_enabled(!breakout_active);
|
|
}
|
|
if !breakout_active {
|
|
window.present();
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn queue_breakout_window_surface(window: >k::ApplicationWindow) {
|
|
let window = window.clone();
|
|
glib::timeout_add_local(Duration::from_millis(350), move || {
|
|
window.minimize();
|
|
glib::ControlFlow::Break
|
|
});
|
|
}
|
|
|
|
#[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<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());
|
|
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,
|
|
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(not(coverage))]
|
|
fn build_preview_pane(title: &str) -> (gtk::Box, gtk::Picture, gtk::Label) {
|
|
let pane = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
pane.set_hexpand(true);
|
|
pane.set_vexpand(true);
|
|
|
|
let label = gtk::Label::new(Some(title));
|
|
label.set_halign(gtk::Align::Start);
|
|
pane.append(&label);
|
|
|
|
let picture = gtk::Picture::new();
|
|
picture.set_hexpand(true);
|
|
picture.set_vexpand(true);
|
|
picture.set_can_shrink(true);
|
|
picture.set_size_request(440, 248);
|
|
pane.append(&picture);
|
|
|
|
let status = gtk::Label::new(Some("Waiting for stream..."));
|
|
status.set_halign(gtk::Align::Start);
|
|
pane.append(&status);
|
|
|
|
(pane, picture, status)
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|