655 lines
30 KiB
Rust
Raw Normal View History

use anyhow::Result;
#[cfg(not(coverage))]
use {
super::clipboard::send_clipboard_to_remote,
super::device_test::DeviceTestController,
super::devices::DeviceCatalog,
super::diagnostics::quality_probe_command,
super::launcher_focus_signal_path,
super::power::{fetch_capture_power, set_capture_power_mode},
super::state::{CapturePowerStatus, DisplaySurface, InputRouting, LauncherState},
super::ui_components::build_launcher_view,
super::ui_runtime::{
dock_display_to_preview, input_control_path, input_state_path, next_input_routing,
open_popout_window, path_marker, read_input_routing_state, reap_exited_child,
refresh_launcher_ui, refresh_test_buttons, routing_name, selected_combo_value,
selected_server_addr, selected_toggle_key, spawn_client_process, stop_child_process,
update_test_action_result, write_input_routing_request,
},
gtk::glib,
gtk::prelude::*,
lesavka_common::lesavka::CapturePowerCommand,
std::cell::{Cell, RefCell},
std::process::Child,
std::rc::Rc,
std::time::{Duration, Instant},
};
#[cfg(not(coverage))]
enum PowerMessage {
Poll(std::result::Result<CapturePowerStatus, String>),
Command(std::result::Result<CapturePowerStatus, String>),
}
#[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 tests = Rc::new(RefCell::new(DeviceTestController::new()));
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);
let tests = Rc::clone(&tests);
app.connect_shutdown(move |_| {
stop_child_process(&child_proc);
tests.borrow_mut().stop_all();
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 tests = Rc::clone(&tests);
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 view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow());
let window = view.window.clone();
let server_entry = view.server_entry.clone();
let camera_combo = view.camera_combo.clone();
let microphone_combo = view.microphone_combo.clone();
let speaker_combo = view.speaker_combo.clone();
let widgets = view.widgets.clone();
let preview = view.preview.clone();
let popouts = Rc::clone(&view.popouts);
{
let mut tests = tests.borrow_mut();
if let Err(err) = tests.bind_camera_preview(
&view.device_stage.camera_preview,
&view.device_stage.camera_status,
) {
widgets
.status_label
.set_text(&format!("Camera preview setup failed: {err}"));
}
if let Err(err) =
tests.set_camera_selection(state.borrow().devices.camera.as_deref())
{
widgets
.status_label
.set_text(&format!("Camera staging setup failed: {err}"));
}
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
let (power_tx, power_rx) = std::sync::mpsc::channel::<PowerMessage>();
let power_request_in_flight = Rc::new(Cell::new(false));
let last_power_poll = Rc::new(RefCell::new(None::<Instant>));
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let tests = Rc::clone(&tests);
let camera_combo = camera_combo.clone();
let camera_combo_read = camera_combo.clone();
camera_combo.connect_changed(move |_| {
let selected = selected_combo_value(&camera_combo_read);
state.borrow_mut().select_camera(selected.clone());
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
widgets
.status_label
.set_text(&format!("Camera preview update failed: {err}"));
}
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let microphone_combo = microphone_combo.clone();
let microphone_combo_read = microphone_combo.clone();
microphone_combo.connect_changed(move |_| {
state
.borrow_mut()
.select_microphone(selected_combo_value(&microphone_combo_read));
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
});
}
{
let state = Rc::clone(&state);
let widgets = widgets.clone();
let child_proc = Rc::clone(&child_proc);
let speaker_combo = speaker_combo.clone();
let speaker_combo_read = speaker_combo.clone();
speaker_combo.connect_changed(move |_| {
state
.borrow_mut()
.select_speaker(selected_combo_value(&speaker_combo_read));
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);
let start_button = widgets.start_button.clone();
let widgets_handle = widgets.clone();
start_button.connect_clicked(move |_| {
if child_proc.borrow().is_some() {
widgets_handle
.status_label
.set_text("Relay is already running.");
refresh_launcher_ui(&widgets_handle, &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();
widgets_handle.status_label.set_text(&format!(
"Relay started. Inputs are routed to {}.",
routing_name(state.borrow().routing)
));
}
Err(err) => {
widgets_handle
.status_label
.set_text(&format!("Relay start failed: {err}"));
}
}
refresh_launcher_ui(
&widgets_handle,
&state.borrow(),
child_proc.borrow().is_some(),
);
});
}
{
let state = Rc::clone(&state);
let child_proc = Rc::clone(&child_proc);
let widgets = widgets.clone();
let stop_button = widgets.stop_button.clone();
let widgets_handle = widgets.clone();
stop_button.connect_clicked(move |_| {
stop_child_process(&child_proc);
let _ = state.borrow_mut().stop_remote();
widgets_handle.status_label.set_text("Relay stopped.");
refresh_launcher_ui(&widgets_handle, &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);
let input_toggle_button = widgets.input_toggle_button.clone();
let widgets_handle = widgets.clone();
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_handle
.status_label
.set_text(&format!("Could not update live input target: {err}"));
refresh_launcher_ui(&widgets_handle, &state.borrow(), true);
return;
}
widgets_handle.status_label.set_text(&format!(
"Input routing switched toward {}.",
routing_name(next)
));
} else {
widgets_handle.status_label.set_text(&format!(
"Relay will start with {} input ownership.",
routing_name(next)
));
}
state.borrow_mut().set_routing(next);
refresh_launcher_ui(&widgets_handle, &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);
widgets.clipboard_button.connect_clicked(move |_| {
if child_proc.borrow().is_none() {
widgets
.status_label
.set_text("Start the relay before sending clipboard text.");
return;
}
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets
.status_label
.set_text("Sending clipboard to the remote target...");
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
}
}
});
});
}
{
let widgets = widgets.clone();
widgets.probe_button.connect_clicked(move |_| {
if let Some(display) = gtk::gdk::Display::default() {
let clipboard = display.clipboard();
clipboard.set_text(quality_probe_command());
widgets
.status_label
.set_text("Quality probe command copied to the local clipboard.");
} else {
widgets
.status_label
.set_text("No desktop clipboard is available in this session.");
}
});
}
{
let widgets = widgets.clone();
let tests = Rc::clone(&tests);
let camera_combo = camera_combo.clone();
let camera_test_button = widgets.camera_test_button.clone();
let widgets_handle = widgets.clone();
camera_test_button.connect_clicked(move |_| {
let selected = selected_combo_value(&camera_combo);
let result = {
let mut tests = tests.borrow_mut();
let _ = tests.set_camera_selection(selected.as_deref());
tests.toggle_camera()
};
update_test_action_result(
&widgets_handle,
&mut tests.borrow_mut(),
result,
"Camera preview started inside the launcher.",
"Camera preview stopped.",
);
});
}
{
let widgets = widgets.clone();
let tests = Rc::clone(&tests);
let microphone_combo = microphone_combo.clone();
let speaker_combo = speaker_combo.clone();
let microphone_test_button = widgets.microphone_test_button.clone();
let widgets_handle = widgets.clone();
microphone_test_button.connect_clicked(move |_| {
let mic = selected_combo_value(&microphone_combo);
let sink = selected_combo_value(&speaker_combo);
let result = tests
.borrow_mut()
.toggle_microphone(mic.as_deref(), sink.as_deref());
update_test_action_result(
&widgets_handle,
&mut tests.borrow_mut(),
result,
"Microphone monitor started.",
"Microphone monitor stopped.",
);
});
}
{
let widgets = widgets.clone();
let tests = Rc::clone(&tests);
let speaker_combo = speaker_combo.clone();
let speaker_test_button = widgets.speaker_test_button.clone();
let widgets_handle = widgets.clone();
speaker_test_button.connect_clicked(move |_| {
let result = tests
.borrow_mut()
.toggle_speaker(selected_combo_value(&speaker_combo).as_deref());
update_test_action_result(
&widgets_handle,
&mut tests.borrow_mut(),
result,
"Speaker test tone started.",
"Speaker test tone stopped.",
);
});
}
{
let widgets = widgets.clone();
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
let power_tx = power_tx.clone();
let power_request_in_flight = Rc::clone(&power_request_in_flight);
let power_auto_button = widgets.power_auto_button.clone();
let widgets_handle = widgets.clone();
power_auto_button.connect_clicked(move |_| {
if power_request_in_flight.replace(true) {
return;
}
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_handle
.status_label
.set_text("Returning capture feeds to automatic mode...");
let tx = power_tx.clone();
std::thread::spawn(move || {
let result =
set_capture_power_mode(&server_addr, CapturePowerCommand::Auto)
.map_err(|err| err.to_string());
let _ = tx.send(PowerMessage::Command(result));
});
});
}
{
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
let power_tx = power_tx.clone();
let power_request_in_flight = Rc::clone(&power_request_in_flight);
let power_on_button = widgets.power_on_button.clone();
let widgets_handle = widgets.clone();
power_on_button.connect_clicked(move |_| {
if power_request_in_flight.replace(true) {
return;
}
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_handle
.status_label
.set_text("Forcing capture feeds on for staging...");
let tx = power_tx.clone();
std::thread::spawn(move || {
let result =
set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOn)
.map_err(|err| err.to_string());
let _ = tx.send(PowerMessage::Command(result));
});
});
}
{
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
let power_tx = power_tx.clone();
let power_request_in_flight = Rc::clone(&power_request_in_flight);
let power_off_button = widgets.power_off_button.clone();
let widgets_handle = widgets.clone();
power_off_button.connect_clicked(move |_| {
if power_request_in_flight.replace(true) {
return;
}
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_handle
.status_label
.set_text("Forcing capture feeds off for staging...");
let tx = power_tx.clone();
std::thread::spawn(move || {
let result =
set_capture_power_mode(&server_addr, CapturePowerCommand::ForceOff)
.map_err(|err| err.to_string());
let _ = tx.send(PowerMessage::Command(result));
});
});
}
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;
};
match state.borrow().display_surface(monitor_id) {
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 tests = Rc::clone(&tests);
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
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())));
let power_request_in_flight = Rc::clone(&power_request_in_flight);
let last_power_poll = Rc::clone(&last_power_poll);
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 and the launcher is focused.");
window.present();
}
while let Ok(message) = power_rx.try_recv() {
power_request_in_flight.set(false);
match message {
PowerMessage::Poll(Ok(power)) => {
state.borrow_mut().set_capture_power(power);
}
PowerMessage::Poll(Err(err)) => {
state.borrow_mut().set_capture_power(CapturePowerStatus {
available: false,
enabled: false,
unit: "relay.service".to_string(),
detail: err,
active_leases: 0,
mode: "auto".to_string(),
});
}
PowerMessage::Command(Ok(power)) => {
let mode = power.mode.clone();
state.borrow_mut().set_capture_power(power);
widgets.status_label.set_text(match mode.as_str() {
"forced-on" => "Capture feeds forced on.",
"forced-off" => "Capture feeds forced off.",
_ => "Capture feeds returned to automatic mode.",
});
}
PowerMessage::Command(Err(err)) => {
widgets
.status_label
.set_text(&format!("Capture power update failed: {err}"));
}
}
}
let should_poll_power = !power_request_in_flight.get()
&& last_power_poll
.borrow()
.map(|stamp| stamp.elapsed() >= Duration::from_millis(1400))
.unwrap_or(true);
if should_poll_power {
*last_power_poll.borrow_mut() = Some(Instant::now());
power_request_in_flight.set(true);
let server_addr =
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
let tx = power_tx.clone();
std::thread::spawn(move || {
let result =
fetch_capture_power(&server_addr).map_err(|err| err.to_string());
let _ = tx.send(PowerMessage::Poll(result));
});
}
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
glib::ControlFlow::Continue
});
}
window.present();
});
}
let _ = app.run_with_args::<&str>(&[]);
Ok(())
}
#[cfg(coverage)]
pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
Ok(())
}
#[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());
}
}