2342 lines
104 KiB
Rust
2342 lines
104 KiB
Rust
use anyhow::Result;
|
|
|
|
#[cfg(not(coverage))]
|
|
use {
|
|
super::clipboard::send_clipboard_text_to_remote,
|
|
super::device_test::{DeviceTestController, DeviceTestKind},
|
|
super::devices::DeviceCatalog,
|
|
super::diagnostics::{PerformanceSample, quality_probe_command},
|
|
super::launcher_clipboard_control_path,
|
|
super::launcher_focus_signal_path,
|
|
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
|
|
super::state::{
|
|
BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface,
|
|
FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT,
|
|
},
|
|
super::ui_components::{build_launcher_view, sync_input_device_combo, sync_stage_device_combo},
|
|
super::ui_runtime::{
|
|
RelayChild, append_session_log, apply_popout_window_size, attach_child_log_streams,
|
|
audio_gain_control_path, capture_swap_key, copy_plain_text, copy_session_log,
|
|
dock_all_displays_to_preview, dock_display_to_preview, input_control_path,
|
|
input_state_path, input_toggle_control_path, next_input_routing, open_diagnostics_popout,
|
|
open_popout_window, open_session_log_popout, path_marker, present_popout_windows,
|
|
read_input_routing_state, reap_exited_child, refresh_launcher_ui, refresh_test_buttons,
|
|
routing_name, selected_combo_value, selected_server_addr, shutdown_launcher_runtime,
|
|
spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result,
|
|
write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request,
|
|
},
|
|
crate::handshake::{HandshakeProbe, probe},
|
|
crate::output::display::enumerate_monitors,
|
|
gtk::glib,
|
|
gtk::prelude::*,
|
|
lesavka_common::lesavka::CapturePowerCommand,
|
|
lesavka_common::process_metrics::ProcessCpuSampler,
|
|
std::cell::{Cell, RefCell},
|
|
std::collections::VecDeque,
|
|
std::process::Command,
|
|
std::rc::Rc,
|
|
std::time::{Duration, Instant},
|
|
};
|
|
|
|
#[cfg(not(coverage))]
|
|
enum PowerMessage {
|
|
Refresh(std::result::Result<CapturePowerStatus, String>),
|
|
Command(std::result::Result<CapturePowerStatus, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum RelayMessage {
|
|
Spawned(std::result::Result<RelayChild, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum CapsMessage {
|
|
Refresh(HandshakeProbe),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
enum ClipboardMessage {
|
|
Finished(std::result::Result<String, String>),
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8);
|
|
|
|
#[cfg(not(coverage))]
|
|
fn usb_audio_kernel_support_missing() -> bool {
|
|
Command::new("modinfo")
|
|
.arg("snd_usb_audio")
|
|
.status()
|
|
.map(|status| !status.success())
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
#[derive(Default)]
|
|
struct NetworkTelemetry {
|
|
rtt_samples: VecDeque<(Instant, f32)>,
|
|
failures: VecDeque<Instant>,
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct NetworkSnapshot {
|
|
rtt_ms: f32,
|
|
probe_spread_ms: f32,
|
|
probe_loss_pct: f32,
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
impl NetworkTelemetry {
|
|
fn record(&mut self, probe: &HandshakeProbe) {
|
|
let now = Instant::now();
|
|
self.trim(now);
|
|
if let Some(rtt_ms) = probe.rtt_ms {
|
|
self.rtt_samples.push_back((now, rtt_ms));
|
|
} else {
|
|
self.failures.push_back(now);
|
|
}
|
|
self.trim(now);
|
|
}
|
|
|
|
fn snapshot(&mut self) -> NetworkSnapshot {
|
|
let now = Instant::now();
|
|
self.trim(now);
|
|
let rtt_ms = self.rtt_samples.back().map(|(_, rtt)| *rtt).unwrap_or(0.0);
|
|
let probe_spread_ms = network_spread_ms(&self.rtt_samples);
|
|
let probe_count = self.rtt_samples.len() + self.failures.len();
|
|
let probe_loss_pct = if probe_count == 0 {
|
|
0.0
|
|
} else {
|
|
self.failures.len() as f32 * 100.0 / probe_count as f32
|
|
};
|
|
NetworkSnapshot {
|
|
rtt_ms,
|
|
probe_spread_ms,
|
|
probe_loss_pct,
|
|
}
|
|
}
|
|
|
|
fn trim(&mut self, now: Instant) {
|
|
while let Some((oldest, _)) = self.rtt_samples.front().copied() {
|
|
if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW {
|
|
let _ = self.rtt_samples.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
while let Some(oldest) = self.failures.front().copied() {
|
|
if now.saturating_duration_since(oldest) > NETWORK_TELEMETRY_WINDOW {
|
|
let _ = self.failures.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn retained_stage_selection(current: Option<&str>, values: &[String]) -> Option<String> {
|
|
current
|
|
.filter(|selected| values.iter().any(|value| value == *selected))
|
|
.map(str::to_string)
|
|
.or_else(|| values.first().cloned())
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn retained_input_selection(current: Option<&str>, values: &[String]) -> Option<String> {
|
|
current
|
|
.filter(|selected| values.iter().any(|value| value == *selected))
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 {
|
|
if samples.len() < 2 {
|
|
return 0.0;
|
|
}
|
|
let mut values = samples.iter().map(|(_, value)| *value).collect::<Vec<_>>();
|
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
let median = values[values.len() / 2];
|
|
let mut deviations = values
|
|
.into_iter()
|
|
.map(|value| (value - median).abs())
|
|
.collect::<Vec<_>>();
|
|
deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
deviations[deviations.len() / 2]
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_capture_power_refresh(
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
server_addr: String,
|
|
delay: Duration,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
if !delay.is_zero() {
|
|
std::thread::sleep(delay);
|
|
}
|
|
let result = fetch_capture_power(&server_addr).map_err(|err| err.to_string());
|
|
let _ = power_tx.send(PowerMessage::Refresh(result));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_capture_power_command(
|
|
power_tx: std::sync::mpsc::Sender<PowerMessage>,
|
|
server_addr: String,
|
|
command: CapturePowerCommand,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
let result = set_capture_power_mode(&server_addr, command).map_err(|err| err.to_string());
|
|
let _ = power_tx.send(PowerMessage::Command(result));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn request_handshake_caps(
|
|
caps_tx: std::sync::mpsc::Sender<CapsMessage>,
|
|
server_addr: String,
|
|
delay: Duration,
|
|
) {
|
|
std::thread::spawn(move || {
|
|
if !delay.is_zero() {
|
|
std::thread::sleep(delay);
|
|
}
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build();
|
|
let probe = match runtime {
|
|
Ok(runtime) => runtime.block_on(probe(&server_addr)),
|
|
Err(_) => HandshakeProbe::default(),
|
|
};
|
|
let _ = caps_tx.send(CapsMessage::Refresh(probe));
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn unavailable_capture_power(detail: String) -> CapturePowerStatus {
|
|
CapturePowerStatus {
|
|
available: false,
|
|
enabled: false,
|
|
unit: "relay.service".to_string(),
|
|
detail,
|
|
active_leases: 0,
|
|
mode: "auto".to_string(),
|
|
detected_devices: 0,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn refresh_eye_feed_controls(
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
state: &LauncherState,
|
|
) {
|
|
for monitor_id in 0..2 {
|
|
super::ui_components::sync_feed_source_combo(
|
|
&widgets.display_panes[monitor_id].feed_source_combo,
|
|
state.feed_source_options(monitor_id),
|
|
state.feed_source_preset(monitor_id),
|
|
);
|
|
if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off {
|
|
let choice = state
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
if state.feed_source_preset(monitor_id) == FeedSourcePreset::ThisEye {
|
|
super::ui_components::sync_capture_resolution_combo(
|
|
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
|
state.capture_size_options(),
|
|
state.capture_size_preset(monitor_id),
|
|
);
|
|
} else {
|
|
super::ui_components::sync_capture_resolution_locked(
|
|
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
|
state.capture_size_options(),
|
|
choice.preset,
|
|
);
|
|
}
|
|
} else {
|
|
super::ui_components::sync_capture_resolution_disabled(
|
|
&widgets.display_panes[monitor_id].capture_resolution_combo,
|
|
);
|
|
}
|
|
super::ui_components::sync_breakout_size_combo(
|
|
&widgets.display_panes[monitor_id].breakout_combo,
|
|
state.breakout_size_options(monitor_id),
|
|
state.breakout_size_preset(monitor_id),
|
|
);
|
|
refresh_preview_frame_ratio(widgets, monitor_id, state);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn refresh_preview_frame_ratio(
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
monitor_id: usize,
|
|
state: &LauncherState,
|
|
) {
|
|
let capture = state
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
widgets.display_panes[monitor_id]
|
|
.preview_frame
|
|
.set_ratio(capture.preset.display_aspect_ratio());
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn eye_caps_changed(state: &LauncherState, caps: &crate::handshake::PeerCaps) -> bool {
|
|
let next_width = caps.eye_width.unwrap_or(state.preview_source.width);
|
|
let next_height = caps.eye_height.unwrap_or(state.preview_source.height);
|
|
let next_fps = caps.eye_fps.unwrap_or(state.preview_source.fps);
|
|
state.preview_source.width != next_width
|
|
|| state.preview_source.height != next_height
|
|
|| state.preview_source.fps != next_fps
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn record_diagnostics_sample(
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
state: &LauncherState,
|
|
preview: Option<&super::preview::LauncherPreview>,
|
|
network: NetworkSnapshot,
|
|
client_process_cpu_pct: f32,
|
|
) {
|
|
let left_metrics = preview
|
|
.and_then(|preview| {
|
|
(state.feed_source_preset(0) != FeedSourcePreset::Off).then_some(
|
|
preview.snapshot_metrics(
|
|
0,
|
|
match state.display_surface(0) {
|
|
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
|
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
|
},
|
|
),
|
|
)
|
|
})
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
let right_metrics = preview
|
|
.and_then(|preview| {
|
|
(state.feed_source_preset(1) != FeedSourcePreset::Off).then_some(
|
|
preview.snapshot_metrics(
|
|
1,
|
|
match state.display_surface(1) {
|
|
DisplaySurface::Preview => super::preview::PreviewSurface::Inline,
|
|
DisplaySurface::Window => super::preview::PreviewSurface::Window,
|
|
},
|
|
),
|
|
)
|
|
})
|
|
.flatten()
|
|
.unwrap_or_default();
|
|
|
|
widgets
|
|
.diagnostics_log
|
|
.borrow_mut()
|
|
.record(PerformanceSample {
|
|
rtt_ms: network.rtt_ms,
|
|
probe_spread_ms: network.probe_spread_ms,
|
|
input_latency_ms: network.rtt_ms * 0.5,
|
|
probe_loss_pct: network.probe_loss_pct,
|
|
client_process_cpu_pct,
|
|
server_process_cpu_pct: left_metrics
|
|
.server_process_cpu_pct
|
|
.max(right_metrics.server_process_cpu_pct),
|
|
video_loss_pct: left_metrics
|
|
.packet_loss_pct
|
|
.max(right_metrics.packet_loss_pct),
|
|
left_receive_fps: left_metrics.receive_fps,
|
|
left_present_fps: left_metrics.present_fps,
|
|
left_server_fps: left_metrics.server_fps,
|
|
left_stream_spread_ms: left_metrics.stream_spread_ms,
|
|
left_packet_gap_peak_ms: left_metrics.packet_gap_peak_ms,
|
|
left_present_gap_peak_ms: left_metrics.present_gap_peak_ms,
|
|
left_queue_depth: left_metrics.queue_depth,
|
|
left_queue_peak: left_metrics.queue_depth_peak,
|
|
left_server_source_gap_peak_ms: left_metrics.server_source_gap_peak_ms,
|
|
left_server_send_gap_peak_ms: left_metrics.server_send_gap_peak_ms,
|
|
left_server_queue_peak: left_metrics.server_queue_peak,
|
|
left_server_encoder_label: left_metrics.server_encoder_label.clone(),
|
|
left_decoder_label: left_metrics.decoder_label.clone(),
|
|
left_stream_caps_label: left_metrics.stream_caps_label.clone(),
|
|
left_decoded_caps_label: left_metrics.decoded_caps_label.clone(),
|
|
left_rendered_caps_label: left_metrics.rendered_caps_label.clone(),
|
|
right_receive_fps: right_metrics.receive_fps,
|
|
right_present_fps: right_metrics.present_fps,
|
|
right_server_fps: right_metrics.server_fps,
|
|
right_stream_spread_ms: right_metrics.stream_spread_ms,
|
|
right_packet_gap_peak_ms: right_metrics.packet_gap_peak_ms,
|
|
right_present_gap_peak_ms: right_metrics.present_gap_peak_ms,
|
|
right_queue_depth: right_metrics.queue_depth,
|
|
right_queue_peak: right_metrics.queue_depth_peak,
|
|
right_server_source_gap_peak_ms: right_metrics.server_source_gap_peak_ms,
|
|
right_server_send_gap_peak_ms: right_metrics.server_send_gap_peak_ms,
|
|
right_server_queue_peak: right_metrics.server_queue_peak,
|
|
right_server_encoder_label: right_metrics.server_encoder_label.clone(),
|
|
right_decoder_label: right_metrics.decoder_label.clone(),
|
|
right_stream_caps_label: right_metrics.stream_caps_label.clone(),
|
|
right_decoded_caps_label: right_metrics.decoded_caps_label.clone(),
|
|
right_rendered_caps_label: right_metrics.rendered_caps_label.clone(),
|
|
dropped_frames: left_metrics
|
|
.dropped_frames
|
|
.saturating_add(right_metrics.dropped_frames),
|
|
queue_depth: left_metrics.queue_depth.max(right_metrics.queue_depth),
|
|
});
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn largest_monitor_size() -> (u32, u32) {
|
|
let (width, height) = enumerate_monitors()
|
|
.into_iter()
|
|
.max_by_key(|monitor| {
|
|
effective_monitor_width(&monitor) as u64 * effective_monitor_height(&monitor) as u64
|
|
})
|
|
.map(|monitor| {
|
|
(
|
|
effective_monitor_width(&monitor),
|
|
effective_monitor_height(&monitor),
|
|
)
|
|
})
|
|
.unwrap_or((1920, 1080));
|
|
(width.max(2), height.max(2))
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn largest_monitor_physical_size() -> (u32, u32) {
|
|
if let Some((width, height)) = probe_kscreen_display_size() {
|
|
return (width, height);
|
|
}
|
|
normalize_breakout_limit(largest_monitor_size().0, largest_monitor_size().1)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn probe_kscreen_display_size() -> Option<(u32, u32)> {
|
|
let output = Command::new("kscreen-doctor").arg("-o").output().ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let text = String::from_utf8(output.stdout).ok()?;
|
|
let mut best = None;
|
|
for line in text.lines() {
|
|
if !line.contains("Modes:") {
|
|
continue;
|
|
}
|
|
let active = line
|
|
.split_whitespace()
|
|
.find(|token| token.contains('*') && token.contains('x'))?;
|
|
let dims = active
|
|
.trim_matches(|ch: char| ch == '*' || ch == '!')
|
|
.split('@')
|
|
.next()?;
|
|
let (width, height) = dims.split_once('x')?;
|
|
let width = width.parse::<u32>().ok()?;
|
|
let height = height.parse::<u32>().ok()?;
|
|
if best
|
|
.map(|(best_w, best_h)| width as u64 * height as u64 > best_w as u64 * best_h as u64)
|
|
.unwrap_or(true)
|
|
{
|
|
best = Some((width, height));
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn effective_monitor_width(monitor: &crate::output::display::MonitorInfo) -> u32 {
|
|
let scale = monitor.scale_factor.max(1) as u32;
|
|
(monitor.geometry.width().max(1) as u32).saturating_mul(scale)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn effective_monitor_height(monitor: &crate::output::display::MonitorInfo) -> u32 {
|
|
let scale = monitor.scale_factor.max(1) as u32;
|
|
(monitor.geometry.height().max(1) as u32).saturating_mul(scale)
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) {
|
|
const STANDARD_SIZES: &[(u32, u32)] = &[
|
|
(3840, 2160),
|
|
(2560, 1440),
|
|
(1920, 1080),
|
|
(1600, 900),
|
|
(1366, 768),
|
|
(1280, 720),
|
|
(960, 540),
|
|
];
|
|
|
|
STANDARD_SIZES
|
|
.iter()
|
|
.copied()
|
|
.find(|(candidate_w, candidate_h)| *candidate_w <= width && *candidate_h <= height)
|
|
.unwrap_or((width.max(2), height.max(2)))
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn launcher_default_size(width: u32, height: u32) -> (i32, i32) {
|
|
let max_width = width.saturating_sub(48).max(640) as i32;
|
|
let max_height = height.saturating_sub(72).max(520) as i32;
|
|
(1380.min(max_width), 860.min(max_height))
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn rebind_inline_preview(
|
|
preview: &super::preview::LauncherPreview,
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
state: &LauncherState,
|
|
monitor_id: usize,
|
|
) {
|
|
if let Some(binding) = widgets.display_panes[monitor_id]
|
|
.preview_binding
|
|
.borrow_mut()
|
|
.take()
|
|
{
|
|
binding.close();
|
|
}
|
|
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
|
widgets.display_panes[monitor_id]
|
|
.picture
|
|
.set_paintable(Option::<>k::gdk::Paintable>::None);
|
|
widgets.display_panes[monitor_id]
|
|
.stream_status
|
|
.set_text("Feed disabled.");
|
|
return;
|
|
}
|
|
let binding = preview.install_on_picture(
|
|
monitor_id,
|
|
super::preview::PreviewSurface::Inline,
|
|
&widgets.display_panes[monitor_id].picture,
|
|
&widgets.display_panes[monitor_id].stream_status,
|
|
);
|
|
*widgets.display_panes[monitor_id]
|
|
.preview_binding
|
|
.borrow_mut() = binding;
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn rebind_popout_preview(
|
|
preview: &super::preview::LauncherPreview,
|
|
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
|
|
state: &LauncherState,
|
|
monitor_id: usize,
|
|
) {
|
|
let mut popouts = popouts.borrow_mut();
|
|
let Some(handle) = popouts.get_mut(monitor_id).and_then(|slot| slot.as_mut()) else {
|
|
return;
|
|
};
|
|
handle.binding.close();
|
|
if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off {
|
|
handle
|
|
.picture
|
|
.set_paintable(Option::<>k::gdk::Paintable>::None);
|
|
handle.status_label.set_text("Feed disabled.");
|
|
return;
|
|
}
|
|
if let Some(binding) = preview.install_on_picture(
|
|
monitor_id,
|
|
super::preview::PreviewSurface::Window,
|
|
&handle.picture,
|
|
&handle.status_label,
|
|
) {
|
|
handle.binding = binding;
|
|
}
|
|
let capture = state
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
handle
|
|
.frame
|
|
.set_ratio(capture.preset.display_aspect_ratio());
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) {
|
|
for monitor_id in 0..2 {
|
|
let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off;
|
|
preview.set_monitor_enabled(monitor_id, enabled);
|
|
let capture = state
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.capture_size_choice(monitor_id));
|
|
let source_monitor_id = state
|
|
.resolved_feed_monitor_id(monitor_id)
|
|
.unwrap_or(monitor_id);
|
|
let breakout = state.breakout_size_choice(monitor_id);
|
|
preview.set_capture_profile(
|
|
monitor_id,
|
|
source_monitor_id,
|
|
capture.width,
|
|
capture.height,
|
|
capture.fps,
|
|
capture.max_bitrate_kbit,
|
|
);
|
|
preview.set_breakout_profile(monitor_id, breakout.width, breakout.height);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn sync_preview_profiles(
|
|
preview: &super::preview::LauncherPreview,
|
|
widgets: &super::ui_components::LauncherWidgets,
|
|
popouts: &Rc<RefCell<[Option<super::ui_components::PopoutWindowHandle>; 2]>>,
|
|
state: &LauncherState,
|
|
) {
|
|
apply_preview_profiles(preview, state);
|
|
for monitor_id in 0..2 {
|
|
rebind_inline_preview(preview, widgets, state, monitor_id);
|
|
rebind_popout_preview(preview, popouts, state, monitor_id);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
fn disconnected_capture_note(mode: &str) -> &'static str {
|
|
match mode {
|
|
"forced-on" => "Relay disconnected. Capture is still forced on for staging.",
|
|
"forced-off" => {
|
|
"Relay disconnected. Capture stays intentionally dark until you return to Auto or Force On."
|
|
}
|
|
_ => {
|
|
"Relay disconnected. The server will hold capture briefly, then let it return to standby."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps remote eye previews tied to a live session while respecting forced-off staging.
|
|
fn session_preview_active(
|
|
state: &crate::launcher::state::LauncherState,
|
|
child_running: bool,
|
|
) -> bool {
|
|
(child_running || state.remote_active) && state.capture_power.mode != "forced-off"
|
|
}
|
|
|
|
#[cfg(not(coverage))]
|
|
pub fn run_gui_launcher(server_addr: String) -> Result<()> {
|
|
let app = gtk::Application::builder()
|
|
.application_id("dev.lesavka.launcher")
|
|
.build();
|
|
let catalog = Rc::new(DeviceCatalog::discover());
|
|
let state = Rc::new(RefCell::new(LauncherState::new()));
|
|
state.borrow_mut().apply_catalog_defaults(&catalog);
|
|
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
|
|
let tests = Rc::new(RefCell::new(DeviceTestController::new()));
|
|
let server_addr = Rc::new(server_addr);
|
|
let focus_signal_path = Rc::new(launcher_focus_signal_path());
|
|
let clipboard_control_path = Rc::new(launcher_clipboard_control_path());
|
|
let input_control_path = Rc::new(input_control_path());
|
|
let input_state_path = Rc::new(input_state_path());
|
|
let input_toggle_control_path = Rc::new(input_toggle_control_path());
|
|
let _ = std::fs::remove_file(focus_signal_path.as_path());
|
|
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
|
|
|
{
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
|
let clipboard_control_path = Rc::clone(&clipboard_control_path);
|
|
let input_control_path = Rc::clone(&input_control_path);
|
|
let input_state_path = Rc::clone(&input_state_path);
|
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
|
let tests = Rc::clone(&tests);
|
|
app.connect_shutdown(move |_| {
|
|
stop_child_process(&child_proc);
|
|
tests.borrow_mut().stop_all();
|
|
let _ = std::fs::remove_file(focus_signal_path.as_path());
|
|
let _ = std::fs::remove_file(clipboard_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
|
});
|
|
}
|
|
|
|
{
|
|
let catalog = Rc::clone(&catalog);
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let server_addr = Rc::clone(&server_addr);
|
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
|
let input_control_path = Rc::clone(&input_control_path);
|
|
let input_state_path = Rc::clone(&input_state_path);
|
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
|
|
|
app.connect_activate(move |app| {
|
|
let (display_width, display_height) = largest_monitor_size();
|
|
let (physical_width, physical_height) = largest_monitor_physical_size();
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_breakout_display_size(display_width, display_height);
|
|
state.set_breakout_limit_size(physical_width, physical_height);
|
|
}
|
|
let view = build_launcher_view(app, server_addr.as_ref(), &catalog, &state.borrow());
|
|
let window = view.window.clone();
|
|
let (launcher_width, launcher_height) = launcher_default_size(display_width, display_height);
|
|
window.set_default_size(launcher_width, launcher_height);
|
|
let server_entry = view.server_entry.clone();
|
|
let camera_combo = view.camera_combo.clone();
|
|
let microphone_combo = view.microphone_combo.clone();
|
|
let speaker_combo = view.speaker_combo.clone();
|
|
let keyboard_combo = view.keyboard_combo.clone();
|
|
let mouse_combo = view.mouse_combo.clone();
|
|
let widgets = view.widgets.clone();
|
|
let preview = view.preview.clone();
|
|
let popouts = Rc::clone(&view.popouts);
|
|
let diagnostics_popout = Rc::clone(&view.diagnostics_popout);
|
|
let log_popout = Rc::clone(&view.log_popout);
|
|
let shutdown_cleaned = Rc::new(Cell::new(false));
|
|
|
|
{
|
|
let shutdown_cleaned = Rc::clone(&shutdown_cleaned);
|
|
let app = app.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let preview = preview.clone();
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
|
let log_popout = Rc::clone(&log_popout);
|
|
window.connect_close_request(move |_| {
|
|
if !shutdown_cleaned.replace(true) {
|
|
shutdown_launcher_runtime(
|
|
&child_proc,
|
|
&tests,
|
|
preview.as_deref(),
|
|
&widgets,
|
|
&popouts,
|
|
&diagnostics_popout,
|
|
&log_popout,
|
|
);
|
|
}
|
|
let app = app.clone();
|
|
glib::idle_add_local_once(move || {
|
|
app.quit();
|
|
});
|
|
glib::Propagation::Stop
|
|
});
|
|
}
|
|
|
|
{
|
|
let shutdown_cleaned = Rc::clone(&shutdown_cleaned);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let preview = preview.clone();
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
|
let log_popout = Rc::clone(&log_popout);
|
|
app.connect_shutdown(move |_| {
|
|
if !shutdown_cleaned.replace(true) {
|
|
shutdown_launcher_runtime(
|
|
&child_proc,
|
|
&tests,
|
|
preview.as_deref(),
|
|
&widgets,
|
|
&popouts,
|
|
&diagnostics_popout,
|
|
&log_popout,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let mut tests = tests.borrow_mut();
|
|
if let Err(err) = tests.bind_camera_preview(
|
|
&view.device_stage.camera_preview,
|
|
&view.device_stage.camera_status,
|
|
) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Camera preview setup failed: {err}"));
|
|
}
|
|
if let Err(err) =
|
|
tests.set_camera_selection(state.borrow().devices.camera.as_deref())
|
|
{
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Camera staging setup failed: {err}"));
|
|
}
|
|
}
|
|
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
|
|
|
let (power_tx, power_rx) = std::sync::mpsc::channel::<PowerMessage>();
|
|
let power_request_in_flight = Rc::new(Cell::new(false));
|
|
let (relay_tx, relay_rx) = std::sync::mpsc::channel::<RelayMessage>();
|
|
let relay_request_in_flight = Rc::new(Cell::new(false));
|
|
let (caps_tx, caps_rx) = std::sync::mpsc::channel::<CapsMessage>();
|
|
let caps_request_in_flight = Rc::new(Cell::new(false));
|
|
let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default()));
|
|
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
|
|
let next_power_probe =
|
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
|
|
let next_diagnostics_probe =
|
|
Rc::new(Cell::new(Instant::now() + Duration::from_millis(250)));
|
|
let next_diagnostics_sample =
|
|
Rc::new(Cell::new(Instant::now() + Duration::from_secs(1)));
|
|
let preview_session_active = Rc::new(Cell::new(false));
|
|
let (clipboard_tx, clipboard_rx) = std::sync::mpsc::channel::<ClipboardMessage>();
|
|
let (log_tx, log_rx) = std::sync::mpsc::channel::<String>();
|
|
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_log_sink(log_tx.clone());
|
|
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let camera_combo = camera_combo.clone();
|
|
let camera_combo_read = camera_combo.clone();
|
|
camera_combo.connect_changed(move |_| {
|
|
let selected = selected_combo_value(&camera_combo_read);
|
|
let preview_was_running =
|
|
tests.borrow_mut().is_running(DeviceTestKind::Camera);
|
|
state.borrow_mut().select_camera(selected.clone());
|
|
if let Err(err) = tests.borrow_mut().set_camera_selection(selected.as_deref()) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Camera preview update failed: {err}"));
|
|
} else if preview_was_running {
|
|
widgets.status_label.set_text(&format!(
|
|
"Local camera preview switched to {}.",
|
|
selected.as_deref().unwrap_or("auto")
|
|
));
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let keyboard_combo = keyboard_combo.clone();
|
|
let keyboard_combo_read = keyboard_combo.clone();
|
|
keyboard_combo.connect_changed(move |_| {
|
|
let selected = selected_combo_value(&keyboard_combo_read);
|
|
state.borrow_mut().select_keyboard(selected.clone());
|
|
let message = match selected.as_deref() {
|
|
Some(path) => {
|
|
format!("The next relay launch will listen only to keyboard {path}.")
|
|
}
|
|
None => "The next relay launch will listen to all keyboards.".to_string(),
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let mouse_combo = mouse_combo.clone();
|
|
let mouse_combo_read = mouse_combo.clone();
|
|
mouse_combo.connect_changed(move |_| {
|
|
let selected = selected_combo_value(&mouse_combo_read);
|
|
state.borrow_mut().select_mouse(selected.clone());
|
|
let message = match selected.as_deref() {
|
|
Some(path) => {
|
|
format!("The next relay launch will listen only to pointer {path}.")
|
|
}
|
|
None => {
|
|
"The next relay launch will listen to all pointer devices."
|
|
.to_string()
|
|
}
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
selected_server_addr(&server_entry, server_addr.as_ref()),
|
|
Duration::ZERO,
|
|
);
|
|
caps_request_in_flight.set(true);
|
|
request_handshake_caps(
|
|
caps_tx.clone(),
|
|
selected_server_addr(&server_entry, server_addr.as_ref()),
|
|
Duration::ZERO,
|
|
);
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let server_entry_read = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let caps_tx = caps_tx.clone();
|
|
let caps_request_in_flight = Rc::clone(&caps_request_in_flight);
|
|
server_entry.connect_changed(move |_| {
|
|
let server_addr =
|
|
selected_server_addr(&server_entry_read, server_addr_fallback.as_ref());
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_server_version(None);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(150),
|
|
);
|
|
caps_request_in_flight.set(true);
|
|
request_handshake_caps(caps_tx.clone(), server_addr, Duration::from_millis(150));
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let preview = preview.clone();
|
|
let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone();
|
|
feed_source_combo.connect_changed(move |combo| {
|
|
let Some(active_id) = combo.active_id() else {
|
|
return;
|
|
};
|
|
let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else {
|
|
return;
|
|
};
|
|
if state.borrow().feed_source_preset(monitor_id) == preset {
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_feed_source_preset(monitor_id, preset);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let preview = preview.clone();
|
|
let resolution_combo =
|
|
widgets.display_panes[monitor_id].capture_resolution_combo.clone();
|
|
resolution_combo.connect_changed(move |combo| {
|
|
let Some(active_id) = combo.active_id() else {
|
|
return;
|
|
};
|
|
let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else {
|
|
return;
|
|
};
|
|
if state.borrow().feed_source_preset(monitor_id) != FeedSourcePreset::ThisEye {
|
|
return;
|
|
}
|
|
if state.borrow().capture_size_preset(monitor_id) == preset {
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_capture_size_preset(monitor_id, preset);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let choice = state
|
|
.borrow()
|
|
.display_capture_size_choice(monitor_id)
|
|
.unwrap_or_else(|| state.borrow().capture_size_choice(monitor_id));
|
|
let source_monitor_id = state
|
|
.borrow()
|
|
.resolved_feed_monitor_id(monitor_id)
|
|
.unwrap_or(monitor_id);
|
|
preview.set_capture_profile(
|
|
monitor_id,
|
|
source_monitor_id,
|
|
choice.width,
|
|
choice.height,
|
|
choice.fps,
|
|
choice.max_bitrate_kbit,
|
|
);
|
|
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let popouts = Rc::clone(&popouts);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let preview = preview.clone();
|
|
let breakout_combo = widgets.display_panes[monitor_id].breakout_combo.clone();
|
|
breakout_combo.connect_changed(move |combo| {
|
|
let Some(active_id) = combo.active_id() else {
|
|
return;
|
|
};
|
|
let Some(preset) = BreakoutSizePreset::from_id(active_id.as_str()) else {
|
|
return;
|
|
};
|
|
if state.borrow().breakout_size_preset(monitor_id) == preset {
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_breakout_size_preset(monitor_id, preset);
|
|
}
|
|
let size = state.borrow().breakout_size_choice(monitor_id);
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_breakout_profile(monitor_id, size.width, size.height);
|
|
}
|
|
let popout_open = {
|
|
popouts
|
|
.borrow()
|
|
.get(monitor_id)
|
|
.and_then(|slot| slot.as_ref())
|
|
.is_some()
|
|
};
|
|
if popout_open {
|
|
if let Some(preview) = preview.as_ref() {
|
|
rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id);
|
|
}
|
|
if let Some(handle) = popouts
|
|
.borrow()
|
|
.get(monitor_id)
|
|
.and_then(|slot| slot.as_ref())
|
|
{
|
|
let display_limit = state.borrow().breakout_display_size();
|
|
apply_popout_window_size(handle, size, display_limit);
|
|
}
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let microphone_combo = microphone_combo.clone();
|
|
let microphone_combo_read = microphone_combo.clone();
|
|
microphone_combo.connect_changed(move |_| {
|
|
state
|
|
.borrow_mut()
|
|
.select_microphone(selected_combo_value(µphone_combo_read));
|
|
if tests.borrow_mut().is_running(DeviceTestKind::Microphone) {
|
|
widgets.status_label.set_text(
|
|
"Microphone selection changed. Restart Monitor Mic to audition the new input.",
|
|
);
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let speaker_combo = speaker_combo.clone();
|
|
let speaker_combo_read = speaker_combo.clone();
|
|
speaker_combo.connect_changed(move |_| {
|
|
state
|
|
.borrow_mut()
|
|
.select_speaker(selected_combo_value(&speaker_combo_read));
|
|
let speaker_running = tests.borrow_mut().is_running(DeviceTestKind::Speaker);
|
|
let microphone_running =
|
|
tests.borrow_mut().is_running(DeviceTestKind::Microphone);
|
|
if speaker_running || microphone_running {
|
|
widgets.status_label.set_text(
|
|
"Speaker selection changed. Restart the local audio tests to hear the new output.",
|
|
);
|
|
}
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let audio_gain_scale = widgets.audio_gain_scale.clone();
|
|
audio_gain_scale.connect_value_changed(move |scale| {
|
|
let percent = scale
|
|
.value()
|
|
.round()
|
|
.clamp(0.0, MAX_AUDIO_GAIN_PERCENT as f64)
|
|
as u32;
|
|
let label = {
|
|
let mut state = state.borrow_mut();
|
|
if state.audio_gain_percent == percent {
|
|
return;
|
|
}
|
|
state.set_audio_gain_percent(percent);
|
|
state.audio_gain_label()
|
|
};
|
|
widgets.audio_gain_value.set_text(&label);
|
|
if child_proc.borrow().is_some() {
|
|
let path = audio_gain_control_path();
|
|
match write_audio_gain_request(&path, percent) {
|
|
Ok(()) => widgets
|
|
.status_label
|
|
.set_text(&format!("Remote audio gain set to {label}.")),
|
|
Err(err) => widgets.status_label.set_text(&format!(
|
|
"Remote audio gain set to {label} for the next relay launch, but live gain control could not be written: {err}"
|
|
)),
|
|
}
|
|
} else {
|
|
widgets.status_label.set_text(&format!(
|
|
"Remote audio gain set to {label} for the next relay launch."
|
|
));
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let widgets = widgets.clone();
|
|
let widgets_handle = widgets.clone();
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let tests = Rc::clone(&tests);
|
|
let camera_combo = camera_combo.clone();
|
|
let microphone_combo = microphone_combo.clone();
|
|
let speaker_combo = speaker_combo.clone();
|
|
let keyboard_combo = keyboard_combo.clone();
|
|
let mouse_combo = mouse_combo.clone();
|
|
widgets.device_refresh_button.connect_clicked(move |_| {
|
|
let catalog = DeviceCatalog::discover();
|
|
let (
|
|
selected_camera,
|
|
selected_microphone,
|
|
selected_speaker,
|
|
selected_keyboard,
|
|
selected_mouse,
|
|
) = {
|
|
let state = state.borrow();
|
|
(
|
|
retained_stage_selection(
|
|
state.devices.camera.as_deref(),
|
|
&catalog.cameras,
|
|
),
|
|
retained_stage_selection(
|
|
state.devices.microphone.as_deref(),
|
|
&catalog.microphones,
|
|
),
|
|
retained_stage_selection(
|
|
state.devices.speaker.as_deref(),
|
|
&catalog.speakers,
|
|
),
|
|
retained_input_selection(
|
|
state.devices.keyboard.as_deref(),
|
|
&catalog.keyboards,
|
|
),
|
|
retained_input_selection(state.devices.mouse.as_deref(), &catalog.mice),
|
|
)
|
|
};
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.select_camera(selected_camera);
|
|
state.select_microphone(selected_microphone);
|
|
state.select_speaker(selected_speaker);
|
|
state.select_keyboard(selected_keyboard);
|
|
state.select_mouse(selected_mouse);
|
|
}
|
|
let state_snapshot = state.borrow().clone();
|
|
sync_stage_device_combo(
|
|
&camera_combo,
|
|
&catalog.cameras,
|
|
state_snapshot.devices.camera.as_deref(),
|
|
);
|
|
sync_stage_device_combo(
|
|
µphone_combo,
|
|
&catalog.microphones,
|
|
state_snapshot.devices.microphone.as_deref(),
|
|
);
|
|
sync_stage_device_combo(
|
|
&speaker_combo,
|
|
&catalog.speakers,
|
|
state_snapshot.devices.speaker.as_deref(),
|
|
);
|
|
sync_input_device_combo(
|
|
&keyboard_combo,
|
|
&catalog.keyboards,
|
|
state_snapshot.devices.keyboard.as_deref(),
|
|
"all keyboards",
|
|
);
|
|
sync_input_device_combo(
|
|
&mouse_combo,
|
|
&catalog.mice,
|
|
state_snapshot.devices.mouse.as_deref(),
|
|
"all mice",
|
|
);
|
|
if let Err(err) = tests
|
|
.borrow_mut()
|
|
.set_camera_selection(state_snapshot.devices.camera.as_deref())
|
|
{
|
|
widgets_handle
|
|
.status_label
|
|
.set_text(&format!("Device refresh succeeded, but the webcam test could not switch cleanly: {err}"));
|
|
} else {
|
|
let message = if usb_audio_kernel_support_missing() {
|
|
"Device staging refreshed. USB audio devices may still stay invisible until the host boots a kernel with snd_usb_audio available; reconnect the relay if you want the live session to use a new webcam, mic, or speaker."
|
|
} else {
|
|
"Device staging refreshed. Newly attached devices are ready for local tests; all-keyboard/all-mouse relay sessions will pick up new input devices automatically."
|
|
};
|
|
widgets_handle.status_label.set_text(message);
|
|
}
|
|
refresh_launcher_ui(
|
|
&widgets_handle,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
refresh_test_buttons(&widgets_handle, &mut tests.borrow_mut());
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let camera_combo = camera_combo.clone();
|
|
let microphone_combo = microphone_combo.clone();
|
|
let speaker_combo = speaker_combo.clone();
|
|
let input_control_path = Rc::clone(&input_control_path);
|
|
let input_state_path = Rc::clone(&input_state_path);
|
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let relay_tx = relay_tx.clone();
|
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
|
let popouts = Rc::clone(&popouts);
|
|
let window = window.clone();
|
|
let start_button = widgets.start_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
start_button.connect_clicked(move |_| {
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if relay_request_in_flight.get() {
|
|
return;
|
|
}
|
|
if child_proc.borrow().is_some() {
|
|
stop_child_process(&child_proc);
|
|
let power_mode = {
|
|
let mut state = state.borrow_mut();
|
|
let _ = state.stop_remote();
|
|
state.capture_power.mode.clone()
|
|
};
|
|
dock_all_displays_to_preview(
|
|
&state,
|
|
&child_proc,
|
|
&popouts,
|
|
&widgets_handle,
|
|
);
|
|
window.present();
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
preview.set_session_active(false);
|
|
}
|
|
if power_mode != "auto" {
|
|
widgets_handle.status_label.set_text(
|
|
"Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
|
|
);
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
} else {
|
|
widgets_handle
|
|
.status_label
|
|
.set_text(disconnected_capture_note(&power_mode));
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_secs(31),
|
|
);
|
|
refresh_launcher_ui(&widgets_handle, &state.borrow(), false);
|
|
return;
|
|
}
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.select_camera(selected_combo_value(&camera_combo));
|
|
state.select_microphone(selected_combo_value(µphone_combo));
|
|
state.select_speaker(selected_combo_value(&speaker_combo));
|
|
state.select_keyboard(selected_combo_value(&keyboard_combo));
|
|
state.select_mouse(selected_combo_value(&mouse_combo));
|
|
}
|
|
let _ = std::fs::remove_file(input_control_path.as_path());
|
|
let _ = std::fs::remove_file(input_state_path.as_path());
|
|
let _ = std::fs::remove_file(input_toggle_control_path.as_path());
|
|
let launch_state = state.borrow().clone();
|
|
let input_toggle_key = launch_state.swap_key.clone();
|
|
let input_control_path = input_control_path.as_ref().clone();
|
|
let input_state_path = input_state_path.as_ref().clone();
|
|
let input_toggle_control_path = input_toggle_control_path.as_ref().clone();
|
|
relay_request_in_flight.set(true);
|
|
widgets_handle.status_label.set_text(&format!(
|
|
"Connecting relay with {} as the swap key...",
|
|
toggle_key_label(&input_toggle_key)
|
|
));
|
|
refresh_launcher_ui(
|
|
&widgets_handle,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
let relay_tx = relay_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let result = spawn_client_process(
|
|
&server_addr,
|
|
&launch_state,
|
|
&input_toggle_key,
|
|
input_control_path.as_path(),
|
|
input_state_path.as_path(),
|
|
input_toggle_control_path.as_path(),
|
|
)
|
|
.map_err(|err| err.to_string());
|
|
let _ = relay_tx.send(RelayMessage::Spawned(result));
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let input_control_path = Rc::clone(&input_control_path);
|
|
let popouts = Rc::clone(&popouts);
|
|
let window = window.clone();
|
|
let input_toggle_button = widgets.input_toggle_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
input_toggle_button.connect_clicked(move |_| {
|
|
let next = next_input_routing(state.borrow().routing);
|
|
let child_running = child_proc.borrow().is_some();
|
|
if child_running {
|
|
if let Err(err) =
|
|
write_input_routing_request(input_control_path.as_path(), next)
|
|
{
|
|
widgets_handle
|
|
.status_label
|
|
.set_text(&format!("Could not update live input target: {err}"));
|
|
refresh_launcher_ui(&widgets_handle, &state.borrow(), true);
|
|
return;
|
|
}
|
|
widgets_handle.status_label.set_text(&format!(
|
|
"Input routing switched toward {}.",
|
|
routing_name(next)
|
|
));
|
|
} else {
|
|
widgets_handle.status_label.set_text(&format!(
|
|
"Relay will start with {} input ownership.",
|
|
routing_name(next)
|
|
));
|
|
}
|
|
state.borrow_mut().set_routing(next);
|
|
refresh_launcher_ui(&widgets_handle, &state.borrow(), child_running);
|
|
if matches!(next, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
} else {
|
|
window.present();
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let swap_key_button = widgets.swap_key_button.clone();
|
|
swap_key_button.connect_clicked(move |_| {
|
|
let token = state.borrow_mut().begin_swap_key_binding();
|
|
widgets
|
|
.status_label
|
|
.set_text("Press a single key within 3 seconds to make it the swap shortcut.");
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
glib::timeout_add_local_once(Duration::from_secs(3), move || {
|
|
if state.borrow_mut().cancel_swap_key_binding(token) {
|
|
widgets.status_label.set_text(
|
|
"Swap-key capture timed out. The previous shortcut is still in place.",
|
|
);
|
|
refresh_launcher_ui(
|
|
&widgets,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
widgets.clipboard_button.connect_clicked(move |_| {
|
|
if child_proc.borrow().is_none() {
|
|
widgets
|
|
.status_label
|
|
.set_text("Start the relay before sending clipboard text.");
|
|
return;
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
let Some(display) = gtk::gdk::Display::default() else {
|
|
widgets
|
|
.status_label
|
|
.set_text("No desktop clipboard is available in this session.");
|
|
return;
|
|
};
|
|
widgets
|
|
.status_label
|
|
.set_text("Reading the local clipboard and packing a remote paste spell...");
|
|
let clipboard = display.clipboard();
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
clipboard.read_text_async(None::<>k::gio::Cancellable>, move |result| {
|
|
match result {
|
|
Ok(Some(text)) => {
|
|
let text = text.trim_end_matches(['\r', '\n']).to_string();
|
|
if text.is_empty() {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
"clipboard is empty".to_string(),
|
|
)));
|
|
return;
|
|
}
|
|
let clipboard_tx = clipboard_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let result = send_clipboard_text_to_remote(&server_addr, &text)
|
|
.map_err(|err| err.to_string());
|
|
let _ = clipboard_tx
|
|
.send(ClipboardMessage::Finished(result));
|
|
});
|
|
}
|
|
Ok(None) => {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
"clipboard is empty".to_string(),
|
|
)));
|
|
}
|
|
Err(err) => {
|
|
let _ = clipboard_tx.send(ClipboardMessage::Finished(Err(
|
|
format!("clipboard read failed: {err}"),
|
|
)));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
widgets.probe_button.connect_clicked(move |_| {
|
|
if let Some(display) = gtk::gdk::Display::default() {
|
|
let clipboard = display.clipboard();
|
|
clipboard.set_text(quality_probe_command());
|
|
widgets
|
|
.status_label
|
|
.set_text("Quality probe command copied to the local clipboard.");
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text("No desktop clipboard is available in this session.");
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let widgets_for_click = widgets.clone();
|
|
widgets.usb_recover_button.connect_clicked(move |_| {
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
widgets_for_click.status_label.set_text(
|
|
"Requesting a forced USB gadget re-enumeration on the relay host...",
|
|
);
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let result =
|
|
reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}"));
|
|
let _ = tx.send(result);
|
|
});
|
|
let widgets = widgets_for_click.clone();
|
|
glib::timeout_add_local(Duration::from_millis(100), move || {
|
|
match rx.try_recv() {
|
|
Ok(Ok(())) => {
|
|
widgets.status_label.set_text(
|
|
"USB gadget recovery requested. Give the host a few seconds to re-enumerate keyboard, mouse, webcam, and audio.",
|
|
);
|
|
glib::ControlFlow::Break
|
|
}
|
|
Ok(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("USB gadget recovery failed: {err}"));
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
widgets.status_label.set_text(
|
|
"USB gadget recovery ended unexpectedly before the relay answered.",
|
|
);
|
|
glib::ControlFlow::Break
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
widgets.diagnostics_copy_button.connect_clicked(move |_| {
|
|
if let Err(err) = copy_plain_text(&widgets.diagnostics_rendered_text.borrow()) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Could not copy the diagnostics report: {err}"));
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text("Diagnostics report copied to the local clipboard.");
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let app = app.clone();
|
|
let widgets = widgets.clone();
|
|
let diagnostics_popout = Rc::clone(&diagnostics_popout);
|
|
widgets.diagnostics_popout_button.connect_clicked(move |_| {
|
|
open_diagnostics_popout(
|
|
&app,
|
|
&diagnostics_popout,
|
|
&widgets.diagnostics_popout_label,
|
|
&widgets.diagnostics_popout_scroll,
|
|
&widgets.diagnostics_rendered_text,
|
|
);
|
|
widgets
|
|
.status_label
|
|
.set_text("Diagnostics report moved into its own window.");
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
widgets.console_copy_button.connect_clicked(move |_| {
|
|
if let Err(err) = copy_session_log(&widgets.session_log_buffer) {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Could not copy the session log: {err}"));
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text("Session log copied to the local clipboard.");
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let app = app.clone();
|
|
let widgets = widgets.clone();
|
|
let log_popout = Rc::clone(&log_popout);
|
|
widgets.console_popout_button.connect_clicked(move |_| {
|
|
open_session_log_popout(&app, &log_popout, &widgets.session_log_buffer);
|
|
widgets
|
|
.status_label
|
|
.set_text("Session log moved into its own window.");
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let tests = Rc::clone(&tests);
|
|
let camera_combo = camera_combo.clone();
|
|
let camera_test_button = widgets.camera_test_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
camera_test_button.connect_clicked(move |_| {
|
|
let selected = selected_combo_value(&camera_combo);
|
|
let result = {
|
|
let mut tests = tests.borrow_mut();
|
|
let _ = tests.set_camera_selection(selected.as_deref());
|
|
tests.toggle_camera()
|
|
};
|
|
update_test_action_result(
|
|
&widgets_handle,
|
|
&mut tests.borrow_mut(),
|
|
result,
|
|
"Camera preview started inside the launcher. This stays local until you start the relay.",
|
|
"Camera preview stopped. The selected camera stays staged for the next launch.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let tests = Rc::clone(&tests);
|
|
let microphone_combo = microphone_combo.clone();
|
|
let speaker_combo = speaker_combo.clone();
|
|
let microphone_test_button = widgets.microphone_test_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
microphone_test_button.connect_clicked(move |_| {
|
|
let mic = selected_combo_value(µphone_combo);
|
|
let sink = selected_combo_value(&speaker_combo);
|
|
let result = tests
|
|
.borrow_mut()
|
|
.toggle_microphone(mic.as_deref(), sink.as_deref());
|
|
update_test_action_result(
|
|
&widgets_handle,
|
|
&mut tests.borrow_mut(),
|
|
result,
|
|
"Microphone monitor started locally through the selected speaker.",
|
|
"Microphone monitor stopped.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let tests = Rc::clone(&tests);
|
|
let speaker_combo = speaker_combo.clone();
|
|
let microphone_replay_button = widgets.microphone_replay_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
microphone_replay_button.connect_clicked(move |_| {
|
|
let result = tests
|
|
.borrow_mut()
|
|
.toggle_microphone_replay(selected_combo_value(&speaker_combo).as_deref());
|
|
update_test_action_result(
|
|
&widgets_handle,
|
|
&mut tests.borrow_mut(),
|
|
result,
|
|
"Replaying the latest local mic capture through the selected speaker.",
|
|
"Mic replay stopped.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let tests = Rc::clone(&tests);
|
|
let speaker_combo = speaker_combo.clone();
|
|
let speaker_test_button = widgets.speaker_test_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
speaker_test_button.connect_clicked(move |_| {
|
|
let result = tests
|
|
.borrow_mut()
|
|
.toggle_speaker(selected_combo_value(&speaker_combo).as_deref());
|
|
update_test_action_result(
|
|
&widgets_handle,
|
|
&mut tests.borrow_mut(),
|
|
result,
|
|
"Speaker tone started locally.",
|
|
"Speaker test tone stopped.",
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let widgets = widgets.clone();
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let power_tx = power_tx.clone();
|
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
|
let power_auto_button = widgets.power_auto_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
power_auto_button.connect_clicked(move |_| {
|
|
if power_request_in_flight.replace(true) {
|
|
return;
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
widgets_handle
|
|
.status_label
|
|
.set_text("Returning capture feeds to automatic mode...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let power_tx = power_tx.clone();
|
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
|
let power_on_button = widgets.power_on_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
power_on_button.connect_clicked(move |_| {
|
|
if power_request_in_flight.replace(true) {
|
|
return;
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
widgets_handle
|
|
.status_label
|
|
.set_text("Forcing capture feeds on for staging...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::ForceOn,
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let power_tx = power_tx.clone();
|
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
|
let power_off_button = widgets.power_off_button.clone();
|
|
let widgets_handle = widgets.clone();
|
|
power_off_button.connect_clicked(move |_| {
|
|
if power_request_in_flight.replace(true) {
|
|
return;
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
widgets_handle
|
|
.status_label
|
|
.set_text("Forcing capture feeds off for staging...");
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
CapturePowerCommand::ForceOff,
|
|
);
|
|
});
|
|
}
|
|
|
|
for monitor_id in 0..2 {
|
|
let app = app.clone();
|
|
let preview = preview.clone();
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let popouts = Rc::clone(&popouts);
|
|
let widgets = widgets.clone();
|
|
let action_button = widgets.display_panes[monitor_id].action_button.clone();
|
|
action_button.connect_clicked(move |_| {
|
|
let Some(preview) = preview.as_ref() else {
|
|
widgets
|
|
.status_label
|
|
.set_text("Preview is unavailable for breakout windows.");
|
|
return;
|
|
};
|
|
let surface = {
|
|
let state = state.borrow();
|
|
state.display_surface(monitor_id)
|
|
};
|
|
match surface {
|
|
DisplaySurface::Preview => {
|
|
open_popout_window(
|
|
&app,
|
|
preview,
|
|
&state,
|
|
&child_proc,
|
|
&popouts,
|
|
&widgets,
|
|
monitor_id,
|
|
);
|
|
widgets.status_label.set_text(&format!(
|
|
"{} moved into its own window.",
|
|
widgets.display_panes[monitor_id].title
|
|
));
|
|
}
|
|
DisplaySurface::Window => {
|
|
dock_display_to_preview(
|
|
&state,
|
|
&child_proc,
|
|
&popouts,
|
|
&widgets,
|
|
monitor_id,
|
|
);
|
|
widgets.status_label.set_text(&format!(
|
|
"{} returned to the launcher preview.",
|
|
widgets.display_panes[monitor_id].title
|
|
));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let input_toggle_control_path = Rc::clone(&input_toggle_control_path);
|
|
let key_controller = gtk::EventControllerKey::new();
|
|
key_controller.connect_key_pressed(move |_, key, _, _| {
|
|
if !state.borrow().swap_key_binding {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
|
|
let Some(swap_key) = capture_swap_key(key) else {
|
|
widgets.status_label.set_text(
|
|
"That key is not a good swap shortcut. Try a letter, digit, function key, or navigation key.",
|
|
);
|
|
refresh_launcher_ui(
|
|
&widgets,
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
);
|
|
return glib::Propagation::Stop;
|
|
};
|
|
|
|
let relay_live = child_proc.borrow().is_some() || state.borrow().remote_active;
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.complete_swap_key_binding(swap_key.clone());
|
|
}
|
|
let status_message = if relay_live {
|
|
match write_input_toggle_key_request(
|
|
input_toggle_control_path.as_path(),
|
|
&swap_key,
|
|
) {
|
|
Ok(()) => format!(
|
|
"Swap key set to {} and applied to the live relay.",
|
|
toggle_key_label(&swap_key)
|
|
),
|
|
Err(err) => format!(
|
|
"Swap key set to {}, but Lesavka could not push it live: {err}",
|
|
toggle_key_label(&swap_key)
|
|
),
|
|
}
|
|
} else {
|
|
format!(
|
|
"Swap key set to {}. The next relay launch will use it.",
|
|
toggle_key_label(&swap_key)
|
|
)
|
|
};
|
|
widgets.status_label.set_text(&status_message);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some());
|
|
glib::Propagation::Stop
|
|
});
|
|
window.add_controller(key_controller);
|
|
}
|
|
|
|
{
|
|
let window = window.clone();
|
|
let state = Rc::clone(&state);
|
|
let child_proc = Rc::clone(&child_proc);
|
|
let widgets = widgets.clone();
|
|
let focus_signal_path = Rc::clone(&focus_signal_path);
|
|
let input_state_path = Rc::clone(&input_state_path);
|
|
let tests = Rc::clone(&tests);
|
|
let server_entry = server_entry.clone();
|
|
let server_addr_fallback = Rc::clone(&server_addr);
|
|
let last_focus_marker =
|
|
Rc::new(RefCell::new(path_marker(focus_signal_path.as_path())));
|
|
let power_request_in_flight = Rc::clone(&power_request_in_flight);
|
|
let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
|
|
let preview = preview.clone();
|
|
let power_tx = power_tx.clone();
|
|
let caps_tx = caps_tx.clone();
|
|
let caps_request_in_flight = Rc::clone(&caps_request_in_flight);
|
|
let diagnostics_network = Rc::clone(&diagnostics_network);
|
|
let diagnostics_process = Rc::clone(&diagnostics_process);
|
|
let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe);
|
|
let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample);
|
|
let preview_session_active = Rc::clone(&preview_session_active);
|
|
let log_tx = log_tx.clone();
|
|
glib::timeout_add_local(Duration::from_millis(180), move || {
|
|
let child_running = reap_exited_child(&child_proc);
|
|
if let Some(preview) = preview.as_ref() {
|
|
let desired_preview_active = {
|
|
let state_snapshot = state.borrow();
|
|
session_preview_active(&state_snapshot, child_running)
|
|
};
|
|
if preview_session_active.get() != desired_preview_active {
|
|
preview.set_session_active(desired_preview_active);
|
|
preview_session_active.set(desired_preview_active);
|
|
}
|
|
}
|
|
if !child_running && state.borrow().remote_active {
|
|
let power_mode = {
|
|
let mut state = state.borrow_mut();
|
|
let _ = state.stop_remote();
|
|
state.capture_power.mode.clone()
|
|
};
|
|
dock_all_displays_to_preview(&state, &child_proc, &popouts, &widgets);
|
|
window.present();
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if power_mode != "auto" {
|
|
widgets.status_label.set_text(
|
|
"Relay disconnected. Returning capture to automatic mode so it can fall back after the disconnect grace.",
|
|
);
|
|
request_capture_power_command(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
CapturePowerCommand::Auto,
|
|
);
|
|
} else {
|
|
widgets
|
|
.status_label
|
|
.set_text(disconnected_capture_note(&power_mode));
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_secs(31),
|
|
);
|
|
}
|
|
|
|
if child_running
|
|
&& let Some(routing) = read_input_routing_state(input_state_path.as_path())
|
|
&& routing != state.borrow().routing
|
|
{
|
|
state.borrow_mut().set_routing(routing);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
|
if matches!(routing, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
} else {
|
|
window.present();
|
|
}
|
|
}
|
|
|
|
let next_focus_marker = path_marker(focus_signal_path.as_path());
|
|
let mut last_focus = last_focus_marker.borrow_mut();
|
|
if next_focus_marker > *last_focus {
|
|
*last_focus = next_focus_marker;
|
|
state.borrow_mut().set_routing(InputRouting::Local);
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
|
widgets
|
|
.status_label
|
|
.set_text("Local control restored and the launcher is focused.");
|
|
window.present();
|
|
}
|
|
|
|
while let Ok(message) = relay_rx.try_recv() {
|
|
relay_request_in_flight.set(false);
|
|
match message {
|
|
RelayMessage::Spawned(Ok(mut child)) => {
|
|
attach_child_log_streams(&mut child, log_tx.clone());
|
|
*child_proc.borrow_mut() = Some(child);
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
let _ = state.start_remote();
|
|
}
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_server_addr(server_addr.clone());
|
|
preview.set_session_active(session_preview_active(
|
|
&state.borrow(),
|
|
child_proc.borrow().is_some(),
|
|
));
|
|
}
|
|
let routing = routing_name(state.borrow().routing);
|
|
let power_mode = state.borrow().capture_power.mode.clone();
|
|
let message = match power_mode.as_str() {
|
|
"forced-off" => format!(
|
|
"Relay connected with inputs routed to {}, but capture is forced off. Return capture to Auto or Force On when you want remote video.",
|
|
routing
|
|
),
|
|
"forced-on" => format!(
|
|
"Relay connected with inputs routed to {}. Capture is being held awake and the eye previews are coming online.",
|
|
routing
|
|
),
|
|
_ => format!(
|
|
"Relay connected with inputs routed to {}. The eye previews will come up with the live session.",
|
|
routing
|
|
),
|
|
};
|
|
widgets.status_label.set_text(&message);
|
|
if matches!(state.borrow().routing, InputRouting::Remote) {
|
|
present_popout_windows(&popouts);
|
|
}
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr.clone(),
|
|
Duration::from_millis(250),
|
|
);
|
|
request_capture_power_refresh(
|
|
power_tx.clone(),
|
|
server_addr,
|
|
Duration::from_millis(1250),
|
|
);
|
|
}
|
|
RelayMessage::Spawned(Err(err)) => {
|
|
state.borrow_mut().set_server_available(false);
|
|
if let Some(preview) = preview.as_ref() {
|
|
preview.set_session_active(false);
|
|
}
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Relay start failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(line) = log_rx.try_recv() {
|
|
append_session_log(&widgets.session_log_buffer, &line);
|
|
let mut end = widgets.session_log_buffer.end_iter();
|
|
widgets
|
|
.session_log_view
|
|
.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0);
|
|
}
|
|
|
|
while let Ok(message) = power_rx.try_recv() {
|
|
power_request_in_flight.set(false);
|
|
match message {
|
|
PowerMessage::Refresh(Ok(power)) => {
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
state.set_capture_power(power);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
}
|
|
PowerMessage::Refresh(Err(err)) => {
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_capture_power(unavailable_capture_power(err));
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
}
|
|
PowerMessage::Command(Ok(power)) => {
|
|
let mode = power.mode.clone();
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(true);
|
|
state.set_capture_power(power);
|
|
}
|
|
if let Some(preview) = preview.as_ref() {
|
|
let preview_active = {
|
|
let state = state.borrow();
|
|
session_preview_active(
|
|
&state,
|
|
child_proc.borrow().is_some(),
|
|
)
|
|
};
|
|
preview.set_session_active(preview_active);
|
|
}
|
|
widgets.status_label.set_text(match mode.as_str() {
|
|
"forced-on" => "Capture feeds forced on. Remote eyes stay awake even if previews or the relay stop.",
|
|
"forced-off" => "Capture feeds forced off. Remote eye previews and session video stay dark until you switch back.",
|
|
_ => "Capture feeds returned to automatic mode. Live previews and relay demand will wake them as needed.",
|
|
});
|
|
}
|
|
PowerMessage::Command(Err(err)) => {
|
|
let mut state = state.borrow_mut();
|
|
state.set_server_available(false);
|
|
state.set_capture_power(unavailable_capture_power(err.clone()));
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Capture power update failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(message) = caps_rx.try_recv() {
|
|
caps_request_in_flight.set(false);
|
|
match message {
|
|
CapsMessage::Refresh(probe_result) => {
|
|
diagnostics_network.borrow_mut().record(&probe_result);
|
|
let caps = probe_result.caps;
|
|
let rebind_preview = eye_caps_changed(&state.borrow(), &caps);
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
if probe_result.reachable {
|
|
state.set_server_available(true);
|
|
} else if child_proc.borrow().is_none() {
|
|
state.set_server_available(false);
|
|
}
|
|
state.set_server_version(caps.server_version.clone());
|
|
}
|
|
if let (Some(width), Some(height)) =
|
|
(caps.eye_width, caps.eye_height)
|
|
{
|
|
let fps = caps
|
|
.eye_fps
|
|
.unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps);
|
|
{
|
|
let mut state = state.borrow_mut();
|
|
state.set_preview_source_profile(width, height, fps);
|
|
}
|
|
if rebind_preview && let Some(preview) = preview.as_ref() {
|
|
sync_preview_profiles(preview, &widgets, &popouts, &state.borrow());
|
|
}
|
|
refresh_eye_feed_controls(&widgets, &state.borrow());
|
|
} else {
|
|
refresh_eye_feed_controls(&widgets, &state.borrow());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Ok(message) = clipboard_rx.try_recv() {
|
|
match message {
|
|
ClipboardMessage::Finished(Ok(detail)) => {
|
|
widgets.status_label.set_text(&format!("✨ {detail}"));
|
|
}
|
|
ClipboardMessage::Finished(Err(err)) => {
|
|
widgets
|
|
.status_label
|
|
.set_text(&format!("Clipboard send failed: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
let now = Instant::now();
|
|
let child_running = child_proc.borrow().is_some();
|
|
|
|
if now >= next_power_probe.get()
|
|
&& !power_request_in_flight.get()
|
|
&& (child_running
|
|
|| state.borrow().capture_power.enabled
|
|
|| state.borrow().remote_active)
|
|
{
|
|
power_request_in_flight.set(true);
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
request_capture_power_refresh(power_tx.clone(), server_addr, Duration::ZERO);
|
|
next_power_probe.set(now + Duration::from_secs(2));
|
|
}
|
|
|
|
if now >= next_diagnostics_probe.get() && !caps_request_in_flight.get() {
|
|
caps_request_in_flight.set(true);
|
|
let server_addr =
|
|
selected_server_addr(&server_entry, server_addr_fallback.as_ref());
|
|
request_handshake_caps(caps_tx.clone(), server_addr, Duration::ZERO);
|
|
next_diagnostics_probe.set(now + Duration::from_secs(2));
|
|
}
|
|
|
|
if now >= next_diagnostics_sample.get() {
|
|
let network = diagnostics_network.borrow_mut().snapshot();
|
|
let client_process_cpu_pct = diagnostics_process
|
|
.borrow_mut()
|
|
.sample_percent()
|
|
.unwrap_or(0.0);
|
|
record_diagnostics_sample(
|
|
&widgets,
|
|
&state.borrow(),
|
|
preview.as_ref().map(|preview| preview.as_ref()),
|
|
network,
|
|
client_process_cpu_pct,
|
|
);
|
|
next_diagnostics_sample.set(now + Duration::from_secs(1));
|
|
}
|
|
|
|
refresh_launcher_ui(&widgets, &state.borrow(), child_running);
|
|
refresh_test_buttons(&widgets, &mut tests.borrow_mut());
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
window.present();
|
|
});
|
|
}
|
|
|
|
let _ = app.run_with_args::<&str>(&[]);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(coverage)]
|
|
pub fn run_gui_launcher(_server_addr: String) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(test, not(coverage)))]
|
|
mod tests {
|
|
use super::apply_preview_profiles;
|
|
use crate::launcher::preview::{LauncherPreview, PreviewSurface};
|
|
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
|
|
|
|
#[test]
|
|
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {
|
|
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
|
let state = LauncherState::default();
|
|
|
|
let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
|
assert_eq!(bootstrap.0, 0);
|
|
assert_eq!(bootstrap.3, 1920);
|
|
assert_eq!(bootstrap.4, 1080);
|
|
assert_eq!(bootstrap.5, 60);
|
|
assert_eq!(bootstrap.6, 18_000);
|
|
|
|
apply_preview_profiles(&preview, &state);
|
|
|
|
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
|
assert_eq!(inline.0, 1);
|
|
assert_eq!(inline.3, 1920);
|
|
assert_eq!(inline.4, 1080);
|
|
assert_eq!(inline.5, 60);
|
|
assert_eq!(inline.6, 18_000);
|
|
|
|
let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap();
|
|
assert_eq!(window.0, 1);
|
|
assert_eq!(window.3, 1920);
|
|
assert_eq!(window.4, 1080);
|
|
assert_eq!(window.5, 60);
|
|
assert_eq!(window.6, 18_000);
|
|
|
|
preview.shutdown_all();
|
|
}
|
|
|
|
#[test]
|
|
fn source_preview_profile_stays_honest_after_apply() {
|
|
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
|
let mut state = LauncherState::default();
|
|
state.set_capture_size_preset(1, CaptureSizePreset::P1080);
|
|
|
|
apply_preview_profiles(&preview, &state);
|
|
|
|
let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap();
|
|
assert_eq!(inline.0, 1);
|
|
assert_eq!(inline.3, 1920);
|
|
assert_eq!(inline.4, 1080);
|
|
assert_eq!(inline.5, 60);
|
|
assert_eq!(inline.6, 18_000);
|
|
|
|
preview.shutdown_all();
|
|
}
|
|
|
|
#[test]
|
|
fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() {
|
|
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
|
let mut state = LauncherState::default();
|
|
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
|
|
|
|
apply_preview_profiles(&preview, &state);
|
|
|
|
let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap();
|
|
let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap();
|
|
assert_eq!(inline.0, 1);
|
|
assert_eq!(window.0, 1);
|
|
|
|
preview.shutdown_all();
|
|
}
|
|
|
|
#[test]
|
|
fn mirrored_preview_profile_inherits_the_source_eye_mode() {
|
|
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
|
let mut state = LauncherState::default();
|
|
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
|
|
state.set_capture_size_preset(1, CaptureSizePreset::P720);
|
|
|
|
apply_preview_profiles(&preview, &state);
|
|
|
|
let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap();
|
|
let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap();
|
|
assert_eq!(inline.0, 1);
|
|
assert_eq!(window.0, 1);
|
|
assert_eq!(inline.3, 1280);
|
|
assert_eq!(inline.4, 720);
|
|
assert_eq!(inline.5, 60);
|
|
assert_eq!(inline.6, 12_000);
|
|
assert_eq!(window.3, 1280);
|
|
assert_eq!(window.4, 720);
|
|
assert_eq!(window.5, 60);
|
|
assert_eq!(window.6, 12_000);
|
|
|
|
preview.shutdown_all();
|
|
}
|
|
|
|
#[test]
|
|
fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() {
|
|
let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap();
|
|
let mut state = LauncherState::default();
|
|
state.set_feed_source_preset(0, FeedSourcePreset::Off);
|
|
|
|
apply_preview_profiles(&preview, &state);
|
|
|
|
assert_eq!(
|
|
preview.feed_disabled_for_test(0, PreviewSurface::Inline),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
preview.feed_disabled_for_test(0, PreviewSurface::Window),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
preview.feed_disabled_for_test(1, PreviewSurface::Inline),
|
|
Some(false)
|
|
);
|
|
|
|
preview.shutdown_all();
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, coverage))]
|
|
mod tests {
|
|
use super::{run_gui_launcher, session_preview_active};
|
|
use crate::launcher::state::{CapturePowerStatus, LauncherState};
|
|
|
|
#[test]
|
|
fn coverage_stub_returns_ok() {
|
|
assert!(run_gui_launcher("http://127.0.0.1:50051".to_string()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn session_preview_stays_idle_when_capture_is_forced_off() {
|
|
let mut state = LauncherState::new();
|
|
state.start_remote();
|
|
state.set_capture_power(CapturePowerStatus {
|
|
available: true,
|
|
enabled: false,
|
|
unit: "relay.service".to_string(),
|
|
detail: "inactive/dead".to_string(),
|
|
active_leases: 1,
|
|
mode: "forced-off".to_string(),
|
|
detected_devices: 0,
|
|
});
|
|
|
|
assert!(!session_preview_active(&state, true));
|
|
}
|
|
}
|