feat(vdisplay): mirror a pinned monitor on Mutter, and scope the HDR probe

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent 53c772702f
commit bf2f54a5fe
3 changed files with 252 additions and 7 deletions
+67 -5
View File
@@ -10,13 +10,18 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd; 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+; /// 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). /// 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 /// 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 /// `"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. /// 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. /// 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 { pub fn gnome_hdr_monitor_active() -> bool {
use ashpd::zbus; use ashpd::zbus;
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is // GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
@@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool {
.body() .body()
.deserialize() .deserialize()
.context("parse GetCurrentState")?; .context("parse GetCurrentState")?;
Ok(monitors.iter().any(|(_spec, _modes, props)| { // `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
props let heads: Vec<(&str, bool)> = monitors
.iter()
.map(|(spec, _modes, props)| {
let hdr = props
.get("color-mode") .get("color-mode")
.and_then(|v| u32::try_from(v).ok()) .and_then(|v| u32::try_from(v).ok())
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100 .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() { 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`). /// 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 /// 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 + /// 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. // See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt); 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")));
}
}
@@ -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<dyn Send>)> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
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<dyn Send>))
}
/// Stops the mirror thread — and with it the recording — when the capturer drops it.
struct MirrorStop(Arc<AtomicBool>);
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<Result<u32, String>>,
stop: Arc<AtomicBool>,
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<MutterSession> {
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. /// The live session objects (held for the stream's lifetime) + the PipeWire node id.
struct MutterSession { struct MutterSession {
rd_session: zbus::Proxy<'static>, rd_session: zbus::Proxy<'static>,
@@ -68,6 +68,10 @@ impl VirtualDisplay for MirrorDisplay {
Compositor::Kwin => { Compositor::Kwin => {
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)? 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!( other => bail!(
"mirroring an existing monitor is not implemented for the {} backend yet — \ "mirroring an existing monitor is not implemented for the {} backend yet — \
unset PUNKTFUNK_CAPTURE_MONITOR", unset PUNKTFUNK_CAPTURE_MONITOR",