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
hyprland.rs / wlroots.rs — focus_output() claims focus for the head we are about to stream, at create.
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.
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
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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_outputdoes 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:
False for wlroots, which is extend-only and never promoted — and Hyprland was not in the list at all. Corrected.
The fix
hyprland.rs/wlroots.rs—focus_output()claims focus for the head we are about to stream, at create.routing.rs—focus_streamed_output(compositor, name), split out solibrary.rsdoesn'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.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-vdisplay239 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):
WAYLAND-1 focused=Trueoutput create headless PF-probe-1ok— butfocused: false← the bug, reproduced directlydispatch focusmonitor PF-probe-1ok, andfocusedmoves to itPF-probe-1← end-to-end proofControl worth keeping: the bare
hyprctl focusmonitor <name>(nodispatch) answersunknown requestat exit 0.focusmonitoris a dispatcher.hyprctl_dispatch'sunknownmarker 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 commandfocus 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/exclusivetopology 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. Noteresolve_topologymakesAutoresolve to Exclusive on any host without aPUNKTFUNK_COMPOSITORpin, 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 indesign/display-management.md§5.2 (wlroots:primary→ log + extend,exclusive→ disable physicals, re-enable on teardown).Also still open, and documented rather than fixed:
library/launch.rs'splugin_recipe(&entry).map(|l| l.command)drops the plugin's resolvedcwd—launch_session_commandhas nocwdparameter at all — so emulator plugins that resolve cores relative to their install dir get the host's cwd.Docs
hyprland.mdandsway.mdgain a Window placement bullet; thevirtual-displays.mdtopology 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.mdgains 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." 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.