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.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"), ); 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) { widgets .camera_test_button .set_label(if tests.is_running(DeviceTestKind::Camera) { "Stop Preview" } else { "Start Preview" }); widgets .microphone_test_button .set_label(if tests.is_running(DeviceTestKind::Microphone) { "Stop Monitor" } else { "Monitor Mic" }); widgets .speaker_test_button .set_label(if tests.is_running(DeviceTestKind::Speaker) { "Stop Tone" } else { "Play Tone" }); } 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 ), } } 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, } }