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
Owner

Reported on Hyprland: "anything from the library opens on my main display instead of the virtual screen."

The mechanism

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 hyprctl output create headless / swaymsg 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 before this: zero hits for focusmonitor, movewindow, windowrule, activatewindow, and no post-map correction anywhere.

The launch path's own doc comment asserted the opposite, which is probably why this survived so long:

KWin / Mutter / wlroots — the host runs inside the user's graphical session (… and the per-session virtual output is promoted primary), so a plain spawn lands the app on the streamed output.

False for wlroots, which is extend-only and never promoted — and Hyprland was not in the list at all. Corrected.

The fix

  1. hyprland.rs / wlroots.rsfocus_output() claims focus for the head we are about to stream, at create.
  2. routing.rsfocus_streamed_output(compositor, name), split out so library.rs doesn't reach into a private backend module. Its match is exhaustive on purpose (no _ arm) so a backend added later has to decide rather than silently inherit "no focus". Gated on each backend's own managed-name predicate, which is what 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.
  3. library/launch.rs — re-asserts focus immediately before the spawn. Create-time focus alone isn't enough: the portal handshake (≤20 s), the encoder build and the first frame all sit between capture bring-up and the launch. 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.

Validation

Full gate green — clippy --all-targets -D warnings, a plain build, pf-vdisplay 239 tests, punktfunk-host library:: 56 tests, cargo fmt --check.

On glass against real Hyprland 0.56.2 (headless instance in a transient scope, 2026-08-17):

step result
baseline WAYLAND-1 focused=True
output create headless PF-probe-1 ok — but focused: false ← the bug, reproduced directly
dispatch focusmonitor PF-probe-1 ok, and focused moves to it
spawn a client afterwards maps onto monitor idx 1 = PF-probe-1 ← end-to-end proof

Control worth keeping: the bare hyprctl focusmonitor <name> (no dispatch) answers unknown request at exit 0. focusmonitor is a dispatcher. hyprctl_dispatch's unknown marker is what turns that into an error — without it the wrong argv would have read as success. Both argv shapes are pinned by tests, because that is exactly where this can silently regress: sway spells its command focus output <name> with the noun second, unlike every other call in that file (output <name> mode|enable|unplug).

⚠️ The sway half is not on-glass — its shape is from sway's documented command surface, and no box in the fleet runs sway. It is the safer of the two to get wrong (sway exits non-zero on an invalid command, so a bad shape surfaces as the logged warning rather than a silent success), but the shape wants an on-glass confirmation.

What this deliberately does not do

primary / exclusive topology remain no-ops on both backends — accepted, echoed by the management API, dropped with a warning. That is sweep 13.18, whose shipped fix was the warning only; the behaviour was never implemented. Note resolve_topology makes Auto resolve to Exclusive on any host without a PUNKTFUNK_COMPOSITOR pin, Hyprland included — so the default policy on a Hyprland box is an Exclusive that does nothing. Focus is the extend-topology answer to window placement and leaves the operator's monitors lit; the real thing is specified in design/display-management.md §5.2 (wlroots: primary → log + extend, exclusive → disable physicals, re-enable on teardown).

Also still open, and documented rather than fixed:

  • No focus restore at teardown — removing the focused headless output leaves the compositor to pick a replacement; the operator's prior focus is neither recorded nor restored.
  • Only new windows are aimed. A launcher that opens a second window later (Steam BPM, Heroic, some emulator front-ends) follows focus at that moment. The troubleshooting entry names Dedicated game sessions as the durable workaround, since a per-launch gamescope makes placement structural.
  • Incidental, not fixed: library/launch.rs's plugin_recipe(&entry).map(|l| l.command) drops the plugin's resolved cwdlaunch_session_command has no cwd parameter at all — so emulator plugins that resolve cores relative to their install dir get the host's cwd.

Docs

hyprland.md and sway.md gain a Window placement bullet; the virtual-displays.md topology table gains the note that "treated as Extend" is backed by a focus claim (and what follows from it being focus rather than promotion); troubleshooting.md gains an entry in the reporter's own words.

Reported on Hyprland: *"anything from the library opens on my main display instead of the virtual screen."* ## The mechanism 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 `hyprctl output create headless` / `swaymsg 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 before this: zero hits for `focusmonitor`, `movewindow`, `windowrule`, `activatewindow`, and no post-map correction anywhere. The launch path's own doc comment asserted the opposite, which is probably why this survived so long: > **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (… and the per-session virtual output is promoted primary), so a plain spawn lands the app on the streamed output. False for wlroots, which is extend-only and never promoted — and Hyprland was not in the list at all. Corrected. ## The fix 1. **`hyprland.rs` / `wlroots.rs`** — `focus_output()` claims focus for the head we are about to stream, at create. 2. **`routing.rs`** — `focus_streamed_output(compositor, name)`, split out so `library.rs` doesn't reach into a private backend module. Its match is **exhaustive on purpose** (no `_` arm) so a backend added later has to decide rather than silently inherit "no focus". Gated on each backend's own managed-name predicate, which is what 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. 3. **`library/launch.rs`** — re-asserts focus immediately before the spawn. Create-time focus alone isn't enough: the portal handshake (≤20 s), the encoder build and the first frame all sit between capture bring-up and the launch. 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. ## Validation Full gate green — `clippy --all-targets -D warnings`, a plain build, `pf-vdisplay` 239 tests, `punktfunk-host library::` 56 tests, `cargo fmt --check`. **On glass against real Hyprland 0.56.2** (headless instance in a transient scope, 2026-08-17): | step | result | |---|---| | baseline | `WAYLAND-1 focused=True` | | `output create headless PF-probe-1` | `ok` — but **`focused: false`** ← the bug, reproduced directly | | `dispatch focusmonitor PF-probe-1` | `ok`, and `focused` **moves** to it | | spawn a client afterwards | maps onto **monitor idx 1 = `PF-probe-1`** ← end-to-end proof | **Control worth keeping:** the bare `hyprctl focusmonitor <name>` (no `dispatch`) answers `unknown request` at **exit 0**. `focusmonitor` is a *dispatcher*. `hyprctl_dispatch`'s `unknown` marker is what turns that into an error — without it the wrong argv would have read as success. Both argv shapes are pinned by tests, because that is exactly where this can silently regress: sway spells its command `focus output <name>` with the noun **second**, unlike every other call in that file (`output <name> mode|enable|unplug`). ⚠️ The **sway half is not on-glass** — its shape is from sway's documented command surface, and no box in the fleet runs sway. It is the safer of the two to get wrong (sway exits non-zero on an invalid command, so a bad shape surfaces as the logged warning rather than a silent success), but the shape wants an on-glass confirmation. ## What this deliberately does *not* do `primary` / `exclusive` topology remain **no-ops on both backends** — accepted, echoed by the management API, dropped with a warning. That is sweep **13.18**, whose shipped fix was the *warning only*; the behaviour was never implemented. Note `resolve_topology` makes `Auto` resolve to **Exclusive** on any host without a `PUNKTFUNK_COMPOSITOR` pin, Hyprland included — so the default policy on a Hyprland box is an Exclusive that does nothing. Focus is the *extend*-topology answer to window placement and leaves the operator's monitors lit; the real thing is specified in `design/display-management.md` §5.2 (wlroots: `primary` → log + extend, `exclusive` → disable physicals, re-enable on teardown). Also still open, and documented rather than fixed: - **No focus restore at teardown** — removing the focused headless output leaves the compositor to pick a replacement; the operator's prior focus is neither recorded nor restored. - **Only *new* windows are aimed.** A launcher that opens a second window later (Steam BPM, Heroic, some emulator front-ends) follows focus at *that* moment. The troubleshooting entry names **Dedicated game sessions** as the durable workaround, since a per-launch gamescope makes placement structural. - **Incidental, not fixed:** `library/launch.rs`'s `plugin_recipe(&entry).map(|l| l.command)` drops the plugin's resolved `cwd` — `launch_session_command` has no `cwd` parameter at all — so emulator plugins that resolve cores relative to their install dir get the host's cwd. ## Docs `hyprland.md` and `sway.md` gain a **Window placement** bullet; the `virtual-displays.md` topology table gains the note that "treated as Extend" is backed by a focus claim (and what follows from it being focus rather than promotion); `troubleshooting.md` gains an entry in the reporter's own words.
enricobuehler added 1 commit 2026-08-17 12:27:30 +00:00
fix(vdisplay): library launches opened on the operator's head — nothing ever focused the streamed one
ci / bun-nix (pull_request) Successful in 25s
ci / rust-arm64 (pull_request) Successful in 1m22s
ci / web (pull_request) Successful in 3m35s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m53s
android / android (pull_request) Successful in 13m10s
77a8ccbbcd
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
<name>` 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.
enricobuehler merged commit 7f969cc429 into main 2026-08-17 12:49:28 +00:00
enricobuehler deleted branch worktree-hyprland-focus-streamed-output 2026-08-17 12:49:37 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#283