From 77a8ccbbcdcf4f480df718796f5d744e17d33763 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 17 Aug 2026 14:20:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(vdisplay):=20library=20launches=20opened=20?= =?UTF-8?q?on=20the=20operator's=20head=20=E2=80=94=20nothing=20ever=20foc?= =?UTF-8?q?used=20the=20streamed=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on Hyprland: "anything from the library opens on my main display instead of the virtual screen." Hyprland and wlroots/sway are the two EXTEND-only backends: the per-session headless output is added *beside* the operator's heads and nothing promotes it (KWin and Mutter promote theirs primary; gamescope nests the app in its own compositor). Both compositors open a new window on the FOCUSED monitor, and `output create headless` / `create_output` does not focus what it creates — so focus stayed on the physical head and every library launch opened where the client could not see it, with the stream showing a bare desktop. Nothing in the tree moved or focused a window: no `focusmonitor`, no window rule, no post-map correction. The launch path's own doc comment asserted the opposite ("KWin / Mutter / wlroots ... the per-session virtual output is promoted primary, so a plain spawn lands the app on the streamed output") — false for wlroots, and Hyprland was not listed. Both backends now claim focus for the head they are about to stream, and the launch path re-asserts it immediately before the spawn: the portal handshake, the encoder build and the first frame all sit between capture bring-up and the launch, so a create-time focus alone can be lost before the app ever starts. The name comes from the same slot the absolute-input pointer is bound to, so focus and cursor land on one head by construction. `focus_streamed_output` is gated on each backend's own managed-name predicate, which keeps the mirror-pin case out — there the streamed head is a physical monitor the operator is using, and stealing its focus is a change nobody asked for. Its match is exhaustive on purpose so a backend added later has to decide rather than inherit "no focus" silently. This does not implement the `primary`/`exclusive` topology levels on these two backends — they are still accepted, echoed by the management API and dropped with a warning (sweep 13.18, whose shipped fix was the warning only). Focus is the extend-topology answer to window placement and leaves the operator's monitors lit. Tests pin the two argv shapes, which is where this can silently regress: `focusmonitor` is a dispatcher and needs `hyprctl dispatch`, while sway spells its command `focus output ` with the noun second, unlike every other call in that file. Both wrong forms are rejected at exit 0 / with an error whose only field symptom is the bug itself. --- crates/pf-vdisplay/src/lib.rs | 6 +- .../src/vdisplay/linux/hyprland.rs | 66 ++++++++++++++++- .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 72 ++++++++++++++++++- crates/pf-vdisplay/src/vdisplay/routing.rs | 43 +++++++++++ crates/punktfunk-host/src/library/launch.rs | 24 ++++++- docs-site/content/docs/hyprland.md | 5 ++ docs-site/content/docs/sway.md | 5 ++ docs-site/content/docs/troubleshooting.md | 27 +++++++ docs-site/content/docs/virtual-displays.md | 13 ++++ 9 files changed, 249 insertions(+), 12 deletions(-) diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index a0679f15..df58a901 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -110,9 +110,9 @@ pub use routing::{ }; #[cfg(target_os = "linux")] pub use routing::{ - cancel_pending_tv_restore, dedicated_game_exited, gamescope_xwayland_cursor_targets, - launch_into_gamescope_session, launch_is_nested, steam_appid_from_launch, - watch_steam_game_exit, + cancel_pending_tv_restore, dedicated_game_exited, focus_streamed_output, + gamescope_xwayland_cursor_targets, launch_into_gamescope_session, launch_is_nested, + steam_appid_from_launch, watch_steam_game_exit, }; /// Compositors punktfunk knows how to drive (plan §6). diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index dc985356..ef5cfce8 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -28,6 +28,13 @@ //! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with //! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`). //! +//! The focus contract [`focus_output`] rests on is verified on **Hyprland 0.56.2** (2026-08-17, +//! headless probe against a real instance): `output create headless` leaves the new head +//! `focused: false` — which is the whole reason [`focus_output`] exists — `dispatch focusmonitor +//! ` replies `ok` and moves `focused` onto it, and a client spawned afterwards maps onto that +//! head. The bare `hyprctl focusmonitor ` (no `dispatch`) answers `unknown request` at +//! **exit 0**, which is what [`hyprctl_dispatch`]'s `unknown` marker turns into an error. +//! //! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0): //! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:` picker format (re-derived //! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the @@ -93,7 +100,7 @@ fn next_output_name() -> String { /// Is `name` an output some punktfunk host created (`PF--`, or a legacy `PF-`)? Pure — /// this is what [`list_monitors`] reports as `managed`, so a user's own monitor called `PF-office` /// must not qualify. -fn is_managed_output(name: &str) -> bool { +pub(crate) fn is_managed_output(name: &str) -> bool { let Some(rest) = name.strip_prefix("PF-") else { return false; }; @@ -232,6 +239,10 @@ impl VirtualDisplay for HyprlandDisplay { // The client's exact mode (also the frame clock — a headless output is timer-paced from it). set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?; + // Put the compositor's focus on the head we are about to stream, so the windows this + // session opens land where the client can see them. + focus_output(&name); + // Steer xdph's custom picker at our new output, then run the portal handshake on its own // thread (it parks to keep the cast alive, like the other backends). Serialized: the // selection is one per-user file, so a concurrent session's write between ours and xdph's @@ -402,10 +413,47 @@ fn reclaim_leftovers_once() { }); } +/// Point Hyprland's focus at the head we are about to stream, so the windows this session opens +/// land where the client can see them. +/// +/// Hyprland opens a new window on the **active workspace of the focused monitor**, and +/// `output create headless` does not focus what it creates — focus stays wherever it already was, +/// which on any box with a physical head is that head. Nothing else in the session ever moves it: +/// the client's pointer is confined to the streamed output (the #240 cursor fix), so no amount of +/// remote mouse motion can focus-follows-mouse its way over, and no window rule names our output. +/// So without this, every app the host launches for the session — the whole game library — opens on +/// a monitor the client cannot see, and the stream shows a bare desktop. This is the EXTEND-topology +/// answer to that: it steers window placement without touching the operator's heads (which is what +/// [`warn_topology_is_extend_only`] is still telling the truth about). +/// +/// Best-effort by construction: a failure costs window placement, not the session, and a box with no +/// physical head was already placing windows correctly. +pub(crate) fn focus_output(name: &str) { + match hyprctl_dispatch(&focus_argv(name)) { + Ok(()) => tracing::info!(output = %name, "focused the streamed headless output"), + Err(e) => tracing::warn!( + output = %name, error = %format!("{e:#}"), + "could not focus the streamed headless output — apps this session launches may open on \ + a physical monitor instead of on the stream" + ), + } +} + +/// The `hyprctl` argv that focuses `name`, split out so a test pins its SHAPE. +/// +/// `focusmonitor` is a **dispatcher**, so it lives behind the `dispatch` subcommand. Getting that +/// wrong is the one mistake here that no type can catch and that reads as success from the outside: +/// `hyprctl` answers an unknown subcommand with an exit-0 error string (see [`hyprctl_dispatch`], +/// which exists for exactly that), and the field symptom would be identical to the bug this fixes — +/// a bare streamed desktop with every launched app on the operator's monitor. +fn focus_argv(name: &str) -> [&str; 3] { + ["dispatch", "focusmonitor", name] +} + /// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per /// create instead of leaving the management API's echo as the only signal that the pin was dropped -/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: nothing here promotes it to -/// primary or disables the operator's heads. +/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: [`focus_output`] steers new +/// windows onto it, but nothing here promotes it to primary or disables the operator's heads. fn warn_topology_is_extend_only() { let topology = crate::effective_topology(); if !matches!( @@ -1076,6 +1124,18 @@ mod tests { assert_eq!(parse_version_tag("wat"), None); } + /// `focusmonitor` is a dispatcher, so it must go through `hyprctl dispatch`. A bare + /// `hyprctl focusmonitor NAME` is not a subcommand and hyprctl reports it with exit 0, so the + /// only signal would be the field symptom this whole call exists to remove: apps opening on the + /// operator's monitor while the stream shows a bare desktop. + #[test] + fn focus_goes_through_the_dispatch_subcommand() { + assert_eq!( + focus_argv("PF-1234-1"), + ["dispatch", "focusmonitor", "PF-1234-1"] + ); + } + #[test] fn output_names_are_unique_and_prefixed() { let a = next_output_name(); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index d241ec4f..058c70ba 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -155,6 +155,10 @@ impl VirtualDisplay for WlrootsDisplay { swaymsg(&["output", &name, "enable"]) .with_context(|| format!("swaymsg output {name} enable"))?; + // Put the compositor's focus on the head we are about to stream, so the windows this + // session opens land where the client can see them. + focus_output(&name); + // Steer xdpw's headless output chooser at our new output, then run the portal handshake on // its own thread (it parks to keep the cast alive, like the other backends). Serialized: // the chooser is one per-user file, so a concurrent session's write between ours and xdpw's @@ -270,6 +274,16 @@ impl Drop for StopGuard { /// it lets us NAME the output (D6). static CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Could `name` be a headless output a punktfunk host created? sway names these itself, so unlike +/// Hyprland's `PF--` there is nothing in the name to attribute — the prefix is the whole +/// answer, and a headless output the operator made by hand is indistinguishable. That is why the two +/// callers are both narrow: [`unplug_strays`] additionally requires the output to have appeared +/// during our own `create_output`, and [`super::super::focus_streamed_output`] only ever passes the +/// name of the head this session is streaming. +pub(crate) fn is_managed_output(name: &str) -> bool { + name.starts_with("HEADLESS-") +} + /// Unplug any headless output that appeared since `before` and that nothing owns — the cleanup for a /// `create_output` whose output we could not identify in time. Only `HEADLESS-*` is touched: a /// physical hotplug in the same window is the operator's, not ours, and `unplug` on a real connector @@ -279,7 +293,7 @@ fn unplug_strays(before: &[String]) { let Ok(now) = output_names() else { return }; for name in now .into_iter() - .filter(|n| n.starts_with("HEADLESS-") && !before.iter().any(|b| b == n)) + .filter(|n| is_managed_output(n) && !before.iter().any(|b| b == n)) { match swaymsg(&["output", &name, "unplug"]) { Ok(_) => tracing::warn!(output = %name, "unplugged a headless output we created but \ @@ -290,10 +304,48 @@ fn unplug_strays(before: &[String]) { } } +/// Point sway's focus at the head we are about to stream, so the windows this session opens land +/// where the client can see them. +/// +/// sway opens a new window on the focused workspace, and `create_output` does not focus what it +/// creates — focus stays on whatever head already had it, which on a box with a physical monitor is +/// that monitor. Nothing else in the session moves it (the client's pointer is confined to the +/// streamed output), so without this every app the host launches for the session opens where the +/// client cannot see it. The Hyprland twin of this is `hyprland::focus_output`; both are the +/// EXTEND-topology answer to window placement, and neither touches the operator's heads. +/// +/// Best-effort: a failure costs window placement, not the session. +pub(crate) fn focus_output(name: &str) { + match swaymsg(&focus_argv(name)) { + Ok(_) => tracing::info!(output = %name, "focused the streamed headless output"), + Err(e) => tracing::warn!( + output = %name, error = %format!("{e:#}"), + "could not focus the streamed headless output — apps this session launches may open on \ + a physical monitor instead of on the stream" + ), + } +} + +/// The `swaymsg` argv that focuses `name`, split out so a test pins its SHAPE. +/// +/// sway's command is `focus output ` — the noun comes SECOND, unlike every other call in this +/// file (`output mode|enable|unplug`), where it comes first. Transposing it yields +/// `output focus `, which sway rejects, and the field symptom is the very bug this fixes. +/// +/// ⚠ Unlike the Hyprland twin, this shape is **from sway's documented command surface, not yet +/// exercised on a live sway** (no box in the fleet runs one — the 2026-08-17 probe had Hyprland +/// only). It is the safer of the two to get wrong: [`swaymsg`] passes these through `--` as a sway +/// *command* and rejects a non-zero exit, and sway exits non-zero on an invalid command (the +/// `Unknown/invalid command` path [`swaymsg_query`] documents), so a bad shape surfaces as the +/// logged warning rather than as a silent success the way `hyprctl`'s exit-0 rejection would. +fn focus_argv(name: &str) -> [&str; 3] { + ["focus", "output", name] +} + /// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per /// create instead of leaving the management API's echo as the only signal that the pin was dropped -/// (sweep 13.18). sway's virtual output is always an EXTENSION: nothing here promotes it to primary -/// or disables the operator's heads. +/// (sweep 13.18). sway's virtual output is always an EXTENSION: [`focus_output`] steers new windows +/// onto it, but nothing here promotes it to primary or disables the operator's heads. fn warn_topology_is_extend_only() { let topology = crate::effective_topology(); if !matches!( @@ -740,3 +792,17 @@ fn portal_thread( } }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// sway spells this one `focus output ` — noun second, unlike the `output …` shape + /// every other call in this file uses. A transposed `output focus ` is rejected, and the + /// only symptom would be the bug the call exists to fix: apps opening on the operator's monitor + /// while the stream shows a bare desktop. + #[test] + fn focus_names_the_output_after_the_verb() { + assert_eq!(focus_argv("HEADLESS-2"), ["focus", "output", "HEADLESS-2"]); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index 69e82560..e64fe6d6 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -277,6 +277,49 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result { gamescope::launch_into_session(cmd) } +/// Put the compositor's focus on the streamed head `name`, so a window mapping **now** lands where +/// the client is looking. Split out for `library.rs` like [`launch_into_gamescope_session`]. +/// +/// Only the two EXTEND backends need (or can act on) this. The streamed head sits *beside* the +/// operator's there, and a new window goes to whatever monitor holds focus — so a session that never +/// claims focus launches every app onto a screen the client cannot see. The other backends are +/// no-ops by construction, not by omission: +/// +/// * **KWin / Mutter** — the virtual output is promoted *primary* (`apply_virtual_primary` / +/// `make_virtual_primary`), which is already the compositor-level answer to "where do new windows +/// go". +/// * **gamescope** — the app is a child of the nested compositor, so placement is structural. +/// * **mirror pins** — the streamed head IS a physical head the operator is using; stealing its +/// focus mid-session would be a change they did not ask for. +/// +/// `name` is checked against the backend's own minting scheme first, which is what keeps the +/// mirror-pin case out: there `name` is a physical connector, and only a head we created ourselves is +/// ours to focus. +/// +/// Returns whether focus was actually asserted, so the caller can log the distinction rather than +/// guess. Best-effort throughout: failing to focus costs window placement, never the session. +#[cfg(target_os = "linux")] +pub fn focus_streamed_output(compositor: Compositor, name: &str) -> bool { + match compositor { + Compositor::Hyprland if hyprland::is_managed_output(name) => { + hyprland::focus_output(name); + true + } + Compositor::Wlroots if wlroots::is_managed_output(name) => { + wlroots::focus_output(name); + true + } + // Exhaustive on purpose (no `_` arm): a backend added later must come here and decide, + // rather than inheriting "no focus" silently — the failure mode is invisible in a log and + // only shows up as a game on the wrong screen. + Compositor::Hyprland + | Compositor::Wlroots + | Compositor::Kwin + | Compositor::Mutter + | Compositor::Gamescope => false, + } +} + /// Every nested Xwayland `(DISPLAY, XAUTHORITY)` of the running gamescope session for the XFixes /// cursor source (remote-desktop-sweep Phase C) — gamescope can run several, and the pointer is on /// whichever is focused. Empty when no gamescope session is running / it exposes no Xwayland (the diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index b5a95935..c8d66049 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -789,9 +789,18 @@ pub struct SpawnedLaunch { /// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed) /// desktop/session rather than tearing the stream down. /// -/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process -/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is -/// promoted primary), so a plain spawn lands the app on the streamed output. +/// * **KWin / Mutter** — the host runs inside the user's graphical session (the process env was +/// retargeted at it by `apply_session_env`) and the per-session virtual output is promoted +/// *primary*, so a plain spawn lands the app on the streamed output. +/// * **Hyprland / wlroots (sway)** — those two are EXTEND-only: the streamed head is added *beside* +/// the operator's and nothing promotes it, so a plain spawn lands the app on whichever monitor +/// holds focus — the operator's physical one. This is the case that was reported from the field as +/// "anything from the library opens on my main display instead of the virtual screen". The spawn is +/// therefore preceded by [`crate::vdisplay::focus_streamed_output`], which claims focus for the +/// streamed head so the window Hyprland/sway is about to map goes there. (The backends also focus +/// it at capture bring-up; re-asserting here is what covers the gap between the two — portal +/// handshake, encoder build and first frame all sit in between, and anything that touches focus in +/// that window would otherwise silently put the launch back on a physical head.) /// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope /// session: spawned with the session's own `DISPLAY`/Wayland env /// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally @@ -807,6 +816,15 @@ pub fn launch_session_command( use std::os::unix::process::CommandExt; let cmd = cmd.trim(); anyhow::ensure!(!cmd.is_empty(), "empty command"); + // Claim focus for the streamed head before spawning, so the window the compositor is about to map + // opens where the client is looking (see the EXTEND note above; a no-op on every other backend). + // The name comes from the same slot the absolute-input pointer is bound to, so focus and cursor + // land on one head by construction rather than by two independent guesses. + if let Some(out) = crate::inject::stream_output() { + if crate::vdisplay::focus_streamed_output(compositor, &out) { + tracing::debug!(output = %out, "claimed focus for the streamed head before launching"); + } + } let (child, group_leader) = match compositor { crate::vdisplay::Compositor::Gamescope => { (crate::vdisplay::launch_into_gamescope_session(cmd)?, false) diff --git a/docs-site/content/docs/hyprland.md b/docs-site/content/docs/hyprland.md index a4a786a6..118f1ab7 100644 --- a/docs-site/content/docs/hyprland.md +++ b/docs-site/content/docs/hyprland.md @@ -47,6 +47,11 @@ See [Configuration](/docs/configuration) for the full reference. portal. To pick the output without a GUI on a headless host, the host writes a managed `~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the new output automatically — no interactive picker dialog to answer. +- **Window placement** — the headless output is an *extension*: it sits beside your real monitors and + nothing promotes it or turns them off. Hyprland opens a new window on the **focused** monitor, so + the host runs `hyprctl dispatch focusmonitor PF-…` — once when the output is ready, and again right + before it launches anything from your library. Without that, games open on whichever physical + monitor had focus and the stream shows a bare desktop. - **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual keyboard** protocols (Hyprland kept them). Gamepads and audio are compositor-independent. diff --git a/docs-site/content/docs/sway.md b/docs-site/content/docs/sway.md index 55992b63..265975cf 100644 --- a/docs-site/content/docs/sway.md +++ b/docs-site/content/docs/sway.md @@ -54,6 +54,11 @@ See [Configuration](/docs/configuration) for the full reference. - **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast portal. The host writes a managed chooser config so the output pick is automatic — no interactive picker dialog to answer. +- **Window placement** — the headless output is an *extension*: it sits beside your real monitors and + nothing promotes it or turns them off. sway opens a new window on the focused workspace, so the host + runs `swaymsg focus output HEADLESS-…` — once when the output is ready, and again right before it + launches anything from your library. Without that, games open on whichever physical monitor had + focus and the stream shows a bare desktop. - **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual keyboard** protocols. diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 3abf2000..216da5d0 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -216,6 +216,33 @@ See [GNOME](/docs/gnome) for the GL/EGL userspace details. auto-detects the live compositor, and the pin points it at one backend even when a different session is live (it also disables Gaming ↔ Desktop following). +## Games from my library open on a physical monitor, not on the stream (Hyprland / sway) + +The stream shows your bare desktop while the game is running on a screen at the machine. On +**Hyprland** and **sway** the virtual display is an *extend* output — it's added beside your real +monitors, and neither compositor moves anything onto a new output by itself. Both open a new window on +whatever monitor holds **focus**, so a session that never claims focus launches everything onto the +monitor you were last using in person. + +The host now claims focus for the streamed display at session start and again just before each library +launch, which is the fix — if you're seeing this, [update](/docs/updating) first, since hosts up to and +including **0.29.0** never claimed it at all. The host log says which head it took +(`focused the streamed headless output`), and warns when it couldn't. + +If it still happens on a host that has the fix: + +- **Are you also using the machine in person?** Focus is per-monitor and live: clicking on a physical + monitor while the game is still starting pulls the new window over to it. Launch, then leave the + host's own keyboard and mouse alone until the game is up. +- **A launcher that opens a second window later** (Steam Big Picture, Heroic, some emulator + front-ends) places that window wherever focus is at *that* moment, not where the first one went. If + this is your normal way to play, set **Virtual displays → Dedicated game sessions** to **Dedicated** + — every launch then gets its own headless gamescope with only the game inside, and placement stops + being a question of focus at all (needs `gamescope` installed). +- **Setting the topology to Primary or Exclusive won't do it.** Neither is implemented on these two + backends — the console accepts the setting and the host logs that it dropped it. See + [Virtual displays → Topology](/docs/virtual-displays#topology). + ## The screen stays black after switching to Game Mode (Nobara) On distros whose Game Mode is display-manager autologin under **plasmalogin** (Nobara), a managed diff --git a/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md index 2a8ed9e0..9845d360 100644 --- a/docs-site/content/docs/virtual-displays.md +++ b/docs-site/content/docs/virtual-displays.md @@ -214,6 +214,19 @@ Per-backend support: | Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ | | Exclusive | ✅ | ✅ | ⏳ following release | ✅ | +On **Sway/wlroots and Hyprland** the virtual display is always an *extend* output — it is added +beside your physical monitors and neither promoted nor allowed to disable them, whatever the topology +says. So that "treated as Extend" doesn't leave your games on the wrong screen, the host **claims the +compositor's focus for the streamed display**: once at session start, and again immediately before it +launches anything from your library. Both compositors open a new window on the focused monitor, so +that is what puts the game on the display you're streaming. + +Two things follow from it being focus rather than promotion. Your physical monitors stay lit and +usable — this is Extend, not Exclusive. And a window that opens *later* (a launcher that spawns a +second window, a game that re-parents itself) follows whatever has focus at that moment, so if you're +also sitting at the machine, clicking on a physical monitor mid-launch can still pull a window over +to it. + ### Conflict handling · identity · layout - **Conflict handling** — what happens when a *different* client connects while one is already -- 2.54.0