lesavka/client/src/launcher/ui_runtime.rs

661 lines
22 KiB
Rust

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<bool>,
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: &gtk::Application,
preview: &LauncherPreview,
state: &Rc<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<Child>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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: &gtk::ComboBoxText) -> Option<String> {
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: &gtk::ComboBoxText) -> String {
combo
.active_id()
.map(|value| value.to_string())
.unwrap_or_else(|| "pause".to_string())
}
pub fn selected_toggle_key_label(combo: &gtk::ComboBoxText) -> String {
combo
.active_text()
.map(|value| value.to_string())
.unwrap_or_else(|| "Pause".to_string())
}
pub fn selected_server_addr(entry: &gtk::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<InputRouting> {
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: &gtk::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<Child> {
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<RefCell<Option<Child>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<Child>>>) -> 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");
}
}