use std::collections::HashMap; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::fd::AsRawFd; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use crate::recording::{Audio, Recording}; use futures_util::StreamExt; use gstreamer as gst; use gstreamer::prelude::*; use tokio::sync::watch; use zbus::zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value}; use zbus::{Connection, Proxy}; type Options = HashMap; const DESKTOP: &str = "org.freedesktop.portal.Desktop"; const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; pub fn event(state: &str, message: Option<&str>, path: Option<&Path>) { let value = serde_json::json!({"state": state, "message": message, "path": path}); let mut stdout = std::io::stdout().lock(); let _ = writeln!(stdout, "{value}"); let _ = stdout.flush(); } fn option_string(value: String) -> OwnedValue { OwnedValue::try_from(Value::from(value)).unwrap() } async fn request( connection: &Connection, method: &str, token: &str, body: &B, stop: &mut watch::Receiver, ) -> Result, String> { let sender = connection .unique_name() .ok_or("No D-Bus identity")? .as_str()[1..] .replace('.', "_"); let path = format!("{DESKTOP_PATH}/request/{sender}/{token}"); let request = Proxy::new( connection, DESKTOP, path.as_str(), "org.freedesktop.portal.Request", ) .await .map_err(|e| e.to_string())?; let mut responses = request .receive_signal("Response") .await .map_err(|e| e.to_string())?; let portal = Proxy::new( connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast", ) .await .map_err(|e| e.to_string())?; let returned: OwnedObjectPath = portal .call(method, body) .await .map_err(|e| format!("Screen recording portal unavailable: {e}"))?; if returned.as_str() != path { return Err("Unexpected portal request handle".into()); } if *stop.borrow() { let _: Result<(), _> = request.call("Close", &()).await; return Ok(None); } tokio::select! { _ = stop.changed() => { let _: Result<(), _> = request.call("Close", &()).await; Ok(None) } signal = tokio::time::timeout(Duration::from_secs(125), responses.next()) => { let signal = signal.map_err(|_| "Display selection timed out")?.ok_or("Portal disconnected")?; let (code, options): (u32, Options) = signal.body().deserialize().map_err(|e| e.to_string())?; match code { 0 => Ok(Some(options)), 1 => Ok(None), _ => Err("Screen recording request failed".into()) } } } } pub async fn run() -> Result<(), String> { gst::init().map_err(|e| e.to_string())?; for factory in [ "pipewiresrc", "queue", "videoconvert", "vp8enc", "webmmux", "fdsink", ] { if gst::ElementFactory::find(factory).is_none() { return Err(format!( "Recording needs the GStreamer {factory} plugin. See the recording installation guide." )); } } let output = output_directory()?; let (stop_send, mut stop) = watch::channel(false); std::thread::spawn(move || { let _ = std::io::stdin().read(&mut [0u8]); let _ = stop_send.send(true); }); let connection = Connection::session().await.map_err(|e| e.to_string())?; let token = |step| format!("ferese_record_{}_{step}", std::process::id()); let Some(created) = request( &connection, "CreateSession", &token(0), &(HashMap::from([ ("handle_token", option_string(token(0))), ("session_handle_token", option_string(token(0))), ]),), &mut stop, ) .await? else { event("cancelled", None, None); return Ok(()); }; let handle = created .get("session_handle") .ok_or("Portal returned no session")?; // The public portal historically returns this path as a D-Bus string. let handle = OwnedObjectPath::try_from( String::try_from(handle.try_clone().map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; // Older/foreign backends keep their normal sharing indicator. Only Ferese // can verify this helper's identity and that its parent owns a bar stop button. let native = matches!( tokio::time::timeout(Duration::from_secs(3), async { let control = Proxy::new( &connection, "org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder", "org.ferese.ScreenRecorder", ) .await?; control .call::<_, _, ()>("UseBarControls", &(handle.clone(),)) .await }) .await, Ok(Ok(())) ); let session = Proxy::new( &connection, DESKTOP, handle.as_str(), "org.freedesktop.portal.Session", ) .await .map_err(|e| e.to_string())?; let result = record_session( &connection, &session, &handle, &token, &output, &mut stop, native, ) .await; let _: Result<(), _> = session.call("Close", &()).await; match result { Ok(Some(path)) => event("saved", None, Some(&path)), Ok(None) => event("cancelled", None, None), Err(e) => return Err(e), } Ok(()) } async fn record_session( connection: &Connection, session: &Proxy<'_>, handle: &OwnedObjectPath, token: &impl Fn(u8) -> String, output: &Path, stop: &mut watch::Receiver, native: bool, ) -> Result, String> { let mut closed = session .receive_signal("Closed") .await .map_err(|e| e.to_string())?; if request( connection, "SelectSources", &token(1), &( handle, HashMap::from([ ("handle_token", option_string(token(1))), ("types", 1u32.into()), ("multiple", false.into()), ("cursor_mode", 2u32.into()), ]), ), stop, ) .await? .is_none() { return Ok(None); } event("selecting", None, None); let Some(started) = request( connection, "Start", &token(2), &( handle, "", HashMap::from([("handle_token", option_string(token(2)))]), ), stop, ) .await? else { return Ok(None); }; let streams: Vec<(u32, Options)> = started .get("streams") .ok_or("Portal returned no video stream")? .try_clone() .map_err(|e| e.to_string())? .try_into() .map_err(|e: zbus::zvariant::Error| e.to_string())?; if streams.len() != 1 { return Err("Recording requires exactly one display".into()); } let recording = if native { let control = Proxy::new( connection, "org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder", "org.ferese.ScreenRecorder", ) .await .map_err(|e| e.to_string())?; let options: String = tokio::time::timeout( Duration::from_secs(3), control.call("RecordingOptions", &(handle,)), ) .await .map_err(|_| "Recording options timed out")? .map_err(|e| { format!( "Cannot read recording options; update the Ferese portal and recorder together: {e}" ) })?; serde_json::from_str::(&options).map_err(|e| e.to_string())? } else { Recording::default() }; let portal = Proxy::new( connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast", ) .await .map_err(|e| e.to_string())?; let remote: OwnedFd = portal .call("OpenPipeWireRemote", &(handle, Options::new())) .await .map_err(|e| e.to_string())?; if *stop.borrow() { return Ok(None); } let stop_capture = Arc::new(AtomicBool::new(*stop.borrow())); let worker_stop = stop_capture.clone(); let node = streams[0].0; let output = output.to_owned(); let mut worker = tokio::task::spawn_blocking(move || { let source = gst::ElementFactory::make("pipewiresrc") .property("fd", remote.as_raw_fd()) .property("path", node.to_string()) .property("do-timestamp", true) // Keep queued frames valid when the portal revokes PipeWire buffers. .property("always-copy", true) .build() .map_err(|e| e.to_string())?; let audio = audio_sources(recording.audio)?; let result = encode(source, audio, recording.crop, &output, worker_stop); drop(remote); result }); let result = tokio::select! { result = &mut worker => result, _ = stop.changed() => { stop_capture.store(true, Ordering::Relaxed); worker.await }, _ = closed.next() => { stop_capture.store(true, Ordering::Relaxed); worker.await }, }; result.map_err(|e| e.to_string())?.map(Some) } fn output_directory() -> Result { let args: Vec<_> = std::env::args_os().skip(1).collect(); match args.as_slice() { [] => dirs::video_dir() .or_else(|| dirs::home_dir().map(|p| p.join("Videos"))) .map(|p| p.join("Ferese")) .ok_or("Cannot find your Videos folder".into()), [flag, path] if flag == "--output-dir" => Ok(PathBuf::from(path)), _ => Err("Usage: ferese-record [--output-dir DIRECTORY]".into()), } } fn recording_file(directory: &Path) -> Result<(File, PathBuf, PathBuf), String> { std::fs::create_dir_all(directory) .map_err(|e| format!("Cannot create recording folder: {e}"))?; let name = format!( "Recording-{}-{}", jiff::Zoned::now().strftime("%Y-%m-%d_%H-%M-%S"), std::process::id() ); for suffix in 0..100 { let final_path = directory.join(format!("{name}-{suffix}.webm")); let partial = final_path.with_extension("webm.part"); if final_path.exists() { continue; } match OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&partial) { Ok(file) => return Ok((file, partial, final_path)), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(format!("Cannot save recording: {e}")), } } Err("Cannot choose a unique recording filename".into()) } fn audio_sources(audio: Audio) -> Result, String> { let devices: &[&str] = match audio { Audio::Off => &[], Audio::System => &["@DEFAULT_MONITOR@"], Audio::Microphone => &["@DEFAULT_SOURCE@"], Audio::Both => &["@DEFAULT_MONITOR@", "@DEFAULT_SOURCE@"], }; devices.iter().map(|device| { gst::ElementFactory::make("pulsesrc") .property("device", *device) .property("client-name", "Ferese Screen Recorder") .property("do-timestamp", true) .property("provide-clock", false) .build().map_err(|e| format!("Cannot record {}. Install the GStreamer audio plugins and run PipeWire-Pulse or PulseAudio: {e}", audio.label())) }).collect() } fn element(factory: &str) -> Result { gst::ElementFactory::make(factory) .build() .map_err(|e| format!("Recording needs the GStreamer {factory} plugin: {e}")) } fn encode( source: gst::Element, audio: Vec, crop: Option, output: &Path, stop: Arc, ) -> Result { if let Some(crop) = crop { crop.validate()?; } let (file, partial, destination) = recording_file(output)?; let pipeline = gst::Pipeline::new(); let result = (|| { let queue = gst::ElementFactory::make("queue") .property("max-size-buffers", 4u32) .property("max-size-bytes", 0u32) .property("max-size-time", 0u64) .property_from_str("leaky", "downstream") .build() .map_err(|e| e.to_string())?; let convert = gst::ElementFactory::make("videoconvert") .build() .map_err(|e| e.to_string())?; let encoder = gst::ElementFactory::make("vp8enc") .property("deadline", 1i64) .property("cpu-used", 8i32) .property("threads", 4i32) .property("target-bitrate", 8_000_000i32) .property("keyframe-max-dist", 60i32) .build() .map_err(|e| e.to_string())?; let mux = gst::ElementFactory::make("webmmux") .build() .map_err(|e| e.to_string())?; let sink = gst::ElementFactory::make("fdsink") .property("fd", file.as_raw_fd()) .property("sync", false) .build() .map_err(|e| e.to_string())?; pipeline .add_many([&source, &queue, &convert, &encoder, &mux, &sink]) .map_err(|e| e.to_string())?; if let Some(crop) = crop { let filter = gst::ElementFactory::make("videocrop") .property("left", crop.left) .property("top", crop.top) .property("right", crop.right) .property("bottom", crop.bottom) .build() .map_err(|e| e.to_string())?; pipeline.add(&filter).map_err(|e| e.to_string())?; gst::Element::link_many([&source, &queue, &filter, &convert, &encoder, &mux, &sink]) .map_err(|e| e.to_string())?; } else { gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink]) .map_err(|e| e.to_string())?; } let mut audio_inputs = Vec::new(); let audio_seen = Arc::new(AtomicBool::new(audio.is_empty())); if !audio.is_empty() { let mixer = element("audiomixer")?; let convert = element("audioconvert")?; let resample = element("audioresample")?; let encoder = element("opusenc")?; let audio_queue = element("queue")?; pipeline .add_many([&mixer, &convert, &resample, &encoder, &audio_queue]) .map_err(|e| e.to_string())?; gst::Element::link_many([&mixer, &convert, &resample, &encoder, &audio_queue, &mux]) .map_err(|e| e.to_string())?; let seen = audio_seen.clone(); audio_queue .static_pad("src") .ok_or("Audio encoder has no output")? .add_probe(gst::PadProbeType::BUFFER, move |_, info| { if info.buffer().is_some_and(|b| b.size() > 0) { seen.store(true, Ordering::Relaxed); } gst::PadProbeReturn::Ok }); for source in audio { let queue = element("queue")?; let convert = element("audioconvert")?; let resample = element("audioresample")?; let caps = gst::ElementFactory::make("capsfilter") .property( "caps", gst::Caps::builder("audio/x-raw") .field("rate", 48_000i32) .field("channels", 2i32) .build(), ) .build() .map_err(|e| e.to_string())?; pipeline .add_many([&source, &queue, &convert, &resample, &caps]) .map_err(|e| e.to_string())?; gst::Element::link_many([&source, &queue, &convert, &resample, &caps, &mixer]) .map_err(|e| e.to_string())?; audio_inputs.push((source, queue)); } } let ended = stop.clone(); source .static_pad("src") .ok_or("Capture has no video output")? .add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, move |_, info| { if info .event() .is_some_and(|event| matches!(event.view(), gst::EventView::Eos(_))) { ended.store(true, Ordering::Relaxed); } gst::PadProbeReturn::Ok }); let announced = Arc::new(AtomicBool::new(false)); let notice = announced.clone(); source .static_pad("src") .ok_or("Capture has no video output")? .add_probe(gst::PadProbeType::BUFFER, move |_, info| { if info.buffer().is_some_and(|b| b.size() > 0) && !notice.swap(true, Ordering::Relaxed) { event("recording", None, None); } gst::PadProbeReturn::Ok }); pipeline .set_state(gst::State::Playing) .map_err(|e| e.to_string())?; let bus = pipeline.bus().ok_or("Encoder has no message bus")?; let mut finishing = None; let started = Instant::now(); let mut failure = None; loop { if stop.load(Ordering::Relaxed) && finishing.is_none() { event("saving", None, None); finishing = Some(Instant::now()); // Inject after the capture source: it may already have disappeared // when the portal closes on lock or monitor removal. let _ = queue .static_pad("sink") .ok_or("Encoder queue has no input")? .send_event(gst::event::Eos::new()); for (source, queue) in &audio_inputs { // Audio devices are still alive when video is revoked. // Let each source order EOS after its own startup events and // buffers; injecting into its queue can race late caps. if !source.send_event(gst::event::Eos::new()) { let _ = queue .static_pad("sink") .ok_or("Audio queue has no input")? .send_event(gst::event::Eos::new()); } } } if finishing.is_some_and(|t: Instant| t.elapsed() > Duration::from_secs(10)) { return Err(failure.unwrap_or("Finishing the recording timed out".into())); } if !announced.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) { return Err("No video frames arrived from the selected display".into()); } if !audio_seen.load(Ordering::Relaxed) && started.elapsed() > Duration::from_secs(15) { failure = Some("No audio arrived from the selected audio source".into()); stop.store(true, Ordering::Relaxed); } let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(50)) else { continue; }; match message.view() { gst::MessageView::Eos(_) => { if !announced.load(Ordering::Relaxed) { return Err("Recording ended before receiving video".into()); } if !audio_seen.load(Ordering::Relaxed) { return Err("Recording ended before receiving audio".into()); } if let Some(failure) = failure { return Err(failure); } return Ok(()); } gst::MessageView::Error(error) => { failure = Some(format!("Recording interrupted: {}", error.error())); stop.store(true, Ordering::Relaxed); } _ => (), } } })(); let _ = pipeline.set_state(gst::State::Null); result.map_err(|e| format!("{e}. Incomplete file: {}", partial.display()))?; file.sync_all() .map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?; std::fs::rename(&partial, &destination) .map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?; Ok(destination) } #[cfg(test)] mod tests { use super::*; #[test] fn filenames_do_not_overwrite_existing_recordings() { let directory = tempfile::tempdir().unwrap(); let (_, first, _) = recording_file(directory.path()).unwrap(); let (_, second, _) = recording_file(directory.path()).unwrap(); assert_ne!(first, second); use std::os::unix::fs::PermissionsExt; assert_eq!( std::fs::metadata(first).unwrap().permissions().mode() & 0o777, 0o600 ); } #[test] #[ignore = "requires an explicitly provided temporary PipeWire test node"] fn encoder_finishes_pipewire_stream() { gst::init().unwrap(); let node = std::env::var("FERESE_TEST_RECORD_NODE").expect("temporary test node required"); let directory = tempfile::tempdir().unwrap(); let source = gst::ElementFactory::make("pipewiresrc") .property("path", node) .property("do-timestamp", true) // Keep queued frames valid when the portal revokes PipeWire buffers. .property("always-copy", true) .build() .unwrap(); let stop = Arc::new(AtomicBool::new(false)); let request_stop = stop.clone(); let stopper = std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(3)); request_stop.store(true, Ordering::Relaxed); }); let result = encode(source, Vec::new(), None, directory.path(), stop); stopper.join().unwrap(); assert_video_decodes(&result.unwrap()); } #[test] #[ignore = "requires GStreamer video plugins"] fn encoder_finishes_a_real_webm() { gst::init().unwrap(); let directory = tempfile::tempdir().unwrap(); let source = gst::ElementFactory::make("videotestsrc") .property("num-buffers", 90i32) .property("is-live", true) .build() .unwrap(); let stop = Arc::new(AtomicBool::new(false)); let request_stop = stop.clone(); let stopper = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(350)); request_stop.store(true, Ordering::Relaxed); }); let path = encode(source, Vec::new(), None, directory.path(), stop).unwrap(); stopper.join().unwrap(); let data = std::fs::read(&path).unwrap(); assert_eq!(&data[..4], &[0x1a, 0x45, 0xdf, 0xa3]); assert!(data.len() > 1000); assert_video_decodes(&path); } #[test] #[ignore = "requires GStreamer video and audio plugins"] fn encoder_saves_cropped_video_and_mixed_audio_when_stopped() { assert_cropped_audio_recording(false, 2, None); } #[test] #[ignore = "requires GStreamer video and audio plugins"] fn video_eos_finishes_live_audio_without_waiting_for_the_user() { // Models the portal ending video on lock or monitor removal while the // audio devices remain available. Every track must finish together. assert_cropped_audio_recording(true, 1, None); } #[test] #[ignore = "requires an explicitly provided isolated PulseAudio test server"] fn encoder_captures_isolated_system_and_microphone_audio() { // The isolated fixture plays 440 Hz on the default output and 660 Hz // on the default microphone. Both tones must survive combined capture. let server = std::env::var("FERESE_TEST_RECORD_PULSE_SERVER") .expect("isolated audio server required"); assert_eq!(std::env::var("PULSE_SERVER").unwrap(), server); let socket = Path::new( server .strip_prefix("unix:") .expect("explicit Unix socket required"), ); assert!(socket.starts_with(std::env::temp_dir())); for audio in [ Audio::System, Audio::Microphone, Audio::Both, Audio::Both, Audio::Both, ] { assert_cropped_audio_recording(false, 0, Some(audio)); } } fn assert_cropped_audio_recording( video_eos: bool, audio_count: usize, selected_audio: Option