fix(client): keep eye recording nonblocking

This commit is contained in:
Brad Stein 2026-05-18 12:44:23 -03:00
parent 589c7eb978
commit 72adce5322
31 changed files with 620 additions and 185 deletions

6
Cargo.lock generated
View File

@ -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",

View File

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

View File

@ -68,6 +68,13 @@ pub struct InputAggregator {
#[cfg(not(coverage))]
clipboard_control_marker: u128,
#[cfg(not(coverage))]
wake_control_path: Option<PathBuf>,
#[cfg(not(coverage))]
last_wake_request_raw: Option<String>,
wake_interval: Option<Duration>,
last_hid_relayed_at: Instant,
wake_nonce: u64,
#[cfg(not(coverage))]
routing_state_path: Option<PathBuf>,
#[cfg(not(coverage))]
published_remote_capture: Option<bool>,

View File

@ -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,

View File

@ -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))]

View File

@ -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;

View File

@ -116,6 +116,24 @@ fn parse_launcher_routing_request(raw: &str) -> Option<bool> {
}
}
#[cfg(not(coverage))]
fn parse_wake_interval(raw: &str) -> Option<Duration> {
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)

View File

@ -135,20 +135,22 @@ struct MouseRuntime<'a> {
}
impl MouseRuntime<'_> {
fn replay_events(&mut self, events: Vec<InputEvent>) {
fn replay_events(&mut self, events: Vec<InputEvent>) -> 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]

View File

@ -177,6 +177,23 @@ impl PreviewFeed {
.map(|mut shared| shared.telemetry.snapshot())
.unwrap_or_default()
}
fn start_recording_tap(&self) -> Option<PreviewRecordingTap> {
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<u8>,
}
#[cfg(not(coverage))]
#[derive(Clone)]
pub(crate) struct PreviewFrameSnapshot {
pub width: i32,
pub height: i32,
pub stride: usize,
pub rgba: Vec<u8>,
}
#[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(

View File

@ -47,6 +47,7 @@ struct PreviewFeed {
#[cfg(not(coverage))]
struct SharedPreviewState {
latest: Option<PreviewFrame>,
recorders: Vec<std::sync::mpsc::SyncSender<PreviewFrameSnapshot>>,
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;

View File

@ -72,6 +72,40 @@ pub struct PreviewBinding {
active_bindings: Arc<AtomicUsize>,
}
#[cfg(not(coverage))]
pub(crate) struct PreviewRecordingTap {
rx: std::sync::mpsc::Receiver<PreviewFrameSnapshot>,
active_bindings: Arc<AtomicUsize>,
}
#[cfg(not(coverage))]
impl PreviewRecordingTap {
fn new(
rx: std::sync::mpsc::Receiver<PreviewFrameSnapshot>,
active_bindings: Arc<AtomicUsize>,
) -> Self {
active_bindings.fetch_add(1, Ordering::AcqRel);
Self {
rx,
active_bindings,
}
}
pub(crate) fn recv_timeout(
&self,
timeout: Duration,
) -> Result<PreviewFrameSnapshot, std::sync::mpsc::RecvTimeoutError> {
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<PreviewRecordingTap> {
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,

View File

@ -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());
});
}

View File

@ -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<std::process::Child, String> {
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<u8>), 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<RecordFrameTask>,
frame_tap: PreviewRecordingTap,
control_rx: std::sync::mpsc::Receiver<RecordFrameTask>,
frame_dir: PathBuf,
output_path: PathBuf,
encode_fps: u32,
@ -74,35 +147,78 @@
audio_mode: EyeRecordAudioMode,
audio_paths: Vec<PathBuf>,
) -> Result<PathBuf, String> {
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<std::process::Child> = 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::<RecordFrameTask>();
let (control_tx, control_rx) = std::sync::mpsc::channel::<RecordFrameTask>();
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<PathBuf, String>>();
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,

View File

@ -3,13 +3,12 @@ const DEFAULT_EYE_RECORD_FPS: u32 = 30;
#[derive(Default)]
struct EyeRecordState {
save_dir_override: Option<PathBuf>,
timer: Option<glib::SourceId>,
recording_active: bool,
frame_dir: Option<PathBuf>,
frame_writer_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
recording_control_tx: Option<std::sync::mpsc::Sender<RecordFrameTask>>,
finalize_rx: Option<std::sync::mpsc::Receiver<Result<PathBuf, String>>>,
audio_mode: EyeRecordAudioMode,
audio_recording: Option<EyeAudioRecording>,
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<EyeAudioRecording>) {
}
}
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: &gtk::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],

View File

@ -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();

View File

@ -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,

View File

@ -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(),

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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,

View File

@ -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<u64>) -> 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<InputRouting> {

View File

@ -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);
}

View File

@ -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();

View File

@ -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;

View File

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

View File

@ -16,7 +16,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.23.0"
version = "0.23.1"
edition = "2024"
autobins = false

View File

@ -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() {

View File

@ -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]);
}
}

View File

@ -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\","));

View File

@ -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)"));