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
1 Commits
Author SHA1 Message Date
enricobuehler 77a8ccbbcd 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
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.
2026-08-17 14:25:29 +02:00