feat: M2 — RemoteDesktop-anchored ScreenCast capture for KWin/GNOME
On RemoteDesktop-capable desktops (KWin, GNOME), select the ScreenCast source on a session created via the RemoteDesktop portal and start it through RemoteDesktop, so a single grant — pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input path — also covers screen capture. Standalone ScreenCast has no such bypass and would raise an un-clickable dialog on a headless box. wlroots/Sway has no RemoteDesktop portal, so it keeps the plain ScreenCast session; the choice keys off inject::default_backend(). The PipeWire consumer is unchanged — the anchored session yields the same fd + node id. Validated on headless KWin (Plasma 6.4): the portal grants the session with no dialog and PipeWire negotiates the format (1920x1080 BGRx, Streaming). Frame delivery on KWin still pends dmabuf import — KWin hands GPU dmabuf buffers and the M0 consumer is CPU-copy/shm only (plan §9, zero-copy) — so it's the next step; the CPU-copy path remains correct on wlroots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -182,7 +182,11 @@ impl Capturer for FastSyntheticCapturer {
|
|||||||
/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule.
|
/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||||
linux::PortalCapturer::open().map(|c| Box::new(c) as Box<dyn Capturer>)
|
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
||||||
|
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
||||||
|
// so use a plain ScreenCast session there.
|
||||||
|
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
||||||
|
linux::PortalCapturer::open(anchored).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
|||||||
@@ -34,12 +34,21 @@ pub struct PortalCapturer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
pub fn open() -> Result<PortalCapturer> {
|
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
||||||
|
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
||||||
|
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
|
||||||
|
pub fn open(anchored: bool) -> Result<PortalCapturer> {
|
||||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("lumen-portal".into())
|
.name("lumen-portal".into())
|
||||||
.spawn(move || portal_thread(setup_tx))
|
.spawn(move || {
|
||||||
|
if anchored {
|
||||||
|
portal_thread_remote_desktop(setup_tx)
|
||||||
|
} else {
|
||||||
|
portal_thread(setup_tx)
|
||||||
|
}
|
||||||
|
})
|
||||||
.context("spawn portal thread")?;
|
.context("spawn portal thread")?;
|
||||||
|
|
||||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||||
@@ -187,6 +196,104 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
|
||||||
|
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
|
||||||
|
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
|
||||||
|
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||||
|
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||||
|
/// identical.
|
||||||
|
fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
||||||
|
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||||
|
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||||
|
use ashpd::desktop::PersistMode;
|
||||||
|
use ashpd::enumflags2::BitFlags;
|
||||||
|
|
||||||
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let err_tx = setup_tx.clone();
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let result: Result<()> = async {
|
||||||
|
let remote = RemoteDesktop::new()
|
||||||
|
.await
|
||||||
|
.context("connect RemoteDesktop portal")?;
|
||||||
|
let screencast = Screencast::new()
|
||||||
|
.await
|
||||||
|
.context("connect ScreenCast portal")?;
|
||||||
|
let session = remote
|
||||||
|
.create_session(Default::default())
|
||||||
|
.await
|
||||||
|
.context("create RemoteDesktop session")?;
|
||||||
|
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
|
||||||
|
// (input injection runs its own), but selecting devices is what makes `start` the
|
||||||
|
// RemoteDesktop grant the kde-authorized bypass covers.
|
||||||
|
remote
|
||||||
|
.select_devices(
|
||||||
|
&session,
|
||||||
|
SelectDevicesOptions::default()
|
||||||
|
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
||||||
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("select_devices")?
|
||||||
|
.response()
|
||||||
|
.context("select_devices rejected")?;
|
||||||
|
screencast
|
||||||
|
.select_sources(
|
||||||
|
&session,
|
||||||
|
SelectSourcesOptions::default()
|
||||||
|
.set_cursor_mode(CursorMode::Hidden)
|
||||||
|
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||||
|
.set_multiple(false)
|
||||||
|
.set_persist_mode(PersistMode::DoNot),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("select_sources")?
|
||||||
|
.response()
|
||||||
|
.context("select_sources rejected (unsupported source type?)")?;
|
||||||
|
let streams = remote
|
||||||
|
.start(&session, None, Default::default())
|
||||||
|
.await
|
||||||
|
.context("start RemoteDesktop+ScreenCast")?
|
||||||
|
.response()
|
||||||
|
.context("start response (grant not pre-authorized / headless dialog?)")?;
|
||||||
|
let stream = streams
|
||||||
|
.streams()
|
||||||
|
.first()
|
||||||
|
.context("portal returned no screencast streams")?
|
||||||
|
.clone();
|
||||||
|
let node_id = stream.pipe_wire_node_id();
|
||||||
|
let fd = screencast
|
||||||
|
.open_pipe_wire_remote(&session, Default::default())
|
||||||
|
.await
|
||||||
|
.context("open_pipe_wire_remote")?;
|
||||||
|
|
||||||
|
setup_tx
|
||||||
|
.send(Ok((fd, node_id)))
|
||||||
|
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||||
|
|
||||||
|
// Keep the proxies + session (and their zbus connection) alive for the capture.
|
||||||
|
let _keep_alive = (&remote, &screencast, &session);
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mod pipewire {
|
mod pipewire {
|
||||||
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
|
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user