Library launches opened on the operator's head — nothing ever focused the streamed one #283
@@ -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).
|
||||
|
||||
@@ -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
|
||||
//! <name>` replies `ok` and moves `focused` onto it, and a client spawned afterwards maps onto that
|
||||
//! head. The bare `hyprctl focusmonitor <name>` (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:<name>` 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-<pid>-<n>`, or a legacy `PF-<n>`)? 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();
|
||||
|
||||
@@ -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-<pid>-<n>` 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 <name>` — the noun comes SECOND, unlike every other call in this
|
||||
/// file (`output <name> mode|enable|unplug`), where it comes first. Transposing it yields
|
||||
/// `output focus <name>`, 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 <name>` — noun second, unlike the `output <name> …` shape
|
||||
/// every other call in this file uses. A transposed `output focus <name>` 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,49 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user