use anyhow::Result; use gtk::{gdk, glib, prelude::*}; use std::{ cell::RefCell, io::{BufRead, BufReader}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, rc::Rc, sync::mpsc::Sender, }; use super::{ LAUNCHER_CLIPBOARD_CONTROL_ENV, LAUNCHER_FOCUS_SIGNAL_ENV, device_test::{DeviceTestController, DeviceTestKind}, diagnostics::{SnapshotReport, quality_probe_command}, launcher_clipboard_control_path, launcher_focus_signal_path, preview::{LauncherPreview, PreviewSurface}, runtime_env_vars, state::{BreakoutSizeChoice, CapturePowerStatus, DisplaySurface, InputRouting, LauncherState}, ui_components::{DisplayPaneWidgets, LauncherWidgets, PopoutWindowHandle}, }; pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; pub type RelayChild = Child; pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, child_running: bool) { let relay_live = child_running || state.remote_active; set_status_light( &widgets.summary.relay_light, StatusLightState::from_active(state.server_available), ); widgets.summary.relay_value.set_text( state .server_version .as_deref() .map(|version| version.trim()) .filter(|version| !version.is_empty()) .map(|version| { if version.starts_with('v') { version.to_string() } else { format!("v{version}") } }) .as_deref() .unwrap_or(""), ); set_status_light( &widgets.summary.routing_light, StatusLightState::from_active(matches!(state.routing, InputRouting::Remote)), ); widgets .summary .routing_value .set_text(&capitalize(routing_name(state.routing))); set_status_light( &widgets.summary.gpio_light, gpio_light_state(&state.capture_power), ); widgets .summary .gpio_value .set_text(&gpio_power_label(&state.capture_power)); widgets .summary .shortcut_value .set_text(&toggle_key_label(&state.swap_key)); widgets .power_detail .set_text(&capture_power_detail(&state.capture_power)); widgets.start_button.set_label(if relay_live { "Disconnect Relay" } else { "Connect Relay" }); widgets.start_button.set_sensitive(true); widgets.start_button.set_tooltip_text(Some(if relay_live { "Disconnect from the relay host, stop the live session, and let capture fall back to grace/standby." } else { "Connect to the relay host, start the live session, and bring the eye previews online." })); widgets.clipboard_button.set_sensitive(relay_live); widgets.probe_button.set_sensitive(true); widgets .usb_recover_button .set_sensitive(state.server_available); widgets.device_refresh_button.set_sensitive(true); widgets.input_toggle_button.set_label("Change Routing"); widgets .input_toggle_button .set_tooltip_text(Some(match state.routing { InputRouting::Remote => { "Inputs are currently going to the remote session. Click to bring them back local." } InputRouting::Local => { "Inputs are currently staying local. Click to hand them to the remote session." } })); widgets.swap_key_button.set_label("Set Swap Key"); widgets.swap_key_button.set_tooltip_text(Some(if state.swap_key_binding { "Waiting for the next key press. The top chip still shows the current live shortcut." } else { "Capture the next key you press and make it the swap shortcut. The current shortcut is shown in the top chip." })); let power_available = state.capture_power.available; widgets .power_auto_button .set_sensitive(power_available && !matches!(state.capture_power.mode.as_str(), "auto")); widgets.power_on_button.set_sensitive( power_available && !matches!(state.capture_power.mode.as_str(), "forced-on"), ); widgets.power_off_button.set_sensitive( power_available && !matches!(state.capture_power.mode.as_str(), "forced-off"), ); sync_power_mode_button_styles(widgets, state.capture_power.mode.as_str()); for monitor_id in 0..2 { refresh_display_pane( &widgets.display_panes[monitor_id], state.display_surface(monitor_id), ); } refresh_diagnostics_report(widgets, state, child_running); } pub fn refresh_test_buttons(widgets: &LauncherWidgets, tests: &mut DeviceTestController) { let camera_running = tests.is_running(DeviceTestKind::Camera); let microphone_running = tests.is_running(DeviceTestKind::Microphone); let microphone_replay_running = tests.is_running(DeviceTestKind::MicrophoneReplay); let speaker_running = tests.is_running(DeviceTestKind::Speaker); widgets.camera_test_button.set_label(if camera_running { "Stop Preview" } else { "Start Preview" }); widgets .microphone_test_button .set_label(if microphone_running { "Stop Monitor" } else { "Monitor Mic" }); widgets .microphone_replay_button .set_label(if microphone_replay_running { "Stop Replay" } else { "Replay Last 3s" }); widgets .microphone_replay_button .set_sensitive(microphone_replay_running || tests.microphone_replay_ready()); widgets.speaker_test_button.set_label(if speaker_running { "Stop Tone" } else { "Play Tone" }); widgets.audio_check_detail.set_text(&local_test_detail( camera_running, microphone_running, speaker_running, microphone_replay_running, )); if microphone_running { widgets .audio_check_meter .set_fraction(tests.microphone_level_fraction()); } else if speaker_running || microphone_replay_running { widgets.audio_check_meter.pulse(); } else { widgets.audio_check_meter.set_fraction(0.0); } } pub fn update_test_action_result( widgets: &LauncherWidgets, tests: &mut DeviceTestController, result: Result, start_msg: &str, stop_msg: &str, ) { match result { Ok(true) => widgets.status_label.set_text(start_msg), Ok(false) => widgets.status_label.set_text(stop_msg), Err(err) => widgets .status_label .set_text(&format!("Device test failed: {err}")), } refresh_test_buttons(widgets, tests); } pub 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 = { let popouts = popouts.borrow(); popouts[monitor_id].is_some() }; if already_open { return; } if let Some(binding) = widgets.display_panes[monitor_id] .preview_binding .borrow() .as_ref() { binding.set_enabled(false); } let (breakout_size, breakout_limit) = { let state = state.borrow(); ( state.breakout_size_choice(monitor_id), state.breakout_display_size(), ) }; let window = gtk::ApplicationWindow::builder() .application(app) .title(format!( "Lesavka {}", widgets.display_panes[monitor_id].title )) .default_width(breakout_size.width) .default_height(breakout_size.height) .build(); super::ui_components::install_css(&window); super::ui_components::install_window_icon(&window); window.set_decorated(false); window.set_resizable(false); let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); picture.set_halign(gtk::Align::Fill); picture.set_valign(gtk::Align::Fill); picture.set_can_shrink(true); picture.set_keep_aspect_ratio(true); picture.set_size_request(breakout_size.width, breakout_size.height); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); root.set_hexpand(true); root.set_vexpand(true); root.set_halign(gtk::Align::Fill); root.set_valign(gtk::Align::Fill); root.set_size_request(breakout_size.width, breakout_size.height); let frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); frame.set_hexpand(true); frame.set_vexpand(true); frame.set_halign(gtk::Align::Fill); frame.set_valign(gtk::Align::Fill); frame.set_size_request(breakout_size.width, breakout_size.height); frame.set_child(Some(&picture)); root.append(&frame); let stream_status = gtk::Label::new(Some("")); let binding = preview .install_on_picture(monitor_id, PreviewSurface::Window, &picture, &stream_status) .expect("preview binding for popout"); window.set_child(Some(&root)); install_popout_drag(&window, &picture); apply_popout_window_geometry(&window, &root, &picture, breakout_size, breakout_limit); 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 .borrow() .as_ref() { preview_binding.set_enabled(true); } { let mut state = state_handle.borrow_mut(); state.set_display_surface(monitor_id, DisplaySurface::Preview); } let child_running = child_proc_handle.borrow().is_some(); let state_snapshot = state_handle.borrow().clone(); refresh_launcher_ui(&widgets_handle, &state_snapshot, child_running); } else { close_binding.close(); } glib::Propagation::Proceed }); { let mut state = state.borrow_mut(); state.set_display_surface(monitor_id, DisplaySurface::Window); } { let mut popouts = popouts.borrow_mut(); popouts[monitor_id] = Some(PopoutWindowHandle { window: window.clone(), frame: frame.clone(), picture: picture.clone(), status_label: stream_status.clone(), binding, }); } let child_running = child_proc.borrow().is_some(); let state_snapshot = state.borrow().clone(); refresh_launcher_ui(widgets, &state_snapshot, child_running); window.present(); schedule_popout_window_geometry( window.clone(), root.clone(), picture.clone(), breakout_size, breakout_limit, ); } pub fn apply_popout_window_size( handle: &PopoutWindowHandle, size: BreakoutSizeChoice, display_limit: super::state::PreviewSourceSize, ) { let Some(root) = handle .picture .parent() .and_then(|widget| widget.downcast::().ok()) else { return; }; apply_popout_window_geometry(&handle.window, &root, &handle.picture, size, display_limit); handle.window.present(); schedule_popout_window_geometry( handle.window.clone(), root.clone(), handle.picture.clone(), size, display_limit, ); } pub 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 .borrow() .as_ref() { binding.set_enabled(true); } { let mut state = state.borrow_mut(); state.set_display_surface(monitor_id, DisplaySurface::Preview); } let child_running = child_proc.borrow().is_some(); let state_snapshot = state.borrow().clone(); refresh_launcher_ui(widgets, &state_snapshot, child_running); } pub fn dock_all_displays_to_preview( state: &Rc>, child_proc: &Rc>>, popouts: &Rc; 2]>>, widgets: &LauncherWidgets, ) { let mut handles = Vec::new(); { let mut popouts = popouts.borrow_mut(); for monitor_id in 0..2 { if let Some(handle) = popouts[monitor_id].take() { handles.push(handle); } } } for handle in handles { handle.binding.close(); handle.window.close(); } for monitor_id in 0..2 { if let Some(binding) = widgets.display_panes[monitor_id] .preview_binding .borrow() .as_ref() { binding.set_enabled(true); } } { let mut state = state.borrow_mut(); for monitor_id in 0..2 { state.set_display_surface(monitor_id, DisplaySurface::Preview); } } let child_running = child_proc.borrow().is_some(); let state_snapshot = state.borrow().clone(); refresh_launcher_ui(widgets, &state_snapshot, child_running); } pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) { if let Some(binding) = pane.preview_binding.borrow().as_ref() { binding.set_enabled(matches!(surface, DisplaySurface::Preview)); } pane.action_button .set_sensitive(pane.preview_binding.borrow().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 feed is running in its own window.\nUse Return To Preview to dock it back here.", ); if pane.preview_binding.borrow().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 running in a dedicated window.\nReturn it here when you want the in-launcher preview back.", pane.title )); pane.stream_status.set_text("Streaming in its own window"); } } } pub fn gpio_power_label(power: &CapturePowerStatus) -> String { if !power.available { return "Unavailable".to_string(); } if !power.enabled { return "Power Off".to_string(); } match power.detected_devices { 0 => "No Eyes".to_string(), 1 => "1 Eye".to_string(), count => format!("{count} Eyes"), } } pub fn capture_power_detail(power: &CapturePowerStatus) -> String { if !power.available { return format!("{} is unavailable: {}", power.unit, power.detail); } let detected = if power.enabled { format!(" • {}", gpio_detection_detail(power.detected_devices)) } else { String::new() }; match power.mode.as_str() { "forced-on" => format!( "{} • awake • {}{} • leases {}", power.unit, power.detail, detected, power.active_leases ), "forced-off" => format!( "{} • dark • {}{} • leases {}", power.unit, power.detail, detected, power.active_leases ), _ => format!( "{} • auto • {}{} • leases {}", power.unit, power.detail, detected, power.active_leases ), } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum StatusLightState { Idle, Live, Warning, Caution, } impl StatusLightState { fn from_active(active: bool) -> Self { if active { Self::Live } else { Self::Idle } } fn css_class(self) -> &'static str { match self { Self::Idle => "status-light-idle", Self::Live => "status-light-live", Self::Warning => "status-light-warning", Self::Caution => "status-light-caution", } } } fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState { if !power.available || !power.enabled { return StatusLightState::Idle; } match power.detected_devices { 0 => StatusLightState::Warning, 1 => StatusLightState::Caution, _ => StatusLightState::Live, } } fn gpio_detection_detail(detected_devices: u32) -> String { match detected_devices { 0 => "no eyes detected".to_string(), 1 => "1 eye detected".to_string(), count => format!("{count} eyes detected"), } } /// Highlights the currently active capture mode so it reads like a segmented control. fn sync_power_mode_button_styles(widgets: &LauncherWidgets, mode: &str) { for button in [ &widgets.power_auto_button, &widgets.power_on_button, &widgets.power_off_button, ] { button.remove_css_class("pill-toggle-active"); } match mode { "forced-on" => widgets.power_on_button.add_css_class("pill-toggle-active"), "forced-off" => widgets.power_off_button.add_css_class("pill-toggle-active"), _ => widgets .power_auto_button .add_css_class("pill-toggle-active"), } } /// Reports which local staging checks are active right now. fn local_test_detail( camera_running: bool, microphone_running: bool, speaker_running: bool, microphone_replay_running: bool, ) -> String { let mut active = Vec::new(); if camera_running { active.push("camera preview"); } if microphone_running { active.push("mic monitor"); } if speaker_running { active.push("speaker tone"); } if microphone_replay_running { active.push("mic replay"); } if active.is_empty() { "Local checks are idle. Use Start Preview, Monitor Mic, Replay Last 3s, or Play Tone before you launch." .to_string() } else { format!( "Local checks running: {}. Stop them whenever staging is complete.", active.join(", ") ) } } fn install_popout_drag(window: >k::ApplicationWindow, widget: &impl IsA) { let drag = gtk::GestureClick::new(); drag.set_button(0); let native = window.clone(); drag.connect_pressed(move |gesture, _press, x, y| { let Some(device) = gesture.current_event_device() else { return; }; let Some(surface) = native.surface() else { return; }; let Some(toplevel) = surface.dynamic_cast_ref::() else { return; }; let timestamp = gesture .current_event() .map(|event| event.time()) .unwrap_or(0); toplevel.begin_move(&device, 1, x, y, timestamp); }); widget.add_controller(drag); } fn apply_popout_window_geometry( window: >k::ApplicationWindow, root: >k::Box, picture: >k::Picture, size: BreakoutSizeChoice, display_limit: super::state::PreviewSourceSize, ) { picture.set_size_request(size.width, size.height); root.set_size_request(size.width, size.height); window.set_default_size(size.width, size.height); if should_cover_display(size, display_limit) { fullscreen_on_largest_monitor(window); } else { window.unfullscreen(); } } fn schedule_popout_window_geometry( window: gtk::ApplicationWindow, root: gtk::Box, picture: gtk::Picture, size: BreakoutSizeChoice, display_limit: super::state::PreviewSourceSize, ) { for delay_ms in [0_u64, 25, 150] { let window = window.clone(); let root = root.clone(); let picture = picture.clone(); glib::timeout_add_local_once(std::time::Duration::from_millis(delay_ms), move || { apply_popout_window_geometry(&window, &root, &picture, size, display_limit); window.present(); }); } } fn fullscreen_on_largest_monitor(window: >k::ApplicationWindow) { let Some(display) = gdk::Display::default() else { window.fullscreen(); return; }; let monitors = display.monitors(); let monitor = (0..monitors.n_items()) .filter_map(|idx| monitors.item(idx)) .filter_map(|obj| obj.downcast::().ok()) .max_by_key(|monitor| { let geometry = monitor.geometry(); let scale = monitor.scale_factor().max(1); geometry.width().max(1) as i64 * scale as i64 * geometry.height().max(1) as i64 * scale as i64 }); if let Some(monitor) = monitor.as_ref() { window.fullscreen_on_monitor(monitor); } else { window.fullscreen(); } } fn should_cover_display( size: BreakoutSizeChoice, display_limit: super::state::PreviewSourceSize, ) -> bool { matches!(size.preset, super::state::BreakoutSizePreset::FillDisplay) || (size.width >= display_limit.width.max(1) as i32 && size.height >= display_limit.height.max(1) as i32) } pub fn present_popout_windows(popouts: &Rc; 2]>>) { for handle in popouts.borrow().iter().flatten() { handle.window.present(); } } #[cfg(test)] /// Prefer the basename for `/dev/...` entries while keeping Pulse names intact. fn compact_device_name(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return "auto".to_string(); } trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() } pub fn capitalize(value: &str) -> String { let mut chars = value.chars(); match chars.next() { Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), None => String::new(), } } pub fn selected_combo_value(combo: >k::ComboBoxText) -> Option { combo .active_id() .map(|value| value.to_string()) .or_else(|| combo.active_text().map(|value| value.to_string())) .and_then(|value| { let value = value.to_string(); let trimmed = value.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") || trimmed.eq_ignore_ascii_case("all") { None } else { Some(trimmed.to_string()) } }) } pub fn selected_server_addr(entry: >k::Entry, fallback: &str) -> String { let current = entry.text(); let trimmed = current.trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } pub fn input_control_path() -> PathBuf { std::env::var(INPUT_CONTROL_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_CONTROL_PATH)) } pub fn input_state_path() -> PathBuf { std::env::var(INPUT_STATE_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_INPUT_STATE_PATH)) } pub fn write_input_routing_request(path: &Path, routing: InputRouting) -> Result<()> { std::fs::write(path, format!("{}\n", routing_name(routing)))?; Ok(()) } pub 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, } } pub fn routing_name(routing: InputRouting) -> &'static str { match routing { InputRouting::Local => "local", InputRouting::Remote => "remote", } } pub 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() } pub fn set_combo_active_text(combo: >k::ComboBoxText, wanted: Option<&str>) { let wanted = wanted.unwrap_or("auto"); if combo.set_active_id(Some(wanted)) { return; } if combo.set_active_id(Some("auto")) { return; } let _ = combo.set_active_id(Some("all")); } pub fn toggle_key_label(raw: &str) -> String { match raw.trim().to_ascii_lowercase().as_str() { "" | "off" | "none" | "disabled" => "Disabled".to_string(), "scrolllock" | "scroll_lock" | "scroll-lock" => "Scroll Lock".to_string(), "sysrq" | "sysreq" | "prtsc" | "printscreen" | "print_screen" | "print-screen" => { "SysRq / PrtSc".to_string() } "pause" | "pausebreak" | "pause_break" | "pause-break" => "Pause".to_string(), "pageup" | "page_up" | "page-up" => "Page Up".to_string(), "pagedown" | "page_down" | "page-down" => "Page Down".to_string(), "capslock" | "caps_lock" | "caps-lock" => "Caps Lock".to_string(), "backspace" | "back_space" | "back-space" => "Backspace".to_string(), "space" | "spacebar" => "Space".to_string(), "escape" | "esc" => "Escape".to_string(), value if value.starts_with('f') && value.len() <= 3 && value[1..].chars().all(|ch| ch.is_ascii_digit()) => { value.to_ascii_uppercase() } value if value.len() == 1 => value.to_ascii_uppercase(), value => capitalize(&value.replace('_', " ").replace('-', " ")), } } pub fn capture_swap_key(key: gtk::gdk::Key) -> Option { let normalized_name = key.name()?.to_string().to_ascii_lowercase(); match normalized_name.as_str() { "shift_l" | "shift_r" | "control_l" | "control_r" | "alt_l" | "alt_r" | "super_l" | "super_r" | "meta_l" | "meta_r" | "hyper_l" | "hyper_r" | "iso_level3_shift" | "multi_key" => None, "scroll_lock" => Some("scrolllock".to_string()), "sys_req" | "print" => Some("sysrq".to_string()), "pause" | "break" => Some("pause".to_string()), "page_up" => Some("pageup".to_string()), "page_down" => Some("pagedown".to_string()), "caps_lock" => Some("capslock".to_string()), "backspace" => Some("backspace".to_string()), "return" => Some("enter".to_string()), "space" => Some("space".to_string()), "escape" => Some("escape".to_string()), "kp_0" => Some("0".to_string()), "kp_1" => Some("1".to_string()), "kp_2" => Some("2".to_string()), "kp_3" => Some("3".to_string()), "kp_4" => Some("4".to_string()), "kp_5" => Some("5".to_string()), "kp_6" => Some("6".to_string()), "kp_7" => Some("7".to_string()), "kp_8" => Some("8".to_string()), "kp_9" => Some("9".to_string()), other if other.starts_with('f') && other.len() <= 3 && other[1..].chars().all(|ch| ch.is_ascii_digit()) => { Some(other.to_string()) } other if other.len() == 1 => { let ch = other.chars().next()?; if ch.is_ascii_alphanumeric() { Some(ch.to_ascii_lowercase().to_string()) } else { None } } "insert" | "delete" | "home" | "end" | "left" | "right" | "up" | "down" | "tab" => { Some(normalized_name) } _ => None, } } pub 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); command.arg("--no-launcher"); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); command.env("LESAVKA_LAUNCHER_CHILD", "1"); command.env("LESAVKA_SERVER_ADDR", server_addr); command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka"); command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); command.env( LAUNCHER_CLIPBOARD_CONTROL_ENV, launcher_clipboard_control_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", "1"); for (key, value) in runtime_env_vars(state) { command.env(key, value); } Ok(command.spawn()?) } pub fn attach_child_log_streams(child: &mut RelayChild, tx: Sender) { if let Some(stdout) = child.stdout.take() { spawn_log_reader(stdout, "[relay] ", tx.clone()); } if let Some(stderr) = child.stderr.take() { spawn_log_reader(stderr, "[relay:stderr] ", tx); } } fn spawn_log_reader(reader: R, prefix: &'static str, tx: Sender) where R: std::io::Read + Send + 'static, { std::thread::spawn(move || { for line in BufReader::new(reader) .lines() .map_while(std::result::Result::ok) { let trimmed = line.trim(); if !trimmed.is_empty() { let _ = tx.send(format!("{prefix}{trimmed}")); } } }); } pub fn append_session_log(buffer: >k::TextBuffer, message: &str) { let cleaned = strip_ansi_sequences(message); let trimmed = cleaned.trim(); if trimmed.is_empty() { return; } let mut end = buffer.end_iter(); let tags = classify_log_tags(trimmed); if tags.is_empty() { buffer.insert(&mut end, &format!("{trimmed}\n")); } else { buffer.insert_with_tags_by_name(&mut end, &format!("{trimmed}\n"), &tags); } } pub fn copy_session_log(buffer: >k::TextBuffer) -> Result<()> { let text = buffer .text(&buffer.start_iter(), &buffer.end_iter(), false) .to_string(); copy_plain_text(&text) } pub fn copy_plain_text(text: &str) -> Result<()> { let display = gtk::gdk::Display::default() .ok_or_else(|| anyhow::anyhow!("no desktop clipboard is available in this session"))?; display.clipboard().set_text(text); Ok(()) } pub fn refresh_diagnostics_report( widgets: &LauncherWidgets, state: &LauncherState, child_running: bool, ) { let mut snapshot = SnapshotReport::from_state( state, &widgets.diagnostics_log.borrow(), quality_probe_command().to_string(), ); if child_running && !snapshot.remote_active { snapshot.recommendations.insert( 0, "The relay child is still alive while launcher state says inactive; give it a moment or reconnect before trusting throughput feel.".to_string(), ); } let rendered = snapshot.to_pretty_text(); if *widgets.diagnostics_rendered_text.borrow() == rendered { return; } let diagnostics_adjustment = widgets.diagnostics_scroll.vadjustment(); let previous_value = diagnostics_adjustment.value(); let previous_max = (diagnostics_adjustment.upper() - diagnostics_adjustment.page_size()).max(0.0); let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); let popout_adjustment = widgets .diagnostics_popout_scroll .borrow() .as_ref() .map(|scroll| scroll.vadjustment()); let popout_state = popout_adjustment.as_ref().map(|adjustment| { let previous_value = adjustment.value(); let previous_max = (adjustment.upper() - adjustment.page_size()).max(0.0); let was_at_bottom = previous_max <= 0.0 || previous_value >= (previous_max - 4.0); (adjustment.clone(), previous_value, was_at_bottom) }); let restore_adjustment = |adjustment: >k::Adjustment, previous_value: f64, was_at_bottom: bool| { let max = (adjustment.upper() - adjustment.page_size()).max(0.0); let target = if was_at_bottom { max } else { previous_value.min(max) }; if (adjustment.value() - target).abs() > 1.0 { adjustment.set_value(target); } }; *widgets.diagnostics_rendered_text.borrow_mut() = rendered.clone(); let update_docked = was_at_bottom || widgets.diagnostics_label.text().is_empty(); if update_docked { widgets.diagnostics_label.set_text(&rendered); restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); } let update_popout = popout_state .as_ref() .map(|(_, _, was_at_bottom)| *was_at_bottom) .unwrap_or(false); if let Some(label) = widgets.diagnostics_popout_label.borrow().as_ref() && (update_popout || label.text().is_empty()) { label.set_text(&rendered); } if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state.as_ref() { restore_adjustment(adjustment, *previous_value, *was_at_bottom); } glib::idle_add_local_once(move || { if update_docked { restore_adjustment(&diagnostics_adjustment, previous_value, was_at_bottom); } if update_popout && let Some((adjustment, previous_value, was_at_bottom)) = popout_state { restore_adjustment(&adjustment, previous_value, was_at_bottom); } }); } pub fn open_session_log_popout( app: >k::Application, handle: &Rc>>, buffer: >k::TextBuffer, ) { open_text_buffer_popout( app, handle, None, buffer, "Lesavka Log", "Copy Log", gtk::WrapMode::WordChar, ); } pub fn open_diagnostics_popout( app: >k::Application, handle: &Rc>>, label_handle: &Rc>>, scroll_handle: &Rc>>, rendered_text: &Rc>, ) { if let Some(window) = handle.borrow().as_ref() { window.present(); return; } let window = gtk::ApplicationWindow::builder() .application(app) .title("Lesavka Diagnostics") .default_width(980) .default_height(680) .build(); super::ui_components::install_css(&window); super::ui_components::install_window_icon(&window); let root = gtk::Box::new(gtk::Orientation::Vertical, 10); root.set_margin_start(14); root.set_margin_end(14); root.set_margin_top(14); root.set_margin_bottom(14); let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); let copy_button = gtk::Button::with_label("Copy Report"); toolbar.append(©_button); root.append(&toolbar); let current_text = rendered_text.borrow().clone(); let label = gtk::Label::new(Some(¤t_text)); label.add_css_class("status-log"); label.set_selectable(true); label.set_xalign(0.0); label.set_yalign(0.0); label.set_wrap(false); label.set_halign(gtk::Align::Start); label.set_valign(gtk::Align::Start); label.set_hexpand(true); let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); shell.set_hexpand(true); shell.set_vexpand(false); shell.append(&label); let scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) .child(&shell) .build(); *label_handle.borrow_mut() = Some(label.clone()); *scroll_handle.borrow_mut() = Some(scroll.clone()); root.append(&scroll); window.set_child(Some(&root)); window.maximize(); { let rendered_text = Rc::clone(rendered_text); copy_button.connect_clicked(move |_| { let current_text = rendered_text.borrow().clone(); let _ = copy_plain_text(¤t_text); }); } { let handle = Rc::clone(handle); let label_handle = Rc::clone(label_handle); let scroll_handle = Rc::clone(scroll_handle); window.connect_close_request(move |_| { handle.borrow_mut().take(); label_handle.borrow_mut().take(); scroll_handle.borrow_mut().take(); glib::Propagation::Proceed }); } *handle.borrow_mut() = Some(window.clone()); window.present(); } fn open_text_buffer_popout( app: >k::Application, handle: &Rc>>, scroll_handle: Option<&Rc>>>, buffer: >k::TextBuffer, title: &str, copy_button_label: &str, wrap_mode: gtk::WrapMode, ) { if let Some(window) = handle.borrow().as_ref() { window.present(); return; } let window = gtk::ApplicationWindow::builder() .application(app) .title(title) .default_width(980) .default_height(680) .build(); super::ui_components::install_css(&window); super::ui_components::install_window_icon(&window); let root = gtk::Box::new(gtk::Orientation::Vertical, 10); root.set_margin_start(14); root.set_margin_end(14); root.set_margin_top(14); root.set_margin_bottom(14); let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); let copy_button = gtk::Button::with_label(copy_button_label); toolbar.append(©_button); root.append(&toolbar); let view = gtk::TextView::with_buffer(buffer); view.add_css_class("status-log"); view.set_editable(false); view.set_cursor_visible(false); view.set_monospace(true); view.set_wrap_mode(wrap_mode); let scroll = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) .child(&view) .build(); if let Some(scroll_handle) = scroll_handle { *scroll_handle.borrow_mut() = Some(scroll.clone()); } root.append(&scroll); window.set_child(Some(&root)); window.maximize(); { let buffer = buffer.clone(); copy_button.connect_clicked(move |_| { let _ = copy_session_log(&buffer); }); } { let handle = Rc::clone(handle); let scroll_handle = scroll_handle.cloned(); window.connect_close_request(move |_| { handle.borrow_mut().take(); if let Some(scroll_handle) = &scroll_handle { scroll_handle.borrow_mut().take(); } glib::Propagation::Proceed }); } *handle.borrow_mut() = Some(window.clone()); window.present(); } pub fn stop_child_process(child_proc: &Rc>>) { if let Some(mut child) = child_proc.borrow_mut().take() { let _ = child.kill(); let _ = child.wait(); } } pub fn shutdown_launcher_runtime( child_proc: &Rc>>, tests: &Rc>, preview: Option<&LauncherPreview>, widgets: &LauncherWidgets, popouts: &Rc; 2]>>, diagnostics_popout: &Rc>>, log_popout: &Rc>>, ) { stop_child_process(child_proc); tests.borrow_mut().stop_all(); if let Some(preview) = preview { preview.set_session_active(false); preview.shutdown_all(); } for pane in &widgets.display_panes { if let Some(binding) = pane.preview_binding.borrow_mut().take() { binding.close(); } pane.picture.set_paintable(Option::<&gdk::Paintable>::None); pane.stream_status.set_text(""); } let mut detached_popouts = Vec::new(); { let mut slots = popouts.borrow_mut(); for slot in slots.iter_mut() { if let Some(handle) = slot.take() { detached_popouts.push(handle); } } } for handle in detached_popouts { handle.binding.close(); handle .picture .set_paintable(Option::<&gdk::Paintable>::None); handle.window.set_child(Option::<>k::Widget>::None); handle.window.hide(); } if let Some(window) = diagnostics_popout.borrow_mut().take() { widgets.diagnostics_popout_label.borrow_mut().take(); widgets.diagnostics_popout_scroll.borrow_mut().take(); window.set_child(Option::<>k::Widget>::None); window.hide(); } if let Some(window) = log_popout.borrow_mut().take() { window.set_child(Option::<>k::Widget>::None); window.hide(); } } pub 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, } } pub fn next_input_routing(routing: InputRouting) -> InputRouting { match routing { InputRouting::Remote => InputRouting::Local, InputRouting::Local => InputRouting::Remote, } } fn set_status_light(light: >k::Box, state: StatusLightState) { light.remove_css_class("status-light-live"); light.remove_css_class("status-light-idle"); light.remove_css_class("status-light-warning"); light.remove_css_class("status-light-caution"); light.add_css_class(state.css_class()); } fn classify_log_tags(message: &str) -> Vec<&'static str> { let mut tags = Vec::new(); if message.starts_with("[launcher]") { tags.push("log-launcher"); } else if message.starts_with("[relay:stderr]") { tags.push("log-stderr"); } else if message.starts_with("[relay]") { tags.push("log-relay"); } else if message.starts_with("[preview:") { tags.push("log-preview"); } else { tags.push("log-launcher"); } let uppercase = message.to_ascii_uppercase(); if uppercase.contains(" ERROR ") || uppercase.contains("FAILED") || uppercase.contains("PANIC") || uppercase.contains(" RPC FAILED") { tags.push("log-error"); } else if uppercase.contains(" WARN ") || uppercase.contains("UNAVAILABLE") || uppercase.contains("WAITING FOR CAPTURE PIPELINE") { tags.push("log-warn"); } tags } fn strip_ansi_sequences(input: &str) -> String { let mut output = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\u{1b}' { match chars.peek().copied() { Some('[') => { let _ = chars.next(); while let Some(next) = chars.next() { if ('@'..='~').contains(&next) { break; } } } Some(']') => { let _ = chars.next(); while let Some(next) = chars.next() { if next == '\u{7}' { break; } if next == '\u{1b}' && matches!(chars.peek(), Some('\\')) { let _ = chars.next(); break; } } } _ => {} } continue; } output.push(ch); } output } #[cfg(test)] mod tests { use super::*; use crate::launcher::{ devices::DeviceCatalog, preview::PreviewBinding, state::LauncherState, ui_components::build_launcher_view, }; use serial_test::serial; use std::{cell::RefCell, rc::Rc}; #[test] fn local_test_detail_mentions_idle_and_running_modes() { assert!(local_test_detail(false, false, false, false).contains("idle")); let running = local_test_detail(true, true, false, false); assert!(running.contains("camera preview")); assert!(running.contains("mic monitor")); } #[test] fn gpio_power_label_tracks_detected_devices() { let mut power = CapturePowerStatus::default(); assert_eq!(gpio_power_label(&power), "Unavailable"); power.available = true; assert_eq!(gpio_power_label(&power), "Power Off"); power.enabled = true; assert_eq!(gpio_power_label(&power), "No Eyes"); power.detected_devices = 1; assert_eq!(gpio_power_label(&power), "1 Eye"); power.detected_devices = 2; assert_eq!(gpio_power_label(&power), "2 Eyes"); } #[test] fn capture_power_detail_mentions_detected_eyes_when_powered() { let mut power = CapturePowerStatus::default(); power.available = true; power.enabled = true; power.detail = "active/running".to_string(); power.detected_devices = 1; assert!(capture_power_detail(&power).contains("1 eye detected")); } #[test] fn compact_device_name_prefers_basename_when_available() { assert_eq!(compact_device_name("/dev/video0"), "video0"); assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb"); } #[test] fn strip_ansi_sequences_removes_terminal_codes() { let raw = "\u{1b}[32mINFO\u{1b}[0m hello"; assert_eq!(strip_ansi_sequences(raw), "INFO hello"); } #[test] fn classify_log_tags_assigns_prefix_and_severity_colors() { let tags = classify_log_tags("[relay] WARN pipeline failed"); assert!(tags.contains(&"log-relay")); assert!(tags.contains(&"log-error") || tags.contains(&"log-warn")); } #[gtk::test] #[serial] fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() { if gtk::gdk::Display::default().is_none() { return; } let app = gtk::Application::builder() .application_id("dev.lesavka.test-dock") .build(); let _ = app.register(None::<>k::gio::Cancellable>); let state = Rc::new(RefCell::new(LauncherState::new())); state .borrow_mut() .set_display_surface(0, DisplaySurface::Window); state .borrow_mut() .set_display_surface(1, DisplaySurface::Window); let state_snapshot = state.borrow().clone(); let view = build_launcher_view( &app, "http://127.0.0.1:50051", &DeviceCatalog::default(), &state_snapshot, ); let child_proc = Rc::new(RefCell::new(None::)); let left_binding = PreviewBinding::test_stub(); let right_binding = PreviewBinding::test_stub(); { let mut popouts = view.popouts.borrow_mut(); popouts[0] = Some(PopoutWindowHandle { window: gtk::ApplicationWindow::builder() .application(&app) .title("Left") .build(), frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: left_binding, }); popouts[1] = Some(PopoutWindowHandle { window: gtk::ApplicationWindow::builder() .application(&app) .title("Right") .build(), frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: right_binding, }); } dock_all_displays_to_preview(&state, &child_proc, &view.popouts, &view.widgets); assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); assert_eq!(state.borrow().display_surface(1), DisplaySurface::Preview); } #[gtk::test] #[serial] fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() { if gtk::gdk::Display::default().is_none() { return; } let app = gtk::Application::builder() .application_id("dev.lesavka.test-reentrant-dock") .build(); let _ = app.register(None::<>k::gio::Cancellable>); let state = Rc::new(RefCell::new(LauncherState::new())); state .borrow_mut() .set_display_surface(0, DisplaySurface::Window); let state_snapshot = state.borrow().clone(); let view = build_launcher_view( &app, "http://127.0.0.1:50051", &DeviceCatalog::default(), &state_snapshot, ); let child_proc = Rc::new(RefCell::new(None::)); let popouts = Rc::clone(&view.popouts); let window = gtk::ApplicationWindow::builder() .application(&app) .title("Reentrant") .build(); { let popouts = Rc::clone(&popouts); window.connect_close_request(move |_| { let _ = popouts.borrow_mut()[0].take(); glib::Propagation::Proceed }); } { let mut slot = popouts.borrow_mut(); slot[0] = Some(PopoutWindowHandle { window, frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: PreviewBinding::test_stub(), }); } dock_all_displays_to_preview(&state, &child_proc, &popouts, &view.widgets); assert!(popouts.borrow().iter().all(|handle| handle.is_none())); assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview); } #[gtk::test] #[serial] fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() { if gtk::gdk::Display::default().is_none() { return; } let app = gtk::Application::builder() .application_id("dev.lesavka.test-shutdown") .build(); let _ = app.register(None::<>k::gio::Cancellable>); let state = Rc::new(RefCell::new(LauncherState::new())); let state_snapshot = state.borrow().clone(); let view = build_launcher_view( &app, "http://127.0.0.1:50051", &DeviceCatalog::default(), &state_snapshot, ); let child_proc = Rc::new(RefCell::new(None::)); let tests = Rc::new(RefCell::new(DeviceTestController::new())); let left_binding = PreviewBinding::test_stub(); let right_binding = PreviewBinding::test_stub(); *view.widgets.display_panes[0].preview_binding.borrow_mut() = Some(left_binding.clone()); *view.widgets.display_panes[1].preview_binding.borrow_mut() = Some(right_binding.clone()); { let mut popouts = view.popouts.borrow_mut(); popouts[0] = Some(PopoutWindowHandle { window: gtk::ApplicationWindow::builder() .application(&app) .title("Left") .build(), frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: PreviewBinding::test_stub(), }); } *view.diagnostics_popout.borrow_mut() = Some( gtk::ApplicationWindow::builder() .application(&app) .title("Diagnostics") .build(), ); *view.log_popout.borrow_mut() = Some( gtk::ApplicationWindow::builder() .application(&app) .title("Log") .build(), ); shutdown_launcher_runtime( &child_proc, &tests, None, &view.widgets, &view.popouts, &view.diagnostics_popout, &view.log_popout, ); assert!(view.popouts.borrow().iter().all(|handle| handle.is_none())); assert!( view.widgets.display_panes[0] .preview_binding .borrow() .is_none() ); assert!( view.widgets.display_panes[1] .preview_binding .borrow() .is_none() ); assert!(view.diagnostics_popout.borrow().is_none()); assert!(view.log_popout.borrow().is_none()); } }