lesavka/client/src/launcher/ui_runtime.rs

1620 lines
53 KiB
Rust

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<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<RelayChild>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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::<gtk::Box>().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<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<RelayChild>>>,
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
.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<RefCell<LauncherState>>,
child_proc: &Rc<RefCell<Option<RelayChild>>>,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 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: &gtk::ApplicationWindow, widget: &impl IsA<gtk::Widget>) {
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::<gdk::Toplevel>() 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: &gtk::ApplicationWindow,
root: &gtk::Box,
picture: &gtk::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: &gtk::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::<gdk::Monitor>().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<RefCell<[Option<PopoutWindowHandle>; 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: &gtk::ComboBoxText) -> Option<String> {
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: &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)) {
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<String> {
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<RelayChild> {
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<String>) {
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<R>(reader: R, prefix: &'static str, tx: Sender<String>)
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: &gtk::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: &gtk::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: &gtk::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: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
buffer: &gtk::TextBuffer,
) {
open_text_buffer_popout(
app,
handle,
None,
buffer,
"Lesavka Log",
"Copy Log",
gtk::WrapMode::WordChar,
);
}
pub fn open_diagnostics_popout(
app: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
label_handle: &Rc<RefCell<Option<gtk::Label>>>,
scroll_handle: &Rc<RefCell<Option<gtk::ScrolledWindow>>>,
rendered_text: &Rc<RefCell<String>>,
) {
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(&copy_button);
root.append(&toolbar);
let current_text = rendered_text.borrow().clone();
let label = gtk::Label::new(Some(&current_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(&current_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: &gtk::Application,
handle: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
scroll_handle: Option<&Rc<RefCell<Option<gtk::ScrolledWindow>>>>,
buffer: &gtk::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(&copy_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<RefCell<Option<RelayChild>>>) {
if let Some(mut child) = child_proc.borrow_mut().take() {
let _ = child.kill();
let _ = child.wait();
}
}
pub fn shutdown_launcher_runtime(
child_proc: &Rc<RefCell<Option<RelayChild>>>,
tests: &Rc<RefCell<DeviceTestController>>,
preview: Option<&LauncherPreview>,
widgets: &LauncherWidgets,
popouts: &Rc<RefCell<[Option<PopoutWindowHandle>; 2]>>,
diagnostics_popout: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
log_popout: &Rc<RefCell<Option<gtk::ApplicationWindow>>>,
) {
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::<&gtk::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::<&gtk::Widget>::None);
window.hide();
}
if let Some(window) = log_popout.borrow_mut().take() {
window.set_child(Option::<&gtk::Widget>::None);
window.hide();
}
}
pub fn reap_exited_child(child_proc: &Rc<RefCell<Option<RelayChild>>>) -> 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: &gtk::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::<&gtk::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::<RelayChild>));
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::<&gtk::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::<RelayChild>));
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::<&gtk::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::<RelayChild>));
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());
}
}