From de3ef50434eed54ab2d53f5e540fb83137246776 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 23:22:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(native):=20a=20mirrored=20monitor=20has=20a?= =?UTF-8?q?=20fixed=20mode=20=E2=80=94=20refuse=20to=20resize=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A physical head runs at the mode its owner set, and MirrorDisplay::create ignores the requested one on purpose. So a mid-stream Reconfigure against a mirror could only ever tear the cast down and rebuild the identical one at the identical size — a hitch that buys nothing, and an invitation to the worse reflex of reconfiguring the display someone is sitting at. reconfig_allowed grows a third gate beside gamescope and per-client-mode identity, and the session captures it at bring-up like the others: this session opened its display under whatever the pin said then, so a console change mid-session must not retroactively change the answer it gives. Linux-only, because vdisplay::open only routes to the mirror there — a pin left in a Windows host's settings streams nothing different and must not disable resize as a side effect. design/per-monitor-portal-capture.md §7.3, open item 4. Co-Authored-By: Claude Opus 5 (1M context) --- crates/punktfunk-host/src/native.rs | 14 +++++++- crates/punktfunk-host/src/native/stream.rs | 40 +++++++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 39afdd41..71392ef3 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1003,6 +1003,9 @@ async fn serve_session( // so a resize would resolve a DIFFERENT slot — on Windows a fresh monitor ADD instead of the // in-place reconfigure, on KWin a differently-named output — defeating the policy's // per-resolution identity. Honest downgrade: reject, client scales (H5). + // * a monitor MIRROR (a `capture_monitor` pin): a physical head runs at the mode its owner set + // and the mirror backend ignores the requested one, so a resize would restart the identical + // cast at the identical size (design/per-monitor-portal-capture.md §7.3). // The SYNTHETIC source stays reconfigurable on purpose (nothing to rebuild — the ack round-trip // is the whole effect): it is the compositor-free protocol test source, and the C-ABI roundtrip // test + client harnesses exercise the Reconfigure/Reconfigured plumbing through it. @@ -1011,7 +1014,16 @@ async fn serve_session( let per_client_mode_identity = crate::vdisplay::policy::prefs() .configured_effective() .is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode); - reconfig_allowed(compositor, per_client_mode_identity) + // Read once here, like the identity above: this session opened its display under whatever + // the pin said at bring-up, so a console change mid-session must not retroactively change + // what THIS session answers a Reconfigure with. Linux-only because `vdisplay::open` only + // routes to the mirror there — a pin left in a Windows host's settings streams nothing + // different, and must not silently disable resize as a side effect. + #[cfg(target_os = "linux")] + let mirrored = crate::vdisplay::capture_monitor().is_some(); + #[cfg(not(target_os = "linux"))] + let mirrored = false; + reconfig_allowed(compositor, per_client_mode_identity, mirrored) }; // Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's // `async move` captures a copy and it stays usable for the data-plane SessionContext below. diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 1d353eb6..1a1c02e0 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -496,13 +496,20 @@ struct SendStats { /// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a /// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output), /// defeating the policy — honest downgrade is to reject and let the client scale. +/// * a **monitor mirror** (`mirrored`): the source is a physical head running at the mode its owner +/// set, and `MirrorDisplay::create` ignores the requested one by design +/// (design/per-monitor-portal-capture.md §7.3). A resize would tear the cast down and re-`create` +/// the *same* head at the *same* size — a visible hitch that changes nothing — or, worse, invite +/// the reflex of reconfiguring the display someone is sitting in front of. Reject; the client +/// scales, exactly as it already does for gamescope. /// /// Every other compositor (and the synthetic protocol-test source) with the default identity accepts. pub(super) fn reconfig_allowed( compositor: Option, per_client_mode: bool, + mirrored: bool, ) -> bool { - compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode + compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode && !mirrored } #[allow(clippy::too_many_arguments)] @@ -3524,22 +3531,39 @@ mod tests { use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots}; // gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of // the identity policy. - assert!(!reconfig_allowed(Some(Gamescope), false)); - assert!(!reconfig_allowed(Some(Gamescope), true)); + assert!(!reconfig_allowed(Some(Gamescope), false, false)); + assert!(!reconfig_allowed(Some(Gamescope), true, false)); // A per-client-mode identity policy rejects on every backend — the resize resolves a // different display-identity slot (H5). - assert!(!reconfig_allowed(Some(Kwin), true)); - assert!(!reconfig_allowed(Some(Mutter), true)); - assert!(!reconfig_allowed(None, true)); + assert!(!reconfig_allowed(Some(Kwin), true, false)); + assert!(!reconfig_allowed(Some(Mutter), true, false)); + assert!(!reconfig_allowed(None, true, false)); // Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place). for c in [Kwin, Mutter, Wlroots, Hyprland] { assert!( - reconfig_allowed(Some(c), false), + reconfig_allowed(Some(c), false, false), "{c:?} should allow live reconfigure" ); } // The synthetic source (no compositor) is the protocol-test path — always reconfigurable. - assert!(reconfig_allowed(None, false)); + assert!(reconfig_allowed(None, false, false)); + } + + /// A mirrored physical head has a fixed mode (§7.3): every backend that would otherwise accept + /// a live reconfigure must reject one while the session is streaming someone's real monitor. + #[test] + fn reconfig_allowed_rejects_a_monitor_mirror_on_every_backend() { + use crate::vdisplay::Compositor::{Hyprland, Kwin, Mutter, Wlroots}; + for c in [Kwin, Mutter, Wlroots, Hyprland] { + assert!( + reconfig_allowed(Some(c), false, false), + "{c:?} without a pin should still allow live reconfigure" + ); + assert!( + !reconfig_allowed(Some(c), false, true), + "{c:?} mirroring a physical head must reject a resize" + ); + } } #[test]