feat: add KWin/KDE Plasma screen capture via xdg-desktop-portal ScreenCast + PipeWire

Add a second capture backend for compositors without wlr-screencopy
(KWin, GNOME, etc.) using the xdg-desktop-portal ScreenCast interface
and PipeWire DMA-BUF streaming.

New files:
- src/backend_detect.rs: auto-detect wlr-screencopy vs portal backend
- src/cap_portal.rs: Portal session setup + PipeWire DMA-BUF thread
- src/state_portal.rs: StatePortal encoder pipeline (DMA-BUF → VAAPI)

Changes:
- Cargo.toml: add ashpd 0.13, tokio 1, pipewire 0.9, libspa 0.9,
  crossbeam-channel 0.5
- src/args.rs: add --backend CLI flag
- src/avhw.rs: extract create_encoder() from inline State code
- src/main.rs: route to portal or wlr-screencopy based on backend
- src/state.rs: fix params.destroy() on dup failure, cleanup
  in_flight_surface on copy fail, use create_encoder()
- tests/integration_test.rs: add --backend flag tests
This commit is contained in:
dailz
2026-05-11 08:49:08 +08:00
parent 2972216a02
commit d7fbb5256c
12 changed files with 2198 additions and 79 deletions

411
src/cap_portal.rs Normal file
View File

@@ -0,0 +1,411 @@
use std::os::fd::{FromRawFd, OwnedFd};
use std::thread::{self, JoinHandle};
use anyhow::Result;
use crossbeam_channel::{Receiver, Sender, bounded};
use tokio::runtime::Runtime;
use crate::args::Args;
pub struct PwDmaBufFrame {
pub fd: OwnedFd,
pub offset: u64,
pub stride: u32,
pub modifier: u64,
pub width: u32,
pub height: u32,
pub format: u32,
pub pts: i64,
}
pub enum PwEvent {
Frame(PwDmaBufFrame),
StreamEnded,
Error(String),
}
pub enum PwCmd {
Shutdown,
}
pub struct CapPortal {
cmd_tx: Sender<PwCmd>,
frame_rx: Receiver<PwEvent>,
pw_thread: Option<JoinHandle<()>>,
rt: Runtime,
}
struct PwThreadCtx {
frame_tx: Sender<PwEvent>,
cmd_rx: Receiver<PwCmd>,
pw_fd: OwnedFd,
node_id: u32,
fps: u32,
}
impl CapPortal {
pub fn new(args: &Args) -> Result<Self> {
let rt = Runtime::new()?;
let (pw_fd, node_id) = rt.block_on(async {
Self::setup_portal().await
})?;
let (frame_tx, frame_rx) = bounded(3);
let (cmd_tx, cmd_rx) = bounded(1);
let ctx = PwThreadCtx {
frame_tx,
cmd_rx,
pw_fd,
node_id,
fps: args.fps,
};
let pw_thread = thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || {
pipewire_thread(ctx);
})?;
Ok(Self {
cmd_tx,
frame_rx,
pw_thread: Some(pw_thread),
rt,
})
}
pub fn frame_receiver(&self) -> &Receiver<PwEvent> {
&self.frame_rx
}
async fn setup_portal() -> Result<(OwnedFd, u32)> {
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
let proxy = Screencast::new().await.map_err(|e| {
anyhow::anyhow!("Failed to create Screencast proxy: {e}")
})?;
let session = proxy
.create_session(Default::default())
.await
.map_err(|e| anyhow::anyhow!("Failed to create ScreenCast session: {e}"))?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.map_err(|e| {
anyhow::anyhow!("屏幕共享权限被拒绝 / Screen sharing permission denied: {e}")
})?;
let response = proxy
.start(&session, None, Default::default())
.await
.map_err(|e| anyhow::anyhow!("ScreenCast start failed: {e}"))?
.response()
.map_err(|e| anyhow::anyhow!("ScreenCast response error: {e}"))?;
let stream = response
.streams()
.first()
.ok_or_else(|| anyhow::anyhow!("No streams returned from ScreenCast"))?;
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.map_err(|e| anyhow::anyhow!("Failed to open PipeWire remote: {e}"))?;
tracing::info!("Portal session established: node_id={node_id}");
Ok((fd, node_id))
}
}
impl Drop for CapPortal {
fn drop(&mut self) {
let _ = self.cmd_tx.send(PwCmd::Shutdown);
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}
}
}
fn pipewire_thread(ctx: PwThreadCtx) {
use pipewire as pw;
use pw::properties::properties;
use pw::stream::{StreamBox, StreamFlags};
use std::cell::Cell;
use std::rc::Rc;
use pw::spa::param::video::VideoInfoRaw;
pw::init();
let PwThreadCtx {
frame_tx,
cmd_rx,
pw_fd,
node_id,
fps: _fps,
} = ctx;
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("MainLoop::new failed: {e}")));
return;
}
};
let context = match pw::context::ContextBox::new(mainloop.loop_(), None) {
Ok(c) => c,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("Context::new failed: {e}")));
return;
}
};
let core = match context.connect_fd(pw_fd, None) {
Ok(c) => c,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("connect_fd failed: {e}")));
return;
}
};
let stream = match StreamBox::new(
&core,
"wl-webrtc",
properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
},
) {
Ok(s) => s,
Err(e) => {
let _ = frame_tx.send(PwEvent::Error(format!("Stream::new failed: {e}")));
return;
}
};
// Shared format state: (width, height, drm_fourcc, modifier)
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> =
Rc::new(Cell::new(None));
let frame_tx_clone = frame_tx.clone();
let _listener = stream
.add_local_listener::<()>()
.state_changed(move |_, _, old, new| {
tracing::debug!("PipeWire stream state: {old:?} -> {new:?}");
match new {
pw::stream::StreamState::Error(_)
| pw::stream::StreamState::Unconnected => {
let _ = frame_tx_clone.send(PwEvent::StreamEnded);
}
_ => {}
}
})
.param_changed({
let format_info = format_info.clone();
move |_, _, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = VideoInfoRaw::new();
if let Err(e) = info.parse(param) {
tracing::warn!("Failed to parse video format: {e}");
return;
}
let width = info.size().width;
let height = info.size().height;
let drm_format = spa_to_drm_fourcc(info.format());
let modifier = info.modifier();
format_info.set(Some((width, height, drm_format, modifier)));
tracing::info!(
"PipeWire format negotiated: {width}x{height}, \
drm_format={drm_format:#010x}, modifier={modifier:#x}"
);
}
})
.process({
let format_info = format_info.clone();
let frame_tx = frame_tx.clone();
move |stream, _| {
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
if raw_buf.is_null() {
return;
}
let spa_buf = unsafe { (*raw_buf).buffer };
if spa_buf.is_null() {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let n_datas = unsafe { (*spa_buf).n_datas };
let datas_ptr = unsafe { (*spa_buf).datas };
if n_datas == 0 || datas_ptr.is_null() {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
// Access first data item through libspa Data wrapper
let data_ref: &pw::spa::buffer::Data = unsafe { &*(datas_ptr as *const pw::spa::buffer::Data) };
let fd = data_ref.fd();
if fd < 0 {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let chunk = data_ref.chunk();
let offset = chunk.offset() as u64;
let stride = chunk.stride() as u32;
// Get PTS from SPA_META_Header metadata
let pts: i64 = unsafe {
let mut pts_val: i64 = 0;
let n_metas = (*spa_buf).n_metas;
let metas = (*spa_buf).metas;
if !metas.is_null() {
for i in 0..n_metas {
let meta = &*metas.add(i as usize);
if meta.type_ == libspa::sys::SPA_META_Header
&& meta.size as usize >= std::mem::size_of::<libspa::sys::spa_meta_header>()
&& !meta.data.is_null()
{
let header = &*(meta.data as *const libspa::sys::spa_meta_header);
pts_val = header.pts;
break;
}
}
}
pts_val
};
let dup_fd = unsafe { libc::dup(fd) };
if dup_fd < 0 {
unsafe { stream.queue_raw_buffer(raw_buf) };
return;
}
let (width, height, format, modifier) =
format_info.get().unwrap_or((0, 0, 0, 0));
let frame = PwDmaBufFrame {
fd: unsafe { OwnedFd::from_raw_fd(dup_fd) },
offset,
stride,
modifier,
width,
height,
format,
pts,
};
let _ = frame_tx.send(PwEvent::Frame(frame));
unsafe { stream.queue_raw_buffer(raw_buf) };
}
})
.register();
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
pw::spa::utils::Direction::Input,
Some(node_id),
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
&mut params,
) {
let _ = frame_tx.send(PwEvent::Error(format!("stream.connect failed: {e}")));
return;
}
let loop_ = mainloop.loop_();
loop_.add_signal_local(
pw::loop_::Signal::SIGINT,
Box::new(|| {}),
);
loop_.add_signal_local(
pw::loop_::Signal::SIGTERM,
Box::new(|| {}),
);
// Store raw pointer as usize so it is Send-safe across threads.
// PipeWire's pw_main_loop_quit is thread-safe by design.
let mainloop_ptr = mainloop.as_raw_ptr() as usize;
let cmd_rx_moved = cmd_rx;
std::thread::spawn(move || {
let _ = cmd_rx_moved.recv();
// SAFETY: mainloop is still alive on the pipewire thread while we wait
// for cmd_rx, and quit() is thread-safe in PipeWire C API.
unsafe { pipewire::sys::pw_main_loop_quit(mainloop_ptr as *mut _) };
});
mainloop.run();
// SAFETY: pipewire has been initialized with pw::init() above and all
// PipeWire resources (mainloop, stream) have been dropped.
unsafe { pw::deinit() };
}
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
fn spa_to_drm_fourcc(format: libspa::param::video::VideoFormat) -> u32 {
use libspa::param::video::VideoFormat;
match format {
VideoFormat::BGRA => fourcc(b'B', b'G', b'R', b'A'),
VideoFormat::BGRx => fourcc(b'B', b'G', b'R', b'X'),
VideoFormat::RGBA => fourcc(b'R', b'G', b'B', b'A'),
VideoFormat::RGBx => fourcc(b'R', b'G', b'B', b'X'),
VideoFormat::ARGB => fourcc(b'A', b'R', b'2', b'4'),
VideoFormat::xRGB => fourcc(b'X', b'R', b'2', b'4'),
VideoFormat::ABGR => fourcc(b'A', b'B', b'2', b'4'),
VideoFormat::xBGR => fourcc(b'X', b'B', b'2', b'4'),
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spa_to_drm_fourcc_bgra() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::BGRA), fourcc(b'B', b'G', b'R', b'A'));
}
#[test]
fn spa_to_drm_fourcc_rgba() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::RGBA), fourcc(b'R', b'G', b'B', b'A'));
}
#[test]
fn spa_to_drm_fourcc_unknown_returns_zero() {
use libspa::param::video::VideoFormat;
assert_eq!(spa_to_drm_fourcc(VideoFormat::Unknown), 0);
}
#[test]
fn fourcc_encoding() {
assert_eq!(fourcc(b'B', b'G', b'R', b'A'), 0x41524742);
}
}