1539 lines
70 KiB
Rust
1539 lines
70 KiB
Rust
use anyhow::Result;
|
|
|
|
#[cfg(not(coverage))]
|
|
use {
|
|
super::clipboard::send_clipboard_text_to_remote,
|
|
super::device_test::{DeviceTestController, DeviceTestKind},
|
|
super::devices::DeviceCatalog,
|
|
super::diagnostics::quality_probe_command,
|
|
super::launcher_clipboard_control_path,
|
|
super::launcher_focus_signal_path,
|
|
super::power::{fetch_capture_power, set_capture_power_mode},
|
|
super::state::{
|
|
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting,
|
|
LauncherState,
|
|
},
|
|
super::ui_components::build_launcher_view,
|
|
super::ui_runtime::{
|
|
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
|
capture_swap_key, copy_session_log, dock_all_displays_to_preview, dock_display_to_preview,
|
|
input_control_path, input_state_path, next_input_routing, open_diagnostics_popout,
|
|
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
|
|
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
|
|
routing_name, selected_combo_value, selected_server_addr, spawn_client_process,
|
|
stop_child_process, toggle_key_label, update_test_action_result,
|
|
write_input_routing_request,
|
|
},
|
|
crate::handshake::{PeerCaps, negotiate},
|
|
crate::output::display::enumerate_monitors,
|
|
gtk::glib,
|
|
gtk::prelude::*,
|
|
lesavka_common::lesavka::CapturePowerCommand,
|
|
std::cell::{Cell, RefCell},
|
|
std::process::Command,
|
|
std::rc::Rc,
|
|
std::time::Duration,
|
|
};
|
|
|
|
#[cfg(not(coverage))]
|
|
enum PowerMessage {
|
|
Refresh(std::result::Result<CapturePowerStatus, String>),
|
|
Command(std::result::Result<CapturePowerStatus, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum RelayMessage {
|
|
Spawned(std::result::Result<RelayChild, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum CapsMessage {
|
|
Refresh(PeerCaps),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum ClipboardMessage {
|
|
Finished(std::result::Result<String, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_capture_power_refresh(
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
server_addr: String,
|
|
delay: Duration,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
if !delay.is_zero() {
|
|
std::thread::sleep(delay);
|
|
}
|
|
let result = fetch_capture_power(&server_addr).map_err(|err| err.to_string());
|
|
let _ = power_tx.send(PowerMessage::Refresh(result));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_capture_power_command(
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
server_addr: String,
|
|
command: CapturePowerCommand,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string());
|
|
let _ = power_tx.send(PowerMessage::Command(result));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_handshake_caps(
|
|
caps_tx: std::sync::mpsc::Sender<CapsMessage>,
|
|
server_addr: String,
|
|
delay: Duration,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
if !delay.is_zero() {
|
|
std::thread::sleep(delay);
|
|
}
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build();
|
|
let caps = match runtime {
|
|
Ok(runtime) => runtime.block_on(negotiate(&server_addr)),
|
|
Err(_) => PeerCaps::default(),
|
|
};
|
|
let _ = caps_tx.send(CapsMessage::Refresh(caps));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn unavailable_capture_power(detail: String) -> CapturePowerStatus {
|
|
CapturePowerStatus {
|
|
available: false,
|
|
enabled: false,
|
|
unit: "relay.service".to_string(),
|
|
detail,
|
|
active_leases: 0,
|
|
mode: "auto".to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn refresh_eye_feed_controls(
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
state: &LauncherState,
|
|
) {
|
|
for monitor_id in 0..2 {
|
|
super::ui_components::sync_capture_size_combo(
|
|
&widgets.display_panes[monitor_id].capture_combo,
|
|
state.capture_size_options(),
|
|
state.capture_size_preset(monitor_id),
|
|
);
|
|
super::ui_components::sync_breakout_size_combo(
|
|
&widgets.display_panes[monitor_id].breakout_combo,
|
|
state.breakout_size_options(),
|
|
state.breakout_size_preset(monitor_id),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn largest_monitor_size() -> (u32, u32) {
|
|
let (width, height) = enumerate_monitors()
|
|
.into_iter()
|
|
.max_by_key(|monitor| {
|
|
effective_monitor_width(&monitor) as u64 * effective_monitor_height(&monitor) as u64
|
|
})
|
|
.map(|monitor| {
|
|
(
|
|
effective_monitor_width(&monitor),
|
|
effective_monitor_height(&monitor),
|
|
)
|
|
})
|
|
.unwrap_or((1920, 1080));
|
|
(width.max(2), height.max(2))
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn largest_monitor_physical_size() -> (u32, u32) {
|
|
if let Some((width, height)) = probe_kscreen_display_size() {
|
|
return (width, height);
|
|
}
|
|
normalize_breakout_limit(largest_monitor_size().0, largest_monitor_size().1)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn probe_kscreen_display_size() -> Option<(u32, u32)> {
|
|
let output = Command::new("kscreen-doctor").arg("-o").output().ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let text = String::from_utf8(output.stdout).ok()?;
|
|
let mut best = None;
|
|
for line in text.lines() {
|
|
if !line.contains("Modes:") {
|
|
continue;
|
|
}
|
|
let active = line
|
|
.split_whitespace()
|
|
.find(|token| token.contains('*') && token.contains('x'))?;
|
|
let dims = active
|
|
.trim_matches(|ch: char| ch == '*' || ch == '!')
|
|
.split('@')
|
|
.next()?;
|
|
let (width, height) = dims.split_once('x')?;
|
|
let width = width.parse::<u32>().ok()?;
|
|
let height = height.parse::<u32>().ok()?;
|
|
if best
|
|
.map(|(best_w, best_h)| width as u64 * height as u64 > best_w as u64 * best_h as u64)
|
|
.unwrap_or(true)
|
|
{
|
|
best = Some((width, height));
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn effective_monitor_width(monitor: &crate::output::display::MonitorInfo) -> u32 {
|
|
let scale = monitor.scale_factor.max(1) as u32;
|
|
(monitor.geometry.width().max(1) as u32).saturating_mul(scale)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn effective_monitor_height(monitor: &crate::output::display::MonitorInfo) -> u32 {
|
|
let scale = monitor.scale_factor.max(1) as u32;
|
|
(monitor.geometry.height().max(1) as u32).saturating_mul(scale)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) {
|
|
const STANDARD_SIZES: &[(u32, u32)] = &[
|
|
(3840, 2160),
|
|
(2560, 1440),
|
|
(1920, 1080),
|
|
(1600, 900),
|
|
(1366, 768),
|
|
(1280, 720),
|
|
(960, 540),
|
|
];
|
|
|
|
STANDARD_SIZES
|
|
.iter()
|
|
.copied()
|
|
.find(|(candidate_w, candidate_h)| *candidate_w <= width && *candidate_h <= height)
|
|
.unwrap_or((width.max(2), height.max(2)))
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn rebind_inline_preview(
|
|
preview: &super::preview::LauncherPreview,
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
monitor_id: usize,
|
|
) {
|
|
if let Some(binding) = widgets.display_panes[monitor_id]
|
|
.preview_binding
|
|
.borrow_mut()
|
|
.take()
|
|
{
|
|
binding.close();
|
|
}
|
|
let binding = preview.install_on_picture(
|
|
monitor_id,
|
|
super::preview::PreviewSurface::Inline,
|
|
&widgets.display_panes[monitor_id].picture,
|
|
&widgets.display_panes[monitor_id].stream_status,
|
|
);
|
|
*widgets.display_panes[monitor_id]
|
|
.preview_binding
|
|
.borrow_mut() = binding;
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn rebind_popout_preview(
|
|
preview: &super::preview::LauncherPreview,
|
|
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
|
|
monitor_id: usize,
|
|
) {
|
|
let mut popouts = popouts.borrow_mut();
|
|
let Some(handle) = popouts.get_mut(monitor_id).and_then(|slot| slot.as_mut()) else {
|
|
return;
|
|
};
|
|
handle.binding.close();
|
|
if let Some(binding) = preview.install_on_picture(
|
|
monitor_id,
|
|
super::preview::PreviewSurface::Window,
|
|
&handle.picture,
|
|
&handle.status_label,
|
|
) {
|
|
handle.binding = binding;
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn disconnected_capture_note(mode: &str) -> &'static str {
|
|
match mode {
|
|
"forced-on" => "Relay disconnected. Capture is still forced on for staging.",
|
|
"forced-off" => {
|
|
"Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On."
|
|
}
|
|
_ => {
|
|
"Relay disconnected. The server will hold capture briefly, then let it return to standby."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps remote eye previews tied to a live session while respecting forced-off staging.
|
|
fn session_preview_active(
|
|
state: &crate::launcher::state::LauncherState,
|
|
child_running: bool,
|
|
) -> bool {
|
|
(child_running || state.remote_active) && state.capture_power.mode != "forced-off"
|
|
}
|
|
|
|
#[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::<RelayChild>));
|
|
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 clipboard_control_path = Rc::new(launcher_clipboard_control_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(clipboard_control_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 clipboard_control_path = Rc::clone(&clipboard_control_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(clipboard_control_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 (display_width, display_height) = largest_monitor_size();
|
|
let (physical_width, physical_height) = largest_monitor_physical_size();
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_breakout_display_size(display_width, display_height);
|
|
state.set_breakout_limit_size(physical_width, physical_height);
|
|
}
|
|
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 keyboard_combo = view.keyboard_combo.clone();
|
|
let mouse_combo = view.mouse_combo.clone();
|
|
let widgets = view.widgets.clone();
|
|
let preview = view.preview.clone();
|
|
let popouts = Rc::clone(&view.popouts);
|
|
let diagnostics_popout = Rc::clone(&view.diagnostics_popout);
|
|
let log_popout = Rc::clone(&view.log_popout);
|
|
|
|
{
|
|
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 (relay_tx, relay_rx) = std::sync::mpsc::channel::<RelayMessage>();
|
|
let relay_request_in_flight = Rc::new(Cell::new(false));
|
|
let (caps_tx, caps_rx) = std::sync::mpsc::channel::<CapsMessage>();
|
|
let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::<ClipboardMessage>();
|
|
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
|
|
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_log_sink(log_tx.clone());
|
|
}
|
|
|
|
{
|
|
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);
|
|
let preview_was_running =
|
|
tests.borrow_mut().is_running(DeviceTestKind::Camera);
|
|
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}"));
|
|
} else if preview_was_running {
|
|
widgets.status_label.set_text(&format!(
|
|
"Local camera preview switched to {}.",
|
|
selected.as_deref().unwrap_or("auto")
|
|
));
|
|
}
|
|
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 keyboard_combo = keyboard_combo.clone();
|
|
let keyboard_combo_read = keyboard_combo.clone();
|
|
keyboard_combo.connect_changed(move |_| {
|
|
let selected = selected_combo_value(&keyboard_combo_read);
|
|
state.borrow_mut().select_keyboard(selected.clone());
|
|
let message = match selected.as_deref() {
|
|
Some(path) => {
|
|
format!("The next relay launch will listen only to keyboard {path}.")
|
|
}
|
|
None => "The next relay launch will listen to all keyboards.".to_string(),
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
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 mouse_combo = mouse_combo.clone();
|
|
let mouse_combo_read = mouse_combo.clone();
|
|
mouse_combo.connect_changed(move |_| {
|
|
let selected = selected_combo_value(&mouse_combo_read);
|
|
state.borrow_mut().select_mouse(selected.clone());
|
|
let message = match selected.as_deref() {
|
|
Some(path) => {
|
|
format!("The next relay launch will listen only to pointer {path}.")
|
|
}
|
|
None => {
|
|
"The next relay launch will listen to all pointer devices."
|
|
.to_string()
|
|
}
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
selected_server_addr(&server_entry, server_addr.as_ref()),
|
|
Duration::ZERO,
|
|
);
|
|
request_handshake_caps(
|
|
caps_tx.clone(),
|
|
selected_server_addr(&server_entry, server_addr.as_ref()),
|
|
Duration::ZERO,
|
|
);
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let server_entry_read = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let caps_tx = caps_tx.clone();
|
|
server_entry.connect_changed(move |_| {
|
|
let server_addr =
|
|
selected_server_addr(&server_entry_read, server_addr_fallback.as_ref());
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_server_version(None);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(150),
|
|
);
|
|
request_handshake_caps(caps_tx.clone(), server_addr, Duration::from_millis(150));
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let preview = preview.clone();
|
|
let capture_combo = widgets.display_panes[monitor_id].capture_combo.clone();
|
|
capture_combo.connect_changed(move |combo| {
|
|
let Some(active_id) = combo.active_id() else {
|
|
return;
|
|
};
|
|
let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
|
|
return;
|
|
};
|
|
if state.borrow().capture_size_preset(monitor_id) == preset {
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_capture_size_preset(monitor_id, preset);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let choice = state.borrow().capture_size_choice(monitor_id);
|
|
preview.set_capture_profile(
|
|
monitor_id,
|
|
choice.width,
|
|
choice.height,
|
|
choice.fps,
|
|
choice.max_bitrate_kbit,
|
|
);
|
|
rebind_inline_preview(preview, &widgets, monitor_id);
|
|
rebind_popout_preview(preview, &popouts, monitor_id);
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let preview = preview.clone();
|
|
let breakout_combo = widgets.display_panes[monitor_id].breakout_combo.clone();
|
|
breakout_combo.connect_changed(move |combo| {
|
|
let Some(active_id) = combo.active_id() else {
|
|
return;
|
|
};
|
|
let Some(preset) = BreakoutSizePreset::from_id(active_id.as_str()) else {
|
|
return;
|
|
};
|
|
if state.borrow().breakout_size_preset(monitor_id) == preset {
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_breakout_size_preset(monitor_id, preset);
|
|
}
|
|
let size = state.borrow().breakout_size_choice(monitor_id);
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_breakout_profile(monitor_id, size.width, size.height);
|
|
}
|
|
let popout_open = {
|
|
popouts
|
|
.borrow()
|
|
.get(monitor_id)
|
|
.and_then(|slot| slot.as_ref())
|
|
.is_some()
|
|
};
|
|
if popout_open {
|
|
if let Some(preview) = preview.as_ref() {
|
|
rebind_popout_preview(preview, &popouts, monitor_id);
|
|
}
|
|
if let Some(handle) = popouts
|
|
.borrow()
|
|
.get(monitor_id)
|
|
.and_then(|slot| slot.as_ref())
|
|
{
|
|
let display_limit = state.borrow().breakout_display_size();
|
|
apply_popout_window_size(handle, size, display_limit);
|
|
}
|
|
}
|
|
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 tests = Rc::clone(&tests);
|
|
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(µphone_combo_read));
|
|
if tests.borrow_mut().is_running(DeviceTestKind::Microphone) {
|
|
widgets.status_label.set_text(
|
|
"Microphone selection changed. Restart Monitor Mic to audition the new input.",
|
|
);
|
|
}
|
|
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 tests = Rc::clone(&tests);
|
|
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));
|
|
let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker);
|
|
let microphone_running =
|
|
tests.borrow_mut().is_running(DeviceTestKind::Microphone);
|
|
if speaker_running || microphone_running {
|
|
widgets.status_label.set_text(
|
|
"Speaker selection changed. Restart the local audio tests to hear the new output.",
|
|
);
|
|
}
|
|
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 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 preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let relay_tx = relay_tx.clone();
|
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
|
let popouts = Rc::clone(&popouts);
|
|
let window = window.clone();
|
|
let start_button = widgets.start_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
start_button.connect_clicked(move |_| {
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if relay_request_in_flight.get() {
|
|
return;
|
|
}
|
|
if child_proc.borrow().is_some() {
|
|
stop_child_process(&child_proc);
|
|
let power_mode = {
|
|
let mut state = state.borrow_mut();
|
|
let _ = state.stop_remote();
|
|
state.capture_power.mode.clone()
|
|
};
|
|
dock_all_displays_to_preview(
|
|
&state,
|
|
&child_proc,
|
|
&popouts,
|
|
&widgets_handle,
|
|
);
|
|
window.present();
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
preview.set_session_active(false);
|
|
}
|
|
if power_mode != "auto" {
|
|
widgets_handle.status_label.set_text(
|
|
"Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
|
|
);
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
} else {
|
|
widgets_handle
|
|
.status_label
|
|
.set_text(disconnected_capture_note(&power_mode));
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_secs(31),
|
|
);
|
|
refresh_launcher_ui(&widgets_handle, &state.borrow(), false);
|
|
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));
|
|
state.select_keyboard(selected_combo_value(&keyboard_combo));
|
|
state.select_mouse(selected_combo_value(&mouse_combo));
|
|
}
|
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
|
let launch_state = state.borrow().clone();
|
|
let input_toggle_key = launch_state.swap_key.clone();
|
|
let input_control_path = input_control_path.as_ref().clone();
|
|
let input_state_path = input_state_path.as_ref().clone();
|
|
relay_request_in_flight.set(true);
|
|
widgets_handle.status_label.set_text(&format!(
|
|
"Connecting relay with {} as the swap key...",
|
|
toggle_key_label(&input_toggle_key)
|
|
));
|
|
refresh_launcher_ui(
|
|
&widgets_handle,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
let relay_tx = relay_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let result = spawn_client_process(
|
|
&server_addr,
|
|
&launch_state,
|
|
&input_toggle_key,
|
|
input_control_path.as_path(),
|
|
input_state_path.as_path(),
|
|
)
|
|
.map_err(|err| err.to_string());
|
|
let _ = relay_tx.send(RelayMessage::Spawned(result));
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
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 popouts = Rc::clone(&popouts);
|
|
let window = window.clone();
|
|
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);
|
|
if matches!(next, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
} else {
|
|
window.present();
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let swap_key_button = widgets.swap_key_button.clone();
|
|
swap_key_button.connect_clicked(move |_| {
|
|
let token = state.borrow_mut().begin_swap_key_binding();
|
|
widgets
|
|
.status_label
|
|
.set_text("Press a single key within 3 seconds to make it the swap shortcut.");
|
|
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();
|
|
glib::timeout_add_local_once(Duration::from_secs(3), move || {
|
|
if state.borrow_mut().cancel_swap_key_binding(token) {
|
|
widgets.status_label.set_text(
|
|
"Swap-key capture timed out. The previous shortcut is still in place.",
|
|
);
|
|
refresh_launcher_ui(
|
|
&widgets,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
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);
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
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());
|
|
let Some(display) = gtk::gdk::Display::default() else {
|
|
widgets
|
|
.status_label
|
|
.set_text("No desktop clipboard is available in this session.");
|
|
return;
|
|
};
|
|
widgets
|
|
.status_label
|
|
.set_text("Reading the local clipboard and packing a remote paste spell...");
|
|
let clipboard = display.clipboard();
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
|
|
match result {
|
|
Ok(Some(text)) => {
|
|
let text = text.trim_end_matches(['\r', '\n']).to_string();
|
|
if text.is_empty() {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
"clipboard is empty".to_string(),
|
|
)));
|
|
return;
|
|
}
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let result = send_clipboard_text_to_remote(&server_addr, &text)
|
|
.map_err(|err| err.to_string());
|
|
let _ = clipboard_tx
|
|
.send(ClipboardMessage::Finished(result));
|
|
});
|
|
}
|
|
Ok(None) => {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
"clipboard is empty".to_string(),
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
format!("clipboard read failed: {err}"),
|
|
)));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
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();
|
|
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
|
if let Err(err) = copy_session_log(&widgets.diagnostics_buffer) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text("Diagnostics report copied to the local clipboard.");
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let app = app.clone();
|
|
let widgets = widgets.clone();
|
|
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
|
widgets.diagnostics_popout_button.connect_clicked(move |_| {
|
|
open_diagnostics_popout(&app, &diagnostics_popout, &widgets.diagnostics_buffer);
|
|
widgets
|
|
.status_label
|
|
.set_text("Diagnostics report moved into its own window.");
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
widgets.console_copy_button.connect_clicked(move |_| {
|
|
if let Err(err) = copy_session_log(&widgets.session_log_buffer) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Could not copy the session log: {err}"));
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text("Session log copied to the local clipboard.");
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let app = app.clone();
|
|
let widgets = widgets.clone();
|
|
let log_popout = Rc::clone(&log_popout);
|
|
widgets.console_popout_button.connect_clicked(move |_| {
|
|
open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer);
|
|
widgets
|
|
.status_label
|
|
.set_text("Session log moved into its own window.");
|
|
});
|
|
}
|
|
|
|
{
|
|
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. This stays local until you start the relay.",
|
|
"Camera preview stopped. The selected camera stays staged for the next launch.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
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(µphone_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 locally through the selected speaker.",
|
|
"Microphone monitor stopped.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let tests = Rc::clone(&tests);
|
|
let speaker_combo = speaker_combo.clone();
|
|
let microphone_replay_button = widgets.microphone_replay_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
microphone_replay_button.connect_clicked(move |_| {
|
|
let result = tests
|
|
.borrow_mut()
|
|
.toggle_microphone_replay(selected_combo_value(&speaker_combo).as_deref());
|
|
update_test_action_result(
|
|
&widgets_handle,
|
|
&mut tests.borrow_mut(),
|
|
result,
|
|
"Replaying the latest local mic capture through the selected speaker.",
|
|
"Mic replay 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 tone started locally.",
|
|
"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...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
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...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::ForceOn,
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
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...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::ForceOff,
|
|
);
|
|
});
|
|
}
|
|
|
|
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 = {
|
|
let state = state.borrow();
|
|
state.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 state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let key_controller = gtk::EventControllerKey::new();
|
|
key_controller.connect_key_pressed(move |_, key, _, _| {
|
|
if !state.borrow().swap_key_binding {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
|
|
let Some(swap_key) = capture_swap_key(key) else {
|
|
widgets.status_label.set_text(
|
|
"That key is not a good swap shortcut. Try a letter, digit, function key, or navigation key.",
|
|
);
|
|
refresh_launcher_ui(
|
|
&widgets,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
return glib::Propagation::Stop;
|
|
};
|
|
|
|
let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active;
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.complete_swap_key_binding(swap_key.clone());
|
|
}
|
|
let status_message = if relay_live {
|
|
format!(
|
|
"Swap key set to {}. Disconnect and reconnect the relay to use it live.",
|
|
toggle_key_label(&swap_key)
|
|
)
|
|
} else {
|
|
format!(
|
|
"Swap key set to {}. The next relay launch will use it.",
|
|
toggle_key_label(&swap_key)
|
|
)
|
|
};
|
|
widgets.status_label.set_text(&status_message);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
glib::Propagation::Stop
|
|
});
|
|
window.add_controller(key_controller);
|
|
}
|
|
|
|
{
|
|
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 relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
|
let preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let log_tx = log_tx.clone();
|
|
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 power_mode = {
|
|
let mut state = state.borrow_mut();
|
|
let _ = state.stop_remote();
|
|
state.capture_power.mode.clone()
|
|
};
|
|
dock_all_displays_to_preview(&state, &child_proc, &popouts, &widgets);
|
|
window.present();
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if power_mode != "auto" {
|
|
widgets.status_label.set_text(
|
|
"Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
|
|
);
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text(disconnected_capture_note(&power_mode));
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_secs(31),
|
|
);
|
|
}
|
|
|
|
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);
|
|
if matches!(routing, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
} else {
|
|
window.present();
|
|
}
|
|
}
|
|
}
|
|
|
|
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) = relay_rx.try_recv() {
|
|
relay_request_in_flight.set(false);
|
|
match message {
|
|
RelayMessage::Spawned(Ok(mut child)) => {
|
|
attach_child_log_streams(&mut child, log_tx.clone());
|
|
*child_proc.borrow_mut() = Some(child);
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
let _ = state.start_remote();
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
preview.set_session_active(session_preview_active(
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
));
|
|
}
|
|
let routing = routing_name(state.borrow().routing);
|
|
let power_mode = state.borrow().capture_power.mode.clone();
|
|
let message = match power_mode.as_str() {
|
|
"forced-off" => format!(
|
|
"Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.",
|
|
routing
|
|
),
|
|
"forced-on" => format!(
|
|
"Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.",
|
|
routing
|
|
),
|
|
_ => format!(
|
|
"Relay connected with inputs routed to {}. The eye previews will come up with the live session.",
|
|
routing
|
|
),
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
if matches!(state.borrow().routing, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_millis(1250),
|
|
);
|
|
}
|
|
RelayMessage::Spawned(Err(err)) => {
|
|
state.borrow_mut().set_server_available(false);
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Relay start failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(line) = log_rx.try_recv() {
|
|
append_session_log(&widgets.session_log_buffer, &line);
|
|
let mut end = widgets.session_log_buffer.end_iter();
|
|
widgets
|
|
.session_log_view
|
|
.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
|
|
}
|
|
|
|
while let Ok(message) = power_rx.try_recv() {
|
|
power_request_in_flight.set(false);
|
|
match message {
|
|
PowerMessage::Refresh(Ok(power)) => {
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
state.set_capture_power(power);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
}
|
|
PowerMessage::Refresh(Err(err)) => {
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_capture_power(unavailable_capture_power(err));
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
}
|
|
PowerMessage::Command(Ok(power)) => {
|
|
let mode = power.mode.clone();
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
state.set_capture_power(power);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
widgets.status_label.set_text(match mode.as_str() {
|
|
"forced-on" => "Capture feeds forced on. Remote eyes stay awake even if previews or the relay stop.",
|
|
"forced-off" => "Capture feeds forced off. Remote eye previews and session video stay dark until you switch back.",
|
|
_ => "Capture feeds returned to automatic mode. Live previews and relay demand will wake them as needed.",
|
|
});
|
|
}
|
|
PowerMessage::Command(Err(err)) => {
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_capture_power(unavailable_capture_power(err.clone()));
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Capture power update failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(message) = caps_rx.try_recv() {
|
|
match message {
|
|
CapsMessage::Refresh(caps) => {
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_version(caps.server_version.clone());
|
|
}
|
|
if let (Some(width), Some(height)) =
|
|
(caps.eye_width, caps.eye_height)
|
|
{
|
|
let fps = caps.eye_fps.unwrap_or(30);
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_preview_source_profile(width, height, fps);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
for monitor_id in 0..2 {
|
|
let capture = state.borrow().capture_size_choice(monitor_id);
|
|
let breakout = state.borrow().breakout_size_choice(monitor_id);
|
|
preview.set_capture_profile(
|
|
monitor_id,
|
|
capture.width,
|
|
capture.height,
|
|
capture.fps,
|
|
capture.max_bitrate_kbit,
|
|
);
|
|
preview.set_breakout_profile(
|
|
monitor_id,
|
|
breakout.width,
|
|
breakout.height,
|
|
);
|
|
rebind_inline_preview(preview, &widgets, monitor_id);
|
|
rebind_popout_preview(preview, &popouts, monitor_id);
|
|
}
|
|
}
|
|
refresh_eye_feed_controls(&widgets, &state.borrow());
|
|
} else {
|
|
refresh_eye_feed_controls(&widgets, &state.borrow());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(message) = clipboard_rx.try_recv() {
|
|
match message {
|
|
ClipboardMessage::Finished(Ok(detail)) => {
|
|
widgets.status_label.set_text(&format!("✨ {detail}"));
|
|
}
|
|
ClipboardMessage::Finished(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Clipboard send failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
let child_running = child_proc.borrow().is_some();
|
|
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, session_preview_active};
|
|
use crate::launcher::state::{CapturePowerStatus, LauncherState};
|
|
|
|
#[test]
|
|
fn coverage_stub_returns_ok() {
|
|
assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn session_preview_stays_idle_when_capture_is_forced_off() {
|
|
let mut state = LauncherState::new();
|
|
state.start_remote();
|
|
state.set_capture_power(CapturePowerStatus {
|
|
available: true,
|
|
enabled: false,
|
|
unit: "relay.service".to_string(),
|
|
detail: "inactive/dead".to_string(),
|
|
active_leases: 1,
|
|
mode: "forced-off".to_string(),
|
|
});
|
|
|
|
assert!(!session_preview_active(&state, true));
|
|
}
|
|
}
|