From 0a79a8209b3965e8cf6f44f1696548053929c83a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 14:09:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20=E2=80=94=20RemoteDesktop-anchored?= =?UTF-8?q?=20ScreenCast=20capture=20for=20KWin/GNOME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/lumen-host/src/capture.rs | 6 +- crates/lumen-host/src/capture/linux.rs | 111 ++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/crates/lumen-host/src/capture.rs b/crates/lumen-host/src/capture.rs index 704a61f..8771ee2 100644 --- a/crates/lumen-host/src/capture.rs +++ b/crates/lumen-host/src/capture.rs @@ -182,7 +182,11 @@ impl Capturer for FastSyntheticCapturer { /// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule. #[cfg(target_os = "linux")] pub fn open_portal_monitor() -> Result> { - linux::PortalCapturer::open().map(|c| Box::new(c) as Box) + // 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) } #[cfg(not(target_os = "linux"))] diff --git a/crates/lumen-host/src/capture/linux.rs b/crates/lumen-host/src/capture/linux.rs index 8c7f18f..1e94be2 100644 --- a/crates/lumen-host/src/capture/linux.rs +++ b/crates/lumen-host/src/capture/linux.rs @@ -34,12 +34,21 @@ pub struct PortalCapturer { } impl PortalCapturer { - pub fn open() -> Result { + /// `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 { // Portal handshake (async) on its own thread; hands back the PW fd + node id. let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); thread::Builder::new() .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")?; 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>) { + 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 { //! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).