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",