//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → `pipewiresrc`. //! This module owns only the portal handshake and the source-element args; //! the shared encode/mux tail, gst spawn, and serving live in //! [`super::pipeline`]. use anyhow::{Context, Result}; use ashpd::{ WindowIdentifier, desktop::{ PersistMode, screencast::{CursorMode, Screencast, SourceType}, }, }; use nix::fcntl::{FcntlArg, FdFlag, fcntl}; use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use super::pipeline::{self, CaptureHandle}; use super::quality::EffectiveQuality; use crate::cli::HostOpts; pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result { // 1. Negotiate the screencast session with the portal. let proxy = Screencast::new() .await .context("could not reach the xdg-desktop-portal ScreenCast interface")?; let session = proxy.create_session().await?; let source = if opts.window { SourceType::Window } else { SourceType::Monitor }; proxy .select_sources( &session, CursorMode::Embedded, source.into(), false, None, PersistMode::DoNot, ) .await .context("select_sources failed")?; let response = proxy .start(&session, &WindowIdentifier::default()) .await .context("portal Start failed (did the user cancel the picker?)")? .response()?; let stream = response .streams() .first() .context("portal returned no screencast streams")?; let node_id = stream.pipe_wire_node_id(); let (w, h) = stream .size() .context("portal returned a stream with no size — pipewiresrc can't infer dimensions")?; let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?; tracing::info!(node_id, width = w, height = h, "portal handshake complete"); // The fd is CLOEXEC by default; the gst child needs to inherit it across // exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst // inherits its own copy at exec) by moving it into the after_spawn hook, // which drops — and so closes — the parent's copy once gst is running. If // pipeline::spawn errors *before* calling the hook (e.g. audio setup or the // gst spawn fails), the unused closure is dropped, dropping the fd just the // same — so the portal fd never leaks on the error path. clear_cloexec(&pw_fd)?; let raw_fd: RawFd = pw_fd.as_raw_fd(); let source_args = vec![ "pipewiresrc".to_string(), format!("fd={raw_fd}"), format!("path={node_id}"), "do-timestamp=true".to_string(), ]; pipeline::spawn( opts, quality, Some((w as u32, h as u32)), source_args, move || { // Parent no longer needs the pipewire fd — gst inherited its own copy. drop(pw_fd); }, ) .await } fn clear_cloexec(fd: &impl AsFd) -> Result<()> { let flags_int = fcntl(fd.as_fd(), FcntlArg::F_GETFD).context("F_GETFD on pipewire fd")?; let mut flags = FdFlag::from_bits_truncate(flags_int); flags.remove(FdFlag::FD_CLOEXEC); fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?; Ok(()) }