fix(client): keep eye recording nonblocking
This commit is contained in:
parent
589c7eb978
commit
72adce5322
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: >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],
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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\","));
|
||||
|
||||
@ -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)"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user