1pub mod mp4;
40pub mod ogg;
41
42use std::collections::VecDeque;
43use std::os::unix::fs::OpenOptionsExt;
44use std::os::unix::net::UnixStream;
45use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
46use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
47use std::sync::{Arc, Mutex, mpsc};
48use std::thread;
49use std::time::{Duration, Instant};
50
51use crate::CaptureSettings;
52use crate::ThreadCommand;
53use crate::encoders::software::EncodedStripe;
54
55const QUEUE_CAP: usize = 256;
58
59enum Tap {
63 #[expect(
64 clippy::rc_buffer,
65 reason = "the encoder's buffer as it hands it over; an `Arc<[u8]>` would copy every frame"
66 )]
67 Video(Arc<Vec<u8>>, usize, u64),
68 Audio(Vec<u8>, u64),
69}
70
71const EARLY_AUDIO_CAP: usize = 500;
74
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum PreferredBackend {
78 X11,
79 Wayland,
80}
81
82#[derive(Clone)]
85pub struct RecordOptions {
86 pub path: String,
87 pub display_id: u32,
88 pub fps: f64,
91 pub bitrate_kbps: i32,
93 pub keyframe_interval_s: f64,
97 pub backend: Option<PreferredBackend>,
98 pub capture: Option<CaptureSettings>,
101 pub audio_socket: String,
103}
104
105impl RecordOptions {
106 pub fn from_env(path: String) -> Self {
108 let f = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<f64>().ok());
109 let backend = match std::env::var("PIXELFLUX_RECORD_BACKEND").ok().as_deref() {
110 Some("x11") => Some(PreferredBackend::X11),
111 Some("wayland") => Some(PreferredBackend::Wayland),
112 _ => None,
113 };
114 Self {
115 path,
116 display_id: f("PIXELFLUX_RECORD_DISPLAY").map(|v| v as u32).unwrap_or(0),
117 fps: f("PIXELFLUX_RECORD_FPS").unwrap_or(0.0),
118 bitrate_kbps: f("PIXELFLUX_RECORD_BITRATE").map(|v| v as i32).unwrap_or(0),
119 keyframe_interval_s: f("PIXELFLUX_RECORD_KEYFRAME_S").unwrap_or(0.0),
120 backend,
121 capture: None,
122 audio_socket: std::env::var("PIXELFLUX_RECORD_AUDIO").unwrap_or_default(),
123 }
124 }
125
126 fn effective_fps(&self) -> f64 {
127 if self.fps > 0.0 { self.fps } else { 30.0 }
128 }
129
130 fn effective_keyframe_s(&self) -> f64 {
131 if self.keyframe_interval_s > 0.0 {
132 self.keyframe_interval_s
133 } else {
134 2.0
135 }
136 }
137}
138
139struct RecShared {
142 start: Instant,
143 enqueued: AtomicU64,
144 dropped: AtomicU64,
145 skipped_non_h264: AtomicU64,
146 muxed: AtomicU64,
147 sync_frames: AtomicU64,
148 audio_muxed: AtomicU64,
149 bytes: AtomicU64,
150 width: AtomicU32,
151 height: AtomicU32,
152 last_idr_req_us: AtomicU64,
153 error: Mutex<Option<String>>,
154}
155
156impl RecShared {
157 fn new() -> Arc<Self> {
158 Arc::new(Self {
159 start: Instant::now(),
160 enqueued: AtomicU64::new(0),
161 dropped: AtomicU64::new(0),
162 skipped_non_h264: AtomicU64::new(0),
163 muxed: AtomicU64::new(0),
164 sync_frames: AtomicU64::new(0),
165 audio_muxed: AtomicU64::new(0),
166 bytes: AtomicU64::new(0),
167 width: AtomicU32::new(0),
168 height: AtomicU32::new(0),
169 last_idr_req_us: AtomicU64::new(0),
170 error: Mutex::new(None),
171 })
172 }
173
174 fn set_error(&self, msg: String) {
175 let mut g = self.error.lock().unwrap();
176 if g.is_none() {
177 eprintln!("[recorder] {msg}");
178 *g = Some(msg);
179 }
180 }
181}
182
183#[derive(Clone, Debug)]
185pub struct RecordingStatus {
186 pub active: bool,
187 pub path: String,
188 pub backend: &'static str,
189 pub mode: &'static str,
190 pub frames: u64,
191 pub sync_frames: u64,
192 pub audio_frames: u64,
193 pub dropped: u64,
194 pub skipped_non_h264: u64,
195 pub bytes: u64,
196 pub duration_s: f64,
197 pub width: u32,
198 pub height: u32,
199 pub error: Option<String>,
200}
201
202#[derive(Clone)]
205struct WlTap {
206 display_id: u32,
207 tx: SyncSender<Tap>,
208 shared: Arc<RecShared>,
209 idr: Option<IdrRequester>,
212 keyframe_interval_us: u64,
213}
214
215#[derive(Clone)]
217struct IdrRequester {
218 tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
219 display_id: u32,
220}
221
222impl IdrRequester {
223 fn request(&self) {
224 let _ = self.tx.send(ThreadCommand::RequestIdr {
225 display_id: self.display_id,
226 });
227 }
228}
229
230enum RecordingMode {
232 X11Own {
234 controls: Arc<crate::x11::Controls>,
235 join: thread::JoinHandle<()>,
236 },
237 WaylandOwn { display_id: u32 },
240 WaylandAttached,
242}
243
244struct ActiveRecording {
245 path: String,
246 backend: &'static str,
247 mode_name: &'static str,
248 mode: RecordingMode,
249 tx: SyncSender<Tap>,
250 shared: Arc<RecShared>,
251 writer: thread::JoinHandle<()>,
252 audio_stop: Arc<AtomicBool>,
253 audio: Option<thread::JoinHandle<()>>,
254}
255
256static ACTIVE: Mutex<Option<ActiveRecording>> = Mutex::new(None);
257static LAST_FINISHED: Mutex<Option<RecordingStatus>> = Mutex::new(None);
258static WL_TAP_ARMED: AtomicBool = AtomicBool::new(false);
261static WL_TAP: Mutex<Option<WlTap>> = Mutex::new(None);
262
263pub(crate) fn wayland_tap(display_id: u32, stripes: &[EncodedStripe]) {
267 if !WL_TAP_ARMED.load(Ordering::Relaxed) {
268 return;
269 }
270 let Some(tap) = WL_TAP.lock().unwrap().clone() else {
271 return;
272 };
273 if tap.display_id != display_id {
274 return;
275 }
276 offer_frame(&tap.shared, &tap.tx, stripes);
277 if let Some(ref idr) = tap.idr {
278 pace_idr_requests(&tap.shared, idr, tap.keyframe_interval_us);
279 }
280}
281
282fn pace_idr_requests(shared: &RecShared, idr: &IdrRequester, interval_us: u64) {
285 if interval_us == 0 {
286 return;
287 }
288 let now = shared.start.elapsed().as_micros() as u64;
289 let last = shared.last_idr_req_us.load(Ordering::Relaxed);
290 if now.saturating_sub(last) >= interval_us
291 && shared
292 .last_idr_req_us
293 .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
294 .is_ok()
295 {
296 idr.request();
297 }
298}
299
300fn offer_frame(shared: &RecShared, tx: &SyncSender<Tap>, stripes: &[EncodedStripe]) {
304 if stripes.is_empty() {
305 return;
306 }
307 if stripes.len() != 1
308 || stripes[0].codec != crate::encoders::Codec::H264
309 || stripes[0].stripe_y_start != 0
310 {
311 shared.skipped_non_h264.fetch_add(1, Ordering::Relaxed);
312 return;
313 }
314 let s = &stripes[0];
315 if s.data.is_empty() {
316 return;
317 }
318 let offset = if s.data.len() >= 10 && s.data[0] == 0x04 {
319 10
320 } else {
321 0
322 };
323 if s.data.len() == offset {
324 return;
325 }
326 let pts_us = shared.start.elapsed().as_micros() as u64;
327 match tx.try_send(Tap::Video(s.data.clone(), offset, pts_us)) {
328 Ok(()) => {
329 shared.enqueued.fetch_add(1, Ordering::Relaxed);
330 }
331 Err(TrySendError::Full(_)) => {
332 shared.dropped.fetch_add(1, Ordering::Relaxed);
333 }
334 Err(TrySendError::Disconnected(_)) => {}
335 }
336}
337
338fn writer_thread(
343 rx: Receiver<Tap>,
344 file: std::fs::File,
345 path: String,
346 backend: &'static str,
347 mode_name: &'static str,
348 audio: Option<mp4::AudioTrackConfig>,
349 shared: Arc<RecShared>,
350) {
351 let mut writer = mp4::FragmentWriter::new(std::io::BufWriter::new(file));
352 if let Some(cfg) = audio {
353 writer = writer.with_audio(cfg);
354 }
355 let mut builder = mp4::H264SampleBuilder::new();
356 let mut early_audio: VecDeque<(Vec<u8>, u64)> = VecDeque::new();
357
358 'recv: for item in rx.iter() {
359 let (buf, offset, pts_us) = match item {
360 Tap::Video(buf, offset, pts_us) => (buf, offset, pts_us),
361 Tap::Audio(packet, dts) => {
362 if !writer.init_written() {
363 if early_audio.len() >= EARLY_AUDIO_CAP {
364 early_audio.pop_front();
365 }
366 early_audio.push_back((packet, dts));
367 } else if let Err(e) = writer.push_audio(packet, dts) {
368 shared.set_error(format!("MP4 write failed: {e}"));
369 break 'recv;
370 }
371 shared
372 .audio_muxed
373 .store(writer.stats().audio_samples, Ordering::Relaxed);
374 continue;
375 }
376 };
377 let Some(sample) = builder.build_sample(&buf[offset..]) else {
378 continue;
379 };
380 if !writer.init_written() {
381 if !sample.sync || !builder.have_parameter_sets() {
382 continue;
383 }
384 let Some(cfg) = builder.track_config() else {
385 shared.set_error("failed to parse H.264 SPS for MP4 init".to_string());
386 break 'recv;
387 };
388 shared.width.store(cfg.width, Ordering::Relaxed);
389 shared.height.store(cfg.height, Ordering::Relaxed);
390 if let Err(e) = writer.write_init(&cfg) {
391 shared.set_error(format!("MP4 init write failed: {e}"));
392 break 'recv;
393 }
394 for (packet, dts) in early_audio.drain(..) {
395 if let Err(e) = writer.push_audio(packet, dts) {
396 shared.set_error(format!("MP4 write failed: {e}"));
397 break 'recv;
398 }
399 }
400 }
401 if let Err(e) = writer.push_sample(sample.data, sample.sync, pts_us) {
402 shared.set_error(format!("MP4 write failed: {e}"));
403 break 'recv;
404 }
405 let st = writer.stats();
406 shared.muxed.store(st.samples + 1, Ordering::Relaxed);
408 shared.sync_frames.store(st.sync_samples, Ordering::Relaxed);
409 shared.bytes.store(st.bytes, Ordering::Relaxed);
410 }
411
412 let final_stats = match writer.finish() {
413 Ok(st) => st,
414 Err(e) => {
415 shared.set_error(format!("MP4 finalize failed: {e}"));
416 mp4::Mp4Stats::default()
417 }
418 };
419 if final_stats.samples == 0 {
420 shared.set_error(
421 "no recordable H.264 frames received (the session must produce a single \
422 full-frame H.264 stream; JPEG and the other codecs cannot be recorded)"
423 .to_string(),
424 );
425 let _ = std::fs::remove_file(&path);
426 }
427 let status = RecordingStatus {
428 active: false,
429 path,
430 backend,
431 mode: mode_name,
432 frames: final_stats.samples,
433 sync_frames: final_stats.sync_samples,
434 audio_frames: final_stats.audio_samples,
435 dropped: shared.dropped.load(Ordering::Relaxed),
436 skipped_non_h264: shared.skipped_non_h264.load(Ordering::Relaxed),
437 bytes: final_stats.bytes,
438 duration_s: final_stats.duration_us as f64 / 1e6,
439 width: shared.width.load(Ordering::Relaxed),
440 height: shared.height.load(Ordering::Relaxed),
441 error: shared.error.lock().unwrap().clone(),
442 };
443 println!(
444 "[recorder] finished {}: {} frames ({} sync), {} audio packets, {:.2}s, {} bytes",
445 status.path,
446 status.frames,
447 status.sync_frames,
448 status.audio_frames,
449 status.duration_s,
450 status.bytes
451 );
452 *LAST_FINISHED.lock().unwrap() = Some(status);
453}
454
455fn open_audio(path: &str) -> Result<(ogg::PageReader<UnixStream>, ogg::OpusHead), String> {
458 let deadline = Instant::now() + Duration::from_secs(2);
459 let stream = loop {
460 match UnixStream::connect(path) {
461 Ok(s) => break s,
462 Err(e) if Instant::now() < deadline => {
463 let _ = e;
464 thread::sleep(Duration::from_millis(100));
465 }
466 Err(e) => return Err(format!("audio socket {path}: {e}")),
467 }
468 };
469 stream
470 .set_read_timeout(Some(Duration::from_secs(2)))
471 .map_err(|e| e.to_string())?;
472 let mut reader = ogg::PageReader::new(stream);
473 let (first, _) = reader
474 .next_packet()
475 .map_err(|e| format!("audio socket {path}: {e}"))?
476 .ok_or_else(|| format!("audio socket {path} closed before its headers"))?;
477 let head = ogg::parse_opus_head(&first)
478 .ok_or_else(|| format!("audio socket {path} does not start with OpusHead"))?;
479 reader
480 .next_packet()
481 .map_err(|e| format!("audio socket {path}: {e}"))?;
482 Ok((reader, head))
483}
484
485fn page_start(position: u64, granule: u64, samples: u64) -> u64 {
491 granule.saturating_sub(samples).max(position)
492}
493
494fn audio_thread(
499 mut reader: ogg::PageReader<UnixStream>,
500 tx: SyncSender<Tap>,
501 shared: Arc<RecShared>,
502 stop: Arc<AtomicBool>,
503) {
504 let _ = reader
505 .inner_mut()
506 .set_read_timeout(Some(Duration::from_millis(200)));
507 let mut position: u64 = 0;
508 let mut anchor: Option<(u64, u64)> = None;
509 let mut page: Vec<(Vec<u8>, u64)> = Vec::new();
510 let mut open = true;
511 while open {
512 let granule = if stop.load(Ordering::Relaxed) {
513 None
514 } else {
515 match reader.next_packet() {
516 Ok(Some((packet, granule))) => {
517 let samples = u64::from(mp4::opus_packet_samples(&packet));
518 page.push((packet, samples));
519 match granule {
520 Some(granule) => Some(granule),
521 None => continue,
522 }
523 }
524 Ok(None) => None,
525 Err(e)
526 if matches!(
527 e.kind(),
528 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
529 ) =>
530 {
531 continue;
532 }
533 Err(e) => {
534 eprintln!("[recorder] audio stream ended: {e}");
535 None
536 }
537 }
538 };
539 open = granule.is_some();
540 let samples: u64 = page.iter().map(|p| p.1).sum();
541 position = granule.map_or(position, |g| page_start(position, g, samples));
542 let (clock, base) = *anchor.get_or_insert_with(|| {
543 (
544 shared.start.elapsed().as_micros() as u64 * u64::from(mp4::OPUS_TIMESCALE)
545 / 1_000_000,
546 position,
547 )
548 });
549 for (packet, samples) in page.drain(..) {
550 let dts = clock + position.saturating_sub(base);
551 position += samples;
552 match tx.try_send(Tap::Audio(packet, dts)) {
553 Ok(()) => {}
554 Err(TrySendError::Full(_)) => {
555 shared.dropped.fetch_add(1, Ordering::Relaxed);
556 }
557 Err(TrySendError::Disconnected(_)) => return,
558 }
559 }
560 }
561}
562
563fn own_capture_settings(opts: &RecordOptions) -> Result<CaptureSettings, String> {
567 let mut s = match &opts.capture {
568 Some(explicit) => {
569 if explicit.codec != crate::encoders::Codec::H264 {
570 return Err("recording requires H.264 capture settings (codec='h264')".to_string());
571 }
572 explicit.clone()
573 }
574 None => CaptureSettings {
575 width: 0,
576 height: 0,
577 codec: crate::encoders::Codec::H264,
578 capture_cursor: true,
579 target_fps: opts.effective_fps(),
580 ..Default::default()
581 },
582 };
583 s.recording_socket = String::new();
584 if opts.fps > 0.0 {
585 s.target_fps = opts.fps;
586 }
587 if opts.bitrate_kbps > 0 {
588 s.video_bitrate_kbps = opts.bitrate_kbps;
589 }
590 if s.keyframe_interval_s <= 0.0 {
591 s.keyframe_interval_s = opts.effective_keyframe_s();
592 }
593 Ok(s)
594}
595
596pub fn start(opts: RecordOptions) -> Result<RecordingStatus, String> {
598 let mut guard = ACTIVE.lock().unwrap();
599 if guard.is_some() {
600 return Err("a recording is already active".to_string());
601 }
602 if opts.path.is_empty() {
603 return Err("recording path is empty".to_string());
604 }
605 if let Some(cap) = &opts.capture
606 && cap.codec != crate::encoders::Codec::H264
607 {
608 return Err("recording requires H.264 capture settings (codec='h264')".to_string());
609 }
610
611 let wl_tx = crate::computer_use::wayland_command_sender();
612 let backend = match opts.backend {
613 Some(PreferredBackend::X11) => PreferredBackend::X11,
614 Some(PreferredBackend::Wayland) => {
615 if wl_tx.is_none() {
616 return Err("no Wayland compositor is running in this process".to_string());
617 }
618 PreferredBackend::Wayland
619 }
620 None => {
621 if wl_tx.is_some() {
622 PreferredBackend::Wayland
623 } else if std::env::var("DISPLAY")
624 .map(|v| !v.is_empty())
625 .unwrap_or(false)
626 {
627 PreferredBackend::X11
628 } else {
629 return Err(
630 "no capture backend available: no in-process Wayland compositor and DISPLAY is unset"
631 .to_string(),
632 );
633 }
634 }
635 };
636
637 let file = open_recording(&opts.path)?;
638 let shared = RecShared::new();
639 let (tx, rx) = sync_channel::<Tap>(QUEUE_CAP);
640 let audio_source = if opts.audio_socket.is_empty() {
641 None
642 } else {
643 match open_audio(&opts.audio_socket) {
644 Ok(source) => Some(source),
645 Err(e) => {
646 eprintln!("[recorder] recording without sound: {e}");
647 None
648 }
649 }
650 };
651
652 let (mode, mode_name, backend_name) = match backend {
653 PreferredBackend::Wayland => {
654 let cmd_tx = wl_tx.unwrap();
655 let display_id = opts.display_id;
656 let attached = crate::wayland_alive().lock().unwrap().contains(&display_id);
657 if attached {
658 *WL_TAP.lock().unwrap() = Some(WlTap {
659 display_id,
660 tx: tx.clone(),
661 shared: shared.clone(),
662 idr: Some(IdrRequester {
663 tx: cmd_tx.clone(),
664 display_id,
665 }),
666 keyframe_interval_us: (opts.effective_keyframe_s() * 1e6) as u64,
667 });
668 WL_TAP_ARMED.store(true, Ordering::Relaxed);
669 let _ = cmd_tx.send(ThreadCommand::RequestIdr { display_id });
672 (RecordingMode::WaylandAttached, "attached", "wayland")
673 } else {
674 let mut settings = own_capture_settings(&opts)?;
675 if settings.width <= 0 || settings.height <= 0 {
676 let (reply_tx, reply_rx) = mpsc::channel();
677 cmd_tx
678 .send(ThreadCommand::ListOutputs { reply: reply_tx })
679 .map_err(|_| "wayland compositor is not accepting commands".to_string())?;
680 let outputs = reply_rx
681 .recv_timeout(Duration::from_secs(5))
682 .map_err(|_| "wayland compositor did not report its outputs".to_string())?;
683 let out = outputs
684 .iter()
685 .find(|o| o.0 == display_id)
686 .ok_or_else(|| format!("no wayland output with display id {display_id}"))?;
687 settings.width = out.3;
688 settings.height = out.4;
689 settings.scale = out.5;
690 }
691 *WL_TAP.lock().unwrap() = Some(WlTap {
692 display_id,
693 tx: tx.clone(),
694 shared: shared.clone(),
695 idr: None,
696 keyframe_interval_us: 0,
697 });
698 WL_TAP_ARMED.store(true, Ordering::Relaxed);
699 cmd_tx
700 .send(ThreadCommand::StartCapture {
701 display_id,
702 callback: None,
703 settings,
704 })
705 .map_err(|_| {
706 disarm_tap();
707 "wayland compositor is not accepting commands".to_string()
708 })?;
709 let deadline = Instant::now() + Duration::from_secs(3);
711 loop {
712 if crate::wayland_alive().lock().unwrap().contains(&display_id) {
713 break;
714 }
715 if Instant::now() >= deadline {
716 disarm_tap();
717 return Err(format!(
718 "wayland capture on display {display_id} failed to start"
719 ));
720 }
721 thread::sleep(Duration::from_millis(20));
722 }
723 (
724 RecordingMode::WaylandOwn { display_id },
725 "own-capture",
726 "wayland",
727 )
728 }
729 }
730 PreferredBackend::X11 => {
731 let settings = own_capture_settings(&opts)?;
732 let controls = Arc::new(crate::x11::Controls::new(&settings));
733 crate::live_x11().lock().unwrap().push(controls.clone());
734 let err_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
735 let err_slot2 = err_slot.clone();
736 let (etid_tx, etid_rx) = mpsc::channel();
737 let cap_controls = controls.clone();
738 let feed_tx = tx.clone();
739 let feed_shared = shared.clone();
740 let join = thread::spawn(move || {
741 let on_frame = move |stripes: Vec<EncodedStripe>| {
742 offer_frame(&feed_shared, &feed_tx, &stripes);
743 };
744 if let Err(e) = crate::x11::run_capture(settings, cap_controls, etid_tx, on_frame) {
745 eprintln!("[recorder] x11 capture error: {e}");
746 *err_slot2.lock().unwrap() = Some(e);
747 }
748 });
749 match etid_rx.recv_timeout(Duration::from_secs(3)) {
750 Ok(_) => {}
751 Err(mpsc::RecvTimeoutError::Disconnected) => {
752 let _ = join.join();
753 crate::live_x11()
754 .lock()
755 .unwrap()
756 .retain(|c| !Arc::ptr_eq(c, &controls));
757 let msg = err_slot
758 .lock()
759 .unwrap()
760 .clone()
761 .unwrap_or_else(|| "X11 capture exited during start".to_string());
762 return Err(msg);
763 }
764 Err(mpsc::RecvTimeoutError::Timeout) => {}
766 }
767 (
768 RecordingMode::X11Own { controls, join },
769 "own-capture",
770 "x11",
771 )
772 }
773 };
774
775 let audio_stop = Arc::new(AtomicBool::new(false));
776 let (audio_cfg, audio) = match audio_source {
777 Some((reader, head)) => {
778 let cfg = mp4::AudioTrackConfig {
779 sample_entry: mp4::opus_sample_entry(&head),
780 };
781 let (tx, shared, stop) = (tx.clone(), shared.clone(), audio_stop.clone());
782 let join = thread::Builder::new()
783 .name("pf-recorder-audio".to_string())
784 .spawn(move || audio_thread(reader, tx, shared, stop))
785 .map_err(|e| format!("failed to spawn recorder audio thread: {e}"))?;
786 (Some(cfg), Some(join))
787 }
788 None => (None, None),
789 };
790 let writer = {
791 let shared = shared.clone();
792 let path = opts.path.clone();
793 thread::Builder::new()
794 .name("pf-recorder".to_string())
795 .spawn(move || {
796 writer_thread(rx, file, path, backend_name, mode_name, audio_cfg, shared)
797 })
798 .map_err(|e| format!("failed to spawn recorder writer thread: {e}"))?
799 };
800
801 println!(
802 "[recorder] recording to {} ({} {})",
803 opts.path, backend_name, mode_name
804 );
805 let status = RecordingStatus {
806 active: true,
807 path: opts.path.clone(),
808 backend: backend_name,
809 mode: mode_name,
810 frames: 0,
811 sync_frames: 0,
812 audio_frames: 0,
813 dropped: 0,
814 skipped_non_h264: 0,
815 bytes: 0,
816 duration_s: 0.0,
817 width: 0,
818 height: 0,
819 error: None,
820 };
821 *guard = Some(ActiveRecording {
822 path: opts.path,
823 backend: backend_name,
824 mode_name,
825 mode,
826 tx,
827 shared,
828 writer,
829 audio_stop,
830 audio,
831 });
832 Ok(status)
833}
834
835fn disarm_tap() {
836 WL_TAP_ARMED.store(false, Ordering::Relaxed);
837 *WL_TAP.lock().unwrap() = None;
838}
839
840pub fn stop() -> Result<RecordingStatus, String> {
843 let active = ACTIVE
844 .lock()
845 .unwrap()
846 .take()
847 .ok_or_else(|| "no recording is active".to_string())?;
848 disarm_tap();
849 match active.mode {
850 RecordingMode::X11Own { controls, join } => {
851 controls.stop.store(true, Ordering::Relaxed);
852 let _ = join.join();
853 crate::live_x11()
854 .lock()
855 .unwrap()
856 .retain(|c| !Arc::ptr_eq(c, &controls));
857 }
858 RecordingMode::WaylandOwn { display_id } => {
859 let client_owns = crate::wayland_owners()
862 .lock()
863 .unwrap()
864 .contains_key(&display_id);
865 if !client_owns && let Some(tx) = crate::computer_use::wayland_command_sender() {
866 let _ = tx.send(ThreadCommand::StopCapture { display_id });
867 let (ack_tx, ack_rx) = mpsc::channel();
871 if tx.send(ThreadCommand::Barrier { reply: ack_tx }).is_ok() {
872 let _ = ack_rx.recv_timeout(Duration::from_secs(2));
873 }
874 }
875 }
876 RecordingMode::WaylandAttached => {}
877 }
878 active.audio_stop.store(true, Ordering::Relaxed);
881 if let Some(join) = active.audio {
882 let _ = join.join();
883 }
884 drop(active.tx);
885 let _ = active.writer.join();
886 let finished = LAST_FINISHED
887 .lock()
888 .unwrap()
889 .clone()
890 .ok_or_else(|| format!("recording of {} produced no final status", active.path))?;
891 match finished.error {
892 Some(ref e) => Err(e.clone()),
893 None => Ok(finished),
894 }
895}
896
897pub fn status() -> Option<RecordingStatus> {
900 let guard = ACTIVE.lock().unwrap();
901 if let Some(a) = guard.as_ref() {
902 return Some(RecordingStatus {
903 active: true,
904 path: a.path.clone(),
905 backend: a.backend,
906 mode: a.mode_name,
907 frames: a.shared.muxed.load(Ordering::Relaxed),
908 sync_frames: a.shared.sync_frames.load(Ordering::Relaxed),
909 audio_frames: a.shared.audio_muxed.load(Ordering::Relaxed),
910 dropped: a.shared.dropped.load(Ordering::Relaxed),
911 skipped_non_h264: a.shared.skipped_non_h264.load(Ordering::Relaxed),
912 bytes: a.shared.bytes.load(Ordering::Relaxed),
913 duration_s: a.shared.start.elapsed().as_secs_f64(),
914 width: a.shared.width.load(Ordering::Relaxed),
915 height: a.shared.height.load(Ordering::Relaxed),
916 error: a.shared.error.lock().unwrap().clone(),
917 });
918 }
919 drop(guard);
920 LAST_FINISHED.lock().unwrap().clone()
921}
922
923pub fn finalize_on_exit() {
926 if ACTIVE.lock().unwrap().is_some() {
927 let _ = stop();
928 }
929}
930
931pub fn autostart_from_env() {
935 use std::sync::OnceLock;
936 static STARTED: OnceLock<()> = OnceLock::new();
937 let Ok(path) = std::env::var("PIXELFLUX_RECORD") else {
938 return;
939 };
940 if path.is_empty() {
941 return;
942 }
943 let mut first = false;
944 STARTED.get_or_init(|| first = true);
945 if !first {
946 return;
947 }
948 thread::spawn(move || {
949 let opts = RecordOptions::from_env(path);
950 let deadline = Instant::now() + Duration::from_secs(300);
951 let mut last_err = String::new();
952 loop {
953 let wayland_up = crate::computer_use::wayland_command_sender().is_some();
954 let x11_up = std::env::var("DISPLAY")
955 .map(|v| !v.is_empty())
956 .unwrap_or(false);
957 let ready = match opts.backend {
958 Some(PreferredBackend::X11) => x11_up,
959 Some(PreferredBackend::Wayland) => wayland_up,
960 None => wayland_up || x11_up,
961 };
962 if ready {
963 match start(opts.clone()) {
964 Ok(_) => return,
965 Err(e) => last_err = e,
966 }
967 }
968 if Instant::now() >= deadline {
969 eprintln!(
970 "[recorder] PIXELFLUX_RECORD autostart gave up: {}",
971 if last_err.is_empty() {
972 "no capture backend appeared"
973 } else {
974 &last_err
975 }
976 );
977 return;
978 }
979 thread::sleep(Duration::from_millis(250));
980 }
981 });
982}
983
984pub fn status_to_json(s: &RecordingStatus) -> serde_json::Value {
986 serde_json::json!({
987 "active": s.active,
988 "path": s.path,
989 "backend": s.backend,
990 "mode": s.mode,
991 "frames": s.frames,
992 "sync_frames": s.sync_frames,
993 "audio_frames": s.audio_frames,
994 "dropped": s.dropped,
995 "skipped_non_h264": s.skipped_non_h264,
996 "bytes": s.bytes,
997 "duration_s": s.duration_s,
998 "width": s.width,
999 "height": s.height,
1000 "error": s.error,
1001 })
1002}
1003
1004fn open_recording(path: &str) -> Result<std::fs::File, String> {
1008 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1009 let file = std::fs::OpenOptions::new()
1010 .write(true)
1011 .create(true)
1012 .truncate(true)
1013 .mode(0o600)
1014 .custom_flags(libc::O_NOFOLLOW)
1015 .open(path)
1016 .map_err(|e| format!("cannot create {path}: {e}"))?;
1017 let meta = file
1018 .metadata()
1019 .map_err(|e| format!("cannot inspect {path}: {e}"))?;
1020 if meta.uid() != unsafe { libc::geteuid() } || meta.nlink() != 1 {
1021 return Err(format!("{path} is not this user's file alone"));
1022 }
1023 file.set_permissions(std::fs::Permissions::from_mode(0o600))
1024 .map_err(|e| format!("cannot restrict {path}: {e}"))?;
1025 Ok(file)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use std::io::Write;
1032
1033 #[test]
1036 fn a_recording_opens_only_into_a_file_of_this_users_alone() {
1037 use std::os::unix::fs::PermissionsExt;
1038 let dir = std::env::temp_dir().join(format!("pf-record-open-{}", std::process::id()));
1039 let _ = std::fs::remove_dir_all(&dir);
1040 std::fs::create_dir(&dir).unwrap();
1041 let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
1042 let mode = |name: &str| {
1043 std::fs::metadata(dir.join(name))
1044 .unwrap()
1045 .permissions()
1046 .mode()
1047 & 0o777
1048 };
1049
1050 assert!(open_recording(&p("new.mp4")).is_ok());
1051 assert_eq!(mode("new.mp4"), 0o600);
1052 std::fs::write(dir.join("own.mp4"), b"old").unwrap();
1053 std::fs::set_permissions(dir.join("own.mp4"), std::fs::Permissions::from_mode(0o644))
1054 .unwrap();
1055 assert!(open_recording(&p("own.mp4")).is_ok());
1056 assert_eq!(mode("own.mp4"), 0o600);
1057 std::os::unix::fs::symlink(dir.join("own.mp4"), dir.join("link.mp4")).unwrap();
1058 assert!(open_recording(&p("link.mp4")).is_err());
1059 std::fs::hard_link(dir.join("own.mp4"), dir.join("second-name.mp4")).unwrap();
1060 assert!(open_recording(&p("second-name.mp4")).is_err());
1061 std::fs::remove_dir_all(&dir).unwrap();
1062 }
1063
1064 fn ogg_page(granule: u64, packets: &[&[u8]]) -> Vec<u8> {
1065 let mut out = b"OggS\0\0".to_vec();
1066 out.extend_from_slice(&granule.to_le_bytes());
1067 out.extend_from_slice(&[0u8; 12]);
1068 out.push(packets.len() as u8);
1069 out.extend(packets.iter().map(|p| p.len() as u8));
1070 packets.iter().for_each(|p| out.extend_from_slice(p));
1071 out
1072 }
1073
1074 #[test]
1078 fn audio_packets_keep_their_place_across_a_gap() {
1079 let (mut writer, reader) = UnixStream::pair().unwrap();
1080 let packet: &[u8] = &[0xf8, 0, 0];
1081 for (granule, packets) in [(960, 1), (1_920, 1), (50_880, 1), (52_800, 2), (53_000, 1)] {
1082 writer
1083 .write_all(&ogg_page(granule, &vec![packet; packets]))
1084 .unwrap();
1085 }
1086 drop(writer);
1087 let (tx, rx) = sync_channel::<Tap>(16);
1088 audio_thread(
1089 ogg::PageReader::new(reader),
1090 tx,
1091 RecShared::new(),
1092 Arc::new(AtomicBool::new(false)),
1093 );
1094 let dts: Vec<u64> = rx
1095 .try_iter()
1096 .map(|t| match t {
1097 Tap::Audio(_, dts) => dts,
1098 _ => unreachable!(),
1099 })
1100 .collect();
1101 assert_eq!(
1102 dts.iter().map(|d| d - dts[0]).collect::<Vec<_>>(),
1103 [0, 960, 49_920, 50_880, 51_840, 52_800]
1104 );
1105 }
1106}