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:
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user