feat(vdisplay/mutter): optional virtual-output-as-primary for monitored GNOME hosts

PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY=1: after RecordVirtual, promote the per-session
virtual output to the primary monitor (physical kept on, secondary) via
org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig, restoring on teardown.

Without it, a GNOME host that also has a physical monitor attached keeps the physical
primary, so the virtual output is an empty extended desktop — the client streams only
the wallpaper. (The backend was validated on headless GNOME, where the virtual output
is the only display.)

Best-effort + opt-in: default behavior is unchanged; any DisplayConfig failure just
logs and streaming continues. method=temporary, so nothing is written to monitors.xml
and Mutter auto-reverts the layout when the virtual output is torn down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:18:32 +00:00
parent 1c94f46be8
commit 9fe7b7877f
+283 -5
View File
@@ -22,16 +22,20 @@ use super::{Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use ashpd::zbus; use ashpd::zbus;
use futures_util::StreamExt; use futures_util::StreamExt;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::{Duration, Instant};
use zbus::zvariant::{OwnedObjectPath, Value}; use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop"; const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop";
const BUS_SC: &str = "org.gnome.Mutter.ScreenCast"; const BUS_SC: &str = "org.gnome.Mutter.ScreenCast";
const BUS_DC: &str = "org.gnome.Mutter.DisplayConfig";
/// `ApplyMonitorsConfig` method: 1 = temporary (auto-reverts on the next monitor change —
/// e.g. when our virtual output is torn down — so we never persist a layout to monitors.xml).
const APPLY_TEMPORARY: u32 = 1;
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends). /// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
const CURSOR_EMBEDDED: u32 = 1; const CURSOR_EMBEDDED: u32 = 1;
@@ -66,7 +70,7 @@ impl VirtualDisplay for MutterDisplay {
let stop_thread = stop.clone(); let stop_thread = stop.clone();
thread::Builder::new() thread::Builder::new()
.name("punktfunk-mutter-vout".into()) .name("punktfunk-mutter-vout".into())
.spawn(move || session_thread(setup_tx, stop_thread)) .spawn(move || session_thread(setup_tx, stop_thread, mode))
.context("spawn Mutter virtual-output thread")?; .context("spawn Mutter virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -101,7 +105,7 @@ impl Drop for StopGuard {
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire /// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
/// node id, then hold the connection until stopped. /// node id, then hold the connection until stopped.
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>) { fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>, mode: Mode) {
let rt = match tokio::runtime::Builder::new_multi_thread() let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1) .worker_threads(1)
.enable_all() .enable_all()
@@ -114,6 +118,26 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>)
} }
}; };
rt.block_on(async move { rt.block_on(async move {
// Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the
// new (virtual) connector apart and restore the layout on teardown. Best-effort.
let dc_pre = if virtual_primary_enabled() {
match display_config().await {
Ok(dc) => match get_state(&dc).await {
Ok(state) => Some((dc, state)),
Err(e) => {
tracing::warn!("mutter: GetCurrentState (pre) failed ({e:#}); leaving displays as-is");
None
}
},
Err(e) => {
tracing::warn!("mutter: DisplayConfig unavailable ({e:#}); leaving displays as-is");
None
}
}
} else {
None
};
let session = match connect().await { let session = match connect().await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -122,10 +146,36 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>)
} }
}; };
let _ = setup_tx.send(Ok(session.node_id)); let _ = setup_tx.send(Ok(session.node_id));
// Make the freshly-created virtual output the PRIMARY monitor so the GNOME shell + new
// windows land on the surface we stream. Without this, on a host that also has a physical
// monitor attached, the virtual output is an empty extended desktop — you stream only the
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
let mut restore: Option<(zbus::Proxy<'static>, Vec<ApplyLogical>)> = None;
if let Some((dc, pre)) = &dc_pre {
match make_virtual_primary(dc, mode, pre).await {
Ok(()) => {
restore = Some((dc.clone(), to_apply_logicals(pre)));
tracing::info!("mutter: virtual output set as the primary monitor");
}
Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
),
}
}
// Park, keeping `session` (and its zbus connection) alive until told to stop. // Park, keeping `session` (and its zbus connection) alive until told to stop.
while !stop.load(Ordering::Relaxed) { while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
} }
// Restore the original monitor layout (physical primary) before tearing the session down.
// (Mutter also auto-reverts the temporary config when the virtual output disappears.)
if let Some((dc, original)) = restore {
if let Err(e) = apply_config(&dc, &original).await {
tracing::warn!("mutter: monitor-layout restore failed ({e:#}); Mutter reverts the temporary config on teardown");
}
}
// Best-effort explicit teardown before the connection drops. // Best-effort explicit teardown before the connection drops.
let _ = session.rd_session.call_method("Stop", &()).await; let _ = session.rd_session.call_method("Stop", &()).await;
}); });
@@ -233,3 +283,231 @@ async fn connect() -> Result<MutterSession> {
node_id, node_id,
}) })
} }
// ---------------------------------------------------------------------------------------------
// Optional: make the per-session virtual output the PRIMARY monitor (PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY).
//
// `RecordVirtual` adds the virtual monitor as an *extended* desktop. On a headless host that's the
// only display, so the shell + windows live there. But when a physical monitor is attached, GNOME
// keeps it primary and the virtual output is an empty extension — the stream shows only the
// wallpaper. We fix that by promoting the virtual output to primary (physical kept on, secondary)
// via `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`, and restore on teardown.
// ---------------------------------------------------------------------------------------------
/// `org.gnome.Mutter.DisplayConfig.GetCurrentState` reply shapes (see the interface XML):
/// monitors: `a((ssss)a(siiddada{sv})a{sv})`
/// logical_monitors: `a(iiduba(ssss)a{sv})`
type MonitorSpec = (String, String, String, String); // connector, vendor, product, serial
type DbusMode = (
String,
i32,
i32,
f64,
f64,
Vec<f64>,
HashMap<String, OwnedValue>,
);
type MonitorInfo = (MonitorSpec, Vec<DbusMode>, HashMap<String, OwnedValue>);
type LogicalMonitor = (
i32,
i32,
f64,
u32,
bool,
Vec<MonitorSpec>,
HashMap<String, OwnedValue>,
);
type CurrentState = (
u32,
Vec<MonitorInfo>,
Vec<LogicalMonitor>,
HashMap<String, OwnedValue>,
);
/// `ApplyMonitorsConfig` logical-monitor shape: `(iiduba(ssa{sv}))`, monitor = `(ssa{sv})`.
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
/// session — independent of the RemoteDesktop/ScreenCast connection).
async fn display_config() -> Result<zbus::Proxy<'static>> {
let conn = zbus::Connection::session()
.await
.context("connect session D-Bus (DisplayConfig)")?;
zbus::Proxy::new(
&conn,
BUS_DC,
"/org/gnome/Mutter/DisplayConfig",
"org.gnome.Mutter.DisplayConfig",
)
.await
.context("DisplayConfig proxy")
}
async fn get_state(dc: &zbus::Proxy<'_>) -> Result<CurrentState> {
dc.call("GetCurrentState", &())
.await
.context("DisplayConfig.GetCurrentState")
}
fn connectors(state: &CurrentState) -> HashSet<String> {
state.1.iter().map(|m| m.0 .0.clone()).collect()
}
fn mode_flag(md: &DbusMode, key: &str) -> bool {
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
}
/// The current (else preferred, else first) mode of `connector` → (mode_id, width, height).
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
let pick = mon
.1
.iter()
.find(|md| mode_flag(md, "is-current"))
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
.or_else(|| mon.1.first())?;
Some((pick.0.clone(), pick.1, pick.2))
}
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
/// which lands shortly after the node id), then promote it to primary with the physicals kept on.
async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> {
let pre_conns = connectors(pre);
let deadline = Instant::now() + Duration::from_secs(6);
loop {
let state = get_state(dc).await?;
// The virtual connector = present now, absent in the pre-snapshot.
let virt = state
.1
.iter()
.map(|m| m.0 .0.clone())
.find(|c| !pre_conns.contains(c));
if let Some(vconn) = virt {
// Prefer the mode matching the client's WxH; fall back to whatever is current.
let vmode = state
.1
.iter()
.find(|m| m.0 .0 == vconn)
.and_then(|m| {
m.1.iter()
.find(|md| md.1 == mode.width as i32 && md.2 == mode.height as i32)
.map(|md| md.0.clone())
})
.or_else(|| current_mode(&state, &vconn).map(|(id, _, _)| id));
let Some(vmode) = vmode else {
bail!("virtual monitor {vconn} has no usable mode yet");
};
let config = build_primary_config(&state, &vconn, &vmode, mode.width as i32);
let _: () = dc
.call(
"ApplyMonitorsConfig",
&(
state.0,
APPLY_TEMPORARY,
config,
HashMap::<String, Value<'static>>::new(),
),
)
.await
.context("DisplayConfig.ApplyMonitorsConfig (set virtual primary)")?;
return Ok(());
}
if Instant::now() >= deadline {
bail!("the virtual monitor did not appear in DisplayConfig within 6s");
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
/// Virtual output = primary at the top-left; physical monitors kept enabled but secondary, laid
/// out adjacently to its right (so the local screen never blanks).
fn build_primary_config(
state: &CurrentState,
vconn: &str,
vmode: &str,
virt_width: i32,
) -> Vec<ApplyLogical> {
let mut logicals: Vec<ApplyLogical> = Vec::new();
logicals.push((
0,
0,
1.0,
0,
true,
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
));
let mut x = virt_width;
for lm in &state.2 {
if lm.5.iter().any(|s| s.0 == vconn) {
continue; // skip the virtual output's own logical monitor
}
let mons: Vec<ApplyMon> =
lm.5.iter()
.filter_map(|s| {
current_mode(state, &s.0).map(|(id, _, _)| (s.0.clone(), id, HashMap::new()))
})
.collect();
if mons.is_empty() {
continue;
}
let width =
lm.5.first()
.and_then(|s| current_mode(state, &s.0))
.map(|(_, w, _)| w)
.unwrap_or(1920);
logicals.push((x, 0, lm.2, lm.3, false, mons));
x += width;
}
logicals
}
/// Convert a captured `GetCurrentState` layout back into an `ApplyMonitorsConfig` argument (used
/// to restore the physical-primary layout on teardown).
fn to_apply_logicals(state: &CurrentState) -> Vec<ApplyLogical> {
state
.2
.iter()
.filter_map(|lm| {
let mons: Vec<ApplyMon> = lm
.5
.iter()
.filter_map(|s| {
current_mode(state, &s.0).map(|(id, _, _)| (s.0.clone(), id, HashMap::new()))
})
.collect();
if mons.is_empty() {
return None;
}
Some((lm.0, lm.1, lm.2, lm.3, lm.4, mons))
})
.collect()
}
async fn apply_config(dc: &zbus::Proxy<'_>, logicals: &[ApplyLogical]) -> Result<()> {
let state = get_state(dc).await?;
let _: () = dc
.call(
"ApplyMonitorsConfig",
&(
state.0,
APPLY_TEMPORARY,
logicals.to_vec(),
HashMap::<String, Value<'static>>::new(),
),
)
.await
.context("DisplayConfig.ApplyMonitorsConfig (restore)")?;
Ok(())
}