diff --git a/Cargo.lock b/Cargo.lock index ae51993..4089721 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index 82c3ed7..d36e7a8 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.15.0" +version = "0.15.2" edition = "2024" [dependencies] diff --git a/client/src/launcher/diagnostics/diagnostics_models.rs b/client/src/launcher/diagnostics/diagnostics_models.rs index edf7ee1..62b136b 100644 --- a/client/src/launcher/diagnostics/diagnostics_models.rs +++ b/client/src/launcher/diagnostics/diagnostics_models.rs @@ -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, pub selected_mouse: Option, pub status: String, diff --git a/client/src/launcher/diagnostics/snapshot_report.rs b/client/src/launcher/diagnostics/snapshot_report.rs index 0391adf..faf9c71 100644 --- a/client/src/launcher/diagnostics/snapshot_report.rs +++ b/client/src/launcher/diagnostics/snapshot_report.rs @@ -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(); diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 6baa8a2..5238091 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -22,6 +22,33 @@ impl LauncherState { }); } + pub fn set_server_media_caps( + &mut self, + camera: Option, + microphone: Option, + camera_output: Option, + camera_codec: Option, + ) { + 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"), diff --git a/client/src/launcher/state/selection_models.rs b/client/src/launcher/state/selection_models.rs index 26cd6bf..f395d11 100644 --- a/client/src/launcher/state/selection_models.rs +++ b/client/src/launcher/state/selection_models.rs @@ -324,6 +324,10 @@ impl Default for ChannelSelection { pub struct LauncherState { pub server_available: bool, pub server_version: Option, + pub server_camera: Option, + pub server_microphone: Option, + pub server_camera_output: Option, + pub server_camera_codec: Option, 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], diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index a65de02..8675f5f 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -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), diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 9948a24..656eed2 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -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"); diff --git a/client/src/launcher/ui/runtime_poll.rs b/client/src/launcher/ui/runtime_poll.rs index c33ca06..1717e35 100644 --- a/client/src/launcher/ui/runtime_poll.rs +++ b/client/src/launcher/ui/runtime_poll.rs @@ -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) diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 89e61ad..1d31679 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -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, + timer: Option, + frame_dir: Option, + output_path: Option, + 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 { + 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: >k::Picture) -> Result { + let paintable = picture + .paintable() + .ok_or_else(|| "no live frame is available yet".to_string())?; + paintable + .downcast::() + .map_err(|_| "the current frame is not directly exportable".to_string()) + } + + fn save_texture_png(texture: >k::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: >k::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 { + 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::<>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 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::<>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 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."); + }); + } } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 1e5f189..4b5cb90 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -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, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 201ad98..2b950c8 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -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(), diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index 3f61642..993cdda 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -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, diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index b0ced7f..3864a9b 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -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(>k::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, diff --git a/client/src/launcher/ui_components/build_shell.rs b/client/src/launcher/ui_components/build_shell.rs index 8a42a93..3ba1bae 100644 --- a/client/src/launcher/ui_components/build_shell.rs +++ b/client/src/launcher/ui_components/build_shell.rs @@ -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, } } diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index ed6efef..8213a8d 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -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(), diff --git a/client/src/launcher/ui_components/style.rs b/client/src/launcher/ui_components/style.rs index c215f2e..f68ddb7 100644 --- a/client/src/launcher/ui_components/style.rs +++ b/client/src/launcher/ui_components/style.rs @@ -41,11 +41,11 @@ pub fn install_css(window: >k::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: >k::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 { diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index a9cae17..aa23c1d 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -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>>, 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; diff --git a/client/src/launcher/ui_runtime/display_popouts.rs b/client/src/launcher/ui_runtime/display_popouts.rs index 8548b5a..021c9ca 100644 --- a/client/src/launcher/ui_runtime/display_popouts.rs +++ b/client/src/launcher/ui_runtime/display_popouts.rs @@ -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); } diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 940e25f..0e3eb09 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -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; diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index 5393a33..6247670 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -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 diff --git a/common/Cargo.toml b/common/Cargo.toml index 7603966..54eda69 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.15.0" +version = "0.15.2" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 9b0ef11..dc30ae2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.15.0" +version = "0.15.2" edition = "2024" autobins = false diff --git a/testing/tests/client_launcher_layout_contract.rs b/testing/tests/client_launcher_layout_contract.rs index ef6cce9..0a1f140 100644 --- a/testing/tests/client_launcher_layout_contract.rs +++ b/testing/tests/client_launcher_layout_contract.rs @@ -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);") diff --git a/testing/tests/client_launcher_runtime_contract.rs b/testing/tests/client_launcher_runtime_contract.rs index 33b1e36..12f1b57 100644 --- a/testing/tests/client_launcher_runtime_contract.rs +++ b/testing/tests/client_launcher_runtime_contract.rs @@ -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."));