use anyhow::Result; use gtk::{glib, prelude::*}; use std::{ cell::RefCell, path::{Path, PathBuf}, process::{Child, Command}, rc::Rc, }; use super::{ LAUNCHER_FOCUS_SIGNAL_ENV, device_test::{DeviceTestController, DeviceTestKind}, launcher_focus_signal_path, preview::LauncherPreview, runtime_env_vars, state::{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 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(&capitalize(routing_name(state.routing))); widgets .summary .power_value .set_text(&capture_power_label(&state.capture_power)); widgets.summary.displays_value.set_text(&format!( "L {} / R {}", 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 .power_detail .set_text(&capture_power_detail(&state.capture_power)); widgets .launch_plan_title .set_text(&launch_plan_title(state, child_running)); widgets .launch_plan_summary .set_text(&launch_plan_summary(state)); widgets .launch_plan_detail .set_text(&launch_plan_detail(state, child_running)); widgets.start_button.set_label(if child_running { "Relay Running" } else { "Start Relay" }); widgets.start_button.set_sensitive(!child_running); widgets.stop_button.set_sensitive(child_running); widgets.clipboard_button.set_sensitive(child_running); widgets.probe_button.set_sensitive(true); widgets.input_toggle_button.set_label(match state.routing { InputRouting::Remote => "Route Inputs To Local", InputRouting::Local => "Route Inputs To Remote", }); 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), ); } } 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 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.speaker_test_button.set_label(if speaker_running { "Stop Tone" } else { "Play Tone" }); widgets.local_test_detail.set_text(&local_test_detail( camera_running, microphone_running, speaker_running, )); } 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, ) { if popouts.borrow()[monitor_id].is_some() { 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(); super::ui_components::install_css(&window); window.maximize(); 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 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(); } 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.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()); } pub 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 feed is running in its own window.\nUse Return To Preview to dock it back here.", ); 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 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 capture_power_label(power: &CapturePowerStatus) -> String { if !power.available { return "Unavailable".to_string(); } match power.mode.as_str() { "forced-on" => "Forced On".to_string(), "forced-off" => "Forced Off".to_string(), _ => { if power.enabled { "Auto • Live".to_string() } else { "Auto • Standby".to_string() } } } } pub fn capture_power_detail(power: &CapturePowerStatus) -> String { if !power.available { return format!("{} is unavailable: {}", power.unit, power.detail); } match power.mode.as_str() { "forced-on" => format!( "{} • manual override holding feeds up • {} • leases {}", power.unit, power.detail, power.active_leases ), "forced-off" => format!( "{} • manual override holding feeds down • {} • leases {}", power.unit, power.detail, power.active_leases ), _ => format!( "{} • automatic mode follows live previews and session demand • {} • leases {}", power.unit, power.detail, power.active_leases ), } } /// 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"), } } /// Summarizes the current staging state as a short operator-facing heading. fn launch_plan_title(state: &LauncherState, child_running: bool) -> String { if child_running || state.remote_active { return match state.capture_power.mode.as_str() { "forced-off" => "Relay live, but capture is intentionally dark.".to_string(), "forced-on" => "Relay live with capture held awake.".to_string(), _ => "Relay live with automatic capture management.".to_string(), }; } match state.capture_power.mode.as_str() { "forced-off" => "Staging mode is holding capture off.".to_string(), "forced-on" => "Capture is pre-warmed for staging.".to_string(), _ => "Stage locally, then start the relay.".to_string(), } } /// Shows the exact devices the next relay launch will inherit. fn launch_plan_summary(state: &LauncherState) -> String { format!( "Camera: {}\nMicrophone: {}\nSpeaker: {}", selected_device_label(state.devices.camera.as_deref()), selected_device_label(state.devices.microphone.as_deref()), selected_device_label(state.devices.speaker.as_deref()) ) } /// Explains the consequence of the current capture and session state. fn launch_plan_detail(state: &LauncherState, child_running: bool) -> String { if child_running || state.remote_active { return match state.capture_power.mode.as_str() { "forced-off" => format!( "Inputs are routed to {}. Return capture to Auto or Force On when you want the remote eyes and session video to wake up.", capitalize(routing_name(state.routing)) ), "forced-on" => format!( "Inputs are routed to {}. The relay host is keeping the capture feeds up even without preview demand.", capitalize(routing_name(state.routing)) ), _ => format!( "Inputs are routed to {}. Live eye previews and session demand will wake capture automatically as needed.", capitalize(routing_name(state.routing)) ), }; } if !state.capture_power.available { return format!( "Capture power status from {} is unavailable right now. You can still stage devices locally before you try the relay host again.", state.capture_power.unit ); } match state.capture_power.mode.as_str() { "forced-off" => { "Remote eye previews and the next relay session will stay dark until you return to Auto or Force On." .to_string() } "forced-on" => { "The relay host is already holding capture awake, which is useful for preflight framing checks before the session starts." .to_string() } _ => { "Automatic capture mode wakes the remote feeds only while eye previews or the live relay session actually need them." .to_string() } } } /// Reports which local staging checks are active right now. fn local_test_detail( camera_running: bool, microphone_running: bool, speaker_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 active.is_empty() { "Local checks are idle. Use Start Preview, Monitor Mic, or Play Tone before you launch." .to_string() } else { format!( "Local checks running: {}. Stop them whenever staging is complete.", active.join(", ") ) } } /// Formats a selected device for the launch-plan summary. fn selected_device_label(value: Option<&str>) -> String { value .map(compact_device_name) .unwrap_or_else(|| "auto".to_string()) } /// 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_text().and_then(|value| { let value = value.to_string(); let trimmed = value.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { None } else { Some(trimmed.to_string()) } }) } pub fn selected_toggle_key(combo: >k::ComboBoxText) -> String { combo .active_id() .map(|value| value.to_string()) .unwrap_or_else(|| "pause".to_string()) } pub fn selected_toggle_key_label(combo: >k::ComboBoxText) -> String { combo .active_text() .map(|value| value.to_string()) .unwrap_or_else(|| "Pause".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)) { let _ = combo.set_active_id(Some("auto")); } } 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.env("LESAVKA_LAUNCHER_CHILD", "1"); command.env("LESAVKA_SERVER_ADDR", server_addr); command.env("LESAVKA_INPUT_TOGGLE_KEY", input_toggle_key); command.env("LESAVKA_LAUNCHER_WINDOW_TITLE", "Lesavka Launcher"); command.env("LESAVKA_FOCUS_LAUNCHER_ON_LOCAL", "1"); command.env(LAUNCHER_FOCUS_SIGNAL_ENV, launcher_focus_signal_path()); 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); } Ok(command.spawn()?) } 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 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, } } #[cfg(test)] mod tests { use super::*; #[test] fn launch_plan_summary_compacts_selected_devices() { let mut state = LauncherState::new(); state.select_camera(Some( "/dev/v4l/by-id/usb-Logitech_C920-video-index0".to_string(), )); state.select_microphone(Some("alsa_input.usb-focusrite".to_string())); state.select_speaker(Some("alsa_output.studio".to_string())); let summary = launch_plan_summary(&state); assert!(summary.contains("usb-Logitech_C920-video-index0")); assert!(summary.contains("alsa_input.usb-focusrite")); assert!(summary.contains("alsa_output.studio")); assert!(!summary.contains("/dev/v4l/by-id/")); } #[test] fn launch_plan_detail_calls_out_forced_off_sessions() { let mut state = LauncherState::new(); state.set_capture_power(CapturePowerStatus { available: true, enabled: false, unit: "relay.service".to_string(), detail: "inactive/dead".to_string(), active_leases: 0, mode: "forced-off".to_string(), }); state.start_remote(); let detail = launch_plan_detail(&state, true); assert!(detail.contains("Return capture to Auto or Force On")); } #[test] fn local_test_detail_mentions_idle_and_running_modes() { assert!(local_test_detail(false, false, false).contains("idle")); let running = local_test_detail(true, true, false); assert!(running.contains("camera preview")); assert!(running.contains("mic monitor")); } #[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"); } }