From cb7ddc0411fff70bdfa80be258adb15b62416e87 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 00:18:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(vdisplay):=20topology=20decoupling=20?= =?UTF-8?q?=E2=80=94=20distinct=20primary=20level=20(Stage=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/punktfunk-host/src/vdisplay.rs | 71 ++++++------ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 53 +++++---- .../src/vdisplay/linux/mutter.rs | 101 ++++++++++++++---- 3 files changed, 143 insertions(+), 82 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 2a186e0..5d03d8c 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -403,44 +403,11 @@ pub fn apply_session_env(active: &ActiveSession) { if active.kind == ActiveKind::DesktopGnome { std::env::set_var("PUNKTFUNK_FORCE_SHM", "1"); } - // Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so - // the panels + windows land on the streamed surface, not an unstreamed real output (the - // auto-detected desktop path *is* "stream this desktop"). The per-compositor backends read - // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`; drive it here from the display-management topology. - // - // 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 => {} - } - } + // Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read + // [`effective_topology`] directly at create time — the console policy, else the legacy + // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the + // 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. } #[cfg(not(target_os = "linux"))] 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 // backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat. #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index a234d6a..efad859 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -111,14 +111,19 @@ impl VirtualDisplay for KwinDisplay { } else { mode.refresh_hz }; - // Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we - // stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the - // client sees only the wallpaper of an empty extended output). Opt-in - // (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY. - let restore = if virtual_primary_enabled() { - apply_virtual_primary() - } else { - Vec::new() + // Display-management topology (Stage 2): `Extend` leaves the streamed output an extension; + // `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled; + // `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so + // plasmashell + windows land on the streamed surface, not the headless `kwin --virtual` + // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). + use crate::vdisplay::policy::Topology; + let restore = match crate::vdisplay::effective_topology() { + 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 { node_id, @@ -213,21 +218,6 @@ fn read_active_refresh(output: &str) -> Option { 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 /// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` /// (same source as [`read_active_refresh`]). @@ -292,6 +282,23 @@ fn apply_virtual_primary() -> Vec { 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 /// drops the Wayland connection and makes KWin reclaim the output. struct StopGuard { diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index 92714b7..3c51be5 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs @@ -118,9 +118,19 @@ fn session_thread(setup_tx: Sender>, stop: Arc, } }; 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() { + // Display-management topology (Stage 2): the console policy's level, resolved to a concrete + // value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes + // 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 { Ok(dc) => match get_state(&dc).await { Ok(state) => Some((dc, state)), @@ -152,8 +162,12 @@ fn session_thread(setup_tx: Sender>, stop: Arc, // 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. if let Some((dc, pre)) = &dc_pre { - match make_virtual_primary(dc, mode, pre).await { - Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"), + match make_virtual_primary(dc, mode, pre, exclusive).await { + Ok(()) => tracing::info!( + exclusive, + "mutter: virtual output set as the primary monitor (physicals {})", + if exclusive { "disabled" } else { "kept" } + ), Err(e) => tracing::warn!( "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>); // connector, mode_id, props type ApplyLogical = (i32, i32, f64, u32, bool, Vec); -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 /// 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 @@ -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 /// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed /// 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 deadline = Instant::now() + Duration::from_secs(6); loop { @@ -437,7 +445,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta let Some(vmode) = vmode else { 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 .call( "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 -/// disables them for the session. This confines the cursor, windows, and keyboard focus to the +/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so +/// 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 /// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to /// vanish). The physical layout is restored on teardown. -fn build_primary_config(vconn: &str, vmode: &str) -> Vec { +fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec { vec![( 0, 0, @@ -474,3 +489,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec { 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 { + let mut logicals: Vec = 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 +}