feat(vdisplay): topology decoupling — distinct primary level (Stage 2)
The three topology levels become distinct behaviors (Stage 0 only did extend-vs-exclusive, faking primary): - vdisplay::effective_topology() -> the concrete level (console policy > legacy *_VIRTUAL_PRIMARY env > Auto default). Backends read it directly at create time; apply_session_env no longer writes the boolean env (one fewer connect- path env mutation). - Mutter: extend (no config), primary (virtual primary + physicals kept as secondaries — build_primary_keeping_physicals), exclusive (sole, physicals disabled). KWin: extend (no-op), primary (kscreen primary only), exclusive (primary + disable others). - Windows should_isolate treats primary as isolate (the primary-only CCD variant is a follow-up); wlroots exclusive + the physical-keep effect need a display-attached box (headless lab boxes can't observe primary vs exclusive). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -403,44 +403,11 @@ pub fn apply_session_env(active: &ActiveSession) {
|
|||||||
if active.kind == ActiveKind::DesktopGnome {
|
if active.kind == ActiveKind::DesktopGnome {
|
||||||
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
|
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
|
||||||
}
|
}
|
||||||
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
// the panels + windows land on the streamed surface, not an unstreamed real output (the
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
// auto-detected desktop path *is* "stream this desktop"). The per-compositor backends read
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`; drive it here from the display-management topology.
|
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
||||||
//
|
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
||||||
// Stage 0 keeps today's behavior exactly UNLESS the console configured a policy: when a
|
|
||||||
// `display-settings.json` exists, the effective topology wins (Exclusive → sole desktop,
|
|
||||||
// Extend → leave the streamed output extended, Primary → treated as Exclusive until the
|
|
||||||
// primary-only path lands in the topology stage). Unconfigured hosts fall through to the
|
|
||||||
// historical default-on-for-desktop behavior, honoring an explicit operator env var.
|
|
||||||
let var = match active.kind {
|
|
||||||
ActiveKind::DesktopKde => Some("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY"),
|
|
||||||
ActiveKind::DesktopGnome => Some("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY"),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(var) = var {
|
|
||||||
match policy::prefs().configured_effective() {
|
|
||||||
Some(eff) => {
|
|
||||||
let sole = match resolve_topology(eff.topology) {
|
|
||||||
policy::Topology::Extend => false,
|
|
||||||
policy::Topology::Exclusive => true,
|
|
||||||
policy::Topology::Primary => {
|
|
||||||
tracing::info!(
|
|
||||||
"display policy: topology=primary treated as exclusive at this stage \
|
|
||||||
(primary-only lands in the topology stage)"
|
|
||||||
);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
// resolve_topology never returns Auto.
|
|
||||||
policy::Topology::Auto => true,
|
|
||||||
};
|
|
||||||
std::env::set_var(var, if sole { "1" } else { "0" });
|
|
||||||
}
|
|
||||||
// Unconfigured: today's behavior — default-on unless the operator set it explicitly.
|
|
||||||
None if std::env::var_os(var).is_none() => std::env::set_var(var, "1"),
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||||
@@ -779,6 +746,34 @@ pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The concrete display topology for the current session — what the per-compositor backends (and the
|
||||||
|
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
|
||||||
|
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
|
||||||
|
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
|
||||||
|
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
|
||||||
|
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
|
||||||
|
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
|
||||||
|
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
|
||||||
|
/// path.
|
||||||
|
pub fn effective_topology() -> policy::Topology {
|
||||||
|
if let Some(e) = policy::prefs().configured_effective() {
|
||||||
|
return resolve_topology(e.topology);
|
||||||
|
}
|
||||||
|
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
|
||||||
|
// most one of these is set), else the Auto default.
|
||||||
|
let legacy = [
|
||||||
|
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
|
||||||
|
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|k| std::env::var(k).ok());
|
||||||
|
match legacy.as_deref().map(str::trim) {
|
||||||
|
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
|
||||||
|
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
|
||||||
|
_ => resolve_topology(policy::Topology::Auto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||||
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -111,14 +111,19 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
} else {
|
} else {
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
};
|
};
|
||||||
// Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we
|
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
|
||||||
// stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the
|
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||||
// client sees only the wallpaper of an empty extended output). Opt-in
|
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||||
// (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY.
|
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||||
let restore = if virtual_primary_enabled() {
|
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||||
apply_virtual_primary()
|
use crate::vdisplay::policy::Topology;
|
||||||
} else {
|
let restore = match crate::vdisplay::effective_topology() {
|
||||||
Vec::new()
|
Topology::Exclusive => apply_virtual_primary(),
|
||||||
|
Topology::Primary => {
|
||||||
|
apply_virtual_primary_only();
|
||||||
|
Vec::new() // nothing disabled → nothing to restore
|
||||||
|
}
|
||||||
|
Topology::Extend | Topology::Auto => Vec::new(),
|
||||||
};
|
};
|
||||||
Ok(VirtualOutput {
|
Ok(VirtualOutput {
|
||||||
node_id,
|
node_id,
|
||||||
@@ -213,21 +218,6 @@ fn read_active_refresh(output: &str) -> Option<u32> {
|
|||||||
Some(hz.round() as u32)
|
Some(hz.round() as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no
|
|
||||||
/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance
|
|
||||||
/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop
|
|
||||||
/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate.
|
|
||||||
fn virtual_primary_enabled() -> bool {
|
|
||||||
std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY")
|
|
||||||
.map(|v| {
|
|
||||||
matches!(
|
|
||||||
v.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless
|
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless
|
||||||
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
|
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
|
||||||
/// (same source as [`read_active_refresh`]).
|
/// (same source as [`read_active_refresh`]).
|
||||||
@@ -292,6 +282,23 @@ fn apply_virtual_primary() -> Vec<String> {
|
|||||||
others
|
others
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
|
||||||
|
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
||||||
|
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
||||||
|
fn apply_virtual_primary_only() {
|
||||||
|
let ours = format!("Virtual-{VOUT_NAME}");
|
||||||
|
let ok = std::process::Command::new("kscreen-doctor")
|
||||||
|
.arg(format!("output.{ours}.primary"))
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("KWin: could not set the virtual output primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||||
/// drops the Wayland connection and makes KWin reclaim the output.
|
/// drops the Wayland connection and makes KWin reclaim the output.
|
||||||
struct StopGuard {
|
struct StopGuard {
|
||||||
|
|||||||
@@ -118,9 +118,19 @@ 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
|
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||||
// new (virtual) connector apart and restore the layout on teardown. Best-effort.
|
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||||
let dc_pre = if virtual_primary_enabled() {
|
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||||
|
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
|
||||||
|
let topo = crate::vdisplay::effective_topology();
|
||||||
|
let want_config = matches!(
|
||||||
|
topo,
|
||||||
|
crate::vdisplay::policy::Topology::Primary | crate::vdisplay::policy::Topology::Exclusive
|
||||||
|
);
|
||||||
|
let exclusive = matches!(topo, crate::vdisplay::policy::Topology::Exclusive);
|
||||||
|
// Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new
|
||||||
|
// connector apart and restore on teardown) whenever we're going to touch the topology.
|
||||||
|
let dc_pre = if want_config {
|
||||||
match display_config().await {
|
match display_config().await {
|
||||||
Ok(dc) => match get_state(&dc).await {
|
Ok(dc) => match get_state(&dc).await {
|
||||||
Ok(state) => Some((dc, state)),
|
Ok(state) => Some((dc, state)),
|
||||||
@@ -152,8 +162,12 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
|||||||
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
// 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.
|
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
||||||
if let Some((dc, pre)) = &dc_pre {
|
if let Some((dc, pre)) = &dc_pre {
|
||||||
match make_virtual_primary(dc, mode, pre).await {
|
match make_virtual_primary(dc, mode, pre, exclusive).await {
|
||||||
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
|
Ok(()) => tracing::info!(
|
||||||
|
exclusive,
|
||||||
|
"mutter: virtual output set as the primary monitor (physicals {})",
|
||||||
|
if exclusive { "disabled" } else { "kept" }
|
||||||
|
),
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
||||||
),
|
),
|
||||||
@@ -338,17 +352,6 @@ type CurrentState = (
|
|||||||
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
||||||
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true
|
/// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true
|
||||||
/// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual
|
/// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual
|
||||||
/// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The
|
/// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The
|
||||||
@@ -411,7 +414,12 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
|
|||||||
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
|
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
|
||||||
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
|
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
|
||||||
/// surface. Restored on teardown.
|
/// surface. Restored on teardown.
|
||||||
async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> {
|
async fn make_virtual_primary(
|
||||||
|
dc: &zbus::Proxy<'_>,
|
||||||
|
mode: Mode,
|
||||||
|
pre: &CurrentState,
|
||||||
|
exclusive: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let pre_conns = connectors(pre);
|
let pre_conns = connectors(pre);
|
||||||
let deadline = Instant::now() + Duration::from_secs(6);
|
let deadline = Instant::now() + Duration::from_secs(6);
|
||||||
loop {
|
loop {
|
||||||
@@ -437,7 +445,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
|
|||||||
let Some(vmode) = vmode else {
|
let Some(vmode) = vmode else {
|
||||||
bail!("virtual monitor {vconn} has no usable mode yet");
|
bail!("virtual monitor {vconn} has no usable mode yet");
|
||||||
};
|
};
|
||||||
let config = build_primary_config(&vconn, &vmode);
|
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
||||||
|
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
||||||
|
// (On a headless host with no physicals the two are identical.)
|
||||||
|
let config = if exclusive {
|
||||||
|
build_exclusive_config(&vconn, &vmode)
|
||||||
|
} else {
|
||||||
|
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
|
||||||
|
};
|
||||||
let _: () = dc
|
let _: () = dc
|
||||||
.call(
|
.call(
|
||||||
"ApplyMonitorsConfig",
|
"ApplyMonitorsConfig",
|
||||||
@@ -459,12 +474,12 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The virtual output as the SOLE, primary monitor — physical outputs are omitted, so Mutter
|
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
|
||||||
/// disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
||||||
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
||||||
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
||||||
/// vanish). The physical layout is restored on teardown.
|
/// vanish). The physical layout is restored on teardown.
|
||||||
fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
||||||
vec![(
|
vec![(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
@@ -474,3 +489,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
|||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
||||||
|
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
|
||||||
|
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
|
||||||
|
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
|
||||||
|
///
|
||||||
|
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
||||||
|
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||||
|
fn build_primary_keeping_physicals(
|
||||||
|
state: &CurrentState,
|
||||||
|
vconn: &str,
|
||||||
|
vmode: &str,
|
||||||
|
virt_width: i32,
|
||||||
|
) -> Vec<ApplyLogical> {
|
||||||
|
let mut logicals: Vec<ApplyLogical> = vec![(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
|
)];
|
||||||
|
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
|
||||||
|
// the virtual output, as a non-primary secondary.
|
||||||
|
let mut x = virt_width.max(0);
|
||||||
|
for mon in &state.1 {
|
||||||
|
let conn = &mon.0 .0;
|
||||||
|
if conn == vconn {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
|
||||||
|
logicals.push((
|
||||||
|
x,
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
vec![(conn.clone(), mode_id, HashMap::new())],
|
||||||
|
));
|
||||||
|
x += w.max(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logicals
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user