Library launches opened on the operator's head — nothing ever focused the streamed one #283

Merged
enricobuehler merged 1 commits from worktree-hyprland-focus-streamed-output into main 2026-08-17 12:49:28 +00:00
9 changed files with 249 additions and 12 deletions
+3 -3
View File
@@ -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
+21 -3
View File
@@ -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)
+5
View File
@@ -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.
+5
View File
@@ -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.
+27
View File
@@ -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