From 2f7cc449763573c41d50cfee54a507d93aeeea53 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 14 Apr 2026 20:05:26 -0300 Subject: [PATCH] launcher: stabilize routing and per-display breakout --- client/src/input/inputs.rs | 151 ++- client/src/launcher/preview.rs | 70 +- client/src/launcher/state.rs | 74 +- client/src/launcher/ui.rs | 1214 ++++++++++++++----------- scripts/ci/hygiene_gate_baseline.json | 22 +- scripts/ci/quality_gate_baseline.json | 10 +- 6 files changed, 960 insertions(+), 581 deletions(-) diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index 88856f2..eb44c0e 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -6,6 +6,8 @@ use anyhow::{Context, Result}; use evdev::{AbsoluteAxisCode, Device, EventType, KeyCode, RelativeAxisCode}; use std::collections::HashSet; use std::time::Instant; +#[cfg(not(coverage))] +use std::path::{Path, PathBuf}; use tokio::{ sync::broadcast::Sender, time::{Duration, interval}, @@ -35,6 +37,14 @@ pub struct InputAggregator { quick_toggle_down: bool, quick_toggle_debounce: Duration, last_quick_toggle_at: Option, + #[cfg(not(coverage))] + routing_control_path: Option, + #[cfg(not(coverage))] + routing_control_marker: u128, + #[cfg(not(coverage))] + routing_state_path: Option, + #[cfg(not(coverage))] + published_remote_capture: Option, } impl InputAggregator { @@ -55,6 +65,10 @@ impl InputAggregator { capture_remote_boot: bool, ) -> Self { let quick_toggle_key = quick_toggle_key_from_env(); + #[cfg(not(coverage))] + let routing_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_CONTROL"); + #[cfg(not(coverage))] + let routing_state_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_INPUT_STATE"); Self { kbd_tx, mou_tx, @@ -72,6 +86,17 @@ impl InputAggregator { quick_toggle_down: false, quick_toggle_debounce: quick_toggle_debounce_from_env(), last_quick_toggle_at: None, + #[cfg(not(coverage))] + routing_control_marker: routing_control_path + .as_deref() + .map(path_marker) + .unwrap_or_default(), + #[cfg(not(coverage))] + routing_control_path, + #[cfg(not(coverage))] + routing_state_path, + #[cfg(not(coverage))] + published_remote_capture: None, } } @@ -267,12 +292,14 @@ impl InputAggregator { // Example approach: poll each aggregator in a simple loop let mut tick = interval(Duration::from_millis(10)); let mut current = Layout::SideBySide; + self.publish_routing_state_if_changed(); loop { let mut want_kill = false; for kbd in &mut self.keyboards { kbd.process_events(); want_kill |= kbd.magic_kill(); } + self.poll_launcher_routing_request(); let quick_toggle_now = self.quick_toggle_active(); self.observe_quick_toggle(quick_toggle_now); let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); @@ -328,6 +355,7 @@ impl InputAggregator { if !self.pending_kill { focus_launcher_on_local_if_enabled(); } + self.publish_routing_state_if_changed(); if self.pending_kill { return Ok(()); } @@ -355,32 +383,42 @@ impl InputAggregator { tracing::info!("🧙 magic chord - freeing devices 🪄 EXPELLIARMUS!!! 🔓🕊️"); } if self.released { - for k in &mut self.keyboards { - k.reset_state(); - k.set_send(true); - k.set_grab(true); - } - for m in &mut self.mice { - m.reset_state(); - m.set_send(true); - m.set_grab(true); - } - self.released = false; - self.pending_release = false; + self.enable_remote_capture(); + #[cfg(not(coverage))] + self.publish_routing_state_if_changed(); } else { - for k in &mut self.keyboards { - k.send_empty_report(); - k.set_send(false); - } - for m in &mut self.mice { - m.reset_state(); - m.set_send(false); - } - self.pending_release = true; - self.capture_pending_keys(); + self.begin_local_release(); } } + fn enable_remote_capture(&mut self) { + for k in &mut self.keyboards { + k.reset_state(); + k.set_send(true); + k.set_grab(true); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(true); + m.set_grab(true); + } + self.released = false; + self.pending_release = false; + } + + fn begin_local_release(&mut self) { + for k in &mut self.keyboards { + k.send_empty_report(); + k.set_send(false); + } + for m in &mut self.mice { + m.reset_state(); + m.set_send(false); + } + self.pending_release = true; + self.capture_pending_keys(); + } + fn capture_pending_keys(&mut self) { self.pending_keys.clear(); for k in &self.keyboards { @@ -414,6 +452,47 @@ impl InputAggregator { } self.quick_toggle_down = quick_toggle_now; } + + #[cfg(not(coverage))] + fn poll_launcher_routing_request(&mut self) { + let Some(path) = self.routing_control_path.as_deref() else { + return; + }; + let marker = path_marker(path); + if marker <= self.routing_control_marker { + return; + } + self.routing_control_marker = marker; + let Some(remote_capture) = read_launcher_routing_request(path) else { + return; + }; + if self.pending_release || self.pending_kill || remote_capture == !self.released { + return; + } + if remote_capture { + info!("🎛️ launcher requested remote input capture"); + self.enable_remote_capture(); + self.publish_routing_state_if_changed(); + } else { + info!("🎛️ launcher requested local input capture"); + self.begin_local_release(); + } + } + + #[cfg(not(coverage))] + fn publish_routing_state_if_changed(&mut self) { + let remote_capture = !self.released; + if self.published_remote_capture == Some(remote_capture) { + return; + } + if let Some(path) = self.routing_state_path.as_deref() { + let _ = std::fs::write( + path, + if remote_capture { "remote\n" } else { "local\n" }, + ); + } + self.published_remote_capture = Some(remote_capture); + } } /// The classification function @@ -560,3 +639,31 @@ fn focus_launcher_on_local_if_enabled() { .args(["-a", &title]) .status(); } + +#[cfg(not(coverage))] +fn launcher_routing_path_from_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +#[cfg(not(coverage))] +fn read_launcher_routing_request(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "remote" => Some(true), + "local" => Some(false), + _ => None, + } +} + +#[cfg(not(coverage))] +fn path_marker(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|stamp| stamp.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 9b10666..9a75b03 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -31,6 +31,13 @@ pub struct LauncherPreview { feeds: [PreviewFeed; 2], } +#[cfg(not(coverage))] +#[derive(Clone)] +pub struct PreviewBinding { + enabled: Arc, + alive: Arc, +} + #[cfg(not(coverage))] impl LauncherPreview { pub fn new(server_addr: String) -> Result { @@ -48,48 +55,59 @@ impl LauncherPreview { monitor_id: usize, picture: >k::Picture, status_label: >k::Label, - ) { - if let Some(feed) = self.feeds.get(monitor_id) { - feed.install_on_picture(picture, status_label); - } + ) -> Option { + self.feeds + .get(monitor_id) + .map(|feed| feed.install_on_picture(picture, status_label)) + } +} + +#[cfg(not(coverage))] +impl PreviewBinding { + pub fn set_enabled(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Relaxed); } - pub fn set_enabled(&self, enabled: bool) { - for feed in &self.feeds { - feed.set_enabled(enabled); - } + pub fn close(&self) { + self.alive.store(false, Ordering::Relaxed); } } #[cfg(not(coverage))] struct PreviewFeed { latest: Arc>>, - enabled: Arc, } #[cfg(not(coverage))] impl PreviewFeed { fn spawn(server_addr: String, monitor_id: u32) -> Result { let latest = Arc::new(Mutex::new(None)); - let enabled = Arc::new(AtomicBool::new(true)); let store = Arc::clone(&latest); - let enabled_flag = Arc::clone(&enabled); std::thread::spawn(move || { - if let Err(err) = run_preview_feed(server_addr, monitor_id, store, enabled_flag) { + if let Err(err) = run_preview_feed(server_addr, monitor_id, store) { warn!(monitor_id, ?err, "launcher preview feed exited"); } }); - Ok(Self { latest, enabled }) + Ok(Self { latest }) } - fn install_on_picture(&self, picture: >k::Picture, status_label: >k::Label) { + fn install_on_picture( + &self, + picture: >k::Picture, + status_label: >k::Label, + ) -> PreviewBinding { let picture = picture.clone(); let status_label = status_label.clone(); let latest = Arc::clone(&self.latest); - let enabled = Arc::clone(&self.enabled); + let enabled = Arc::new(AtomicBool::new(true)); + let alive = Arc::new(AtomicBool::new(true)); + let enabled_flag = Arc::clone(&enabled); + let alive_flag = Arc::clone(&alive); glib::timeout_add_local(Duration::from_millis(120), move || { - if !enabled.load(Ordering::Relaxed) { - status_label.set_text("Paused for pop-out windows"); + if !alive_flag.load(Ordering::Relaxed) { + return glib::ControlFlow::Break; + } + if !enabled_flag.load(Ordering::Relaxed) { return glib::ControlFlow::Continue; } let next = latest.lock().ok().and_then(|mut slot| slot.take()); @@ -107,15 +125,7 @@ impl PreviewFeed { } glib::ControlFlow::Continue }); - } - - fn set_enabled(&self, enabled: bool) { - self.enabled.store(enabled, Ordering::Relaxed); - if !enabled { - if let Ok(mut slot) = self.latest.lock() { - *slot = None; - } - } + PreviewBinding { enabled, alive } } } @@ -132,7 +142,6 @@ fn run_preview_feed( server_addr: String, monitor_id: u32, latest: Arc>>, - enabled: Arc, ) -> Result<()> { let (pipeline, appsrc, appsink) = build_preview_pipeline()?; pipeline @@ -162,10 +171,6 @@ fn run_preview_feed( let _ = rt.block_on(async move { loop { - if !enabled.load(Ordering::Relaxed) { - tokio::time::sleep(Duration::from_millis(120)).await; - continue; - } let channel = match Channel::from_shared(server_addr.clone()) { Ok(endpoint) => match endpoint.tcp_nodelay(true).connect().await { Ok(channel) => channel, @@ -190,9 +195,6 @@ fn run_preview_feed( Ok(mut stream) => { debug!(monitor_id, "launcher preview connected"); while let Some(item) = stream.get_mut().message().await.transpose() { - if !enabled.load(Ordering::Relaxed) { - break; - } match item { Ok(pkt) => push_preview_packet(&appsrc, pkt), Err(err) => { diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index ea8234d..2554541 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -32,6 +32,21 @@ impl ViewMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DisplaySurface { + Preview, + Window, +} + +impl DisplaySurface { + pub fn label(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Window => "window", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct DeviceSelection { pub camera: Option, @@ -43,6 +58,7 @@ pub struct DeviceSelection { pub struct LauncherState { pub routing: InputRouting, pub view_mode: ViewMode, + pub displays: [DisplaySurface; 2], pub devices: DeviceSelection, pub remote_active: bool, pub notes: Vec, @@ -53,6 +69,7 @@ impl Default for LauncherState { Self { routing: InputRouting::Remote, view_mode: ViewMode::Unified, + displays: [DisplaySurface::Preview, DisplaySurface::Preview], devices: DeviceSelection::default(), remote_active: false, notes: Vec::new(), @@ -71,6 +88,39 @@ impl LauncherState { pub fn set_view_mode(&mut self, view_mode: ViewMode) { self.view_mode = view_mode; + self.displays = match view_mode { + ViewMode::Unified => [DisplaySurface::Preview, DisplaySurface::Preview], + ViewMode::Breakout => [DisplaySurface::Window, DisplaySurface::Window], + }; + } + + pub fn display_surface(&self, monitor_id: usize) -> DisplaySurface { + self.displays + .get(monitor_id) + .copied() + .unwrap_or(DisplaySurface::Preview) + } + + pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { + if let Some(slot) = self.displays.get_mut(monitor_id) { + *slot = surface; + self.view_mode = if self + .displays + .iter() + .any(|display| matches!(display, DisplaySurface::Window)) + { + ViewMode::Breakout + } else { + ViewMode::Unified + }; + } + } + + pub fn breakout_count(&self) -> usize { + self.displays + .iter() + .filter(|surface| matches!(surface, DisplaySurface::Window)) + .count() } pub fn select_camera(&mut self, camera: Option) { @@ -119,7 +169,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "mode={} view={} active={} camera={} mic={} speaker={}", + "mode={} view={} active={} d1={} d2={} camera={} mic={} speaker={}", match self.routing { InputRouting::Local => "local", InputRouting::Remote => "remote", @@ -129,6 +179,8 @@ impl LauncherState { ViewMode::Breakout => "breakout", }, self.remote_active, + self.displays[0].label(), + self.displays[1].label(), self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), @@ -164,12 +216,30 @@ mod tests { let state = LauncherState::new(); assert_eq!(state.routing, InputRouting::Remote); assert_eq!(state.view_mode, ViewMode::Unified); + assert_eq!(state.display_surface(0), DisplaySurface::Preview); + assert_eq!(state.display_surface(1), DisplaySurface::Preview); assert!(!state.remote_active); assert!(state.devices.camera.is_none()); assert!(state.devices.microphone.is_none()); assert!(state.devices.speaker.is_none()); } + #[test] + fn display_surface_updates_global_view_summary() { + let mut state = LauncherState::new(); + state.set_display_surface(1, DisplaySurface::Window); + assert_eq!(state.view_mode, ViewMode::Breakout); + assert_eq!(state.breakout_count(), 1); + + state.set_display_surface(1, DisplaySurface::Preview); + assert_eq!(state.view_mode, ViewMode::Unified); + assert_eq!(state.breakout_count(), 0); + + state.set_view_mode(ViewMode::Breakout); + assert_eq!(state.display_surface(0), DisplaySurface::Window); + assert_eq!(state.display_surface(1), DisplaySurface::Window); + } + #[test] fn selecting_auto_or_blank_clears_explicit_device() { let mut state = LauncherState::new(); @@ -227,6 +297,8 @@ mod tests { assert!(status.contains("mode=local")); assert!(status.contains("view=unified")); assert!(status.contains("active=true")); + assert!(status.contains("d1=preview")); + assert!(status.contains("d2=preview")); assert!(status.contains("camera=/dev/video0")); assert!(status.contains("mic=alsa_input.usb")); assert!(status.contains("speaker=alsa_output.usb")); diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index c8721fa..360c252 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -6,19 +6,69 @@ use { super::devices::DeviceCatalog, super::diagnostics::quality_probe_command, super::launcher_focus_signal_path, - super::preview::LauncherPreview, + super::preview::{LauncherPreview, PreviewBinding}, super::runtime_env_vars, - super::state::{InputRouting, LauncherState, ViewMode}, + super::state::{DisplaySurface, InputRouting, LauncherState}, super::LAUNCHER_FOCUS_SIGNAL_ENV, - gtk::prelude::*, gtk::glib, + gtk::prelude::*, std::cell::RefCell, - std::path::Path, + std::path::{Path, PathBuf}, std::process::{Child, Command}, std::rc::Rc, std::time::Duration, }; +#[cfg(not(coverage))] +const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; +#[cfg(not(coverage))] +const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; +#[cfg(not(coverage))] +const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; +#[cfg(not(coverage))] +const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; + +#[cfg(not(coverage))] +#[derive(Clone)] +struct SummaryWidgets { + relay_value: gtk::Label, + routing_value: gtk::Label, + displays_value: gtk::Label, + shortcut_value: gtk::Label, +} + +#[cfg(not(coverage))] +#[derive(Clone)] +struct DisplayPaneWidgets { + root: gtk::Box, + stack: gtk::Stack, + picture: gtk::Picture, + stream_status: gtk::Label, + placeholder: gtk::Label, + action_button: gtk::Button, + preview_binding: Option, + title: String, +} + +#[cfg(not(coverage))] +struct PopoutWindowHandle { + window: gtk::ApplicationWindow, + binding: PreviewBinding, +} + +#[cfg(not(coverage))] +#[derive(Clone)] +struct LauncherWidgets { + status_label: gtk::Label, + summary: SummaryWidgets, + display_panes: [DisplayPaneWidgets; 2], + start_button: gtk::Button, + stop_button: gtk::Button, + input_toggle_button: gtk::Button, + clipboard_button: gtk::Button, + toggle_key_combo: gtk::ComboBoxText, +} + #[cfg(not(coverage))] pub fn run_gui_launcher(server_addr: String) -> Result<()> { let app = gtk::Application::builder() @@ -30,15 +80,22 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let child_proc = Rc::new(RefCell::new(None::)); let server_addr = Rc::new(server_addr); let focus_signal_path = Rc::new(launcher_focus_signal_path()); + let input_control_path = Rc::new(input_control_path()); + let input_state_path = Rc::new(input_state_path()); let _ = std::fs::remove_file(focus_signal_path.as_path()); + let _ = std::fs::remove_file(input_control_path.as_path()); + let _ = std::fs::remove_file(input_state_path.as_path()); { let child_proc = Rc::clone(&child_proc); + let focus_signal_path = Rc::clone(&focus_signal_path); + let input_control_path = Rc::clone(&input_control_path); + let input_state_path = Rc::clone(&input_state_path); app.connect_shutdown(move |_| { - if let Some(mut child) = child_proc.borrow_mut().take() { - let _ = child.kill(); - let _ = child.wait(); - } + stop_child_process(&child_proc); + let _ = std::fs::remove_file(focus_signal_path.as_path()); + let _ = std::fs::remove_file(input_control_path.as_path()); + let _ = std::fs::remove_file(input_state_path.as_path()); }); } @@ -48,121 +105,88 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let child_proc = Rc::clone(&child_proc); let server_addr = Rc::clone(&server_addr); let focus_signal_path = Rc::clone(&focus_signal_path); + let input_control_path = Rc::clone(&input_control_path); + let input_state_path = Rc::clone(&input_state_path); app.connect_activate(move |app| { let window = gtk::ApplicationWindow::builder() .application(app) .title("Lesavka Launcher") - .default_width(980) - .default_height(860) + .default_width(1120) + .default_height(920) .build(); let scroll = gtk::ScrolledWindow::new(); scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); - let root = gtk::Box::new(gtk::Orientation::Vertical, 8); - root.set_margin_start(14); - root.set_margin_end(14); - root.set_margin_top(14); - root.set_margin_bottom(14); + let root = gtk::Box::new(gtk::Orientation::Vertical, 14); + root.set_margin_start(18); + root.set_margin_end(18); + root.set_margin_top(18); + root.set_margin_bottom(18); - let heading = gtk::Label::new(Some("Lesavka Session Launcher")); + let heading = gtk::Label::new(Some("Lesavka Control Deck")); heading.add_css_class("title-2"); heading.set_halign(gtk::Align::Start); root.append(&heading); - let status_label = gtk::Label::new(Some("Idle - preview warming up")); + let status_label = gtk::Label::new(Some( + "Launcher ready - previews stay here, relay starts only when you ask for it.", + )); status_label.set_halign(gtk::Align::Start); + status_label.set_wrap(true); status_label.set_selectable(true); root.append(&status_label); - let 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 (summary_frame, summary_box) = build_section("Session Status"); + let summary_grid = gtk::Grid::new(); + summary_grid.set_row_spacing(8); + summary_grid.set_column_spacing(12); + summary_box.append(&summary_grid); + let summary = SummaryWidgets { + relay_value: attach_summary_row(&summary_grid, 0, "Relay", "Stopped"), + routing_value: attach_summary_row(&summary_grid, 1, "Input Target", "Remote"), + displays_value: attach_summary_row( + &summary_grid, + 2, + "Displays", + "Display 1: preview | Display 2: preview", + ), + shortcut_value: attach_summary_row(&summary_grid, 3, "Swap Key", "Pause"), + }; + root.append(&summary_frame); - let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let (connection_frame, connection_box) = build_section("Connection"); + let server_row = gtk::Box::new(gtk::Orientation::Horizontal, 10); let server_label = gtk::Label::new(Some("Server")); server_label.set_halign(gtk::Align::Start); let server_entry = gtk::Entry::new(); server_entry.set_hexpand(true); server_entry.set_text(server_addr.as_ref()); + let start_button = gtk::Button::with_label("Start Relay"); + let stop_button = gtk::Button::with_label("Stop Relay"); server_row.append(&server_label); server_row.append(&server_entry); - root.append(&server_row); + server_row.append(&start_button); + server_row.append(&stop_button); + connection_box.append(&server_row); + let connection_note = gtk::Label::new(Some( + "Starting relay launches the live input/audio session to the remote host. Stopping relay severs that session cleanly.", + )); + connection_note.set_wrap(true); + connection_note.set_halign(gtk::Align::Start); + connection_box.append(&connection_note); + root.append(&connection_frame); - let controls = gtk::Grid::new(); - controls.set_row_spacing(8); - controls.set_column_spacing(8); - root.append(&controls); + let (inputs_frame, inputs_box) = build_section("Input Routing And Devices"); + let inputs_grid = gtk::Grid::new(); + inputs_grid.set_row_spacing(8); + inputs_grid.set_column_spacing(12); + inputs_box.append(&inputs_grid); - let 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 input_toggle_button = gtk::Button::with_label("Switch To Local Inputs"); + inputs_grid.attach(>k::Label::new(Some("Live Input Target")), 0, 0, 1, 1); + inputs_grid.attach(&input_toggle_button, 1, 0, 1, 1); let toggle_key_combo = gtk::ComboBoxText::new(); toggle_key_combo.append(Some("scrolllock"), "Scroll Lock"); @@ -173,372 +197,236 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { 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); + inputs_grid.attach(>k::Label::new(Some("Swap Key")), 0, 1, 1, 1); + inputs_grid.attach(&toggle_key_combo, 1, 1, 1, 1); - let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - root.append(&button_row); + let camera_combo = gtk::ComboBoxText::new(); + camera_combo.append(Some("auto"), "auto"); + for camera in &catalog.cameras { + camera_combo.append(Some(camera), camera); + } + set_combo_active_text(&camera_combo, state.borrow().devices.camera.as_deref()); + inputs_grid.attach(>k::Label::new(Some("Camera")), 0, 2, 1, 1); + inputs_grid.attach(&camera_combo, 1, 2, 1, 1); - let 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 microphone_combo = gtk::ComboBoxText::new(); + microphone_combo.append(Some("auto"), "auto"); + for microphone in &catalog.microphones { + microphone_combo.append(Some(microphone), microphone); + } + set_combo_active_text( + µphone_combo, + state.borrow().devices.microphone.as_deref(), + ); + inputs_grid.attach(>k::Label::new(Some("Microphone")), 0, 3, 1, 1); + inputs_grid.attach(µphone_combo, 1, 3, 1, 1); + + let speaker_combo = gtk::ComboBoxText::new(); + speaker_combo.append(Some("auto"), "auto"); + for speaker in &catalog.speakers { + speaker_combo.append(Some(speaker), speaker); + } + set_combo_active_text(&speaker_combo, state.borrow().devices.speaker.as_deref()); + inputs_grid.attach(>k::Label::new(Some("Speaker")), 0, 4, 1, 1); + inputs_grid.attach(&speaker_combo, 1, 4, 1, 1); + + let inputs_note = gtk::Label::new(Some( + "Press the swap key while relay is running to flip between local and remote input ownership. The launcher reflects that live state and macros stay launcher-only.", + )); + inputs_note.set_wrap(true); + inputs_note.set_halign(gtk::Align::Start); + inputs_box.append(&inputs_note); + root.append(&inputs_frame); + + let (displays_frame, displays_box) = build_section("Displays"); + let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 14); + let left_pane = build_display_pane("Display 1"); + let right_pane = build_display_pane("Display 2"); + display_row.append(&left_pane.root); + display_row.append(&right_pane.root); + displays_box.append(&display_row); + root.append(&displays_frame); + + let (actions_frame, actions_box) = build_section("Remote Actions"); let clipboard_button = gtk::Button::with_label("Send Clipboard"); - 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); + actions_box.append(&clipboard_button); + let actions_note = gtk::Label::new(Some( + "Clipboard paste is a launcher action only. It types the current local clipboard into the remote target machine when relay is active.", + )); + actions_note.set_wrap(true); + actions_note.set_halign(gtk::Align::Start); + actions_box.append(&actions_note); + root.append(&actions_frame); + let (diagnostics_frame, diagnostics_box) = build_section("Diagnostics"); let probe_hint = gtk::Label::new(Some(quality_probe_command())); probe_hint.set_halign(gtk::Align::Start); probe_hint.set_selectable(true); - 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.", + diagnostics_box.append(&probe_hint); + let diagnostics_note = gtk::Label::new(Some( + "Keep the hygiene and quality gates green before calling the launcher changes done. Metrics still land in the local Prometheus textfile output.", )); - note.set_wrap(true); - note.set_halign(gtk::Align::Start); - root.append(¬e); + diagnostics_note.set_wrap(true); + diagnostics_note.set_halign(gtk::Align::Start); + diagnostics_box.append(&diagnostics_note); + root.append(&diagnostics_frame); let preview = match LauncherPreview::new(server_addr.as_ref().to_string()) { - Ok(preview) => { - 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) - } + Ok(preview) => Some(Rc::new(preview)), Err(err) => { let msg = format!("Preview unavailable: {err}"); - left_status.set_text(&msg); - right_status.set_text(&msg); + status_label.set_text(&msg); None } }; - sync_preview_runtime_state( - &window, - &preview_frame, - preview.as_deref(), - &state.borrow(), - child_proc.borrow().is_some(), - ); + + let mut left_pane = left_pane; + let mut right_pane = right_pane; + if let Some(preview) = preview.as_ref() { + left_pane.preview_binding = + preview.install_on_picture(0, &left_pane.picture, &left_pane.stream_status); + right_pane.preview_binding = + preview.install_on_picture(1, &right_pane.picture, &right_pane.stream_status); + } else { + left_pane.stream_status.set_text("Preview unavailable"); + right_pane.stream_status.set_text("Preview unavailable"); + } + + let widgets = LauncherWidgets { + status_label: status_label.clone(), + summary, + display_panes: [left_pane.clone(), right_pane.clone()], + start_button: start_button.clone(), + stop_button: stop_button.clone(), + input_toggle_button: input_toggle_button.clone(), + clipboard_button: clipboard_button.clone(), + toggle_key_combo: toggle_key_combo.clone(), + }; + let popouts = Rc::new(RefCell::new([None, None])); + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); { - let 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 widgets = widgets.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 child_proc = Rc::clone(&child_proc); + toggle_key_combo.connect_changed(move |_| { + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); - let 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 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 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(); - + let input_control_path = Rc::clone(&input_control_path); + let input_state_path = Rc::clone(&input_state_path); + let server_addr_fallback = Rc::clone(&server_addr); start_button.connect_clicked(move |_| { + if child_proc.borrow().is_some() { + widgets.status_label.set_text("Relay is already running"); + refresh_launcher_ui(&widgets, &state.borrow(), true); + return; + } { let mut state = state.borrow_mut(); - 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())); + let _ = std::fs::remove_file(input_control_path.as_path()); + let _ = std::fs::remove_file(input_state_path.as_path()); + let server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + let launch_state = state.borrow().clone(); + let input_toggle_key = selected_toggle_key(&widgets.toggle_key_combo); + match spawn_client_process( + &server_addr, + &launch_state, + &input_toggle_key, + input_control_path.as_path(), + input_state_path.as_path(), + ) { + Ok(child) => { + *child_proc.borrow_mut() = Some(child); + let _ = state.borrow_mut().start_remote(); + let routing = state.borrow().routing; + widgets.status_label.set_text(&format!( + "Relay started - input target is {}", + routing_name(routing) + )); } Err(err) => { - 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}")); + widgets + .status_label + .set_text(&format!("Relay start failed: {err}")); } } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); - let window = window.clone(); - let preview_frame = preview_frame.clone(); - let preview = preview.clone(); - let status_label = status_label.clone(); + let widgets = widgets.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"); + widgets.status_label.set_text("Relay stopped"); + refresh_launcher_ui(&widgets, &state.borrow(), false); }); } { let state = Rc::clone(&state); let child_proc = Rc::clone(&child_proc); - let 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}")); - } + let widgets = widgets.clone(); + let input_control_path = Rc::clone(&input_control_path); + input_toggle_button.connect_clicked(move |_| { + let next = next_input_routing(state.borrow().routing); + let child_running = child_proc.borrow().is_some(); + if child_running { + if let Err(err) = + write_input_routing_request(input_control_path.as_path(), next) + { + widgets.status_label.set_text(&format!( + "Could not update live input target: {err}" + )); + refresh_launcher_ui(&widgets, &state.borrow(), true); + return; } + widgets.status_label.set_text(&format!( + "Requested {} input control - pressing the swap key mirrors this live.", + routing_name(next) + )); } else { - 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())); + widgets.status_label.set_text(&format!( + "Relay will start with {} input control.", + routing_name(next) + )); } + state.borrow_mut().set_routing(next); + refresh_launcher_ui(&widgets, &state.borrow(), child_running); }); } { let child_proc = Rc::clone(&child_proc); - let status_label = status_label.clone(); + let widgets = widgets.clone(); let server_entry = server_entry.clone(); - let server_addr = Rc::clone(&server_addr); + let server_addr_fallback = Rc::clone(&server_addr); clipboard_button.connect_clicked(move |_| { if child_proc.borrow().is_none() { - status_label.set_text("Start Relay before sending clipboard"); + widgets.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 server_addr = + selected_server_addr(&server_entry, server_addr_fallback.as_ref()); + widgets.status_label.set_text("Sending clipboard to remote..."); let (result_tx, result_rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { let message = match send_clipboard_to_remote(&server_addr) { @@ -548,7 +436,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let _ = result_tx.send(message); }); - let status_label = status_label.clone(); + let status_label = widgets.status_label.clone(); glib::timeout_add_local(Duration::from_millis(100), move || { match result_rx.try_recv() { Ok(message) => { @@ -559,7 +447,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { glib::ControlFlow::Continue } Err(std::sync::mpsc::TryRecvError::Disconnected) => { - status_label.set_text("Clipboard send failed: launcher worker exited"); + status_label + .set_text("Clipboard send failed: launcher worker exited"); glib::ControlFlow::Break } } @@ -567,6 +456,101 @@ pub fn run_gui_launcher(server_addr: String) -> 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; + }; + let surface = state.borrow().display_surface(monitor_id); + match surface { + DisplaySurface::Preview => { + open_popout_window( + &app, + preview, + &state, + &child_proc, + &popouts, + &widgets, + monitor_id, + ); + widgets.status_label.set_text(&format!( + "{} moved into its own window", + widgets.display_panes[monitor_id].title + )); + } + DisplaySurface::Window => { + dock_display_to_preview( + &state, + &child_proc, + &popouts, + &widgets, + monitor_id, + ); + widgets.status_label.set_text(&format!( + "{} returned to the launcher preview", + widgets.display_panes[monitor_id].title + )); + } + } + }); + } + + { + let window = window.clone(); + let state = Rc::clone(&state); + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + let focus_signal_path = Rc::clone(&focus_signal_path); + let input_state_path = Rc::clone(&input_state_path); + let last_focus_marker = + Rc::new(RefCell::new(path_marker(focus_signal_path.as_path()))); + let last_state_marker = + Rc::new(RefCell::new(path_marker(input_state_path.as_path()))); + glib::timeout_add_local(Duration::from_millis(180), move || { + let child_running = reap_exited_child(&child_proc); + if !child_running && state.borrow().remote_active { + let _ = state.borrow_mut().stop_remote(); + widgets.status_label.set_text("Relay ended"); + } + + let next_state_marker = path_marker(input_state_path.as_path()); + let mut last_state = last_state_marker.borrow_mut(); + if next_state_marker > *last_state { + *last_state = next_state_marker; + if let Some(routing) = read_input_routing_state(input_state_path.as_path()) + { + state.borrow_mut().set_routing(routing); + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + } + } + + let next_focus_marker = path_marker(focus_signal_path.as_path()); + let mut last_focus = last_focus_marker.borrow_mut(); + if next_focus_marker > *last_focus { + *last_focus = next_focus_marker; + state.borrow_mut().set_routing(InputRouting::Local); + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + widgets + .status_label + .set_text("Local control restored - launcher focused"); + window.present(); + } + + refresh_launcher_ui(&widgets, &state.borrow(), child_running); + glib::ControlFlow::Continue + }); + } + scroll.set_child(Some(&root)); window.set_child(Some(&scroll)); window.present(); @@ -582,6 +566,273 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> { Ok(()) } +#[cfg(not(coverage))] +fn build_section(title: &str) -> (gtk::Frame, gtk::Box) { + let frame = gtk::Frame::new(Some(title)); + let body = gtk::Box::new(gtk::Orientation::Vertical, 10); + body.set_margin_start(12); + body.set_margin_end(12); + body.set_margin_top(12); + body.set_margin_bottom(12); + frame.set_child(Some(&body)); + (frame, body) +} + +#[cfg(not(coverage))] +fn attach_summary_row(grid: >k::Grid, row: i32, label: &str, value: &str) -> gtk::Label { + let key = gtk::Label::new(Some(label)); + key.set_halign(gtk::Align::Start); + let value_label = gtk::Label::new(Some(value)); + value_label.set_halign(gtk::Align::Start); + value_label.set_selectable(true); + grid.attach(&key, 0, row, 1, 1); + grid.attach(&value_label, 1, row, 1, 1); + value_label +} + +#[cfg(not(coverage))] +fn build_display_pane(title: &str) -> DisplayPaneWidgets { + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + root.set_hexpand(true); + root.set_vexpand(true); + + let title_label = gtk::Label::new(Some(title)); + title_label.add_css_class("title-4"); + title_label.set_halign(gtk::Align::Center); + root.append(&title_label); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_can_shrink(true); + picture.set_size_request(460, 258); + + let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + preview_box.append(&picture); + + let placeholder = gtk::Label::new(Some("This display is docked in the launcher preview.")); + placeholder.set_wrap(true); + placeholder.set_justify(gtk::Justification::Center); + placeholder.set_halign(gtk::Align::Center); + placeholder.set_valign(gtk::Align::Center); + + let placeholder_box = gtk::Box::new(gtk::Orientation::Vertical, 6); + placeholder_box.set_hexpand(true); + placeholder_box.set_vexpand(true); + placeholder_box.set_size_request(460, 258); + placeholder_box.append(&placeholder); + + let stack = gtk::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + stack.add_named(&preview_box, Some("preview")); + stack.add_named(&placeholder_box, Some("placeholder")); + stack.set_visible_child_name("preview"); + root.append(&stack); + + let stream_status = gtk::Label::new(Some("Waiting for stream...")); + stream_status.set_halign(gtk::Align::Start); + root.append(&stream_status); + + let action_button = gtk::Button::with_label("Break Out"); + action_button.set_halign(gtk::Align::Center); + root.append(&action_button); + + DisplayPaneWidgets { + root, + stack, + picture, + stream_status, + placeholder, + action_button, + preview_binding: None, + title: title.to_string(), + } +} + +#[cfg(not(coverage))] +fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { + widgets.summary.relay_value.set_text(if child_running || state.remote_active { + "Running" + } else { + "Stopped" + }); + widgets + .summary + .routing_value + .set_text(routing_name(state.routing)); + widgets.summary.displays_value.set_text(&format!( + "Display 1: {} | Display 2: {}", + state.display_surface(0).label(), + state.display_surface(1).label() + )); + widgets + .summary + .shortcut_value + .set_text(&selected_toggle_key_label(&widgets.toggle_key_combo)); + + widgets.start_button.set_sensitive(!child_running); + widgets.stop_button.set_sensitive(child_running); + widgets.clipboard_button.set_sensitive(child_running); + widgets.input_toggle_button.set_label(match state.routing { + InputRouting::Remote => "Switch To Local Inputs", + InputRouting::Local => "Switch To Remote Inputs", + }); + + for monitor_id in 0..2 { + refresh_display_pane(&widgets.display_panes[monitor_id], state.display_surface(monitor_id)); + } +} + +#[cfg(not(coverage))] +fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { + if let Some(binding) = pane.preview_binding.as_ref() { + binding.set_enabled(matches!(surface, DisplaySurface::Preview)); + } + pane.action_button.set_sensitive(pane.preview_binding.is_some()); + match surface { + DisplaySurface::Preview => { + pane.stack.set_visible_child_name("preview"); + pane.action_button.set_label("Break Out"); + pane.placeholder + .set_text("This display is docked in the launcher preview."); + if pane.preview_binding.is_none() { + pane.stream_status.set_text("Preview unavailable"); + } + } + DisplaySurface::Window => { + pane.stack.set_visible_child_name("placeholder"); + pane.action_button.set_label("Return To Preview"); + pane.placeholder.set_text(&format!( + "{} is open in its own window.\nUse \"Return To Preview\" to dock it back here.", + pane.title + )); + pane.stream_status.set_text("Streaming in its own window"); + } + } +} + +#[cfg(not(coverage))] +fn open_popout_window( + app: >k::Application, + preview: &LauncherPreview, + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + let already_open = popouts.borrow()[monitor_id].is_some(); + if already_open { + return; + } + + if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { + binding.set_enabled(false); + } + + let window = gtk::ApplicationWindow::builder() + .application(app) + .title(&format!("Lesavka {}", widgets.display_panes[monitor_id].title)) + .default_width(1280) + .default_height(760) + .build(); + window.maximize(); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 8); + root.set_margin_start(10); + root.set_margin_end(10); + root.set_margin_top(10); + root.set_margin_bottom(10); + + let title = gtk::Label::new(Some(&widgets.display_panes[monitor_id].title)); + title.add_css_class("title-3"); + title.set_halign(gtk::Align::Center); + root.append(&title); + + let picture = gtk::Picture::new(); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_can_shrink(true); + root.append(&picture); + + let stream_status = gtk::Label::new(Some("Waiting for stream...")); + stream_status.set_halign(gtk::Align::Start); + root.append(&stream_status); + + let binding = preview + .install_on_picture(monitor_id, &picture, &stream_status) + .expect("preview binding for popout"); + + window.set_child(Some(&root)); + + let state_handle = Rc::clone(state); + let child_proc_handle = Rc::clone(child_proc); + let popouts_handle = Rc::clone(popouts); + let widgets_handle = widgets.clone(); + let close_binding = binding.clone(); + window.connect_close_request(move |_| { + let handle = { + let mut popouts = popouts_handle.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + if let Some(preview_binding) = + widgets_handle.display_panes[monitor_id].preview_binding.as_ref() + { + preview_binding.set_enabled(true); + } + state_handle + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Preview); + refresh_launcher_ui( + &widgets_handle, + &state_handle.borrow(), + child_proc_handle.borrow().is_some(), + ); + } else { + close_binding.close(); + } + glib::Propagation::Proceed + }); + + state + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Window); + popouts.borrow_mut()[monitor_id] = Some(PopoutWindowHandle { + window: window.clone(), + binding, + }); + refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); + window.present(); +} + +#[cfg(not(coverage))] +fn dock_display_to_preview( + state: &Rc>, + child_proc: &Rc>>, + popouts: &Rc; 2]>>, + widgets: &LauncherWidgets, + monitor_id: usize, +) { + let handle = { + let mut popouts = popouts.borrow_mut(); + popouts[monitor_id].take() + }; + if let Some(handle) = handle { + handle.binding.close(); + handle.window.close(); + } + if let Some(binding) = widgets.display_panes[monitor_id].preview_binding.as_ref() { + binding.set_enabled(true); + } + state + .borrow_mut() + .set_display_surface(monitor_id, DisplaySurface::Preview); + refresh_launcher_ui(widgets, &state.borrow(), child_proc.borrow().is_some()); +} + #[cfg(not(coverage))] fn selected_combo_value(combo: >k::ComboBoxText) -> Option { combo.active_text().and_then(|value| { @@ -603,6 +854,13 @@ fn selected_toggle_key(combo: >k::ComboBoxText) -> String { .unwrap_or_else(|| "pause".to_string()) } +#[cfg(not(coverage))] +fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String { + combo.active_text() + .map(|value| value.to_string()) + .unwrap_or_else(|| "Pause".to_string()) +} + #[cfg(not(coverage))] fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { let current = entry.text(); @@ -615,32 +873,45 @@ fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { } #[cfg(not(coverage))] -/// Applies the current server/key launcher controls and relaunches the child session. -fn relaunch_with_settings( - child_proc: &Rc>>, - state: &Rc>, - 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) +fn input_control_path() -> PathBuf { + std::env::var(INPUT_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) } #[cfg(not(coverage))] +fn input_state_path() -> PathBuf { + std::env::var(INPUT_STATE_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) +} + #[cfg(not(coverage))] -fn focus_signal_marker(path: &Path) -> u128 { +fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { + std::fs::write(path, format!("{}\n", routing_name(routing)))?; + Ok(()) +} + +#[cfg(not(coverage))] +fn read_input_routing_state(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "local" => Some(InputRouting::Local), + "remote" => Some(InputRouting::Remote), + _ => None, + } +} + +#[cfg(not(coverage))] +fn routing_name(routing: InputRouting) -> &'static str { + match routing { + InputRouting::Local => "local", + InputRouting::Remote => "remote", + } +} + +#[cfg(not(coverage))] +fn path_marker(path: &Path) -> u128 { std::fs::metadata(path) .ok() .and_then(|meta| meta.modified().ok()) @@ -649,33 +920,6 @@ fn focus_signal_marker(path: &Path) -> u128 { .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"); @@ -689,6 +933,8 @@ fn spawn_client_process( server_addr: &str, state: &LauncherState, input_toggle_key: &str, + input_control_path: &Path, + input_state_path: &Path, ) -> Result { let exe = std::env::current_exe()?; let mut command = Command::new(exe); @@ -698,6 +944,10 @@ fn spawn_client_process( command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); + command.env(INPUT_CONTROL_ENV, input_control_path); + command.env(INPUT_STATE_ENV, input_state_path); + command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); + command.env("LESAVKA_CLIPBOARD_PASTE", "0"); for (key, value) in runtime_env_vars(state) { command.env(key, value); } @@ -705,7 +955,6 @@ fn spawn_client_process( } #[cfg(not(coverage))] -/// Stops and reaps the launcher child process when one is running. fn stop_child_process(child_proc: &Rc>>) { if let Some(mut child) = child_proc.borrow_mut().take() { let _ = child.kill(); @@ -714,31 +963,21 @@ fn stop_child_process(child_proc: &Rc>>) { } #[cfg(not(coverage))] -/// Restarts the launcher child process with the current launcher state. -fn launch_or_restart_client( - child_proc: &Rc>>, - 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, +fn reap_exited_child(child_proc: &Rc>>) -> bool { + let mut slot = child_proc.borrow_mut(); + match slot.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + *slot = None; + false + } + Ok(None) | Err(_) => true, + }, + None => false, } } #[cfg(not(coverage))] -/// Flips between remote input capture and local input passthrough. fn next_input_routing(routing: InputRouting) -> InputRouting { match routing { InputRouting::Remote => InputRouting::Local, @@ -746,47 +985,6 @@ fn next_input_routing(routing: InputRouting) -> InputRouting { } } -#[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; diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 84a93ce..b79ec94 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -22,8 +22,8 @@ }, "client/src/input/inputs.rs": { "clippy_warnings": 42, - "doc_debt": 11, - "loc": 562 + "doc_debt": 16, + "loc": 669 }, "client/src/input/keyboard.rs": { "clippy_warnings": 24, @@ -71,19 +71,19 @@ "loc": 182 }, "client/src/launcher/preview.rs": { - "clippy_warnings": 22, - "doc_debt": 9, - "loc": 291 + "clippy_warnings": 20, + "doc_debt": 6, + "loc": 293 }, "client/src/launcher/state.rs": { - "clippy_warnings": 8, - "doc_debt": 9, - "loc": 234 + "clippy_warnings": 14, + "doc_debt": 13, + "loc": 306 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 8, - "doc_debt": 8, - "loc": 838 + "clippy_warnings": 18, + "doc_debt": 15, + "loc": 996 }, "client/src/layout.rs": { "clippy_warnings": 6, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 2ce59ef..5eab78e 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -17,8 +17,8 @@ "loc": 368 }, "client/src/input/inputs.rs": { - "line_percent": 97.32, - "loc": 562 + "line_percent": 97.55, + "loc": 669 }, "client/src/input/keyboard.rs": { "line_percent": 95.7, @@ -53,12 +53,12 @@ "loc": 181 }, "client/src/launcher/state.rs": { - "line_percent": 97.97297297297297, - "loc": 234 + "line_percent": 98.0, + "loc": 306 }, "client/src/launcher/ui.rs": { "line_percent": 100.0, - "loc": 838 + "loc": 996 }, "client/src/layout.rs": { "line_percent": 97.72727272727273,