From bf2f54a5fecec374173e3355ad6b334b4c36b2d0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 20:34:18 +0200 Subject: [PATCH] feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 of design/per-monitor-portal-capture.md. Mutter's mirror is the same private ScreenCast session the virtual path already drives, one call different — RecordMonitor instead of RecordVirtual — so it inherits what makes that path work headlessly: a direct D-Bus API that needs no interactive approval, unlike the xdg portal a background service could never answer. Deliberately not under TOPOLOGY_LOCK: that serializes operations which add or remove a monitor or apply a monitor configuration, and mirroring does neither. Nothing is created, no layout changes, and teardown is just Stop — the SIGSEGV-adjacent ordering the virtual path must observe cannot arise when no monitor is being removed. The GNOME HDR probe now honors the pin. Asking "is ANY monitor in BT.2100 mode" was fair while capture took whatever head it was handed; once the operator names one, an HDR TV on the next connector must not talk the host into offering PQ formats for the SDR panel it is actually streaming. A pin that names no live head reports SDR rather than falling back to "any" — the session is about to fail on that same missing monitor, and an over-claimed HDR offer would be a second, quieter wrong answer. ⚠️ RecordMonitor's exact signature is read from the interface, not yet confirmed against a live gnome-shell; no GNOME box was reachable. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-capture/src/linux/portal.rs | 76 +++++++- .../pf-vdisplay/src/vdisplay/linux/mutter.rs | 179 ++++++++++++++++++ crates/pf-vdisplay/src/vdisplay/mirror.rs | 4 + 3 files changed, 252 insertions(+), 7 deletions(-) diff --git a/crates/pf-capture/src/linux/portal.rs b/crates/pf-capture/src/linux/portal.rs index caa10a41..88da412e 100644 --- a/crates/pf-capture/src/linux/portal.rs +++ b/crates/pf-capture/src/linux/portal.rs @@ -10,13 +10,18 @@ use anyhow::{anyhow, Context, Result}; use std::os::fd::OwnedFd; -/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the +/// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the /// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+; /// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ). /// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property /// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48 /// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer. /// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only. +/// +/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md` +/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the +/// capture path took whatever monitor it was handed — but once the operator names the head, an +/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel. pub fn gnome_hdr_monitor_active() -> bool { use ashpd::zbus; // GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is @@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool { .body() .deserialize() .context("parse GetCurrentState")?; - Ok(monitors.iter().any(|(_spec, _modes, props)| { - props - .get("color-mode") - .and_then(|v| u32::try_from(v).ok()) - .is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100 - })) + // `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100. + let heads: Vec<(&str, bool)> = monitors + .iter() + .map(|(spec, _modes, props)| { + let hdr = props + .get("color-mode") + .and_then(|v| u32::try_from(v).ok()) + .is_some_and(|mode| mode == 1); + (spec.0.as_str(), hdr) + }) + .collect(); + Ok(hdr_offer_for( + &heads, + pf_host_config::config().capture_monitor.as_deref(), + )) }) }; match probe() { @@ -91,6 +105,24 @@ pub fn gnome_hdr_monitor_active() -> bool { } } +/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)` +/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin? +/// +/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host +/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports +/// SDR rather than falling back to "any": the session is about to fail on that same missing +/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer. +/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged. +fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool { + match pinned { + Some(want) => heads + .iter() + .find(|(connector, _)| connector.eq_ignore_ascii_case(want)) + .is_some_and(|(_, hdr)| *hdr), + None => heads.iter().any(|(_, hdr)| *hdr), + } +} + /// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`). /// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap /// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + @@ -355,3 +387,33 @@ pub(super) fn portal_thread_remote_desktop( // See `portal_thread`: drop the runtime before the caller's completion signal. drop(rt); } + +#[cfg(test)] +mod hdr_offer_tests { + use super::hdr_offer_for; + + #[test] + fn unpinned_keeps_the_any_monitor_heuristic() { + assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None)); + assert!(!hdr_offer_for(&[("DP-1", false)], None)); + } + + /// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR + /// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them. + #[test] + fn a_pin_ignores_an_hdr_neighbour() { + let heads = [("DP-1", false), ("HDMI-A-1", true)]; + assert!(!hdr_offer_for(&heads, Some("DP-1"))); + assert!(hdr_offer_for(&heads, Some("HDMI-A-1"))); + } + + #[test] + fn a_pin_matches_case_insensitively_like_the_resolver() { + assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1"))); + } + + #[test] + fn a_pin_naming_no_live_head_reports_sdr() { + assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9"))); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 58c9b997..57f3aa84 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -376,6 +376,185 @@ fn session_thread( }); } +/// Record an **existing** monitor by connector — the monitor-mirror path +/// (`design/per-monitor-portal-capture.md` L2). Returns the PipeWire node id and the keepalive +/// whose drop stops the recording. +/// +/// Same private ScreenCast API as the virtual path, one call different: `RecordMonitor` instead of +/// `RecordVirtual`. So it inherits what makes that path work headlessly — Mutter's *direct* D-Bus +/// API needs no interactive approval, unlike the xdg portal a background service could never answer. +/// +/// Deliberately **not** under [`TOPOLOGY_LOCK`]: that lock serializes operations which add/remove a +/// monitor or apply a monitor configuration, and mirroring does neither. It creates nothing, changes +/// no layout, and leaves the head exactly as its owner set it. +pub(crate) fn stream_existing_output( + connector: &str, + hw_cursor: bool, +) -> Result<(u32, Box)> { + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let connector_thread = connector.to_string(); + thread::Builder::new() + .name("punktfunk-mutter-mirror".into()) + .spawn(move || mirror_thread(setup_tx, stop_thread, connector_thread, hw_cursor)) + .context("spawn Mutter monitor-mirror thread")?; + let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => v, + Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"), + Err(_) => bail!("timed out recording the Mutter output {connector:?}"), + }; + Ok((node_id, Box::new(MirrorStop(stop)) as Box)) +} + +/// Stops the mirror thread — and with it the recording — when the capturer drops it. +struct MirrorStop(Arc); + +impl Drop for MirrorStop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Owns the D-Bus connection behind a mirrored monitor's cast: connect, hand back the node id, +/// park until stopped, then `Stop` the session. +fn mirror_thread( + setup_tx: Sender>, + stop: Arc, + connector: String, + hw_cursor: bool, +) { + 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_monitor(&connector, hw_cursor).await { + Ok(s) => s, + Err(e) => { + let _ = setup_tx.send(Err(format!("{e:#}"))); + return; + } + }; + let _ = setup_tx.send(Ok(session.node_id)); + while !stop.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + } + // Stop the cast. Nothing else to undo: no virtual output was added, so there is no monitor + // removal for Mutter to rebuild around — the SIGSEGV-adjacent teardown ordering the virtual + // path has to observe simply doesn't arise here. + let _ = session.rd_session.call_method("Stop", &()).await; + }); +} + +/// The `RecordMonitor` handshake: RemoteDesktop session → ScreenCast session anchored to it → +/// record `connector` → node id on `PipeWireStreamAdded` after `Start`. +async fn connect_monitor(connector: &str, hw_cursor: bool) -> Result { + let conn = zbus::Connection::session() + .await + .context("connect session D-Bus")?; + let rd = zbus::Proxy::new( + &conn, + BUS_RD, + "/org/gnome/Mutter/RemoteDesktop", + "org.gnome.Mutter.RemoteDesktop", + ) + .await + .context("RemoteDesktop proxy (is gnome-shell 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")?; + + 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?; + + // `RecordMonitor(connector, properties) -> stream_path`. Only `cursor-mode` is set: the mode + // belongs to the monitor's owner, not to us. + let mut rec: HashMap<&str, Value> = HashMap::new(); + rec.insert( + "cursor-mode", + Value::from(if hw_cursor { + CURSOR_METADATA + } else { + CURSOR_EMBEDDED + }), + ); + let stream_path: OwnedObjectPath = sc_session + .call("RecordMonitor", &(connector, rec)) + .await + .with_context(|| format!("Session.RecordMonitor({connector:?})"))?; + let stream = zbus::Proxy::new( + &conn, + BUS_SC, + stream_path, + "org.gnome.Mutter.ScreenCast.Stream", + ) + .await?; + + 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")?; + tracing::info!(connector, node_id, "mutter: recording an existing monitor"); + + Ok(MutterSession { + rd_session, + _sc_session: sc_session, + _conn: conn, + node_id, + }) +} + /// The live session objects (held for the stream's lifetime) + the PipeWire node id. struct MutterSession { rd_session: zbus::Proxy<'static>, diff --git a/crates/pf-vdisplay/src/vdisplay/mirror.rs b/crates/pf-vdisplay/src/vdisplay/mirror.rs index e3c9bee0..0bb84e1a 100644 --- a/crates/pf-vdisplay/src/vdisplay/mirror.rs +++ b/crates/pf-vdisplay/src/vdisplay/mirror.rs @@ -68,6 +68,10 @@ impl VirtualDisplay for MirrorDisplay { Compositor::Kwin => { crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)? } + #[cfg(target_os = "linux")] + Compositor::Mutter => { + crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)? + } other => bail!( "mirroring an existing monitor is not implemented for the {} backend yet — \ unset PUNKTFUNK_CAPTURE_MONITOR",