ui(launcher): add eye clip/record/save controls and remove gate probe

This commit is contained in:
Brad Stein 2026-04-29 20:52:55 -03:00
parent ea0b17b769
commit 4d7338d1f9
25 changed files with 982 additions and 251 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"anyhow",
"async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"anyhow",
"base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.15.0"
version = "0.15.2"
edition = "2024"
[dependencies]

View File

@ -160,6 +160,9 @@ pub struct SnapshotReport {
pub mic_gain_label: String,
pub upstream_camera: UpstreamStreamTelemetry,
pub upstream_microphone: UpstreamStreamTelemetry,
pub av_delivery_skew_ms: f32,
pub av_enqueue_skew_ms: f32,
pub av_sync_health: String,
pub selected_keyboard: Option<String>,
pub selected_mouse: Option<String>,
pub status: String,

View File

@ -15,6 +15,25 @@ impl SnapshotReport {
let right_stream_caps = latest
.map(|sample| sample.right_stream_caps_label.clone())
.unwrap_or_default();
let upstream_camera = latest
.map(|sample| sample.upstream_camera.clone())
.unwrap_or_default();
let upstream_microphone = latest
.map(|sample| sample.upstream_microphone.clone())
.unwrap_or_default();
let av_delivery_skew_ms =
(upstream_camera.latest_delivery_age_ms - upstream_microphone.latest_delivery_age_ms)
.abs();
let av_enqueue_skew_ms =
(upstream_camera.latest_enqueue_age_ms - upstream_microphone.latest_enqueue_age_ms)
.abs();
let av_sync_health = av_sync_health_label(
&upstream_camera,
&upstream_microphone,
av_delivery_skew_ms,
av_enqueue_skew_ms,
)
.to_string();
Self {
client_version: crate::VERSION.to_string(),
server_version: state.server_version.clone(),
@ -214,12 +233,11 @@ impl SnapshotReport {
},
audio_gain_label: state.audio_gain_label(),
mic_gain_label: state.mic_gain_label(),
upstream_camera: latest
.map(|sample| sample.upstream_camera.clone())
.unwrap_or_default(),
upstream_microphone: latest
.map(|sample| sample.upstream_microphone.clone())
.unwrap_or_default(),
upstream_camera,
upstream_microphone,
av_delivery_skew_ms,
av_enqueue_skew_ms,
av_sync_health,
selected_keyboard: state.devices.keyboard.clone(),
selected_mouse: state.devices.mouse.clone(),
status: state.status_line(),
@ -350,6 +368,28 @@ impl SnapshotReport {
" uplink microphone: {}",
uplink_summary(&self.upstream_microphone)
);
let _ = writeln!(text, "av sync guardrails");
let _ = writeln!(
text,
" health: {} (target <= {:.0}ms skew, preferred <= {:.0}ms)",
self.av_sync_health, AV_SYNC_WATCH_MS, AV_SYNC_GOOD_MS
);
let _ = writeln!(
text,
" delivery skew: {:.1}ms | enqueue skew: {:.1}ms",
self.av_delivery_skew_ms, self.av_enqueue_skew_ms
);
let _ = writeln!(
text,
" camera ages: enqueue={:.1}ms delivery={:.1}ms",
self.upstream_camera.latest_enqueue_age_ms, self.upstream_camera.latest_delivery_age_ms
);
let _ = writeln!(
text,
" microphone ages: enqueue={:.1}ms delivery={:.1}ms",
self.upstream_microphone.latest_enqueue_age_ms,
self.upstream_microphone.latest_delivery_age_ms
);
let _ = writeln!(
text,
" keyboard: {}",
@ -431,6 +471,31 @@ impl SnapshotReport {
}
}
const AV_SYNC_GOOD_MS: f32 = 35.0;
const AV_SYNC_WATCH_MS: f32 = 80.0;
fn av_sync_health_label(
camera: &crate::uplink_telemetry::UpstreamStreamTelemetry,
microphone: &crate::uplink_telemetry::UpstreamStreamTelemetry,
delivery_skew_ms: f32,
enqueue_skew_ms: f32,
) -> &'static str {
if !camera.enabled || !microphone.enabled {
return "incomplete (camera/mic stream disabled)";
}
if !camera.connected || !microphone.connected {
return "incomplete (uplink not fully connected)";
}
let skew = delivery_skew_ms.max(enqueue_skew_ms);
if skew <= AV_SYNC_GOOD_MS {
"stable"
} else if skew <= AV_SYNC_WATCH_MS {
"watch"
} else {
"drift risk"
}
}
fn uplink_summary(stream: &crate::uplink_telemetry::UpstreamStreamTelemetry) -> String {
if !stream.enabled {
return "disabled".to_string();

View File

@ -22,6 +22,33 @@ impl LauncherState {
});
}
pub fn set_server_media_caps(
&mut self,
camera: Option<bool>,
microphone: Option<bool>,
camera_output: Option<String>,
camera_codec: Option<String>,
) {
self.server_camera = camera;
self.server_microphone = microphone;
self.server_camera_output = camera_output.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
self.server_camera_codec = camera_codec.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
}
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode;
self.displays = match view_mode {
@ -424,7 +451,7 @@ impl LauncherState {
pub fn status_line(&self) -> String {
format!(
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
"server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} camera_quality={} mic={} speaker={} channels=cam:{}/mic:{}/audio:{} remote_caps=cam:{:?}/mic:{:?}/output:{:?}/codec:{:?} audio_gain={} mic_gain={} kbd={} mouse={} swap={}",
self.server_available,
match self.routing {
InputRouting::Local => "local",
@ -455,6 +482,10 @@ impl LauncherState {
self.channels.camera,
self.channels.microphone,
self.channels.audio,
self.server_camera,
self.server_microphone,
self.server_camera_output,
self.server_camera_codec,
self.audio_gain_label(),
self.mic_gain_label(),
self.devices.keyboard.as_deref().unwrap_or("all"),

View File

@ -324,6 +324,10 @@ impl Default for ChannelSelection {
pub struct LauncherState {
pub server_available: bool,
pub server_version: Option<String>,
pub server_camera: Option<bool>,
pub server_microphone: Option<bool>,
pub server_camera_output: Option<String>,
pub server_camera_codec: Option<String>,
pub routing: InputRouting,
pub view_mode: ViewMode,
pub displays: [DisplaySurface; 2],
@ -353,6 +357,10 @@ impl Default for LauncherState {
Self {
server_available: false,
server_version: None,
server_camera: None,
server_microphone: None,
server_camera_output: None,
server_camera_codec: None,
routing: InputRouting::Remote,
view_mode: ViewMode::Unified,
displays: [DisplaySurface::Preview, DisplaySurface::Preview],

View File

@ -74,11 +74,11 @@ fn launcher_shell_measures_inside_a_1080p_desktop_budget() {
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1560 && view.window.width() <= 1560,
min_width <= 1600 && view.window.width() <= 1600,
"launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
min_height <= 960 && view.window.height() <= 960,
min_height <= 980 && view.window.height() <= 980,
"launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
@ -106,11 +106,11 @@ fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() {
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1560 && view.window.width() <= 1560,
min_width <= 1600 && view.window.width() <= 1600,
"populated launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
min_height <= 960 && view.window.height() <= 960,
min_height <= 980 && view.window.height() <= 980,
"populated launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
@ -380,9 +380,9 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
assert_eq!(server_version_label(&state), "-");
state.set_server_available(true);
state.set_server_version(Some("0.13.1".to_string()));
state.set_server_version(Some(crate::VERSION.to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Live);
assert_eq!(server_version_label(&state), "v0.13.1");
assert_eq!(server_version_label(&state), format!("v{}", crate::VERSION));
assert_eq!(
server_light_state(&state, true),

View File

@ -5,7 +5,7 @@ use {
super::clipboard::send_clipboard_text_to_remote,
super::device_test::{DeviceTestController, DeviceTestKind},
super::devices::{CameraMode, DeviceCatalog},
super::diagnostics::{PerformanceSample, quality_probe_command},
super::diagnostics::PerformanceSample,
super::launcher_clipboard_control_path,
super::launcher_focus_signal_path,
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode},
@ -40,9 +40,10 @@ use {
serde_json::json,
std::cell::{Cell, RefCell},
std::collections::VecDeque,
std::path::PathBuf,
std::process::Command,
std::rc::Rc,
std::time::{Duration, Instant},
std::time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
include!("ui/message_and_network_state.rs");

View File

@ -268,6 +268,16 @@
state.set_server_available(false);
}
state.set_server_version(caps.server_version.clone());
if probe_result.reachable {
state.set_server_media_caps(
Some(caps.camera),
Some(caps.microphone),
caps.camera_output.clone(),
caps.camera_codec.clone(),
);
} else {
state.set_server_media_caps(None, None, None, None);
}
}
if let (Some(width), Some(height)) =
(caps.eye_width, caps.eye_height)

View File

@ -1,197 +1,590 @@
{
{
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.");
const EYE_RECORD_FPS: u32 = 20;
const EYE_RECORD_FRAME_INTERVAL_MS: u64 = 1000 / EYE_RECORD_FPS as u64;
#[derive(Default)]
struct EyeRecordState {
save_dir_override: Option<PathBuf>,
timer: Option<glib::SourceId>,
frame_dir: Option<PathBuf>,
output_path: Option<PathBuf>,
next_frame_index: u32,
captured_frames: u32,
}
fn eye_slug(title: &str) -> &'static str {
if title.to_ascii_lowercase().contains("left") {
"left-eye"
} else {
"right-eye"
}
}
fn timestamp_slug() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}-{:03}", now.as_secs(), now.subsec_millis())
}
fn expand_home_token(raw: &str) -> PathBuf {
if raw.contains("$HOME") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(raw.replace("$HOME", &home.to_string_lossy()));
}
}
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(raw)
}
fn default_eye_capture_root() -> PathBuf {
if let Some(raw) = std::env::var_os("XDG_PICTURES_DIR") {
let path = expand_home_token(&raw.to_string_lossy());
if !path.as_os_str().is_empty() {
return path.join("Lesavka");
}
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join("Pictures").join("Lesavka");
}
if let Some(profile) = std::env::var_os("USERPROFILE") {
return PathBuf::from(profile).join("Pictures").join("Lesavka");
}
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("Lesavka")
}
fn ensure_eye_capture_root(override_dir: Option<&PathBuf>) -> Result<PathBuf, String> {
let root = override_dir
.cloned()
.unwrap_or_else(default_eye_capture_root);
std::fs::create_dir_all(&root)
.map_err(|err| format!("could not create {}: {err}", root.display()))?;
Ok(root)
}
fn unique_capture_path(root: &PathBuf, stem: &str, ext: &str) -> PathBuf {
let mut candidate = root.join(format!("{stem}.{ext}"));
if !candidate.exists() {
return candidate;
}
for idx in 1..1000 {
candidate = root.join(format!("{stem}-{idx}.{ext}"));
if !candidate.exists() {
break;
}
}
candidate
}
fn current_eye_texture(picture: &gtk::Picture) -> Result<gtk::gdk::Texture, String> {
let paintable = picture
.paintable()
.ok_or_else(|| "no live frame is available yet".to_string())?;
paintable
.downcast::<gtk::gdk::Texture>()
.map_err(|_| "the current frame is not directly exportable".to_string())
}
fn save_texture_png(texture: &gtk::gdk::Texture, output_path: &PathBuf) -> Result<(), String> {
texture
.save_to_png(output_path)
.map_err(|err| format!("could not write {}: {err}", output_path.display()))
}
fn write_record_frame(state: &mut EyeRecordState, picture: &gtk::Picture) -> Result<(), String> {
let frame_dir = state
.frame_dir
.as_ref()
.ok_or_else(|| "recording session is not initialized".to_string())?
.clone();
let texture = current_eye_texture(picture)?;
let frame_path = frame_dir.join(format!("frame-{:06}.png", state.next_frame_index));
save_texture_png(&texture, &frame_path)?;
state.next_frame_index = state.next_frame_index.saturating_add(1);
state.captured_frames = state.captured_frames.saturating_add(1);
Ok(())
}
fn finalize_recording(state: &mut EyeRecordState) -> Result<PathBuf, String> {
let frame_dir = state
.frame_dir
.take()
.ok_or_else(|| "recording frames were not initialized".to_string())?;
let output_path = state
.output_path
.take()
.ok_or_else(|| "recording output path was not initialized".to_string())?;
let captured_frames = state.captured_frames;
state.captured_frames = 0;
state.next_frame_index = 0;
if captured_frames < 2 {
let _ = std::fs::remove_dir_all(&frame_dir);
return Err("need at least two captured frames to build a recording".to_string());
}
let frame_pattern = frame_dir.join("frame-%06d.png");
let encode = Command::new("ffmpeg")
.args([
"-hide_banner",
"-loglevel",
"error",
"-y",
"-framerate",
&EYE_RECORD_FPS.to_string(),
"-i",
&frame_pattern.to_string_lossy(),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
&output_path.to_string_lossy(),
])
.status()
.map_err(|err| format!("ffmpeg is unavailable: {err}"))?;
if !encode.success() {
return Err(format!(
"ffmpeg failed while encoding {}; frame data is still in {}",
output_path.display(),
frame_dir.display()
));
}
let _ = std::fs::remove_dir_all(&frame_dir);
Ok(output_path)
}
{
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 preparing remote paste...");
let clipboard = display.clipboard();
let clipboard_tx = clipboard_tx.clone();
clipboard.read_text_async(None::<&gtk::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 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 preparing remote paste...");
let clipboard = display.clipboard();
let clipboard_tx = clipboard_tx.clone();
clipboard.read_text_async(None::<&gtk::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 result = send_clipboard_text_to_remote(&server_addr, &text)
.map_err(|err| err.to_string());
let _ = clipboard_tx.send(ClipboardMessage::Finished(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
}
}
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}"
))));
}
});
});
}
for monitor_id in 0..2 {
let pane = widgets.display_panes[monitor_id].clone();
let widgets_for_ui = widgets.clone();
let save_state = Rc::new(RefCell::new(EyeRecordState::default()));
{
let pane = pane.clone();
let widgets = widgets_for_ui.clone();
let save_state = Rc::clone(&save_state);
let window_for_save = window.clone();
pane.save_button.connect_clicked(move |_| {
let chooser = gtk::FileChooserNative::new(
Some("Choose Eye Capture Folder"),
Some(&window_for_save),
gtk::FileChooserAction::SelectFolder,
Some("Select"),
Some("Cancel"),
);
chooser.set_modal(true);
let save_state = Rc::clone(&save_state);
let widgets = widgets.clone();
let eye_name = pane.title.clone();
chooser.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
if let Some(folder) = dialog.file().and_then(|file| file.path()) {
save_state.borrow_mut().save_dir_override = Some(folder.clone());
widgets.status_label.set_text(&format!(
"{} saves now go to {}.",
eye_name,
folder.display()
));
} else {
widgets.status_label.set_text(
"Capture folder selection did not return a filesystem path.",
);
}
});
});
}
{
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.");
}
dialog.destroy();
});
}
chooser.show();
});
}
{
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 pane = pane.clone();
let widgets = widgets_for_ui.clone();
let save_state = Rc::clone(&save_state);
pane.clip_button.connect_clicked(move |_| {
let root = {
let borrowed = save_state.borrow();
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
Ok(path) => path,
Err(err) => {
widgets
.status_label
.set_text(&format!("Could not prepare capture folder: {err}"));
return;
}
}
};
{
let widgets = widgets.clone();
widgets.console_level_combo.connect_changed(move |combo| {
let level = combo
.active_id()
.as_deref()
.and_then(ConsoleLogLevel::from_id)
.unwrap_or_default();
*widgets.session_log_level.borrow_mut() = level;
let stem = format!("{}-clip-{}", eye_slug(&pane.title), timestamp_slug());
let clip_path = unique_capture_path(&root, &stem, "png");
match current_eye_texture(&pane.picture)
.and_then(|texture| save_texture_png(&texture, &clip_path))
{
Ok(()) => {
widgets.status_label.set_text(&format!(
"{} clip saved to {}.",
pane.title,
clip_path.display()
));
}
Err(err) => {
widgets
.status_label
.set_text(&format!("{} clip failed: {err}", pane.title));
}
}
});
}
{
let pane = pane.clone();
let widgets = widgets_for_ui.clone();
let save_state = Rc::clone(&save_state);
let record_button = pane.record_button.clone();
record_button.connect_clicked(move |button| {
if save_state.borrow().timer.is_some() {
let mut state = save_state.borrow_mut();
if let Some(timer) = state.timer.take() {
timer.remove();
}
drop(state);
let mut state = save_state.borrow_mut();
match finalize_recording(&mut state) {
Ok(output) => {
button.set_label("Record");
widgets.status_label.set_text(&format!(
"{} recording saved to {}.",
pane.title,
output.display()
));
}
Err(err) => {
button.set_label("Record");
widgets.status_label.set_text(&format!(
"{} recording stop failed: {err}",
pane.title,
));
}
}
return;
}
let root = {
let borrowed = save_state.borrow();
match ensure_eye_capture_root(borrowed.save_dir_override.as_ref()) {
Ok(path) => path,
Err(err) => {
widgets
.status_label
.set_text(&format!("Could not prepare capture folder: {err}"));
return;
}
}
};
let recording_stem = format!("{}-record-{}", eye_slug(&pane.title), timestamp_slug());
let output_path = unique_capture_path(&root, &recording_stem, "mp4");
let frame_dir = root.join(format!("{}.frames", recording_stem));
if let Err(err) = std::fs::create_dir_all(&frame_dir) {
widgets.status_label.set_text(&format!(
"Console now shows {} relay logs and higher.",
level.label()
"{} record failed creating frame cache {}: {err}",
pane.title,
frame_dir.display()
));
});
}
return;
}
{
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 mut state = save_state.borrow_mut();
state.frame_dir = Some(frame_dir);
state.output_path = Some(output_path.clone());
state.next_frame_index = 0;
state.captured_frames = 0;
}
{
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);
let pane_for_tick = pane.clone();
let widgets_for_tick = widgets.clone();
let save_state_for_tick = Rc::clone(&save_state);
let timer = glib::timeout_add_local(
Duration::from_millis(EYE_RECORD_FRAME_INTERVAL_MS),
move || {
let mut state = save_state_for_tick.borrow_mut();
if state.frame_dir.is_none() {
return glib::ControlFlow::Break;
}
if let Err(err) = write_record_frame(&mut state, &pane_for_tick.picture) {
widgets_for_tick.status_label.set_text(&format!(
"{} recording frame skipped: {err}",
pane_for_tick.title
));
}
glib::ControlFlow::Continue
},
);
save_state.borrow_mut().timer = Some(timer);
button.set_label("Stop");
widgets.status_label.set_text(&format!(
"Recording {}... press Stop to finish.",
pane.title
));
});
}
}
{
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("Session log moved into its own window.");
});
.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();
let server_entry = server_entry.clone();
let server_addr_fallback = Rc::clone(&server_addr);
let widgets_for_click = widgets.clone();
widgets.uac_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 UAC recovery (USB gadget rebuild) 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(
"UAC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate audio.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("UAC 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(
"UAC recovery ended unexpectedly before the relay answered.",
);
glib::ControlFlow::Break
}
});
});
}
{
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.uvc_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 UVC recovery (USB gadget rebuild) 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(
"UVC recovery requested via USB gadget rebuild. Give the host a few seconds to re-enumerate webcam video.",
);
glib::ControlFlow::Break
}
Ok(Err(err)) => {
widgets
.status_label
.set_text(&format!("UVC 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(
"UVC 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_level_combo.connect_changed(move |combo| {
let level = combo
.active_id()
.as_deref()
.and_then(ConsoleLogLevel::from_id)
.unwrap_or_default();
*widgets.session_log_level.borrow_mut() = level;
widgets.status_label.set_text(&format!(
"Console now shows {} relay logs and higher.",
level.label()
));
});
}
{
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.");
});
}
}

View File

@ -41,6 +41,12 @@ pub fn build_launcher_view(
routing_value,
gpio_light,
gpio_value,
usb_light,
usb_value,
uac_light,
uac_value,
uvc_light,
uvc_value,
shortcut_value,
} = include!("ui_components/build_shell.rs");
@ -79,8 +85,9 @@ pub fn build_launcher_view(
server_entry,
start_button,
clipboard_button,
probe_button,
usb_recover_button,
uac_recover_button,
uvc_recover_button,
power_auto_button,
power_on_button,
power_off_button,

View File

@ -110,6 +110,12 @@
routing_value,
gpio_light,
gpio_value,
usb_light,
usb_value,
uac_light,
uac_value,
uvc_light,
uvc_value,
shortcut_value,
},
power_detail,
@ -136,8 +142,9 @@
mic_gain_value: mic_gain_value.clone(),
input_toggle_button: input_toggle_button.clone(),
clipboard_button: clipboard_button.clone(),
probe_button: probe_button.clone(),
usb_recover_button: usb_recover_button.clone(),
uac_recover_button: uac_recover_button.clone(),
uvc_recover_button: uvc_recover_button.clone(),
device_refresh_button: device_refresh_button.clone(),
swap_key_button: swap_key_button.clone(),
camera_test_button: camera_test_button.clone(),

View File

@ -11,6 +11,12 @@ struct LauncherShellContext {
routing_value: gtk::Label,
gpio_light: gtk::Box,
gpio_value: gtk::Label,
usb_light: gtk::Box,
usb_value: gtk::Label,
uac_light: gtk::Box,
uac_value: gtk::Label,
uvc_light: gtk::Box,
uvc_value: gtk::Label,
shortcut_value: gtk::Label,
}
@ -49,8 +55,9 @@ struct OperationsRailContext {
server_entry: gtk::Entry,
start_button: gtk::Button,
clipboard_button: gtk::Button,
probe_button: gtk::Button,
usb_recover_button: gtk::Button,
uac_recover_button: gtk::Button,
uvc_recover_button: gtk::Button,
power_auto_button: gtk::Button,
power_on_button: gtk::Button,
power_off_button: gtk::Button,

View File

@ -17,14 +17,42 @@
start_button.add_css_class("suggested-action");
relay_grid.attach(&start_button, 2, 0, 1, 1);
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
let probe_button = rail_button("Gate Probe", "Copy quality probe.");
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB.");
relay_grid.attach(&clipboard_button, 0, 1, 1, 1);
relay_grid.attach(&probe_button, 1, 1, 1, 1);
relay_grid.attach(&usb_recover_button, 2, 1, 1, 1);
connection_body.append(&relay_grid);
let recovery_heading = gtk::Label::new(Some("Recovery"));
recovery_heading.add_css_class("subgroup-title");
recovery_heading.set_halign(gtk::Align::Start);
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_row.set_hexpand(true);
recovery_heading.set_width_chars(10);
recovery_row.append(&recovery_heading);
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_buttons.set_hexpand(true);
recovery_buttons.set_homogeneous(true);
let usb_recover_button = rail_button("Recover USB", "Re-enumerate remote USB gadget.");
let uac_recover_button = rail_button("Recover UAC", "Rebuild remote USB audio function.");
let uvc_recover_button = rail_button("Recover UVC", "Rebuild remote USB webcam function.");
recovery_buttons.append(&usb_recover_button);
recovery_buttons.append(&uac_recover_button);
recovery_buttons.append(&uvc_recover_button);
recovery_row.append(&recovery_buttons);
connection_body.append(&recovery_row);
let tools_heading = gtk::Label::new(Some("Tools"));
tools_heading.add_css_class("subgroup-title");
tools_heading.set_halign(gtk::Align::Start);
let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
tools_row.set_hexpand(true);
tools_heading.set_width_chars(10);
tools_row.append(&tools_heading);
let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
tools_buttons.set_hexpand(true);
tools_buttons.set_homogeneous(true);
let clipboard_button = rail_button("Clipboard", "Type clipboard remotely.");
tools_buttons.append(&clipboard_button);
tools_row.append(&tools_buttons);
connection_body.append(&tools_row);
connection_body.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
let power_heading = gtk::Label::new(Some("GPIO Power"));
power_heading.add_css_class("subgroup-title");
@ -117,7 +145,7 @@
let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)
.child(&diagnostics_shell)
.build();
diagnostics_scroll.set_propagate_natural_width(false);
@ -125,7 +153,7 @@
diagnostics_body.append(&diagnostics_scroll);
operations.append(&diagnostics_panel);
let (console_panel, console_body) = build_panel("Session Console");
let (console_panel, console_body) = build_panel("Log");
console_panel.set_vexpand(true);
console_panel.set_valign(gtk::Align::Fill);
console_body.set_vexpand(true);
@ -173,7 +201,7 @@
let log_scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.min_content_height(SIDE_LOG_MIN_HEIGHT)
.min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)
.child(&session_log_view)
.build();
log_scroll.set_propagate_natural_width(false);
@ -203,8 +231,9 @@
server_entry,
start_button,
clipboard_button,
probe_button,
usb_recover_button,
uac_recover_button,
uvc_recover_button,
power_auto_button,
power_on_button,
power_off_button,

View File

@ -43,19 +43,34 @@
brand_box.append(&brand_row);
hero.append(&brand_box);
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let chips = gtk::Box::new(gtk::Orientation::Horizontal, 4);
chips.set_halign(gtk::Align::End);
chips.set_hexpand(true);
let (relay_chip, relay_light, relay_value) = build_status_chip_with_light("Server", "");
let (routing_chip, routing_light, routing_value) =
build_status_chip_with_light("Inputs", "Local");
let (gpio_chip, gpio_light, gpio_value) = build_status_chip_with_light("GPIO", "Unknown");
let (usb_chip, usb_light, usb_value) = build_status_chip_with_light("USB", "Unknown");
let (uac_chip, uac_light, uac_value) = build_status_chip_with_light("UAC", "Unknown");
let (uvc_chip, uvc_light, uvc_value) = build_status_chip_with_light("UVC", "Unknown");
let (shortcut_chip, shortcut_value) = build_status_chip("Swap Key", "Pause");
chips.append(&relay_chip);
chips.append(&routing_chip);
chips.append(&gpio_chip);
chips.append(&usb_chip);
chips.append(&uac_chip);
chips.append(&uvc_chip);
chips.append(&shortcut_chip);
hero.append(&chips);
let chips_shell = gtk::ScrolledWindow::builder()
.hexpand(true)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.vscrollbar_policy(gtk::PolicyType::Never)
.child(&chips)
.build();
chips_shell.set_has_frame(false);
chips_shell.set_propagate_natural_width(false);
chips_shell.set_min_content_width(0);
hero.append(&chips_shell);
root.append(&hero);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 5);
@ -106,6 +121,12 @@
routing_value,
gpio_light,
gpio_value,
usb_light,
usb_value,
uac_light,
uac_value,
uvc_light,
uvc_value,
shortcut_value,
}
}

View File

@ -4,17 +4,32 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
root.set_hexpand(true);
root.set_vexpand(false);
let stream_status = gtk::Label::new(Some("Preview pending"));
stream_status.add_css_class("status-line");
stream_status.add_css_class("eye-inline-status");
stream_status.set_halign(gtk::Align::Center);
stream_status.set_valign(gtk::Align::Center);
stream_status.set_hexpand(true);
stream_status.set_xalign(0.5);
stream_status.set_ellipsize(pango::EllipsizeMode::End);
stream_status.set_single_line_mode(true);
stream_status.set_width_chars(10);
stream_status.set_max_width_chars(18);
stream_status.set_tooltip_text(Some("Eye stream status."));
let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
header.set_hexpand(true);
let title_label = gtk::Label::new(Some(title));
title_label.add_css_class("title-4");
title_label.set_halign(gtk::Align::Start);
title_label.set_hexpand(true);
title_label.set_hexpand(false);
let capture_label = gtk::Label::new(Some(capture_path));
capture_label.add_css_class("dim-label");
capture_label.set_halign(gtk::Align::End);
capture_label.set_hexpand(false);
capture_label.set_ellipsize(pango::EllipsizeMode::Start);
header.append(&title_label);
header.append(&stream_status);
header.append(&capture_label);
root.append(&header);
@ -138,21 +153,19 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
breakout_combo.set_size_request(0, -1);
breakout_combo.set_hexpand(true);
let action_button = gtk::Button::with_label("Break Out");
stabilize_button(&action_button, 96);
action_button.set_halign(gtk::Align::End);
let clip_button = gtk::Button::with_label("Clip");
stabilize_button(&clip_button, 72);
clip_button.set_tooltip_text(Some("Capture a still image for this eye."));
let record_button = gtk::Button::with_label("Record");
stabilize_button(&record_button, 84);
record_button.set_tooltip_text(Some("Record this eye feed until you stop."));
let save_button = gtk::Button::with_label("Save");
stabilize_button(&save_button, 72);
save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings."));
let stream_status = gtk::Label::new(Some("Preview pending"));
stream_status.add_css_class("status-line");
stream_status.add_css_class("eye-inline-status");
stream_status.set_halign(gtk::Align::Fill);
stream_status.set_valign(gtk::Align::Center);
stream_status.set_hexpand(true);
stream_status.set_ellipsize(pango::EllipsizeMode::End);
stream_status.set_single_line_mode(true);
stream_status.set_width_chars(10);
stream_status.set_max_width_chars(16);
stream_status.set_tooltip_text(Some("Eye stream status."));
let action_button = gtk::Button::with_label("Break Out");
stabilize_button(&action_button, 90);
action_button.set_halign(gtk::Align::End);
let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6);
footer_shell.set_vexpand(false);
@ -164,13 +177,19 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
let feed_row = build_inline_combo_row("Feed", &feed_source_combo, 6);
let capture_row = build_inline_combo_row("Capture", &capture_resolution_combo, 6);
let breakout_row = build_inline_combo_row("Display", &breakout_combo, 6);
let capture_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
capture_actions.set_hexpand(true);
capture_actions.set_homogeneous(true);
capture_actions.append(&clip_button);
capture_actions.append(&record_button);
capture_actions.append(&save_button);
feed_row.set_hexpand(true);
capture_row.set_hexpand(true);
breakout_row.set_hexpand(true);
controls_grid.attach(&feed_row, 0, 0, 1, 1);
controls_grid.attach(&capture_row, 1, 0, 2, 1);
controls_grid.attach(&breakout_row, 0, 1, 1, 1);
controls_grid.attach(&stream_status, 1, 1, 1, 1);
controls_grid.attach(&capture_actions, 1, 1, 1, 1);
controls_grid.attach(&action_button, 2, 1, 1, 1);
footer_shell.append(&controls_grid);
root.append(&footer_shell);
@ -185,6 +204,9 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
feed_source_combo,
capture_resolution_combo,
breakout_combo,
clip_button,
record_button,
save_button,
action_button,
preview_binding: Rc::new(RefCell::new(None)),
title: title.to_string(),

View File

@ -41,11 +41,11 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
background: rgba(91, 179, 162, 0.12);
border: 1px solid rgba(91, 179, 162, 0.25);
border-radius: 999px;
padding: 6px 9px;
padding: 4px 6px;
}
box.status-light {
min-width: 10px;
min-height: 10px;
min-width: 9px;
min-height: 9px;
border-radius: 999px;
background: rgba(214, 81, 81, 0.92);
}
@ -65,11 +65,11 @@ pub fn install_css(window: &gtk::ApplicationWindow) {
background: rgba(227, 201, 73, 0.95);
}
label.status-chip-label {
font-size: 0.78rem;
font-size: 0.74rem;
opacity: 0.72;
}
label.status-chip-value {
font-size: 0.93rem;
font-size: 0.88rem;
font-weight: 700;
}
box.display-card {

View File

@ -6,6 +6,12 @@ pub struct SummaryWidgets {
pub routing_value: gtk::Label,
pub gpio_light: gtk::Box,
pub gpio_value: gtk::Label,
pub usb_light: gtk::Box,
pub usb_value: gtk::Label,
pub uac_light: gtk::Box,
pub uac_value: gtk::Label,
pub uvc_light: gtk::Box,
pub uvc_value: gtk::Label,
pub shortcut_value: gtk::Label,
}
@ -20,6 +26,9 @@ pub struct DisplayPaneWidgets {
pub feed_source_combo: gtk::ComboBoxText,
pub capture_resolution_combo: gtk::ComboBoxText,
pub breakout_combo: gtk::ComboBoxText,
pub clip_button: gtk::Button,
pub record_button: gtk::Button,
pub save_button: gtk::Button,
pub action_button: gtk::Button,
pub preview_binding: Rc<RefCell<Option<PreviewBinding>>>,
pub title: String,
@ -140,8 +149,9 @@ pub struct LauncherWidgets {
pub mic_gain_value: gtk::Label,
pub input_toggle_button: gtk::Button,
pub clipboard_button: gtk::Button,
pub probe_button: gtk::Button,
pub usb_recover_button: gtk::Button,
pub uac_recover_button: gtk::Button,
pub uvc_recover_button: gtk::Button,
pub device_refresh_button: gtk::Button,
pub swap_key_button: gtk::Button,
pub camera_test_button: gtk::Button,
@ -199,3 +209,5 @@ const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 400;
const EYE_PREVIEW_MIN_HEIGHT: i32 = 315;
const EYE_PREVIEW_MIN_WIDTH: i32 = 560;
const SIDE_LOG_MIN_HEIGHT: i32 = 124;
const SIDE_LOG_RECOVERY_BUDGET_SPLIT: i32 = 63;
const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;

View File

@ -248,8 +248,11 @@ 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());
let preview_ready = pane.preview_binding.borrow().is_some();
pane.action_button.set_sensitive(preview_ready);
pane.clip_button.set_sensitive(preview_ready);
pane.record_button.set_sensitive(preview_ready);
pane.save_button.set_sensitive(true);
match surface {
DisplaySurface::Preview => {
pane.stack.set_visible_child_name("preview");
@ -262,7 +265,7 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface)
}
DisplaySurface::Window => {
pane.stack.set_visible_child_name("placeholder");
pane.action_button.set_label("Return To Preview");
pane.action_button.set_label("Return");
pane.stream_status.set_text("Streaming in its own window");
pane.preview_placeholder.set_visible(false);
}

View File

@ -117,6 +117,63 @@ fn server_version_label(state: &LauncherState) -> String {
}
}
fn recovery_usb_health(state: &LauncherState) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
return (StatusLightState::Live, "Enumerated".to_string());
}
if let Some(output) = state.server_camera_output.as_deref() {
return (StatusLightState::Warning, output.to_ascii_uppercase());
}
if state.server_camera.is_none() && state.server_microphone.is_none() {
return (StatusLightState::Caution, "Unknown".to_string());
}
if state.server_camera == Some(false) && state.server_microphone == Some(false) {
return (StatusLightState::Warning, "Missing".to_string());
}
(StatusLightState::Caution, "Partial".to_string())
}
fn recovery_uac_health(state: &LauncherState) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
match state.server_microphone {
Some(true) => (StatusLightState::Live, "Ready".to_string()),
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
None => (StatusLightState::Caution, "Unknown".to_string()),
}
}
fn recovery_uvc_health(state: &LauncherState) -> (StatusLightState, String) {
if !state.server_available {
return (StatusLightState::Idle, "Offline".to_string());
}
let codec = state
.server_camera_codec
.as_deref()
.map(|value| value.to_ascii_uppercase())
.unwrap_or_else(|| "READY".to_string());
match state.server_camera {
Some(true) => {
if matches!(state.server_camera_output.as_deref(), Some("uvc")) {
(StatusLightState::Live, codec)
} else {
let value = state
.server_camera_output
.as_deref()
.map(|output| format!("{}/{}", output.to_ascii_uppercase(), codec))
.unwrap_or(codec);
(StatusLightState::Caution, value)
}
}
Some(false) => (StatusLightState::Warning, "Missing".to_string()),
None => (StatusLightState::Caution, "Unknown".to_string()),
}
}
fn gpio_light_state(power: &CapturePowerStatus) -> StatusLightState {
if !power.available || !power.enabled {
return StatusLightState::Idle;

View File

@ -73,6 +73,16 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.shortcut_value
.set_text(&toggle_key_label(&state.swap_key));
let (usb_state, usb_value) = recovery_usb_health(state);
set_status_light(&widgets.summary.usb_light, usb_state);
widgets.summary.usb_value.set_text(&usb_value);
let (uac_state, uac_value) = recovery_uac_health(state);
set_status_light(&widgets.summary.uac_light, uac_state);
widgets.summary.uac_value.set_text(&uac_value);
let (uvc_state, uvc_value) = recovery_uvc_health(state);
set_status_light(&widgets.summary.uvc_light, uvc_state);
widgets.summary.uvc_value.set_text(&uvc_value);
widgets
.power_detail
.set_text(&capture_power_detail(&state.capture_power));
@ -122,10 +132,15 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
"Start relay and previews."
}));
widgets.clipboard_button.set_sensitive(relay_live);
widgets.probe_button.set_sensitive(true);
widgets
.usb_recover_button
.set_sensitive(state.server_available);
widgets
.uac_recover_button
.set_sensitive(state.server_available);
widgets
.uvc_recover_button
.set_sensitive(state.server_available);
widgets.device_refresh_button.set_sensitive(!relay_live);
widgets
.camera_combo

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.15.0"
version = "0.15.2"
edition = "2024"
build = "build.rs"

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.15.0"
version = "0.15.2"
edition = "2024"
autobins = false

View File

@ -89,13 +89,27 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width()
assert!(!UI_LAYOUT_SRC.contains("root.append(&stream_status);"));
assert!(UI_LAYOUT_SRC.contains("stream_status.add_css_class(\"eye-inline-status\");"));
assert!(
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
< source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
source_index("header.append(&title_label);")
< source_index("header.append(&stream_status);")
);
assert!(
source_index("controls_grid.attach(&stream_status, 1, 1, 1, 1);")
source_index("header.append(&stream_status);")
< source_index("header.append(&capture_label);")
);
assert!(
source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);")
< source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
);
assert!(
source_index("controls_grid.attach(&capture_actions, 1, 1, 1, 1);")
< source_index("controls_grid.attach(&action_button, 2, 1, 1, 1);")
);
assert!(UI_LAYOUT_SRC.contains("let clip_button = gtk::Button::with_label(\"Clip\");"));
assert!(UI_LAYOUT_SRC.contains("let record_button = gtk::Button::with_label(\"Record\");"));
assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"Save\");"));
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&clip_button);"));
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&record_button);"));
assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&save_button);"));
}
#[test]
@ -197,13 +211,20 @@ fn device_testing_keeps_webcam_and_mic_playback_compact() {
#[test]
fn operations_column_fills_height_and_splits_extra_space_between_logs() {
assert_eq!(const_i32("SIDE_LOG_MIN_HEIGHT"), 124);
assert_eq!(const_i32("SIDE_LOG_RECOVERY_BUDGET_SPLIT"), 63);
assert!(UI_LAYOUT_SRC.contains("operations.set_vexpand(true);"));
assert!(UI_LAYOUT_SRC.contains("operations.set_valign(gtk::Align::Fill);"));
assert!(UI_LAYOUT_SRC.contains("diagnostics_panel.set_vexpand(true);"));
assert!(UI_LAYOUT_SRC.contains("console_panel.set_vexpand(true);"));
assert!(
UI_LAYOUT_SRC.contains(
"const SIDE_LOG_RECOVERY_MIN_HEIGHT: i32 = SIDE_LOG_MIN_HEIGHT - SIDE_LOG_RECOVERY_BUDGET_SPLIT;"
),
"relay-control growth should reduce Diagnostics and Log minima through a shared split budget"
);
assert_eq!(
UI_LAYOUT_SRC
.matches(".min_content_height(SIDE_LOG_MIN_HEIGHT)")
.matches(".min_content_height(SIDE_LOG_RECOVERY_MIN_HEIGHT)")
.count(),
2
);
@ -260,12 +281,25 @@ fn relay_controls_keep_connect_inline_with_server_entry() {
assert!(UI_LAYOUT_SRC.contains("let start_button = rail_button(\"Connect\""));
assert!(UI_LAYOUT_SRC.contains("pub(crate) fn set_rail_button_label("));
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&start_button, 2, 0, 1, 1);"));
assert!(UI_LAYOUT_SRC.contains("let recovery_heading = gtk::Label::new(Some(\"Recovery\"));"));
assert!(UI_LAYOUT_SRC.contains("let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.set_homogeneous(true);"));
assert!(UI_LAYOUT_SRC.contains("recovery_heading.set_width_chars(10);"));
assert!(UI_LAYOUT_SRC.contains("let tools_heading = gtk::Label::new(Some(\"Tools\"));"));
assert!(UI_LAYOUT_SRC.contains("let tools_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("let tools_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);"));
assert!(UI_LAYOUT_SRC.contains("tools_buttons.set_homogeneous(true);"));
assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(10);"));
assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\""));
assert!(UI_LAYOUT_SRC.contains("let probe_button = rail_button(\"Gate Probe\""));
assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(\"Recover USB\""));
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&clipboard_button, 0, 1, 1, 1);"));
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&probe_button, 1, 1, 1, 1);"));
assert!(UI_LAYOUT_SRC.contains("relay_grid.attach(&usb_recover_button, 2, 1, 1, 1);"));
assert!(UI_LAYOUT_SRC.contains("let uac_recover_button = rail_button(\"Recover UAC\""));
assert!(UI_LAYOUT_SRC.contains("let uvc_recover_button = rail_button(\"Recover UVC\""));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&usb_recover_button);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uac_recover_button);"));
assert!(UI_LAYOUT_SRC.contains("recovery_buttons.append(&uvc_recover_button);"));
assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&clipboard_button);"));
assert!(!UI_LAYOUT_SRC.contains("Gate Probe"));
assert!(UI_LAYOUT_SRC.contains("text.set_ellipsize(pango::EllipsizeMode::End);"));
assert!(
source_index("relay_grid.attach(&server_entry, 0, 0, 2, 1);")

View File

@ -166,9 +166,15 @@ fn launcher_utility_buttons_still_bind_to_live_actions() {
assert!(UI_SRC.contains("widgets.clipboard_button.connect_clicked"));
assert!(UI_SRC.contains("send_clipboard_text_to_remote(&server_addr, &text)"));
assert!(UI_SRC.contains("Start the relay before sending clipboard text."));
assert!(UI_SRC.contains("widgets.probe_button.connect_clicked"));
assert!(UI_SRC.contains("clipboard.set_text(quality_probe_command())"));
assert!(UI_SRC.contains("Quality probe command copied to the local clipboard."));
assert!(!UI_SRC.contains("widgets.probe_button.connect_clicked"));
assert!(!UI_SRC.contains("Quality probe command copied to the local clipboard."));
assert!(UI_SRC.contains("pane.save_button.connect_clicked"));
assert!(UI_SRC.contains("Choose Eye Capture Folder"));
assert!(UI_SRC.contains("pane.clip_button.connect_clicked"));
assert!(UI_SRC.contains("clip saved to"));
assert!(UI_SRC.contains("record_button.connect_clicked"));
assert!(UI_SRC.contains("recording saved to"));
assert!(UI_SRC.contains("Recording {}... press Stop to finish."));
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)"));
assert!(UI_SRC.contains("USB gadget recovery requested."));