use std::cell::RefCell;
use std::os::fd::OwnedFd;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use ashpd::desktop::PersistMode;
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use pipewire as pw;
use pw::spa;
use spa::param::video::{VideoFormat, VideoInfoRaw};
use super::channel::FrameChannel;
use super::pump::Geometry;
use super::{Config, FrameStream};
use crate::Error;
use crate::frame::{Frame, I420};
const DEFAULT_FRAMERATE: u32 = 30;
const FORMAT_TIMEOUT: Duration = Duration::from_secs(10);
const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(5);
static RESTORE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
fn err(ctx: &str, e: impl std::fmt::Display) -> Error {
Error::Codec(anyhow::anyhow!("{ctx}: {e}"))
}
pub(super) async fn open(config: &Config, device: Option<&str>) -> Result<FrameStream, Error> {
if let Some(device) = device {
tracing::debug!(%device, "portal screen capture ignores the device selector; the picker owns selection");
}
let (node_id, fd, session) = portal_negotiate(config.cursor).await?;
let chan = FrameChannel::new();
let framerate = config.framerate.unwrap_or(DEFAULT_FRAMERATE).max(1);
let (geo_tx, geo_rx) = tokio::sync::oneshot::channel();
let (quit_tx, quit_rx) = pw::channel::channel::<()>();
let handle = std::thread::spawn({
let chan = chan.clone();
move || {
let state = Rc::new(RefCell::new(State {
format: VideoInfoRaw::default(),
geometry: None,
geo_tx: Some(geo_tx),
last: None,
fresh: false,
}));
if let Err(e) = run_loop(fd, node_id, framerate, chan.clone(), state.clone(), quit_rx) {
match state.borrow_mut().geo_tx.take() {
Some(tx) => drop(tx.send(Err(e))),
None => tracing::warn!(error = %e, "screen capture stream failed"),
}
}
chan.close();
}
});
let guard = LoopGuard {
quit: quit_tx,
handle: Some(handle),
_session: session,
};
let geo = match tokio::time::timeout(FORMAT_TIMEOUT, geo_rx).await {
Ok(Ok(result)) => result?,
Ok(Err(_)) => {
return Err(Error::Codec(anyhow::anyhow!(
"screen capture thread exited before negotiating a format"
)));
}
Err(_) => {
return Err(Error::Codec(anyhow::anyhow!(
"no video format from the compositor within {FORMAT_TIMEOUT:?}"
)));
}
};
let first = match tokio::time::timeout(FIRST_FRAME_TIMEOUT, chan.recv()).await {
Ok(Some(frame)) => frame,
Ok(None) | Err(_) => {
return Err(Error::Codec(anyhow::anyhow!(
"no frames from the compositor within {FIRST_FRAME_TIMEOUT:?}"
)));
}
};
tracing::info!(
node = node_id,
width = geo.width,
height = geo.height,
"opened screen capture (PipeWire)"
);
Ok(FrameStream::new(
chan,
geo.width,
geo.height,
geo.framerate,
geo.device,
Some(first),
Box::new(guard),
))
}
async fn portal_negotiate(cursor: bool) -> Result<(u32, OwnedFd, SessionGuard), Error> {
let proxy = Screencast::new().await.map_err(|e| err("screencast portal", e))?;
let session = proxy
.create_session(Default::default())
.await
.map_err(|e| err("portal session", e))?;
let restore = RESTORE_TOKEN.lock().unwrap().clone();
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(if cursor {
CursorMode::Embedded
} else {
CursorMode::Hidden
})
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::Application)
.set_restore_token(restore.as_deref()),
)
.await
.map_err(|e| err("portal select sources", e))?;
let response = proxy
.start(&session, None, Default::default())
.await
.map_err(|e| err("portal start", e))?
.response()
.map_err(|e| err("screen capture request denied", e))?;
*RESTORE_TOKEN.lock().unwrap() = response.restore_token().map(str::to_string);
let stream = response
.streams()
.first()
.ok_or_else(|| Error::Codec(anyhow::anyhow!("portal granted no streams")))?;
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.map_err(|e| err("portal pipewire remote", e))?;
Ok((node_id, fd, SessionGuard::new(session)))
}
struct SessionGuard {
_close: tokio::sync::oneshot::Sender<()>,
}
impl SessionGuard {
fn new(session: ashpd::desktop::Session<Screencast>) -> Self {
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
let _ = rx.await;
if let Err(e) = session.close().await {
tracing::debug!(error = %e, "failed to close portal session");
}
});
Self { _close: tx }
}
}
struct LoopGuard {
quit: pw::channel::Sender<()>,
handle: Option<JoinHandle<()>>,
_session: SessionGuard,
}
impl Drop for LoopGuard {
fn drop(&mut self) {
let _ = self.quit.send(());
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
struct State {
format: VideoInfoRaw,
geometry: Option<(u32, u32)>,
geo_tx: Option<tokio::sync::oneshot::Sender<Result<Geometry, Error>>>,
last: Option<I420>,
fresh: bool,
}
fn run_loop(
fd: OwnedFd,
node_id: u32,
framerate: u32,
chan: Arc<FrameChannel>,
state: Rc<RefCell<State>>,
quit_rx: pw::channel::Receiver<()>,
) -> Result<(), Error> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| err("pipewire main loop", e))?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| err("pipewire context", e))?;
let core = context
.connect_fd_rc(fd, None)
.map_err(|e| err("pipewire connect", e))?;
let stream = pw::stream::StreamRc::new(
core,
"moq-screen",
pw::properties::properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
},
)
.map_err(|e| err("pipewire stream", e))?;
let _listener = stream
.add_local_listener::<()>()
.state_changed({
let mainloop = mainloop.downgrade();
move |_, _, _, new| {
let done = matches!(
new,
pw::stream::StreamState::Error(_) | pw::stream::StreamState::Unconnected
);
if done {
*RESTORE_TOKEN.lock().unwrap() = None;
tracing::debug!(state = ?new, "screen capture stream ended");
if let Some(mainloop) = mainloop.upgrade() {
mainloop.quit();
}
}
}
})
.param_changed({
let state = state.clone();
let mainloop = mainloop.downgrade();
move |_, _, id, param| {
let Some(param) = param else { return };
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Ok((media_type, media_subtype)) = spa::param::format_utils::parse_format(param) else {
return;
};
if media_type != spa::param::format::MediaType::Video
|| media_subtype != spa::param::format::MediaSubtype::Raw
{
return;
}
let mut state = state.borrow_mut();
if let Err(e) = state.format.parse(param) {
tracing::warn!(error = %e, "failed to parse pipewire video format");
return;
}
let size = state.format.size();
let (width, height) = (size.width & !1, size.height & !1);
if width == 0 || height == 0 {
tracing::warn!(width = size.width, height = size.height, "unusable capture size");
return;
}
if let Some(tx) = state.geo_tx.take() {
state.geometry = Some((width, height));
let fr = state.format.framerate();
let framerate = (fr.num > 0 && fr.denom > 0).then(|| (fr.num / fr.denom).max(1));
let _ = tx.send(Ok(Geometry {
width,
height,
framerate,
device: format!("pipewire:{node_id}"),
}));
} else if state.geometry != Some((width, height)) {
tracing::info!(width, height, "capture size changed; restarting the stream");
if let Some(mainloop) = mainloop.upgrade() {
mainloop.quit();
}
}
}
})
.process({
let state = state.clone();
let chan = chan.clone();
let mainloop = mainloop.downgrade();
move |stream, _| {
let mut state = state.borrow_mut();
let Some((width, height)) = state.geometry else { return };
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else { return };
let offset = data.chunk().offset() as usize;
let size = data.chunk().size() as usize;
let stride = data.chunk().stride();
if size == 0 || data.chunk().flags().contains(spa::buffer::ChunkFlags::CORRUPTED) {
return;
}
let Some(bytes) = data.data() else {
tracing::warn!("pipewire buffer is not CPU-mapped; stopping capture");
if let Some(mainloop) = mainloop.upgrade() {
mainloop.quit();
}
return;
};
let Some(bytes) = bytes.get(offset..offset + size) else {
return;
};
let stride = if stride > 0 {
stride as u32
} else {
state.format.size().width * 4
};
match convert(state.format.format(), bytes, stride, width, height) {
Ok(i420) => {
chan.push(Frame::I420(i420.clone()));
state.last = Some(i420);
state.fresh = true;
}
Err(e) => {
tracing::warn!(error = %e, "screen frame conversion failed; stopping capture");
if let Some(mainloop) = mainloop.upgrade() {
mainloop.quit();
}
}
}
}
})
.register()
.map_err(|e| err("pipewire listener", e))?;
let pod = format_offer(framerate);
let mut params = [spa::pod::Pod::from_bytes(&pod)
.ok_or_else(|| Error::Codec(anyhow::anyhow!("failed to build pipewire format offer")))?];
stream
.connect(
spa::utils::Direction::Input,
Some(node_id),
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.map_err(|e| err("pipewire stream connect", e))?;
let timer = mainloop.loop_().add_timer({
let state = state.clone();
let chan = chan.clone();
move |_| {
let mut state = state.borrow_mut();
if std::mem::take(&mut state.fresh) {
return;
}
if let Some(last) = &state.last {
chan.push(Frame::I420(last.clone()));
}
}
});
let interval = Duration::from_micros(1_000_000 / framerate as u64);
timer
.update_timer(Some(interval), Some(interval))
.into_result()
.map_err(|e| err("pipewire timer", e))?;
let _quit = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.downgrade();
move |_| {
if let Some(mainloop) = mainloop.upgrade() {
mainloop.quit();
}
}
});
mainloop.run();
Ok(())
}
fn convert(format: VideoFormat, bytes: &[u8], stride: u32, width: u32, height: u32) -> Result<I420, Error> {
match format {
VideoFormat::BGRx | VideoFormat::BGRA => I420::from_bgra(bytes, stride, width, height),
VideoFormat::RGBx | VideoFormat::RGBA => I420::from_rgba(bytes, stride, width, height),
other => Err(Error::Codec(anyhow::anyhow!(
"pipewire negotiated an unsupported video format {other:?}"
))),
}
}
fn format_offer(framerate: u32) -> Vec<u8> {
let obj = spa::pod::object!(
spa::utils::SpaTypes::ObjectParamFormat,
spa::param::ParamType::EnumFormat,
spa::pod::property!(
spa::param::format::FormatProperties::MediaType,
Id,
spa::param::format::MediaType::Video
),
spa::pod::property!(
spa::param::format::FormatProperties::MediaSubtype,
Id,
spa::param::format::MediaSubtype::Raw
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFormat,
Choice,
Enum,
Id,
VideoFormat::BGRx,
VideoFormat::BGRx,
VideoFormat::BGRA,
VideoFormat::RGBx,
VideoFormat::RGBA,
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
spa::utils::Rectangle {
width: 1920,
height: 1080
},
spa::utils::Rectangle { width: 1, height: 1 },
spa::utils::Rectangle {
width: 8192,
height: 8192
}
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
spa::utils::Fraction {
num: framerate,
denom: 1
},
spa::utils::Fraction { num: 0, denom: 1 },
spa::utils::Fraction { num: 1000, denom: 1 }
),
);
spa::pod::serialize::PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &spa::pod::Value::Object(obj))
.expect("serializing a static format pod cannot fail")
.0
.into_inner()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::Config;
#[test]
fn format_offer_is_valid_pod() {
let bytes = format_offer(30);
assert!(spa::pod::Pod::from_bytes(&bytes).is_some(), "offer did not round-trip");
}
#[tokio::test]
#[ignore]
async fn portal_captures_frames() {
let mut stream = match open(&Config::default(), None).await {
Ok(stream) => stream,
Err(e) => {
eprintln!("skipping: no portal screen capture available: {e}");
return;
}
};
assert!(stream.width() >= 2 && stream.width() % 2 == 0, "bad width");
assert!(stream.height() >= 2 && stream.height() % 2 == 0, "bad height");
for i in 0..5 {
let frame = stream.read().await.unwrap_or_else(|| panic!("no frame {i}"));
assert_eq!(frame.width(), stream.width());
assert_eq!(frame.height(), stream.height());
}
eprintln!("captured 5 frames at {}x{}", stream.width(), stream.height());
}
}