1620 lines
53 KiB
Rust
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: >k::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: >k::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: >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::<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: >k::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: >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<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: >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<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: >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<RefCell<Option<gtk::ApplicationWindow>>>,
|
|
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<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(©_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<RefCell<Option<gtk::ApplicationWindow>>>,
|
|
scroll_handle: Option<&Rc<RefCell<Option<gtk::ScrolledWindow>>>>,
|
|
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<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::<>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<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: >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::<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::<>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::<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::<>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::<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());
|
|
}
|
|
}
|