diff --git a/Cargo.lock b/Cargo.lock index a452e57..3e710e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.23.0" +version = "0.23.1" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.23.0" +version = "0.23.1" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.23.0" +version = "0.23.1" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 51e7dcd..b8da5ab 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.23.0" +version = "0.23.1" edition = "2024" [dependencies] diff --git a/client/src/input/inputs.rs b/client/src/input/inputs.rs index e39cc43..4a768cf 100644 --- a/client/src/input/inputs.rs +++ b/client/src/input/inputs.rs @@ -68,6 +68,13 @@ pub struct InputAggregator { #[cfg(not(coverage))] clipboard_control_marker: u128, #[cfg(not(coverage))] + wake_control_path: Option, + #[cfg(not(coverage))] + last_wake_request_raw: Option, + wake_interval: Option, + last_hid_relayed_at: Instant, + wake_nonce: u64, + #[cfg(not(coverage))] routing_state_path: Option, #[cfg(not(coverage))] published_remote_capture: Option, diff --git a/client/src/input/inputs/construction_and_scan.rs b/client/src/input/inputs/construction_and_scan.rs index 2409f64..998bd1c 100644 --- a/client/src/input/inputs/construction_and_scan.rs +++ b/client/src/input/inputs/construction_and_scan.rs @@ -26,6 +26,16 @@ impl InputAggregator { #[cfg(not(coverage))] let clipboard_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_CLIPBOARD_CONTROL"); + #[cfg(not(coverage))] + let wake_control_path = launcher_routing_path_from_env("LESAVKA_LAUNCHER_WAKE_CONTROL"); + #[cfg(not(coverage))] + let last_wake_request_raw = wake_control_path + .as_deref() + .and_then(read_launcher_control_snapshot); + #[cfg(not(coverage))] + let wake_interval = last_wake_request_raw.as_deref().and_then(parse_wake_interval); + #[cfg(coverage)] + let wake_interval = None; let remote_failsafe_timeout = remote_failsafe_timeout_from_env(); Self { kbd_tx, @@ -74,6 +84,13 @@ impl InputAggregator { #[cfg(not(coverage))] clipboard_control_path, #[cfg(not(coverage))] + wake_control_path, + #[cfg(not(coverage))] + last_wake_request_raw, + wake_interval, + last_hid_relayed_at: Instant::now(), + wake_nonce: 0, + #[cfg(not(coverage))] routing_state_path, #[cfg(not(coverage))] published_remote_capture: None, diff --git a/client/src/input/inputs/routing_state.rs b/client/src/input/inputs/routing_state.rs index 65310dc..18d6e55 100644 --- a/client/src/input/inputs/routing_state.rs +++ b/client/src/input/inputs/routing_state.rs @@ -156,6 +156,7 @@ impl InputAggregator { } emit_live_keyboard_report(&self.kbd_tx, update.code, update.value, report); self.last_keyboard_report = report; + self.record_hid_relayed(); } } } @@ -269,6 +270,91 @@ impl InputAggregator { }; info!("📋 launcher requested clipboard paste on the live relay session"); keyboard.trigger_clipboard_paste(); + self.record_hid_relayed(); + } + + #[cfg(not(coverage))] + fn poll_launcher_wake_request(&mut self) { + let Some(path) = self.wake_control_path.as_deref() else { + return; + }; + let Some(raw) = read_launcher_control_snapshot(path) else { + return; + }; + if self.last_wake_request_raw.as_deref() == Some(raw.as_str()) { + return; + } + self.last_wake_request_raw = Some(raw.clone()); + self.wake_interval = parse_wake_interval(&raw); + self.last_hid_relayed_at = Instant::now(); + match self.wake_interval { + Some(interval) => info!( + "🛎️ launcher armed Wake after {} minutes without relayed HID input", + interval.as_secs() / 60 + ), + None => info!("🛎️ launcher disabled Wake"), + } + } + + #[cfg(not(coverage))] + fn record_hid_relayed(&mut self) { + self.last_hid_relayed_at = Instant::now(); + } + + #[cfg(coverage)] + fn record_hid_relayed(&mut self) { + self.last_hid_relayed_at = Instant::now(); + } + + #[cfg(not(coverage))] + fn maybe_send_wake_nudge(&mut self) { + let Some(interval) = self.wake_interval else { + return; + }; + if !self.remote_capture_active() || self.last_hid_relayed_at.elapsed() < interval { + return; + } + let (dx, dy) = self.next_wake_delta(); + let nudge = [0, dx as u8, dy as u8, 0]; + let _ = self.mou_tx.send(MouseReport { + data: nudge.to_vec(), + }); + let _ = self.mou_tx.send(MouseReport { data: [0; 4].into() }); + self.record_hid_relayed(); + info!( + "🛎️ Wake sent synthetic mouse nudge dx={} dy={} after {} minutes of quiet HID relay", + dx, + dy, + interval.as_secs() / 60 + ); + } + + #[cfg(not(coverage))] + fn next_wake_delta(&mut self) -> (i8, i8) { + self.wake_nonce = self + .wake_nonce + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407) + ^ std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos() as u64) + .unwrap_or_default(); + let dx = match self.wake_nonce % 5 { + 0 => -2, + 1 => -1, + 2 => 1, + 3 => 2, + _ => 3, + }; + self.wake_nonce = self.wake_nonce.rotate_left(17); + let dy = match self.wake_nonce % 5 { + 0 => -2, + 1 => -1, + 2 => 1, + 3 => 2, + _ => 3, + }; + (dx, dy) } #[cfg(not(coverage))] diff --git a/client/src/input/inputs/run_loop.rs b/client/src/input/inputs/run_loop.rs index ce9a994..8198699 100644 --- a/client/src/input/inputs/run_loop.rs +++ b/client/src/input/inputs/run_loop.rs @@ -65,6 +65,7 @@ impl InputAggregator { self.poll_launcher_routing_request(); self.poll_launcher_quick_toggle_request(); self.poll_launcher_clipboard_request(); + self.poll_launcher_wake_request(); let quick_toggle_now = self.quick_toggle_active(); self.observe_quick_toggle(quick_toggle_now); let magic_now = self.keyboards.iter().any(|k| k.magic_grab()); @@ -131,9 +132,14 @@ impl InputAggregator { } } + let mut mouse_relayed = false; for mouse in &mut self.mice { - mouse.process_events(); + mouse_relayed |= mouse.process_events(); } + if mouse_relayed { + self.record_hid_relayed(); + } + self.maybe_send_wake_nudge(); self.magic_active = magic_now; tick.tick().await; diff --git a/client/src/input/inputs/runtime_controls.rs b/client/src/input/inputs/runtime_controls.rs index 3fc18c6..7e653b1 100644 --- a/client/src/input/inputs/runtime_controls.rs +++ b/client/src/input/inputs/runtime_controls.rs @@ -116,6 +116,24 @@ fn parse_launcher_routing_request(raw: &str) -> Option { } } +#[cfg(not(coverage))] +fn parse_wake_interval(raw: &str) -> Option { + match raw + .split_ascii_whitespace() + .next()? + .to_ascii_lowercase() + .as_str() + { + "off" | "0" => None, + "5" | "5m" => Some(Duration::from_secs(5 * 60)), + "10" | "10m" => Some(Duration::from_secs(10 * 60)), + "20" | "20m" => Some(Duration::from_secs(20 * 60)), + "30" | "30m" => Some(Duration::from_secs(30 * 60)), + "60" | "60m" => Some(Duration::from_secs(60 * 60)), + _ => None, + } +} + #[cfg(not(coverage))] fn path_marker(path: &Path) -> u128 { std::fs::metadata(path) diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index 1f59223..8c42eed 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -135,20 +135,22 @@ struct MouseRuntime<'a> { } impl MouseRuntime<'_> { - fn replay_events(&mut self, events: Vec) { + fn replay_events(&mut self, events: Vec) -> bool { + let mut relayed = false; for event in events { if event.event_type() == EventType::SYNCHRONIZATION { - self.flush(); + relayed |= self.flush(); } else { self.event_state.apply_event(&event); } } + relayed } - fn flush(&mut self) { + fn flush(&mut self) -> bool { let buttons = *self.event_state.buttons; if buttons == *self.last_buttons && Instant::now() < *self.next_send { - return; + return false; } *self.next_send = Instant::now() + SEND_INTERVAL; @@ -159,6 +161,7 @@ impl MouseRuntime<'_> { *self.event_state.wheel as u8, ]; + let mut relayed = false; if !self.sending_disabled { #[cfg(not(coverage))] if let Err(tokio::sync::broadcast::error::SendError(_)) = @@ -175,12 +178,14 @@ impl MouseRuntime<'_> { { let _ = self.tx.send(MouseReport { data: pkt.to_vec() }); } + relayed = true; } *self.event_state.dx = 0; *self.event_state.dy = 0; *self.event_state.wheel = 0; *self.last_buttons = buttons; + relayed } } @@ -297,12 +302,12 @@ impl MouseAggregator { self.sending_disabled = !send; } - pub fn process_events(&mut self) { + pub fn process_events(&mut self) -> bool { let Some(evts) = collect_fetched_events(self.dev.fetch_events(), self.dev_mode) else { - return; + return false; }; log_event_batch(self.dev_mode, self.dev.name(), &evts); - self.runtime().replay_events(evts); + self.runtime().replay_events(evts) } pub fn reset_state(&mut self) { @@ -322,7 +327,7 @@ impl MouseAggregator { #[allow(dead_code)] fn flush(&mut self) { - self.runtime().flush(); + let _ = self.runtime().flush(); } #[inline] diff --git a/client/src/launcher/preview/feed_runtime.rs b/client/src/launcher/preview/feed_runtime.rs index 0505c0b..0710798 100644 --- a/client/src/launcher/preview/feed_runtime.rs +++ b/client/src/launcher/preview/feed_runtime.rs @@ -177,6 +177,23 @@ impl PreviewFeed { .map(|mut shared| shared.telemetry.snapshot()) .unwrap_or_default() } + + fn start_recording_tap(&self) -> Option { + if self.disabled { + return None; + } + let capacity = (self.profile.requested_fps.max(1) as usize).saturating_mul(2); + let (tx, rx) = std::sync::mpsc::sync_channel(capacity.max(4)); + if let Ok(mut shared) = self.shared.lock() { + shared.recorders.push(tx); + Some(PreviewRecordingTap::new( + rx, + Arc::clone(&self.active_bindings), + )) + } else { + None + } + } } #[cfg(not(coverage))] @@ -187,6 +204,27 @@ struct PreviewFrame { rgba: Vec, } +#[cfg(not(coverage))] +#[derive(Clone)] +pub(crate) struct PreviewFrameSnapshot { + pub width: i32, + pub height: i32, + pub stride: usize, + pub rgba: Vec, +} + +#[cfg(not(coverage))] +impl PreviewFrame { + fn snapshot(&self) -> PreviewFrameSnapshot { + PreviewFrameSnapshot { + width: self.width, + height: self.height, + stride: self.stride, + rgba: self.rgba.clone(), + } + } +} + #[cfg(not(coverage))] #[allow(clippy::too_many_arguments)] fn run_preview_feed( diff --git a/client/src/launcher/preview/feed_state.rs b/client/src/launcher/preview/feed_state.rs index 78c5669..878ef6a 100644 --- a/client/src/launcher/preview/feed_state.rs +++ b/client/src/launcher/preview/feed_state.rs @@ -47,6 +47,7 @@ struct PreviewFeed { #[cfg(not(coverage))] struct SharedPreviewState { latest: Option, + recorders: Vec>, status: String, generation: u64, clear_picture: bool, @@ -60,6 +61,7 @@ impl SharedPreviewState { fn new() -> Self { Self { latest: None, + recorders: Vec::new(), status: PREVIEW_IDLE_STATUS.to_string(), generation: 1, clear_picture: true, @@ -87,6 +89,15 @@ impl SharedPreviewState { fn push_frame(&mut self, frame: PreviewFrame) { self.telemetry.record_presented_frame(); + if !self.recorders.is_empty() { + let snapshot = frame.snapshot(); + self.recorders.retain(|sender| { + match sender.try_send(snapshot.clone()) { + Ok(()) | Err(std::sync::mpsc::TrySendError::Full(_)) => true, + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false, + } + }); + } self.latest = Some(frame); self.clear_picture = false; self.last_logged_error = None; diff --git a/client/src/launcher/preview/preview_core.rs b/client/src/launcher/preview/preview_core.rs index 1573b70..6640b44 100644 --- a/client/src/launcher/preview/preview_core.rs +++ b/client/src/launcher/preview/preview_core.rs @@ -72,6 +72,40 @@ pub struct PreviewBinding { active_bindings: Arc, } +#[cfg(not(coverage))] +pub(crate) struct PreviewRecordingTap { + rx: std::sync::mpsc::Receiver, + active_bindings: Arc, +} + +#[cfg(not(coverage))] +impl PreviewRecordingTap { + fn new( + rx: std::sync::mpsc::Receiver, + active_bindings: Arc, + ) -> Self { + active_bindings.fetch_add(1, Ordering::AcqRel); + Self { + rx, + active_bindings, + } + } + + pub(crate) fn recv_timeout( + &self, + timeout: Duration, + ) -> Result { + self.rx.recv_timeout(timeout) + } +} + +#[cfg(not(coverage))] +impl Drop for PreviewRecordingTap { + fn drop(&mut self) { + self.active_bindings.fetch_sub(1, Ordering::AcqRel); + } +} + #[cfg(not(coverage))] #[derive(Clone, Copy, Debug)] pub enum PreviewSurface { @@ -289,6 +323,27 @@ impl LauncherPreview { } } + pub fn start_recording_tap( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + match surface { + PreviewSurface::Inline => self + .inline_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .and_then(|feed| feed.start_recording_tap()), + PreviewSurface::Window => self + .window_feeds + .lock() + .ok() + .and_then(|feeds| feeds.get(monitor_id).cloned()) + .and_then(|feed| feed.start_recording_tap()), + } + } + pub fn set_capture_profile( &self, monitor_id: usize, diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 505ae71..3997ddf 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -16,7 +16,7 @@ use { fetch_capture_power, recover_uac_soft, recover_usb_soft, recover_uvc_soft, set_capture_power_mode, }, - super::preview::{LauncherPreview, PreviewSurface}, + super::preview::{LauncherPreview, PreviewFrameSnapshot, PreviewRecordingTap, PreviewSurface}, super::state::{ BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, DisplaySurface, FeedSourcePreset, InputRouting, LauncherState, MAX_AUDIO_GAIN_PERCENT, @@ -37,8 +37,9 @@ use { selected_combo_value, selected_server_addr, shutdown_launcher_runtime, spawn_client_process, stop_child_process, toggle_key_label, update_test_action_result, uplink_camera_preview_path, uplink_mic_level_path, uplink_telemetry_path, - write_audio_gain_request, write_input_routing_request, write_input_toggle_key_request, - write_media_control_request, write_mic_gain_request, + wake_control_path, write_audio_gain_request, write_input_routing_request, + write_input_toggle_key_request, write_media_control_request, write_mic_gain_request, + write_wake_control_request, }, crate::handshake::{HandshakeProbe, probe}, crate::output::display::enumerate_monitors, @@ -87,11 +88,13 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let input_control_path = Rc::new(input_control_path()); let input_state_path = Rc::new(input_state_path()); let input_toggle_control_path = Rc::new(input_toggle_control_path()); + let wake_control_file = Rc::new(wake_control_path()); let _ = std::fs::remove_file(focus_signal_path.as_path()); let _ = std::fs::remove_file(clipboard_control_path.as_path()); let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); let _ = std::fs::remove_file(input_toggle_control_path.as_path()); + let _ = std::fs::remove_file(wake_control_file.as_path()); { let child_proc = Rc::clone(&child_proc); @@ -100,6 +103,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let input_control_path = Rc::clone(&input_control_path); let input_state_path = Rc::clone(&input_state_path); let input_toggle_control_path = Rc::clone(&input_toggle_control_path); + let wake_control_file = Rc::clone(&wake_control_file); let tests = Rc::clone(&tests); app.connect_shutdown(move |_| { stop_child_process(&child_proc); @@ -109,6 +113,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let _ = std::fs::remove_file(input_control_path.as_path()); let _ = std::fs::remove_file(input_state_path.as_path()); let _ = std::fs::remove_file(input_toggle_control_path.as_path()); + let _ = std::fs::remove_file(wake_control_file.as_path()); }); } diff --git a/client/src/launcher/ui/eye_capture_bindings.rs b/client/src/launcher/ui/eye_capture_bindings.rs index 09afd99..bb2b2d6 100644 --- a/client/src/launcher/ui/eye_capture_bindings.rs +++ b/client/src/launcher/ui/eye_capture_bindings.rs @@ -1,28 +1,112 @@ { - fn encode_recording( - frame_dir: &Path, + fn spawn_raw_video_encoder( + width: i32, + height: i32, output_path: &Path, encode_fps: u32, encode_bitrate_kbit: u32, + ) -> Result { + let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); + let fps_arg = encode_fps.max(1).to_string(); + let video_size = format!("{}x{}", width.max(1), height.max(1)); + let output_arg = output_path.to_string_lossy().into_owned(); + Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "rawvideo", + "-pix_fmt", + "rgba", + "-video_size", + &video_size, + "-framerate", + &fps_arg, + "-i", + "-", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-r", + &fps_arg, + "-b:v", + &bitrate_arg, + ]) + .arg(&output_arg) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|err| format!("ffmpeg video encoder is unavailable: {err}")) + } + + fn normalize_recording_frame(frame: PreviewFrameSnapshot) -> Result<(i32, i32, Vec), String> { + let width = frame.width.max(0) as usize; + let height = frame.height.max(0) as usize; + if width == 0 || height == 0 { + return Err("decoded preview frame had zero size".to_string()); + } + let row_bytes = width.saturating_mul(4); + let needed = row_bytes.saturating_mul(height); + if frame.rgba.len() < needed && frame.stride == row_bytes { + return Err("decoded preview frame was shorter than its declared size".to_string()); + } + if frame.stride == row_bytes && frame.rgba.len() >= needed { + return Ok((frame.width, frame.height, frame.rgba[..needed].to_vec())); + } + if frame.stride < row_bytes || frame.rgba.len() < frame.stride.saturating_mul(height) { + return Err("decoded preview frame stride was inconsistent".to_string()); + } + let mut rgba = Vec::with_capacity(needed); + for row in 0..height { + let start = row.saturating_mul(frame.stride); + rgba.extend_from_slice(&frame.rgba[start..start + row_bytes]); + } + Ok((frame.width, frame.height, rgba)) + } + + fn finish_raw_video_encoder( + child: &mut std::process::Child, + frame_dir: &Path, + output_path: &Path, + ) -> Result<(), String> { + let _ = child.stdin.take(); + let status = child + .wait() + .map_err(|err| format!("ffmpeg video encoder wait failed: {err}"))?; + if !status.success() { + return Err(format!( + "ffmpeg failed while encoding {}; temporary data is still in {}", + output_path.display(), + frame_dir.display() + )); + } + Ok(()) + } + + fn mux_recording_audio( + video_path: &Path, + output_path: &Path, audio_mode: EyeRecordAudioMode, audio_paths: &[PathBuf], ) -> Result<(), String> { - let frame_pattern = frame_dir.join("frame-%06d.png"); - let bitrate_arg = format!("{}k", encode_bitrate_kbit.max(800)); - let fps_arg = encode_fps.max(1).to_string(); - let frame_pattern_arg = frame_pattern.to_string_lossy().into_owned(); - let output_arg = output_path.to_string_lossy().into_owned(); let usable_audio_paths = validated_audio_paths(audio_mode, audio_paths)?; + if usable_audio_paths.is_empty() { + return Ok(()); + } + let video_arg = video_path.to_string_lossy().into_owned(); + let output_arg = output_path.to_string_lossy().into_owned(); let mut command = Command::new("ffmpeg"); command.args([ "-hide_banner", "-loglevel", "error", "-y", - "-framerate", - &fps_arg, "-i", - &frame_pattern_arg, + &video_arg, ]); for audio_path in &usable_audio_paths { command.arg("-i").arg(audio_path); @@ -36,37 +120,26 @@ "-map", "[a]", ]); + } else { + command.args(["-map", "0:v:0", "-map", "1:a:0"]); } - command.args([ - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-r", - &fps_arg, - "-b:v", - &bitrate_arg, - ]); - if !usable_audio_paths.is_empty() { - command.args(["-c:a", "aac", "-b:a", "160k", "-shortest"]); - } - let encode = command + command.args(["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest"]); + let mux = command .arg(&output_arg) .status() .map_err(|err| format!("ffmpeg is unavailable: {err}"))?; - - if !encode.success() { + if !mux.success() { return Err(format!( - "ffmpeg failed while encoding {}; frame data is still in {}", - output_path.display(), - frame_dir.display() + "ffmpeg failed while adding audio to {}", + output_path.display() )); } Ok(()) } fn run_recording_worker( - frame_rx: std::sync::mpsc::Receiver, + frame_tap: PreviewRecordingTap, + control_rx: std::sync::mpsc::Receiver, frame_dir: PathBuf, output_path: PathBuf, encode_fps: u32, @@ -74,35 +147,78 @@ audio_mode: EyeRecordAudioMode, audio_paths: Vec, ) -> Result { + let needs_audio_mux = audio_mode != EyeRecordAudioMode::NoAudio; + let video_output_path = if needs_audio_mux { + frame_dir.join("recording-video.mp4") + } else { + output_path.clone() + }; + let mut encoder: Option = None; + let mut frame_size: Option<(i32, i32)> = None; let mut captured_frames = 0_u32; - while let Ok(task) = frame_rx.recv() { - match task { - RecordFrameTask::Frame { frame_path } => { - if !frame_path.exists() { - return Err(format!( - "recording worker could not find saved frame {}", - frame_path.display() - )); + + loop { + match control_rx.try_recv() { + Ok(RecordFrameTask::Finish) | Err(std::sync::mpsc::TryRecvError::Disconnected) => { + break; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + + match frame_tap.recv_timeout(Duration::from_millis(50)) { + Ok(frame) => { + let (width, height, rgba) = normalize_recording_frame(frame)?; + if let Some((expected_width, expected_height)) = frame_size { + if width != expected_width || height != expected_height { + continue; + } + } else { + frame_size = Some((width, height)); + encoder = Some(spawn_raw_video_encoder( + width, + height, + &video_output_path, + encode_fps, + encode_bitrate_kbit, + )?); } + let encoder = encoder + .as_mut() + .ok_or_else(|| "recording encoder did not start".to_string())?; + let stdin = encoder + .stdin + .as_mut() + .ok_or_else(|| "recording encoder stdin is closed".to_string())?; + std::io::Write::write_all(stdin, &rgba) + .map_err(|err| format!("recording encoder write failed: {err}"))?; captured_frames = captured_frames.saturating_add(1); } - RecordFrameTask::Finish => break, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } } if captured_frames < 2 { + if let Some(mut child) = encoder { + let _ = child.kill(); + let _ = child.wait(); + } let _ = std::fs::remove_dir_all(&frame_dir); return Err("need at least two captured frames to build a recording".to_string()); } - encode_recording( - &frame_dir, + if let Some(mut child) = encoder { + finish_raw_video_encoder(&mut child, &frame_dir, &video_output_path)?; + } + mux_recording_audio( + &video_output_path, &output_path, - encode_fps, - encode_bitrate_kbit, audio_mode, &audio_paths, )?; + if needs_audio_mux { + let _ = std::fs::remove_file(&video_output_path); + } let _ = std::fs::remove_dir_all(&frame_dir); Ok(output_path) } @@ -196,7 +312,7 @@ let save_state = Rc::clone(&save_state); pane.record_audio_button.connect_clicked(move |button| { let mut state = save_state.borrow_mut(); - if state.timer.is_some() { + if state.recording_active { sync_record_audio_button(button, state.audio_mode, true); widgets.status_label.set_text(&format!( "{} recording audio is locked at {} until recording stops.", @@ -224,23 +340,20 @@ let record_button = pane.record_button.clone(); let record_audio_button = pane.record_audio_button.clone(); record_button.connect_clicked(move |button| { - if save_state.borrow().timer.is_some() { + if save_state.borrow().recording_active { button.remove_css_class("recording-active"); let (finalize_rx, audio_mode) = { let mut state = save_state.borrow_mut(); - if let Some(timer) = state.timer.take() { - timer.remove(); - } stop_audio_recording(&mut state.audio_recording); - if let Some(frame_writer_tx) = state.frame_writer_tx.take() { - let _ = frame_writer_tx.send(RecordFrameTask::Finish); + if let Some(control_tx) = state.recording_control_tx.take() { + let _ = control_tx.send(RecordFrameTask::Finish); } - state.next_frame_index = 0; + state.recording_active = false; state.frame_dir = None; (state.finalize_rx.take(), state.audio_mode) }; let Some(finalize_rx) = finalize_rx else { - button.set_label("Record"); + button.set_label("⏺"); sync_record_audio_button(&record_audio_button, audio_mode, false); widgets.status_label.set_text(&format!( "{} recording stop failed: recording worker state was missing.", @@ -250,7 +363,7 @@ }; button.set_sensitive(false); - button.set_label("Finishing..."); + button.set_label("▰▰"); sync_record_audio_button(&record_audio_button, audio_mode, true); let button = button.clone(); let record_audio_button = record_audio_button.clone(); @@ -261,7 +374,7 @@ Ok(Ok(output)) => { button.set_sensitive(true); button.remove_css_class("recording-active"); - button.set_label("Record"); + button.set_label("⏺"); sync_record_audio_button(&record_audio_button, audio_mode, false); widgets.status_label.set_text(&format!( "{} recording saved to {}.", @@ -273,7 +386,7 @@ Ok(Err(err)) => { button.set_sensitive(true); button.remove_css_class("recording-active"); - button.set_label("Record"); + button.set_label("⏺"); sync_record_audio_button(&record_audio_button, audio_mode, false); widgets.status_label.set_text(&format!( "{} recording stop failed: {err}", @@ -287,7 +400,7 @@ Err(std::sync::mpsc::TryRecvError::Disconnected) => { button.set_sensitive(true); button.remove_css_class("recording-active"); - button.set_label("Record"); + button.set_label("⏺"); sync_record_audio_button(&record_audio_button, audio_mode, false); widgets.status_label.set_text(&format!( "{} recording stop failed: recording worker disconnected.", @@ -304,13 +417,19 @@ let state = state.borrow(); best_effort_recording_profile(&state, preview.as_deref(), monitor_id) }; - if let Err(err) = current_eye_texture(&pane.picture) { + let recording_surface = match state.borrow().display_surface(monitor_id) { + DisplaySurface::Preview => PreviewSurface::Inline, + DisplaySurface::Window => PreviewSurface::Window, + }; + let Some(frame_tap) = preview.as_ref().and_then(|preview| { + preview.start_recording_tap(monitor_id, recording_surface) + }) else { widgets.status_label.set_text(&format!( - "{} recording needs a live frame first: {err}", + "{} recording needs a live preview stream first.", pane.title )); return; - } + }; let root = { let borrowed = save_state.borrow(); match ensure_eye_capture_dir(borrowed.save_dir_override.as_deref(), "recordings") { @@ -353,13 +472,14 @@ .map(EyeAudioRecording::audio_paths) .unwrap_or_default(); - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); + let (control_tx, control_rx) = std::sync::mpsc::channel::(); let (result_tx, result_rx) = std::sync::mpsc::channel::>(); let frame_dir_worker = frame_dir.clone(); let output_path_worker = output_path.clone(); std::thread::spawn(move || { - let result = run_recording_worker( - frame_rx, + let result = run_recording_worker( + frame_tap, + control_rx, frame_dir_worker, output_path_worker, record_fps, @@ -373,57 +493,18 @@ { let mut state = save_state.borrow_mut(); state.frame_dir = Some(frame_dir); - state.frame_writer_tx = Some(frame_tx); + state.recording_control_tx = Some(control_tx); state.finalize_rx = Some(result_rx); state.audio_recording = audio_recording; - state.next_frame_index = 0; + state.recording_active = true; } - let pane_for_tick = pane.clone(); - let widgets_for_tick = widgets.clone(); - let save_state_for_tick = Rc::clone(&save_state); - let button_for_tick = button.clone(); - let record_audio_button_for_tick = record_audio_button.clone(); - let timer = glib::timeout_add_local( - Duration::from_millis(recording_interval_ms(record_fps)), - move || { - let mut state = save_state_for_tick.borrow_mut(); - if state.frame_dir.is_none() { - return glib::ControlFlow::Break; - } - if let Err(err) = queue_record_frame(&mut state, &pane_for_tick.picture) { - stop_audio_recording(&mut state.audio_recording); - if let Some(frame_writer_tx) = state.frame_writer_tx.take() { - let _ = frame_writer_tx.send(RecordFrameTask::Finish); - } - state.timer = None; - state.next_frame_index = 0; - state.frame_dir = None; - state.finalize_rx = None; - button_for_tick.remove_css_class("recording-active"); - button_for_tick.set_sensitive(true); - button_for_tick.set_label("Record"); - sync_record_audio_button( - &record_audio_button_for_tick, - state.audio_mode, - false, - ); - widgets_for_tick.status_label.set_text(&format!( - "{} recording frame skipped: {err}", - pane_for_tick.title - )); - return glib::ControlFlow::Break; - } - glib::ControlFlow::Continue - }, - ); - save_state.borrow_mut().timer = Some(timer); button.set_sensitive(true); button.add_css_class("recording-active"); - button.set_label("Stop"); + button.set_label("⏺"); sync_record_audio_button(&record_audio_button, audio_mode, true); widgets.status_label.set_text(&format!( - "Recording {} at {} fps (~{} kbit, {})... press Stop to finish.", + "Recording {} at {} fps (~{} kbit, {})... press the record button again to finish.", pane.title, record_fps, record_bitrate_kbit, diff --git a/client/src/launcher/ui/eye_capture_bindings/recording_support.rs b/client/src/launcher/ui/eye_capture_bindings/recording_support.rs index a12e052..c8883ad 100644 --- a/client/src/launcher/ui/eye_capture_bindings/recording_support.rs +++ b/client/src/launcher/ui/eye_capture_bindings/recording_support.rs @@ -3,13 +3,12 @@ const DEFAULT_EYE_RECORD_FPS: u32 = 30; #[derive(Default)] struct EyeRecordState { save_dir_override: Option, - timer: Option, + recording_active: bool, frame_dir: Option, - frame_writer_tx: Option>, + recording_control_tx: Option>, finalize_rx: Option>>, audio_mode: EyeRecordAudioMode, audio_recording: Option, - next_frame_index: u32, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -33,10 +32,10 @@ impl EyeRecordAudioMode { fn button_label(self) -> &'static str { match self { - Self::NoAudio => "Audio: Off", - Self::UpstreamOnly => "Audio: Up", - Self::DownstreamOnly => "Audio: Down", - Self::Both => "Audio: Both", + Self::NoAudio => "🔇", + Self::UpstreamOnly => "👂↑", + Self::DownstreamOnly => "👂↓", + Self::Both => "👂↕", } } @@ -120,7 +119,6 @@ impl EyeAudioRecording { } enum RecordFrameTask { - Frame { frame_path: PathBuf }, Finish, } @@ -371,11 +369,6 @@ fn stop_audio_recording(recording: &mut Option) { } } -fn recording_interval_ms(record_fps: u32) -> u64 { - let fps = record_fps.max(1); - (1000_u64 / fps as u64).max(1) -} - fn best_effort_recording_profile( state: &LauncherState, preview: Option<&LauncherPreview>, @@ -400,27 +393,6 @@ fn best_effort_recording_profile( (fps, bitrate_kbit) } -fn queue_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 frame_writer_tx = state - .frame_writer_tx - .as_ref() - .ok_or_else(|| "recording worker 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)?; - frame_writer_tx - .send(RecordFrameTask::Frame { frame_path }) - .map_err(|_| "recording worker stopped unexpectedly".to_string())?; - state.next_frame_index = state.next_frame_index.saturating_add(1); - Ok(()) -} - fn validated_audio_paths( audio_mode: EyeRecordAudioMode, audio_paths: &[PathBuf], diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 97d9d9d..a295daf 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -153,6 +153,58 @@ }); }); } + { + let child_proc = Rc::clone(&child_proc); + let widgets = widgets.clone(); + widgets.wake_combo.connect_changed(move |combo| { + let id = combo + .active_id() + .map(|value| value.to_string()) + .unwrap_or_else(|| "off".to_string()); + let minutes = match id.as_str() { + "5" => Some(5), + "10" => Some(10), + "20" => Some(20), + "30" => Some(30), + "60" => Some(60), + _ => None, + }; + let path = wake_control_path(); + match write_wake_control_request(&path, minutes) { + Ok(()) => { + let relay_live = child_proc + .try_borrow() + .map(|child| child.is_some()) + .unwrap_or(false); + match minutes { + Some(minutes) if relay_live => widgets.status_label.set_text(&format!( + "Wake armed: after {minutes}m without relayed HID input, the live relay will send a tiny random mouse nudge." + )), + Some(minutes) => widgets.status_label.set_text(&format!( + "Wake staged: the next relay will nudge after {minutes}m without relayed HID input." + )), + None if relay_live => widgets + .status_label + .set_text("Wake disabled for the live relay."), + None => widgets + .status_label + .set_text("Wake disabled for the next relay launch."), + } + } + Err(err) => widgets + .status_label + .set_text(&format!("Wake setting could not be written: {err}")), + } + combo.set_tooltip_text(Some(match minutes { + Some(5) => "Wake is on: nudge the RCT after 5 minutes without relayed keyboard or mouse input.", + Some(10) => "Wake is on: nudge the RCT after 10 minutes without relayed keyboard or mouse input.", + Some(20) => "Wake is on: nudge the RCT after 20 minutes without relayed keyboard or mouse input.", + Some(30) => "Wake is on: nudge the RCT after 30 minutes without relayed keyboard or mouse input.", + Some(60) => "Wake is on: nudge the RCT after 60 minutes without relayed keyboard or mouse input.", + _ => "Wake is off: Lesavka will not synthesize mouse movement.", + })); + }); + } { let widgets = widgets.clone(); let server_entry = server_entry.clone(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index e100616..d86b612 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -98,6 +98,7 @@ pub fn build_launcher_view( start_button, certs_button, clipboard_button, + wake_combo, usb_recover_button, uac_recover_button, uvc_recover_button, diff --git a/client/src/launcher/ui_components/assemble_view.rs b/client/src/launcher/ui_components/assemble_view.rs index 4b3bac3..f570577 100644 --- a/client/src/launcher/ui_components/assemble_view.rs +++ b/client/src/launcher/ui_components/assemble_view.rs @@ -150,6 +150,7 @@ mic_gain_value: mic_gain_value.clone(), input_toggle_button: input_toggle_button.clone(), clipboard_button: clipboard_button.clone(), + wake_combo: wake_combo.clone(), usb_recover_button: usb_recover_button.clone(), uac_recover_button: uac_recover_button.clone(), uvc_recover_button: uvc_recover_button.clone(), diff --git a/client/src/launcher/ui_components/build_contexts.rs b/client/src/launcher/ui_components/build_contexts.rs index f799a21..b589a47 100644 --- a/client/src/launcher/ui_components/build_contexts.rs +++ b/client/src/launcher/ui_components/build_contexts.rs @@ -65,6 +65,7 @@ struct OperationsRailContext { start_button: gtk::Button, certs_button: gtk::Button, clipboard_button: gtk::Button, + wake_combo: gtk::ComboBoxText, usb_recover_button: gtk::Button, uac_recover_button: gtk::Button, uvc_recover_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 dbfdcfe..f416e5e 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -172,9 +172,29 @@ 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); + tools_buttons.set_homogeneous(false); let clipboard_button = rail_button("Clipboard", "Type clipboard remotely."); + clipboard_button.set_hexpand(true); + let wake_combo = gtk::ComboBoxText::new(); + wake_combo.add_css_class("compact-combo"); + for (id, label) in [ + ("off", "Wake: off"), + ("5", "Wake: 5m"), + ("10", "Wake: 10m"), + ("20", "Wake: 20m"), + ("30", "Wake: 30m"), + ("60", "Wake: 60m"), + ] { + wake_combo.append(Some(id), label); + } + wake_combo.set_active_id(Some("off")); + wake_combo.set_hexpand(false); + wake_combo.set_size_request(92, 36); + wake_combo.set_tooltip_text(Some( + "When remote input is routed and no HID input has been relayed for the selected time, send a tiny random mouse nudge to keep the RCT awake.", + )); tools_buttons.append(&clipboard_button); + tools_buttons.append(&wake_combo); tools_row.append(&tools_buttons); connection_body.append(&tools_row); operations.append(&connection_panel); @@ -299,6 +319,7 @@ start_button, certs_button, clipboard_button, + wake_combo, usb_recover_button, uac_recover_button, uvc_recover_button, diff --git a/client/src/launcher/ui_components/display_pane.rs b/client/src/launcher/ui_components/display_pane.rs index b04bd88..28ad332 100644 --- a/client/src/launcher/ui_components/display_pane.rs +++ b/client/src/launcher/ui_components/display_pane.rs @@ -157,29 +157,31 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { breakout_combo.set_size_request(0, -1); breakout_combo.set_hexpand(true); - let clip_button = gtk::Button::with_label("Clip"); - stabilize_button(&clip_button, 66); + const EYE_CAPTURE_BUTTON_WIDTH: i32 = 54; + let clip_button = gtk::Button::with_label("📷"); + stabilize_button(&clip_button, EYE_CAPTURE_BUTTON_WIDTH); clip_button.set_tooltip_text(Some("Capture a still image for this eye.")); - let record_button = gtk::Button::with_label("Record"); + let record_button = gtk::Button::with_label("⏺"); record_button.add_css_class("media-toggle"); - stabilize_button(&record_button, 78); + stabilize_button(&record_button, EYE_CAPTURE_BUTTON_WIDTH); record_button.set_tooltip_text(Some( - "Record the feed currently routed into this eye block until you stop.", + "Record the feed currently routed into this eye block; click again to stop.", )); - let record_audio_button = gtk::Button::with_label("Audio: Off"); + let record_audio_button = gtk::Button::with_label("🔇"); record_audio_button.add_css_class("media-toggle"); record_audio_button.add_css_class("media-toggle-split"); - stabilize_button(&record_audio_button, 92); + stabilize_button(&record_audio_button, EYE_CAPTURE_BUTTON_WIDTH); record_audio_button.set_tooltip_text(Some( "Choose which audio tracks to bundle with the next recording.", )); - let save_button = gtk::Button::with_label("Save"); - stabilize_button(&save_button, 66); + let save_button = gtk::Button::with_label("📁"); + stabilize_button(&save_button, EYE_CAPTURE_BUTTON_WIDTH); save_button.set_tooltip_text(Some("Choose where this eye saves clips and recordings.")); - let action_button = gtk::Button::with_label("Break Out"); - stabilize_button(&action_button, 82); + let action_button = gtk::Button::with_label("↗"); + stabilize_button(&action_button, EYE_CAPTURE_BUTTON_WIDTH); action_button.set_halign(gtk::Align::End); + action_button.set_tooltip_text(Some("Open this eye in its own breakout window.")); let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); footer_shell.set_vexpand(false); diff --git a/client/src/launcher/ui_components/types.rs b/client/src/launcher/ui_components/types.rs index 654cbd5..8603f88 100644 --- a/client/src/launcher/ui_components/types.rs +++ b/client/src/launcher/ui_components/types.rs @@ -159,6 +159,7 @@ pub struct LauncherWidgets { pub mic_gain_value: gtk::Label, pub input_toggle_button: gtk::Button, pub clipboard_button: gtk::Button, + pub wake_combo: gtk::ComboBoxText, pub usb_recover_button: gtk::Button, pub uac_recover_button: gtk::Button, pub uvc_recover_button: gtk::Button, diff --git a/client/src/launcher/ui_runtime/control_paths.rs b/client/src/launcher/ui_runtime/control_paths.rs index 35d2210..aa1d3db 100644 --- a/client/src/launcher/ui_runtime/control_paths.rs +++ b/client/src/launcher/ui_runtime/control_paths.rs @@ -84,6 +84,12 @@ pub fn input_toggle_control_path() -> PathBuf { .unwrap_or_else(|_| PathBuf::from(DEFAULT_TOGGLE_KEY_CONTROL_PATH)) } +pub fn wake_control_path() -> PathBuf { + std::env::var(WAKE_CONTROL_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_WAKE_CONTROL_PATH)) +} + pub fn audio_gain_control_path() -> PathBuf { std::env::var(AUDIO_GAIN_CONTROL_ENV) .map(PathBuf::from) @@ -169,6 +175,14 @@ pub fn write_input_toggle_key_request(path: &Path, swap_key: &str) -> Result<()> Ok(()) } +pub fn write_wake_control_request(path: &Path, minutes: Option) -> Result<()> { + let value = minutes + .map(|minutes| minutes.to_string()) + .unwrap_or_else(|| "off".to_string()); + std::fs::write(path, format!("{value} {}\n", control_request_nonce()))?; + Ok(()) +} + /// Keeps `read_input_routing_state` explicit because it sits on launcher state/UI wiring, where device choices should remain explainable across refreshes. /// Inputs are the typed parameters; output is the return value or side effect. pub fn read_input_routing_state(path: &Path) -> Option { diff --git a/client/src/launcher/ui_runtime/display_popouts.rs b/client/src/launcher/ui_runtime/display_popouts.rs index 021c9ca..f9b66d8 100644 --- a/client/src/launcher/ui_runtime/display_popouts.rs +++ b/client/src/launcher/ui_runtime/display_popouts.rs @@ -256,7 +256,9 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) match surface { DisplaySurface::Preview => { pane.stack.set_visible_child_name("preview"); - pane.action_button.set_label("Break Out"); + pane.action_button.set_label("↗"); + pane.action_button + .set_tooltip_text(Some("Open this eye in its own breakout window.")); pane.preview_placeholder .set_visible(pane.picture.paintable().is_none()); if pane.preview_binding.borrow().is_none() { @@ -265,7 +267,9 @@ pub fn refresh_display_pane(pane: &DisplayPaneWidgets, surface: DisplaySurface) } DisplaySurface::Window => { pane.stack.set_visible_child_name("placeholder"); - pane.action_button.set_label("Return"); + pane.action_button.set_label("↙"); + pane.action_button + .set_tooltip_text(Some("Return this eye to the launcher preview.")); pane.stream_status.set_text("Streaming in its own window"); pane.preview_placeholder.set_visible(false); } diff --git a/client/src/launcher/ui_runtime/process_logs.rs b/client/src/launcher/ui_runtime/process_logs.rs index 1243b1f..5a99e1a 100644 --- a/client/src/launcher/ui_runtime/process_logs.rs +++ b/client/src/launcher/ui_runtime/process_logs.rs @@ -34,6 +34,8 @@ pub fn spawn_client_process( command.env(INPUT_CONTROL_ENV, input_control_path); command.env(INPUT_STATE_ENV, input_state_path); command.env(TOGGLE_KEY_CONTROL_ENV, input_toggle_control_path); + let wake_path = wake_control_path(); + command.env(WAKE_CONTROL_ENV, wake_path); command.env("LESAVKA_DISABLE_VIDEO_RENDER", "1"); command.env("LESAVKA_CLIPBOARD_PASTE", "1"); let audio_gain_path = audio_gain_control_path(); diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index 9c77079..a145692 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -27,6 +27,7 @@ use super::{ pub const INPUT_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_INPUT_CONTROL"; pub const INPUT_STATE_ENV: &str = "LESAVKA_LAUNCHER_INPUT_STATE"; pub const TOGGLE_KEY_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_TOGGLE_KEY_CONTROL"; +pub const WAKE_CONTROL_ENV: &str = "LESAVKA_LAUNCHER_WAKE_CONTROL"; pub const AUDIO_GAIN_CONTROL_ENV: &str = "LESAVKA_AUDIO_GAIN_CONTROL"; pub const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL"; pub const MEDIA_CONTROL_ENV: &str = crate::live_media_control::MEDIA_CONTROL_ENV; @@ -36,6 +37,7 @@ pub use crate::uplink_telemetry::{DEFAULT_UPLINK_TELEMETRY_PATH, UPLINK_TELEMETR pub const DEFAULT_INPUT_CONTROL_PATH: &str = "/tmp/lesavka-launcher-input.control"; pub const DEFAULT_INPUT_STATE_PATH: &str = "/tmp/lesavka-launcher-input.state"; pub const DEFAULT_TOGGLE_KEY_CONTROL_PATH: &str = "/tmp/lesavka-launcher-toggle-key.control"; +pub const DEFAULT_WAKE_CONTROL_PATH: &str = "/tmp/lesavka-launcher-wake.control"; pub const DEFAULT_AUDIO_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-audio-gain.control"; pub const DEFAULT_MIC_GAIN_CONTROL_PATH: &str = "/tmp/lesavka-mic-gain.control"; pub const DEFAULT_MEDIA_CONTROL_PATH: &str = crate::live_media_control::DEFAULT_MEDIA_CONTROL_PATH; diff --git a/common/Cargo.toml b/common/Cargo.toml index e2949dd..e4d542f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.23.0" +version = "0.23.1" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 124e6ef..19e708d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.23.0" +version = "0.23.1" edition = "2024" autobins = false diff --git a/tests/contract/client/input/inputs/client_inputs_routing_contract.rs b/tests/contract/client/input/inputs/client_inputs_routing_contract.rs index be5bf4c..8d12f7f 100644 --- a/tests/contract/client/input/inputs/client_inputs_routing_contract.rs +++ b/tests/contract/client/input/inputs/client_inputs_routing_contract.rs @@ -211,6 +211,26 @@ mod inputs_contract { ); } + #[test] + fn wake_interval_parser_accepts_launcher_dropdown_values_only() { + assert_eq!(parse_wake_interval("off 123"), None); + assert_eq!(parse_wake_interval("0 123"), None); + assert_eq!( + parse_wake_interval("5 123"), + Some(std::time::Duration::from_secs(5 * 60)) + ); + assert_eq!( + parse_wake_interval("10m 123"), + Some(std::time::Duration::from_secs(10 * 60)) + ); + assert_eq!( + parse_wake_interval("60 123"), + Some(std::time::Duration::from_secs(60 * 60)) + ); + assert_eq!(parse_wake_interval("2 123"), None); + assert_eq!(parse_wake_interval("banana"), None); + } + #[test] #[serial] fn quick_toggle_key_env_defaults_and_respects_explicit_disable() { diff --git a/tests/contract/client/input/mouse/client_mouse_include_contract.rs b/tests/contract/client/input/mouse/client_mouse_include_contract.rs index ee4e1e7..1932537 100644 --- a/tests/contract/client/input/mouse/client_mouse_include_contract.rs +++ b/tests/contract/client/input/mouse/client_mouse_include_contract.rs @@ -470,6 +470,6 @@ mod mouse_contract { let agg = MouseAggregator::new(dev, false, tx); drop(agg); let pkt = rx.try_recv().expect("drop packet"); - assert_eq!(pkt.data, vec![0; 8]); + assert_eq!(pkt.data, vec![0; 4]); } } diff --git a/tests/ui/client/launcher/client_launcher_layout_contract.rs b/tests/ui/client/launcher/client_launcher_layout_contract.rs index e6e41d4..c0fa233 100644 --- a/tests/ui/client/launcher/client_launcher_layout_contract.rs +++ b/tests/ui/client/launcher/client_launcher_layout_contract.rs @@ -140,15 +140,14 @@ fn eye_panes_keep_the_docked_preview_footprint_without_forcing_maximized_width() source_index("controls_grid.attach(&breakout_row, 0, 1, 1, 1);") < source_index("controls_grid.attach(&capture_actions, 1, 1, 2, 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("const EYE_CAPTURE_BUTTON_WIDTH: i32 = 54;")); + assert!(UI_LAYOUT_SRC.contains("let clip_button = gtk::Button::with_label(\"📷\");")); + assert!(UI_LAYOUT_SRC.contains("let record_button = gtk::Button::with_label(\"⏺\");")); assert!(UI_LAYOUT_SRC.contains("record_button.add_css_class(\"media-toggle\");")); - assert!( - UI_LAYOUT_SRC - .contains("let record_audio_button = gtk::Button::with_label(\"Audio: Off\");") - ); + assert!(UI_LAYOUT_SRC.contains("let record_audio_button = gtk::Button::with_label(\"🔇\");")); assert!(UI_LAYOUT_SRC.contains("record_audio_button.add_css_class(\"media-toggle\");")); - assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"Save\");")); + assert!(UI_LAYOUT_SRC.contains("let save_button = gtk::Button::with_label(\"📁\");")); + assert!(UI_LAYOUT_SRC.contains("let action_button = gtk::Button::with_label(\"↗\");")); assert!(UI_LAYOUT_SRC.contains("capture_actions.append(&clip_button);")); assert!( source_index("capture_actions.append(&record_button);") @@ -407,9 +406,14 @@ fn relay_controls_keep_connect_inline_with_server_entry() { 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_buttons.set_homogeneous(false);")); assert!(UI_LAYOUT_SRC.contains("tools_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);")); assert!(UI_LAYOUT_SRC.contains("let clipboard_button = rail_button(\"Clipboard\"")); + assert!(UI_LAYOUT_SRC.contains("let wake_combo = gtk::ComboBoxText::new();")); + assert!(UI_LAYOUT_SRC.contains("(\"off\", \"Wake: off\")")); + assert!(UI_LAYOUT_SRC.contains("(\"60\", \"Wake: 60m\")")); + assert!(UI_LAYOUT_SRC.contains("wake_combo.set_size_request(92, 36);")); + assert!(UI_LAYOUT_SRC.contains("tools_buttons.append(&wake_combo);")); assert!(UI_LAYOUT_SRC.contains("let usb_recover_button = rail_button(")); assert!(UI_LAYOUT_SRC.contains("usb_recover_button.set_hexpand(false);")); assert!(UI_LAYOUT_SRC.contains("\"HID\",")); diff --git a/tests/ui/client/launcher/client_launcher_runtime_contract.rs b/tests/ui/client/launcher/client_launcher_runtime_contract.rs index bdbb443..d9f5f15 100644 --- a/tests/ui/client/launcher/client_launcher_runtime_contract.rs +++ b/tests/ui/client/launcher/client_launcher_runtime_contract.rs @@ -328,19 +328,24 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { assert!(UI_SRC.contains("clip saved to")); assert!(UI_SRC.contains("pane.record_audio_button.connect_clicked")); assert!(UI_SRC.contains("enum EyeRecordAudioMode")); - assert!(UI_SRC.contains("Self::NoAudio => \"Audio: Off\"")); - assert!(UI_SRC.contains("Self::UpstreamOnly => \"Audio: Up\"")); - assert!(UI_SRC.contains("Self::DownstreamOnly => \"Audio: Down\"")); - assert!(UI_SRC.contains("Self::Both => \"Audio: Both\"")); + assert!(UI_SRC.contains("Self::NoAudio => \"🔇\"")); + assert!(UI_SRC.contains("Self::UpstreamOnly => \"👂↑\"")); + assert!(UI_SRC.contains("Self::DownstreamOnly => \"👂↓\"")); + assert!(UI_SRC.contains("Self::Both => \"👂↕\"")); assert!(UI_SRC.contains("Audio mode is locked for the active recording.")); assert!(UI_SRC.contains("LESAVKA_EYE_RECORD_UPSTREAM_AUDIO_SOURCE")); assert!(UI_SRC.contains("LESAVKA_EYE_RECORD_DOWNSTREAM_AUDIO_SOURCE")); assert!(UI_SRC.contains("[1:a][2:a]amix=inputs=2:duration=shortest:normalize=0[a]")); assert!(UI_SRC.contains("record_button.connect_clicked")); + assert!(UI_SRC.contains("preview.start_recording_tap(monitor_id, recording_surface)")); + assert!(UI_SRC.contains("spawn_raw_video_encoder(")); + assert!(UI_SRC.contains("\"-f\",\n \"rawvideo\"")); assert!(UI_SRC.contains("button.add_css_class(\"recording-active\");")); assert!(UI_SRC.contains("button.remove_css_class(\"recording-active\");")); assert!(UI_SRC.contains("recording saved to")); - assert!(UI_SRC.contains("press Stop to finish.")); + assert!(UI_SRC.contains("press the record button again to finish.")); + assert!(!UI_SRC.contains("queue_record_frame(")); + assert!(!UI_SRC.contains("frame-%06d.png")); assert!(UI_SRC.contains("fn default_eye_capture_root() -> PathBuf")); assert!(UI_SRC.contains(".join(\"Pictures\").join(\"lesavka\")")); assert!(UI_SRC.contains("fn capture_day_slug() -> String")); @@ -353,6 +358,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() { ) ); assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked")); + assert!(UI_SRC.contains("widgets.wake_combo.connect_changed")); + assert!(UI_SRC.contains("write_wake_control_request(&path, minutes)")); + assert!(UI_RUNTIME_SRC.contains("WAKE_CONTROL_ENV")); + assert!(UI_RUNTIME_SRC.contains("command.env(WAKE_CONTROL_ENV, wake_path);")); assert!(UI_SRC.contains("recover_usb_soft(&server_addr)")); assert!(UI_SRC.contains("recover_uac_soft(&server_addr)")); assert!(UI_SRC.contains("recover_uvc_soft(&server_addr)"));