From be18a782b1852a758873883f2bc576152f5a8525 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 22:48:53 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20=E2=80=94=20GNOME/Mutter=20virtual?= =?UTF-8?q?-display=20backend=20(RecordVirtual)=20+=20preferred-mode=20neg?= =?UTF-8?q?otiation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third compositor on the VirtualDisplay seam, via Mutter's direct D-Bus APIs (the gnome-remote-desktop headless model, no portal grant): RemoteDesktop.CreateSession → ScreenCast.CreateSession({remote-desktop-session-id}) → Session.RecordVirtual (creates a virtual monitor) → Start → PipeWireStreamAdded(node_id). A keepalive thread owns the zbus connection (sessions die with it — RAII teardown); select with LUMEN_COMPOSITOR=mutter or XDG_CURRENT_DESKTOP=GNOME. Mutter sizes its virtual monitor FROM the PipeWire format negotiation, so VirtualOutput gains preferred_mode (w, h, refresh_hz), threaded into the consumer's format pods as the default size/framerate. KWin/gamescope set it too (their outputs are already exact-size; the preference just confirms it) — both regression-tested intact. Compile/clippy/test clean; live validation needs gnome-shell installed (then `gnome-shell --headless` + m0 --source kwin-virtual with LUMEN_COMPOSITOR=mutter). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/lumen-host/src/capture/linux.rs | 37 ++-- crates/lumen-host/src/vdisplay.rs | 10 +- crates/lumen-host/src/vdisplay/gamescope.rs | 2 + crates/lumen-host/src/vdisplay/kwin.rs | 1 + crates/lumen-host/src/vdisplay/mutter.rs | 226 ++++++++++++++++++++ 5 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 crates/lumen-host/src/vdisplay/mutter.rs diff --git a/crates/lumen-host/src/capture/linux.rs b/crates/lumen-host/src/capture/linux.rs index 894ac97..07c4c4a 100644 --- a/crates/lumen-host/src/capture/linux.rs +++ b/crates/lumen-host/src/capture/linux.rs @@ -64,7 +64,7 @@ impl PortalCapturer { node_id, "ScreenCast portal session started; connecting PipeWire" ); - let (frames, active) = spawn_pipewire(Some(fd), node_id)?; + let (frames, active) = spawn_pipewire(Some(fd), node_id, None)?; Ok(PortalCapturer { frames, active, @@ -81,7 +81,7 @@ impl PortalCapturer { node_id = vout.node_id, "connecting PipeWire to virtual output" ); - let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id)?; + let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id, vout.preferred_mode)?; Ok(PortalCapturer { frames, active, @@ -92,9 +92,12 @@ impl PortalCapturer { /// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` = /// default daemon) and return the frame channel + the activation flag it gates on. +/// `preferred` seeds the format negotiation's default size/framerate — for Mutter virtual +/// monitors this is what actually sizes the monitor. fn spawn_pipewire( fd: Option, node_id: u32, + preferred: Option<(u32, u32, u32)>, ) -> Result<(Receiver, Arc)> { // Frames flow from the pipewire thread over a small bounded channel. let (frame_tx, frame_rx) = sync_channel::(8); @@ -104,7 +107,9 @@ fn spawn_pipewire( thread::Builder::new() .name("lumen-pipewire".into()) .spawn(move || { - if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy) { + if let Err(e) = + pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy, preferred) + { tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); } }) @@ -424,7 +429,11 @@ mod pipewire { /// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a /// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which /// we read back in `param_changed`. - fn build_dmabuf_format(modifiers: &[u64]) -> Result> { + fn build_dmabuf_format( + modifiers: &[u64], + preferred: Option<(u32, u32, u32)>, + ) -> Result> { + let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60)); use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType}; let mut obj = pw::spa::pod::object!( pw::spa::utils::SpaTypes::ObjectParamFormat, @@ -438,8 +447,8 @@ mod pipewire { Range, Rectangle, pw::spa::utils::Rectangle { - width: 1920, - height: 1080 + width: dw, + height: dh }, pw::spa::utils::Rectangle { width: 1, @@ -455,7 +464,7 @@ mod pipewire { Choice, Range, Fraction, - pw::spa::utils::Fraction { num: 60, denom: 1 }, + pw::spa::utils::Fraction { num: dhz, denom: 1 }, pw::spa::utils::Fraction { num: 0, denom: 1 }, pw::spa::utils::Fraction { num: 240, denom: 1 } ), @@ -478,7 +487,8 @@ mod pipewire { /// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any /// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that). - fn build_default_format_obj() -> pw::spa::pod::Object { + fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object { + let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60)); pw::spa::pod::object!( pw::spa::utils::SpaTypes::ObjectParamFormat, pw::spa::param::ParamType::EnumFormat, @@ -515,8 +525,8 @@ mod pipewire { Range, Rectangle, pw::spa::utils::Rectangle { - width: 1920, - height: 1080 + width: dw, + height: dh }, pw::spa::utils::Rectangle { width: 1, @@ -532,7 +542,7 @@ mod pipewire { Choice, Range, Fraction, - pw::spa::utils::Fraction { num: 60, denom: 1 }, + pw::spa::utils::Fraction { num: dhz, denom: 1 }, pw::spa::utils::Fraction { num: 0, denom: 1 }, pw::spa::utils::Fraction { num: 240, denom: 1 } ), @@ -579,6 +589,7 @@ mod pipewire { tx: SyncSender, active: Arc, zerocopy: bool, + preferred: Option<(u32, u32, u32)>, ) -> Result<()> { crate::pwinit::ensure_init(); @@ -903,7 +914,7 @@ mod pipewire { ), ) } else { - build_default_format_obj() + build_default_format_obj(preferred) }; // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers @@ -914,7 +925,7 @@ mod pipewire { let shm_values = serialize_pod(obj)?; let (dmabuf_values, buffers_values) = if want_dmabuf { ( - Some(build_dmabuf_format(&modifiers)?), + Some(build_dmabuf_format(&modifiers, preferred)?), Some(build_dmabuf_buffers()?), ) } else { diff --git a/crates/lumen-host/src/vdisplay.rs b/crates/lumen-host/src/vdisplay.rs index 0171dbb..028158d 100644 --- a/crates/lumen-host/src/vdisplay.rs +++ b/crates/lumen-host/src/vdisplay.rs @@ -29,6 +29,10 @@ pub struct VirtualOutput { /// RemoteDesktop+ScreenCast). `None` means the node is on the user's default PipeWire daemon /// (KWin `zkde_screencast`), captured by connecting to that daemon directly. pub remote_fd: Option, + /// `(width, height, refresh_hz)` to prefer in the PipeWire format negotiation. KWin and + /// gamescope outputs are created at the exact size, so this just confirms it; **Mutter sizes + /// its virtual monitor FROM the negotiation**, so here it's what makes the client's mode real. + pub preferred_mode: Option<(u32, u32, u32)>, /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. pub keepalive: Box, } @@ -94,12 +98,10 @@ pub fn open(compositor: Compositor) -> Result> { match compositor { Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)), Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)), + Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)), Compositor::Wlroots => { anyhow::bail!("wlroots virtual-output backend not yet implemented") } - Compositor::Mutter => { - anyhow::bail!("mutter virtual-output backend not yet implemented") - } } } #[cfg(not(target_os = "linux"))] @@ -120,3 +122,5 @@ pub fn gamescope_ei_socket_file() -> &'static str { mod gamescope; #[cfg(target_os = "linux")] mod kwin; +#[cfg(target_os = "linux")] +mod mutter; diff --git a/crates/lumen-host/src/vdisplay/gamescope.rs b/crates/lumen-host/src/vdisplay/gamescope.rs index 85b0f2a..cf940f7 100644 --- a/crates/lumen-host/src/vdisplay/gamescope.rs +++ b/crates/lumen-host/src/vdisplay/gamescope.rs @@ -44,6 +44,7 @@ impl VirtualDisplay for GamescopeDisplay { return Ok(VirtualOutput { node_id, remote_fd: None, + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(()), }); } @@ -66,6 +67,7 @@ impl VirtualDisplay for GamescopeDisplay { Ok(VirtualOutput { node_id, remote_fd: None, + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(proc), }) } diff --git a/crates/lumen-host/src/vdisplay/kwin.rs b/crates/lumen-host/src/vdisplay/kwin.rs index 3e15e8e..b725110 100644 --- a/crates/lumen-host/src/vdisplay/kwin.rs +++ b/crates/lumen-host/src/vdisplay/kwin.rs @@ -101,6 +101,7 @@ impl VirtualDisplay for KwinDisplay { Ok(VirtualOutput { node_id, remote_fd: None, + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), keepalive: Box::new(StopGuard(stop)), }) } diff --git a/crates/lumen-host/src/vdisplay/mutter.rs b/crates/lumen-host/src/vdisplay/mutter.rs new file mode 100644 index 0000000..f6473ac --- /dev/null +++ b/crates/lumen-host/src/vdisplay/mutter.rs @@ -0,0 +1,226 @@ +//! GNOME/Mutter virtual-display backend via Mutter's *direct* D-Bus APIs (the same path +//! gnome-remote-desktop uses for headless sessions — not the xdg portal, which needs an +//! interactive grant): +//! +//! 1. `org.gnome.Mutter.RemoteDesktop.CreateSession()` → a remote-desktop session (read its +//! `SessionId`). The cast is anchored to it, and it's also the future input path. +//! 2. `org.gnome.Mutter.ScreenCast.CreateSession({"remote-desktop-session-id": id})`. +//! 3. `ScreenCast.Session.RecordVirtual({"cursor-mode": embedded})` → Mutter creates a **virtual +//! monitor** and returns a Stream object. +//! 4. `RemoteDesktop.Session.Start()` → the Stream signals `PipeWireStreamAdded(node_id)`. +//! +//! The virtual monitor's *size* follows the PipeWire format negotiation — Mutter adapts it to +//! what the consumer asks for — so the client's exact WxH is plumbed into our consumer's format +//! pod as the preferred size ([`VirtualOutput::preferred_mode`]) rather than passed here. +//! Sessions die with the D-Bus connection, so a keepalive thread owns it (RAII teardown). +//! +//! Requires a running Mutter (`gnome-shell` session, or `gnome-shell --headless` for the +//! headless host) on the session bus. GNOME is detected via `XDG_CURRENT_DESKTOP=GNOME` or +//! forced with `LUMEN_COMPOSITOR=mutter`. + +use super::{Mode, VirtualDisplay, VirtualOutput}; +use anyhow::{anyhow, bail, Context, Result}; +use ashpd::zbus; +use futures_util::StreamExt; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use zbus::zvariant::{OwnedObjectPath, Value}; + +const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop"; +const BUS_SC: &str = "org.gnome.Mutter.ScreenCast"; + +/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends). +const CURSOR_EMBEDDED: u32 = 1; + +/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a +/// keepalive thread owning the D-Bus sessions behind the virtual monitor. +pub struct MutterDisplay; + +impl MutterDisplay { + pub fn new() -> Result { + Ok(MutterDisplay) + } +} + +impl VirtualDisplay for MutterDisplay { + fn name(&self) -> &'static str { + "mutter" + } + + fn create(&mut self, mode: Mode) -> Result { + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + thread::Builder::new() + .name("lumen-mutter-vout".into()) + .spawn(move || session_thread(setup_tx, stop_thread)) + .context("spawn Mutter virtual-output thread")?; + + let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => v, + Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"), + Err(_) => bail!("timed out creating the Mutter virtual monitor"), + }; + tracing::info!( + node_id, + w = mode.width, + h = mode.height, + "Mutter virtual monitor ready" + ); + Ok(VirtualOutput { + node_id, + remote_fd: None, + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + keepalive: Box::new(StopGuard(stop)), + }) + } +} + +/// Dropping this ends the keepalive thread, closing the D-Bus connection — Mutter then tears +/// the remote-desktop + screencast sessions (and the virtual monitor) down. +struct StopGuard(Arc); + +impl Drop for StopGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire +/// node id, then hold the connection until stopped. +fn session_thread(setup_tx: Sender>, stop: Arc) { + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = setup_tx.send(Err(format!("build tokio runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let session = match connect().await { + Ok(s) => s, + Err(e) => { + let _ = setup_tx.send(Err(format!("{e:#}"))); + return; + } + }; + let _ = setup_tx.send(Ok(session.node_id)); + // Park, keeping `session` (and its zbus connection) alive until told to stop. + while !stop.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + } + // Best-effort explicit teardown before the connection drops. + let _ = session.rd_session.call_method("Stop", &()).await; + }); +} + +/// The live session objects (held for the stream's lifetime) + the PipeWire node id. +struct MutterSession { + rd_session: zbus::Proxy<'static>, + _sc_session: zbus::Proxy<'static>, + _conn: zbus::Connection, + node_id: u32, +} + +/// Run the four-step handshake (see module docs). +async fn connect() -> Result { + let conn = zbus::Connection::session() + .await + .context("connect session D-Bus")?; + + // 1. RemoteDesktop session (the anchor; also the future input path). + let rd = zbus::Proxy::new( + &conn, + BUS_RD, + "/org/gnome/Mutter/RemoteDesktop", + "org.gnome.Mutter.RemoteDesktop", + ) + .await + .context("RemoteDesktop proxy (is gnome-shell / `gnome-shell --headless` running?)")?; + let rd_path: OwnedObjectPath = rd + .call("CreateSession", &()) + .await + .context("RemoteDesktop.CreateSession")?; + let rd_session = zbus::Proxy::new( + &conn, + BUS_RD, + rd_path, + "org.gnome.Mutter.RemoteDesktop.Session", + ) + .await?; + let session_id: String = rd_session + .get_property("SessionId") + .await + .context("read SessionId")?; + + // 2. ScreenCast session anchored to it. + let sc = zbus::Proxy::new( + &conn, + BUS_SC, + "/org/gnome/Mutter/ScreenCast", + "org.gnome.Mutter.ScreenCast", + ) + .await + .context("ScreenCast proxy")?; + let mut props: HashMap<&str, Value> = HashMap::new(); + props.insert("remote-desktop-session-id", Value::from(session_id)); + let sc_path: OwnedObjectPath = sc + .call("CreateSession", &(props,)) + .await + .context("ScreenCast.CreateSession")?; + let sc_session = zbus::Proxy::new( + &conn, + BUS_SC, + sc_path, + "org.gnome.Mutter.ScreenCast.Session", + ) + .await?; + + // 3. The virtual monitor. Size/refresh follow the PipeWire format negotiation. + let mut rec: HashMap<&str, Value> = HashMap::new(); + rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED)); + let stream_path: OwnedObjectPath = sc_session + .call("RecordVirtual", &(rec,)) + .await + .context("Session.RecordVirtual")?; + let stream = zbus::Proxy::new( + &conn, + BUS_SC, + stream_path, + "org.gnome.Mutter.ScreenCast.Stream", + ) + .await?; + + // 4. Subscribe to the node-id signal BEFORE starting, then start the (combined) session. + let mut added = stream + .receive_signal("PipeWireStreamAdded") + .await + .context("subscribe PipeWireStreamAdded")?; + rd_session + .call_method("Start", &()) + .await + .context("RemoteDesktop.Session.Start")?; + let msg = tokio::time::timeout(Duration::from_secs(10), added.next()) + .await + .map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))? + .ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?; + let (node_id,): (u32,) = msg + .body() + .deserialize() + .context("PipeWireStreamAdded body")?; + + Ok(MutterSession { + rd_session, + _sc_session: sc_session, + _conn: conn, + node_id, + }) +}