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:
411
src/cap_portal.rs
Normal file
411
src/cap_portal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user