Compare commits

...
Author SHA1 Message Date
enricobuehler d2c6e1c9c0 Merge pull request '0.31.4 — the screens that stayed lit, the power menu that did nothing, and the console no browser would open' (#394) from worktree-release-0314 into main
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 43s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m11s
ci / bun-nix (push) Successful in 33s
ci / docs-drift (push) Successful in 39s
audit / license-gate (push) Successful in 6m4s
audit / c-abi-asan (push) Successful in 7m33s
audit / miri (push) Successful in 7m48s
android-screenshots / screenshots (push) Successful in 1m17s
ci / rust (push) Successful in 16m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m34s
android / android (push) Successful in 10m54s
decky / build-publish (push) Successful in 58s
arch / build-publish (push) Successful in 12m5s
sbom / sbom (push) Successful in 42s
linux-client-screenshots / screenshots (push) Successful in 6m18s
flatpak / build-publish (push) Successful in 7m39s
docker / builders-arm64cross (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 29s
nix / flake (push) Successful in 25m39s
deb / smoke-install (push) Successful in 3m41s
deb / build-publish (push) Successful in 6m45s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish-gamescope (push) Successful in 37s
deb / build-publish-client-arm64 (push) Successful in 1m28s
apple / distribute (push) Successful in 13m7s
apple / swift (push) Successful in 2m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m52s
apple / screenshots (push) Successful in 9m32s
windows-host / package (push) Successful in 12m0s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
web-screenshots / screenshots (push) Successful in 6m16s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m12s
Reviewed-on: #394
2026-08-24 22:26:27 +00:00
enricobuehler 1d755ebeeb release: 0.31.4 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 39s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 8m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
nix / flake (pull_request) Successful in 8m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 18m25s
21 commits since v0.31.3 (14 non-merge). Cut from origin/main c407f6a6 (#393
merged).

THE NUMBER: a patch, and the cleanest one in a while — NOTHING versioned moves.
WIRE_VERSION 2, C ABI 25 with include/punktfunk_core.h showing NO diff against
the v0.31.3 tag (nor against v0.31.2 or v0.31.1 — third release running), driver
protocol 6 / min 3 with pf-driver-proto unchanged, gamepad channel 3, plugin
index schema 1, host event schema 1 (events.rs no diff), gamescope +pfhdr8 with
no new patch files, SDK 0.1.6 and plugin-kit 0.4.4 both untouched. api/openapi.json
is CONTENT-identical — info.version is the entire diff, the first release since
v0.31.0 where the management API grows nothing. No lockfile moves, in any of the
five. Four feat commits, all additive and all client- or Linux-host-side; v0.31.3
and v0.31.1 each carried two feats as a patch on the same reasoning.

THE SHAPE: Linux-host, and the faults share a premise — a policy written against
ONE desktop that silently did nothing on the others. Topology::Exclusive on a
gamescope session was implemented as org_kde_kwin_dpms and reached only a live
KDE desktop, so a Game Mode box (the deployment whose TV the operator most wants
dark) got nothing: #389 is five commits closing that — a DRM floor for a box with
no desktop at all, sway and Hyprland arms, the managed route's own hold, and the
box-session free that was gated on is_steam_launch. The sleep:idle BLOCK
inhibitor refused every Suspend() for a stream's length including the operator's
own from Steam's power menu, and on a DM-stopping takeover polkit's allow_active
fallback found no session to elect and dropped all three power actions to
auth_admin_keep (#391). The web console served the LEGACY identity because every
launcher names that pair and none of them CAN choose, so browsers rejected it and
the tray's mgmt-pinned probe refused the handshake and labelled a healthy console
dead (#393). Plus the client's encode down-driver (#392), an installTap format
snapshot reaching the ObjC terminate handler (#387), the console's bitrate row
(#388), and a GTK front door for the console UI (#390).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * The sway arm of #389 is NOT verified on glass — the lab has Bazzite, Nobara
    and SteamOS, no sway box. Hyprland, KDE and the DRM floor were all measured
    on real machines. The notes say so in "Thanks" rather than burying it.
  * #389's Hyprland arm shipped WRONG in its first cut and was corrected on glass
    (540e282e): the dpms dispatcher is a TOGGLE that ignores the state word, so
    the blind "send off, later send on" would have LIT an already-dark head at
    stream start and DARKENED a lit one at teardown — the exact failure the
    feature exists to prevent. Read -> act only if different -> verify.

DOCS FRESHNESS, per docs/releases/README.md step 1: #390 and #393 carried their
own docs-site updates (clients.md, and #389's two gamescope.md edits). Two facts
were left owed, both in gamescope.md and both the "stale sentence" class that
docs-drift cannot catch:
  * the darkening mechanism list still read "KDE, else DRM" and never learned
    about the sway and Hyprland arms from e0a82201;
  * the "could not deliver a dark screen" example was a box in Game Mode, which
    is now precisely the case that IS served by the DRM floor. The real remaining
    gap is GNOME/Mutter, so it says that instead.
Also: the punktfunk group grew a third grant this cycle (the three logind power
actions, via 49-punktfunk-power.rules), and the group's own callout block
enumerated only the usbip nodes — one sentence added there.
No new PUNKTFUNK_* variable this cycle (PUNKTFUNK_UI_TLS_CERT/KEY are
pre-existing and already in the baseline), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean, and PROVEN LIVE rather than
assumed — a deliberate one-byte break of the openapi copy makes it fail, and it
passes again restored; scripts/ci/check-docs-links.sh clean; the android.yml Play
notes gate run verbatim, 481/500 characters and unique against every other
release's file; both openapi copies cmp identical and stamped 0.31.4; cargo fmt
--all --check clean; git diff v0.31.3..HEAD empty on include/punktfunk_core.h, on
crates/pf-driver-proto, on punktfunk-core/src/lib.rs, on punktfunk-host/src/events.rs
and on api/openapi.json, which is the direct evidence for those five version rows;
git diff v0.31.1..HEAD on the C header also empty, which is the "third release
running" claim; Cargo.lock's 36 workspace version strings moved with Cargo.toml
and zero 0.31.3 strings remain; 27 crate dirs / 39 workspace members, both
unchanged; notes voice scan clean (zero backticked terms above ## For developers,
and zero internal names on a scan for gamescope/polkit/logind/DPMS/CRTC/DRM/
systemd/kwin/mutter/wlroots/rustls/ioctl); CHANGELOG link pinned to
src/tag/v0.31.4.

NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edits are
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.

LEFT AS A DECISION, not made here: the tag itself. Notes, CHANGELOG and Play text
are authored ahead of it exactly as docs/releases/README.md step 1 requires, so
whichever workflow wins the create race seeds a complete release body — but
pushing v0.31.4 fans out every build workflow, and that is step 2.
2026-08-24 23:34:38 +02:00
enricobuehler c407f6a6d9 The console served the identity nothing pins, and the tray called it dead (#393)
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-drift (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 28s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 25s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m53s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m17s
deb / build-publish-host (push) Successful in 5m42s
ci / rust (push) Successful in 7m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
docker / builders-arm64cross (push) Successful in 19s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 8m54s
arch / build-publish (push) Successful in 9m32s
deb / smoke-install (push) Successful in 4m10s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
nix / flake (push) Successful in 20m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m30s
The web console never followed the identity split: every launcher names the legacy cert.pem/key.pem, so it served a CN-only, SAN-less RSA cert. Browsers reject that outright, and the tray's liveness probe — pinned to the native identity the mgmt API serves — could never complete the handshake, labelling a healthy console "not responding" beside a tooltip reading "idle".

Resolved at the Nitro entry, the one place every launcher routes through: prefer the native pair when both halves are present, usable and in the same directory; fall back to the legacy pair otherwise. The tray's console probe drops its pin rather than gaining a second one, and `serve` now mints the native identity before the legacy one.

Reviewed adversarially (no blockers; the one real finding — a pair that could be assembled from two different directories — fixed in 1e2b956d). All 8 checks green, including the native-only clippy gate. Verified end to end against a built server: both pairs → P-256 with SANs; legacy only → RSA as before; 0-byte native cert → clean fallback rather than a restart loop.
2026-08-24 20:55:06 +00:00
enricobuehler 1e2b956de6 fix(web,tray,host): review follow-ups — pair the halves, and stop two comments overclaiming
ci / docs-drift (pull_request) Successful in 25s
ci / bun-nix (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m11s
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m52s
android / android (pull_request) Successful in 5m43s
ci / rust (pull_request) Successful in 5m43s
nix / flake (pull_request) Successful in 6m34s
Review of the parent commit. One real defect, the rest accuracy.

The resolver could hand back a MISMATCHED pair, which is the one invariant its
own header promised it never would: `nativeCert` came from `dirname(cert)` and
`nativeKey` from `dirname(key)`, two independent directories, so
("/a/cert.pem", "/b/key.pem") resolved to /a/native-cert.pem + /b/native-key.pem
— two unrelated files presented as a pair. No shipped launcher splits them, but
the guard is one comparison and it is the whole point of the module.

Rewritten off a suffix test instead of `node:path`, which fixes two more things
in passing. `node:path` resolves per-RUNTIME, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and
Windows, where windows/service.rs hands us exactly that, is the platform the CI
job can never exercise. The suffix test gives the same answer everywhere and is
now covered by a win32 case. It also leaves the prefix VERBATIM, where
`join(dirname(p), …)` normalised /a/b/../cert.pem into a different directory the
moment `b` was a symlink.

Existence is no longer enough: `pf_paths::write_secret_file` is
create+truncate+write rather than temp+rename, so a console starting mid-write
could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart. Not
every launcher retries forever — the Steam Deck unit is `Restart=on-failure`
under the default rate limit, i.e. permanently dead. The check is now a
non-empty stat, mirroring the host's own `!c.trim().is_empty()`. Verified: with
native-cert.pem truncated to 0 bytes the console starts and serves the legacy
pair.

Two comments of mine overclaimed and are corrected rather than left to mislead:

  * serve() said "cert.pem existing implies the native pair does too". False on
    an upgraded host whose native clients pinned the legacy cert — load_or_adopt
    returns it and writes no native files at all. The ordering claim that IS
    true is narrower: whenever that call writes a native pair, it does so before
    cert.pem appears.
  * the tray said the console entry "always opens the same URL regardless of the
    probe". True of the menu entry, but win.rs gates the tray-icon single-click
    on console_up. Also notes that the Windows probe was never pinned to begin
    with (punktfunk_config_dir is None off Linux), so that half is a no-op.

Rest is doc drift the parent commit annotated in two launchers but not the other
four: web.env.example, README, web-run.cmd, and the ci.yml comment that still
said the web test step was "Scoped to server/".

18/18 web tests (was 14), biome and rustfmt clean, and the runtime check re-run
against a fresh build: both pairs -> P-256 with SANs; 0-byte native cert ->
legacy RSA, console still serving.
2026-08-24 22:45:47 +02:00
enricobuehler 49b5ffa2d8 fix(web,tray,host): the console served the identity nothing pins, and the tray called it dead
android / android (pull_request) Successful in 4m33s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m54s
nix / flake (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 7m2s
A Linux operator saw "Open web console (not responding)" in the tray next to a
tooltip reading "idle", and the console would not load in a browser either.

The host has kept two identities since the identity split (crate::identity):
native-cert.pem/native-key.pem (P-256, real SANs — what the native QUIC plane,
the mgmt API and every native client pin) and the legacy cert.pem/key.pem (RSA,
CN=punktfunk, NO SAN, kept byte-stable for Moonlight). The web console never
followed the split. Every launcher — the systemd unit, the NixOS module, the
Windows service supervisor, web-run.cmd, the Steam Deck installer — still names
the LEGACY pair, and none of them CAN choose: `Environment=` has no "this file,
else that one". So the console served a certificate with no SAN at all, which
costs twice over:

  * browsers reject a CN-only cert outright (ERR_CERT_COMMON_NAME_INVALID), so
    the console the operator was told to open does not load;
  * the tray's loopback liveness probe reused the agent PINNED to the mgmt
    identity — the native cert — so rustls refused the handshake and a perfectly
    healthy console was labelled "not responding". The "idle" tooltip beside it
    is the proof: the same agent reached mgmt fine on the very same tick.

The entry is the one place every launcher routes through, so the choice is made
there: prefer the native sibling pair when both files exist, as a PAIR or not at
all (a native cert with the legacy key completes no handshake with anyone). A
host that never took the split has no native pair on disk and falls through
unchanged, as does an operator-supplied cert under any other name. This also
hands the bundled bun the smaller secret: on a default build key.pem is the
Moonlight pairing SIGNING key, native-key.pem is only a TLS key.

The tray's console probe loses its pin rather than gaining a second one. It is a
different server and there is no rule that it presents the mgmt certificate — an
operator fronting the console with their own LAN-CA cert would have hit this just
as squarely. The probe sends no credentials, reads no body, and decides only a
menu label, so there is nothing for a pin to protect.

`serve` now resolves the native identity BEFORE minting the legacy one. That
closes a first-run window where the console (which waits on cert.pem) could start
between the two writes and serve the SAN-less cert for the rest of the boot, and
it fixes a second latent fault: with cert.pem missing but native clients paired,
the old order let load_or_create mint a brand-new cert.pem that load_or_adopt
then adopted while logging that it was preserving their pins.

Verified against the built server: configured exactly as the shipped unit does
(PUNKTFUNK_UI_TLS_CERT=.../cert.pem), it now serves the P-256 cert with
DNS:localhost/IP:127.0.0.1; with the native pair removed it serves the RSA cert
as before. 14/14 web tests pass, biome and rustfmt clean.
2026-08-24 20:30:25 +02:00
enricobuehler d65b9f3b1b Merge pull request 'Exclusive topology left the box's own screen lit — honor it on every desktop we drive' (#389) from worktree-gamescope-exclusive-panel-off into main
ci / rust-arm64 (push) Successful in 2m22s
ci / web (push) Successful in 1m27s
ci / bun-nix (push) Successful in 26s
ci / docs-site (push) Successful in 1m45s
ci / docs-drift (push) Successful in 28s
deb / build-publish-gamescope (push) Successful in 53s
deb / build-publish-client-arm64 (push) Successful in 2m16s
ci / rust (push) Successful in 6m46s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m55s
arch / build-publish (push) Successful in 11m51s
android / android (push) Successful in 12m13s
deb / smoke-install (push) Successful in 3m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
windows-host / package (push) Successful in 14m51s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m50s
2026-08-24 16:06:39 +00:00
enricobuehler cd0b53f8fe Merge pull request 'The encode down-driver stands down when the rate isn't the lever' (#392) from worktree-abr-encode-downdriver into main
android / android (push) Canceled after 26s
arch / build-publish (push) Canceled after 31s
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Canceled after 7s
ci / web (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 37s
ci / docs-site (push) Canceled after 37s
ci / rust (push) Canceled after 38s
deb / build-publish (push) Canceled after 22s
deb / build-publish-gamescope (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 3s
deb / smoke-install (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
windows-host / winget-source (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
apple / swift (push) Successful in 2m5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
flatpak / build-publish (push) Successful in 8m41s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 35s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m47s
apple / distribute (push) Successful in 11m36s
apple / screenshots (push) Successful in 9m57s
Reviewed-on: #392
2026-08-24 16:06:13 +00:00
enricobuehler 540e282e60 fix(hyprland): its dpms dispatcher is a TOGGLE, and the classic argv does not parse under Lua
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m45s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m45s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Successful in 7m40s
Verified on the NixOS VM (125, Hyprland 0.55.4) — and the arm I shipped for it in
the previous commit was wrong twice over. This is why it went on glass.

**The argv did not work at all.** `hyprctl dispatch dpms off <name>`, the form
this file's own probe notes use, dies on the Lua config manager: `dispatch` is
shorthand for `hl.dispatch(...)`, so the bare words are parsed as a Lua
expression —

    error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
           ')' expected near 'off'

The Lua spelling is `hl.dsp.dpms("off", "<name>")` (found by enumerating
`hl.dsp` through `hyprctl eval`, which only exists on that manager). A hyprlang
box wants the classic form, there is no stable probe for which manager is
loaded, and `hyprctl_dispatch` already catches the exit-0 rejections both
produce — so try classic, then Lua, and report both failures if neither lands.

**And the dispatcher is a TOGGLE that ignores the state word.** Measured:

    On  ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  Off   <- asked ON, got OFF
    Off ==[ hl.dsp.dpms("on",  "HDMI-A-1") ]==>  On
    Off ==[ hl.dsp.dpms{state="off", ...}  ]==>  On    <- asked OFF, got ON

Both spellings, positional and table. So the blind "send off, later send on" the
previous commit shipped would LIGHT an already-dark head at stream start and
DARKEN a lit one at teardown — the operator's screen left off after the stream,
precisely the failure this policy exists to prevent. (It bit me while probing:
a restore fired at an already-On head turned it off, which for a while looked
like "dpms on cannot restore on Hyprland".)

So `dpms_one` is read → act only if the state differs → verify, via
`hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's
sysfs `dpms` exactly, in both states, and a dark monitor stays listed). That
shape is also correct where the call really is a set, so it is not conditional
on detecting the manager. It returns whether it CHANGED anything, and
`dpms_other_heads` records only those — a head already in the wanted state is
left alone, because "fixing" it would break it, and reporting it would have the
re-light toggle a head we never darkened.

The on-glass assertion is relaxed from "every connected head goes dark" to "at
least one did, and all are restored": this VM carries a virtio `Virtual-1`
beside the real `HDMI-A-1` and Hyprland manages only the latter, so the strict
form failed on a difference that is not a defect.

Verified end to end, real Rust through the real dispatcher on a live Hyprland:

    before: [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    during: [("card0-HDMI-A-1", "Off"), ("card1-Virtual-1", "On")]
    after:  [("card0-HDMI-A-1", "On"), ("card1-Virtual-1", "On")]
    went dark: ["card0-HDMI-A-1"]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The unmanaged `Virtual-1` is correctly untouched, and the test exercises the
double-acquire (reconnect) path too, so the toggle-safe release is covered.

cargo test -p pf-vdisplay 255 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean. sway remains the one arm not verified
on glass — there is no sway box in the lab.
2026-08-24 15:29:24 +02:00
enricobuehler e0a822016f feat(vdisplay): honor exclusive on sway and Hyprland too, not just KDE
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 2m32s
android / android (pull_request) Successful in 6m20s
ci / rust (pull_request) Successful in 16m32s
`exclusive` on a gamescope session meant "turn the box's own panels off", and
that only actually happened on two kinds of box: one running KDE (via
`org_kde_kwin_dpms`) and one running no desktop at all (via the DRM floor added
in the previous commit). A gamescope session on a **sway** or **Hyprland**
desktop got nothing — the desktop keeps driving its panels, and the DRM floor
cannot help because that compositor holds DRM master, so `SET_MASTER` is refused.

There is no cross-compositor DPMS protocol, so the module becomes a dispatcher,
each arm self-gating on its own IPC so a box only pays for the one that answers:

| desktop        | mechanism                                              |
|----------------|--------------------------------------------------------|
| KDE / KWin     | in-process `org_kde_kwin_dpms`, then `kscreen-doctor`   |
| sway (wlroots) | `swaymsg output <name> dpms off`                        |
| Hyprland       | `hyprctl dispatch dpms off <name>`                      |
| none at all    | `drm_dpms` — the CRTCs off over DRM                     |
| GNOME / Mutter | cannot be served (below)                                |

**`kwin_dpms` is renamed `panel_dpms`.** A module that darkens sway and Hyprland
should not be named after KWin, and this file has already paid for one stale
name: the comment authorising the DPMS skip on the other gamescope routes ("its
takeover already stopped the desktop") outlived the stop it named by three
commits and hid the whole defect. (The rename's sed also caught
`org_kde_kwin_dpms` itself — the vendored protocol's interface names, which are
not ours to rename — and those are restored.)

**DPMS, not the `disable` these backends already have.** Both wlroots and
Hyprland implement their OWN exclusive topology by disabling heads; that is
right when the streamed output lives on that compositor and has to become "the
desk", and wrong here — a gamescope spawn owns no output there, so disabling
would move the operator's workspaces and re-home their windows for a stream that
is not even on their compositor. It matters most on Hyprland, where this
module's own notes record that a disabled head has no clean undo: the only known
restore is re-reading the operator's whole config, which drops every runtime
override they set by hand. DPMS is a separate axis and round-trips cleanly.

Both arms reuse their backend's existing `heads_to_disable` filter with an EMPTY
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots
session's `HEADLESS-*` still must be spared or darkening blacks out that client's
stream. They address heads by NAME and report which ones actually changed, so the
re-light undoes exactly those.

⚠ **GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to
clients; its own `exclusive` mechanism (`ApplyMonitorsConfig` omitting the
physicals) needs a virtual output of its own to keep enabled, which a gamescope
session does not have; and the DRM floor is refused because Mutter holds master.
The `warn!` now names that instead of saying nothing.

⚠ **The sway and Hyprland arms are NOT verified on glass.** The lab has Bazzite,
Nobara and SteamOS — no sway or Hyprland box. Their argv shapes and the
sibling-sparing filter are unit-tested and everything compiles clean, but the
commands themselves have not been run against a live compositor. Treat them as
unproven until someone streams from one.

Verified: `cargo test -p pf-vdisplay` 254 passed / 0 failed; clippy
--all-targets clean; fmt --check and check-docs-drift.sh clean. Re-ran BOTH
on-glass tests on the Nobara VM after the rename — the managed path falls
through KDE → sway → Hyprland → DRM correctly on a box with none of the first
three, and the panel still goes On → Off → On:

    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok
    test drm_dpms::tests::live_the_panels_go_dark_and_come_back ... ok
2026-08-24 15:07:03 +02:00
enricobuehler b6938a9890 fix(gamescope): the managed route honors exclusive too — it is the one that matters most
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m5s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 6m7s
ci / rust (pull_request) Successful in 7m3s
The previous commit left Managed without a darken hold and wrote down a reason:
its display reports `DisplayOwnership::SessionManaged`, and `registry::acquire`
returns for anything not `Owned` ABOVE its `take_topology_restore()` pickup, so a
hold registered the way the bare spawn does would never be released — and a panel
dark after every stream is worse than one left lit.

That reasoning was right and the conclusion was wrong. Managed is the recommended
route, it gives the best experience, and it is the ONLY way to serve a client its
own virtual output at its own mode — so it is the route where `exclusive` most
needs to mean something. "No release path on the registry" is an argument for
putting the release somewhere else, not for skipping the feature.

Somewhere else is `do_restore_tv_session`: the one teardown every managed path
funnels through, and already the home of the drop-in sweep for exactly this class
of reason ("must not leak past an early return"). The release goes at the very
TOP of it rather than beside that sweep, because the SteamOS branch returns above
it. Safe there: releasing re-lights, and every path below either hands the box
back or deliberately keeps a headless session on a box with no connected display
— nothing lit to darken in that case anyway.

The hold is a bool, not a count, and that is load-bearing. The managed SESSION is
what gets darkened, not each connect: it outlives client disconnects (the point
of `MANAGED_SESSION`) and a same-mode reconnect reuses it warm without a
relaunch. Acquiring per connect would ratchet `kwin_dpms`'s refcount up with no
matching releases and pin the operator's panel dark for the rest of the host's
life. The edges are split into pure `managed_darken_{acquire,release}_edge` — the
same shape as `kwin_dpms::Holds` and for the same reason — so the balance rule is
unit-tested without a live compositor: reconnect takes no second hold, the
unconditional release is idempotent, a full cycle re-arms, and `extend` /
`SharedDesktop` still take no hold at all.

Verified on the Nobara VM (123, Fedora 44) in the takeover state, against a real
connected head, driving the actual managed helpers rather than the pure edges:

    before: [("card0-HDMI-A-1", "On")]
    during: [("card0-HDMI-A-1", "Off")]     <- incl. a second acquire (reconnect)
    after:  [("card0-HDMI-A-1", "On")]
    test gamescope::tests::live_the_managed_hold_darkens_a_real_panel ... ok

The bare-spawn on-glass test still passes alongside it. (`mutter::live_mutter_
create_drop` fails on that box and is unrelated — it needs a GNOME session.)

`gamescope.md`'s per-model table said Managed does not darken a desktop beside
it; it now says what both owning routes actually do, and that neither needs root.

cargo test -p pf-vdisplay 252 passed / 0 failed; clippy --all-targets clean;
fmt --check and check-docs-drift.sh clean.
2026-08-24 14:48:07 +02:00
enricobuehler faf94087c5 feat(vdisplay): turn the box's own panel off over DRM, for a box with no desktop to ask
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
android / android (pull_request) Successful in 5m8s
ci / rust (pull_request) Successful in 6m14s
`kwin_dpms` asks KWin for DPMS-off, which is the right answer whenever there is a
KDE desktop to ask. A box sitting in Game Mode has none — it runs gamescope and
no KWin — and that is *precisely* the deployment whose TV the operator wants
dark. So `exclusive` silently did nothing there, which is the field report.

Measured on the Nobara VM (123, Fedora 44) before writing any of this, because
the obvious cheaper answers all fail on real hardware:

* Getting the box's session out of the way is NOT enough. After the takeover
  idles it, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On` indefinitely —
  when no client holds DRM master the kernel just keeps the CRTC configured.
  That refutes `gamescope.rs:1573` ("left the Deck in a headless session with its
  panel dark") as a general claim: that was a Deck's internal panel.
* `setterm --blank force` is unreachable: `/dev/tty1` is `crw------- root:tty`.
* The connector's `dpms` sysfs attribute is read-only even as root.
* `/sys/class/graphics/fb0/blank` DOES work (`dpms=Off`) — but needs root, and
  the takeover deliberately stopped needing privilege in 0.31.0. Not worth
  re-introducing a group requirement for.

What does work, unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for
whoever holds the active seat (`crw-rw----+`), and `SET_MASTER` succeeds while
nobody else is master — exactly the state the takeover has just produced. So
walk `GETRESOURCES`, `GETCRTC` each id, and `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The
connector goes `enabled=disabled dpms=Off`, the same end state `kscreen-doctor
--dpms off` reaches through KWin.

Two measured properties shrank the code. The darkness **survives DROP_MASTER**,
so mastership is handed straight back — a host still holding it would starve the
box's own gamescope when the restore relaunches its session, turning a dark panel
into a session that cannot start. And the re-light is **`close(fd)`**: the
kernel's last-close restores the console, so there is no saved mode to replay, no
restore that can half-fail, and crash safety is free (a dead host has its fds
closed by the kernel and the box lights up) — the same property `kwin_dpms` gets
from DPMS being non-persistent. Nothing to journal, nothing to sweep at startup.

`SET_MASTER` failing is also the natural guard for the Attach route: a card
already mastered by a live compositor is not ours, and darkening it would darken
the very picture being streamed.

Four ioctls, three `repr(C)` structs, one `unsafe` block. The ioctl numbers
encode their payload size (`0x40`, `0x68`), so the layouts are pinned by
`const _: () = assert!(...)` at compile time — a struct that drifts would have
the kernel read or write the wrong byte count on someone's TV — plus a test that
restates it greppably and pins the two field offsets the count/data-pass dance
depends on.

The on-glass test earned its place immediately. Everything else was green —
ABI tests, clippy, 251 unit tests, and a Python probe that had proven the ioctl
sequence itself — while the first Rust cut still left the panel lit: `darken_card`
closed its fd before returning and `darken` re-opened a fresh one to hold. That
close is the kernel's LAST close, which restores the console; the new fd held
nothing. It reported `darkened cards: ["card0"]` over a connector at
`enabled=enabled dpms=On`. The fd that does the work is now the fd that is held.

Verified on that VM with this exact binary, against a real connected head:

    before: [("card0-HDMI-A-1", "enabled", "On")]
    darkened cards: ["card0"]
    during: [("card0-HDMI-A-1", "disabled", "Off")]
    after:  [("card0-HDMI-A-1", "enabled", "On")]

cargo test -p pf-vdisplay 251 passed / 0 failed; clippy --all-targets clean
(incl. the workspace's undocumented_unsafe_blocks deny); fmt --check and
check-docs-drift.sh clean.
2026-08-24 13:53:54 +02:00
enricobuehler 8c628b4e6c fix(gamescope): exclusive topology left the box's own screen lit under a dedicated game session
ci / rust-arm64 (pull_request) Successful in 2m20s
ci / web (pull_request) Successful in 2m17s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 26s
android / android (pull_request) Successful in 13m20s
ci / rust (pull_request) Successful in 27m51s
Field report, Nobara, 2026-08-24: launching a game from the library with
`game_session=dedicated` and `topology: exclusive` left the operator's physical
screen on. Two independent causes, neither of which had a line in the log.

The first is ours and recent. `c2f5e91b` replaced the managed takeover's
display-manager STOP with an idle drop-in, deliberately, so the box keeps a DM
able to service the user's own "Switch to Desktop". That stop was also, as a
side effect, the only thing that took the box's own panel down. Nothing replaced
it, and the comment authorising the DPMS skip on this file's other routes — "its
takeover already stopped the desktop" — outlived the stop it named by three
commits, so the gap read as intentional. That comment is now the two reasons that
actually hold, written at the returns they belong to: Managed reports
`SessionManaged` and `registry::acquire` returns above its
`take_topology_restore()` pickup, so a hold taken there would never be released
(a panel left dark after every stream is worse than one left lit — `relight` is
written as "the last line of defence for a dark monitor" for that reason); and
Attach mirrors a gamescope that may be lighting the panel, so darkening it would
darken the picture being streamed.

The second is older and louder, and is what the report actually hit.
`stop_autologin_sessions` was reached only for a Steam launch — `is_steam_launch`
is `cmd.split_whitespace().next() == Some("steam")` — because freeing Steam's
single instance was the only requirement anyone had for it. But on a Game Mode
box that same session is the DRM master of the TV (`gamescope/heads.rs`), so a
NON-Steam library game left it running and the panel showed live Game Mode for
the whole stream. `exclusive` is a second, separate reason to want that session
out of the way, and it is now stated as one: `free_box_session_for_exclusive` is
pure and unit-tested over all four Steam x Exclusive combinations, including that
`extend` / the `SharedDesktop` preset ("never blank the real monitors") must
still leave the box strictly alone. Best-effort, unlike the Steam arm above it —
there it costs a dark screen, not the game, so it warns and continues.

Neither could be diagnosed from a log. `kwin_dpms::darken` is only ever reached
because the operator selected `exclusive`, so every decline is "you asked for
your screens off and they stayed on" — and the definitive not-KDE arm sat at
`debug!`. A box already in Game Mode has no KWin to answer DPMS at all, which is
precisely the box this fires on. It says so at `warn!` now, the same honesty
`relight` has always had for the dark direction.

`gamescope.md` claimed managed "deliberately takes the session over and blanks
the panel". It takes the session over; blanking is Topology's job and differs per
model, so the page now says which model can do what. The polkit/lingering block
above it is NOT stale — it is scoped to the restore path and pre-0.31.0 adoption,
and is left alone.

Verified: `cargo test -p pf-vdisplay` 249 passed / 0 failed, clippy --all-targets
clean, docs-drift gate clean. Not yet exercised against a live Nobara box — the
DPMS half cannot fire there by construction (no KWin in Game Mode), so what wants
measuring on the VM is whether releasing the DRM master leaves the connector dark
or merely blank-but-powered.
2026-08-24 11:23:45 +02:00
29 changed files with 1837 additions and 113 deletions
+5 -3
View File
@@ -370,9 +370,11 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
# mode is a well-formed header that only a browser rejects, and the one picking which of the
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
# Neither would be caught anywhere else.
- name: Test
run: bun run test
+410
View File
@@ -12,6 +12,416 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.31.4
21 commits since v0.31.3 (14 non-merge), counted at the tip this was cut from.
**Nothing versioned moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25**
`include/punktfunk_core.h` is **byte-identical to the v0.31.3 tag, and to v0.31.2 and v0.31.1
before it**, the third release running with no diff at all — and so do the driver protocol, the
gamepad channel, the plugin index schema, the host event schema and the gamescope patch level.
`pf-driver-proto` shows no diff. `api/openapi.json` is **content-identical**: only `info.version` is
re-stamped, so this is the first release since v0.31.0 where the management API grows nothing.
`@punktfunk/host` stays **0.1.6** and `@punktfunk/plugin-kit` stays **0.4.4**; nothing under `sdk/`
or `plugin-kit/` moved. No dependency moves in any of the five Rust lockfiles.
The cycle is **Linux-host shaped**, and the faults share a premise: *a policy that was written
against one desktop and silently did nothing on the others.* `Topology::Exclusive` on a gamescope
session was implemented as `org_kde_kwin_dpms` and reached only a live KDE desktop, so a Game Mode
box — the deployment whose TV the operator most wants dark — got nothing (#389, five commits:
a DRM floor for a box with no desktop at all, sway and Hyprland arms, the managed route's own
hold, and the box-session free that was gated on Steam). The `sleep:idle` block inhibitor refused
every `Suspend()` for the length of a stream, including the operator's own from Steam's power menu,
and on a display-manager-stopping takeover polkit's `allow_active` fallback found no session to
elect and dropped all three power actions to `auth_admin_keep` (#391). The web console served the
**legacy** identity because every launcher names that pair and none of them *can* choose, so
browsers rejected it outright and the tray's mgmt-pinned probe refused the handshake and labelled a
healthy console dead (#393). Alongside: the client's encode down-driver, whose thresholds were
absolute durations calibrated at 120 Hz and whose stand-down was permanent (#392); an
`installTap` format snapshot that could reach the Objective-C terminate handler (#387); the
console's bitrate row (#388); and a GTK entry point for the console UI (#390).
### Versions
| | v0.31.3 | v0.31.4 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.3 tag — nor against v0.31.2 or v0.31.1. Third release running |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged). `drm_dpms.rs` is a new **module** inside `pf-vdisplay`, not a new crate |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.3 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged. Nothing under the Windows gamepad backends moved |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs` shows no diff) |
| `api/openapi.json` | 0.31.3 | **0.31.4** | **content-identical** — the `info.version` stamp is the whole diff. No route, schema or field added, removed or re-shaped. Re-stamped, not regenerated (`punktfunk-host` does not build on macOS); `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, `packaging/gamescope/PKGBUILD` still declares `pfhdr8` |
| `@punktfunk/host` (SDK) | 0.1.6 | **0.1.6** | unchanged; nothing under `sdk/` moved, and the API it wraps did not either |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
### ⚠ Breaking changes
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, and
this time not even an additive API change. Every 0.31.x host, client, driver and plugin keeps
interoperating in both directions with no re-pairing and no rebuild.
One **source-level rename**, internal to `pf-vdisplay` and visible to nobody outside it:
`kwin_dpms``panel_dpms`. It is no longer a KWin module. `org_kde_kwin_dpms` itself — the
vendored protocol's interface name, which is not ours to rename — is untouched.
Five **behaviour** changes that break no build but change what a machine does:
- **The `sleep:idle` inhibitor is no longer held for the whole stream.** It is held only while the
stream is quiet, dropped synchronously on any client input on either plane, and re-taken after
`QUIET_BEFORE_VETO` (30 s) of silence. A host that previously vetoed every `Suspend()` for a
session's length now vetoes only the ones that arrive during genuine inactivity.
- **`Topology::Exclusive` now darkens panels on sway, Hyprland and a desktop-less box**, where it
previously did nothing. If you run a gamescope session on one of those and had selected
`exclusive` expecting the old no-op, you now get dark panels. `extend`, `primary` and the
`SharedDesktop` preset are unchanged and still take no hold at all.
- **`stop_autologin_sessions` is now reached for a non-Steam library launch** when the topology is
exclusive. Previously only `is_steam_launch` reached it. Best-effort: it warns and continues,
unlike the Steam arm above it, because there the cost is a dark screen rather than the game.
- **The encode-attributed backoff re-arms after standing down**, on a doubling interval, rather
than being disarmed for the rest of the session. Its SEVERE/MILD thresholds are now sized from
the negotiated refresh (a fraction of the session's frame budget) instead of the absolute
durations calibrated at 120 Hz, so a 60 Hz session no longer takes an immediate ×0.7 on an
ordinary one-frame hiccup.
- **The web console prefers the native identity pair.** When `PUNKTFUNK_UI_TLS_CERT` names
`cert.pem` and `native-cert.pem`/`native-key.pem` both exist beside it, the console serves the
native pair. A host with no native pair on disk, or an operator-supplied cert under any other
name, falls through unchanged. `serve` now resolves the native identity **before** minting the
legacy one.
### `Topology::Exclusive` becomes a per-desktop dispatcher
`kwin_dpms` asked KWin for DPMS-off, which is the right answer whenever there is a KDE desktop to
ask. A gamescope session owns no output on the box's own desktop, so the desktop backends' `disable`
route is wrong here — disabling would move the operator's workspaces and re-home their windows for a
stream that is not even on their compositor, and on Hyprland a disabled head has no clean undo (the
only known restore is `hyprctl reload`, which drops every runtime override). DPMS is a separate axis
and round-trips cleanly. The module is renamed `panel_dpms` and becomes a dispatcher, each arm
self-gating on its own IPC so a box only pays for the one that answers:
| desktop | mechanism |
|---|---|
| KDE / KWin | in-process `org_kde_kwin_dpms`, then `kscreen-doctor` |
| sway (wlroots) | `swaymsg output <name> dpms off` |
| Hyprland | `hyprctl dispatch dpms off <name>`, then the Lua `hl.dsp.dpms(...)` spelling |
| none at all | `drm_dpms` — the CRTCs off over DRM |
| GNOME / Mutter | **cannot be served** |
Both compositor arms reuse their backend's existing `heads_to_disable` filter with an **empty**
`ours` — a gamescope session owns nothing to spare, but a concurrent wlroots session's `HEADLESS-*`
still must be spared or darkening blacks out that client's stream. They address heads by name and
report which ones actually changed, so the re-light undoes exactly those.
**GNOME is a structural gap, not an omission.** Mutter exposes no DPMS to clients; its own
`exclusive` mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its
own to keep enabled, which a gamescope session does not have; and the DRM floor is refused because
Mutter holds master. The decline was at `debug!` and is now a `warn!` naming the reason — every one
of these means "you asked for your screens off and they stayed on".
**Not verified on glass: the sway arm.** The lab has Bazzite, Nobara and SteamOS — no sway box. Its
argv shape and the sibling-sparing filter are unit-tested and it compiles clean, but the command has
not been run against a live sway session. Hyprland, KDE and DRM were all measured on real machines.
### `drm_dpms`: the CRTCs off over DRM, for a box with no desktop to ask
Measured on the Nobara VM (123, Fedora 44) before any of it was written, because the cheaper answers
all fail on real hardware: getting the box's session out of the way is **not** enough (with no
client holding DRM master the kernel keeps the CRTC configured — `enabled=enabled dpms=On`
indefinitely); `setterm --blank force` is unreachable (`/dev/tty1` is `crw------- root:tty`); the
connector's `dpms` sysfs attribute is read-only even as root; and `/sys/class/graphics/fb0/blank`
works but needs root, which the takeover deliberately stopped needing in 0.31.0.
What works unprivileged: `/dev/dri/cardN` carries a logind uaccess ACL for whoever holds the active
seat (`crw-rw----+`), and `SET_MASTER` succeeds while nobody else is master — exactly the state the
takeover has just produced. Walk `GETRESOURCES`, `GETCRTC` each id, `SETCRTC` with `fb_id=0,
mode_valid=0, count_connectors=0` on the ones actually driving something. The connector reaches
`enabled=disabled dpms=Off`, the same end state `kscreen-doctor --dpms off` reaches through KWin.
Two measured properties shrank the code. The darkness **survives `DROP_MASTER`**, so mastership is
handed straight back — a host still holding it would starve the box's own gamescope when the restore
relaunches its session. And the re-light is **`close(fd)`**: the kernel's last-close restores the
console, so there is no saved mode to replay, no restore that can half-fail, and crash safety is
free. Nothing to journal, nothing to sweep at startup. `SET_MASTER` failing is also the natural
guard for the Attach route — a card already mastered by a live compositor is not ours, and darkening
it would darken the very picture being streamed.
Four ioctls, three `#[repr(C)]` structs, one `unsafe` block. The ioctl numbers encode their payload
size (`0x40`, `0x68`), so the layouts are pinned by `const _: () = assert!(...)` at compile time,
plus a test that restates it greppably and pins the two field offsets the count/data-pass dance
depends on.
### The managed gamescope route takes the hold too
Managed reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for anything not
`Owned` **above** its `take_topology_restore()` pickup — so a hold registered the way the bare spawn
does would never be released, and a panel dark after every stream is worse than one left lit. That
is an argument for putting the release somewhere else, not for skipping the feature on the route
that matters most (it is the recommended one, and the only way to serve a client its own virtual
output at its own mode).
Somewhere else is `do_restore_tv_session`, the one teardown every managed path funnels through, at
the very **top** of it because the SteamOS branch returns above the existing drop-in sweep.
The hold is a **bool, not a count**, and that is load-bearing: the managed *session* is what gets
darkened, not each connect. It outlives client disconnects and a same-mode reconnect reuses it warm,
so acquiring per connect would ratchet the refcount up with no matching releases and pin the
operator's panel dark for the rest of the host's life. The edges are split into pure
`managed_darken_{acquire,release}_edge` so the balance rule is unit-tested without a live
compositor: reconnect takes no second hold, the unconditional release is idempotent, a full cycle
re-arms, and `extend` / `SharedDesktop` still take no hold at all.
### The box's own session is freed for `exclusive`, not just for Steam
`stop_autologin_sessions` was reached only via `is_steam_launch`
(`cmd.split_whitespace().next() == Some("steam")`), because freeing Steam's single instance was the
only requirement anyone had for it. But on a Game Mode box that same session is the DRM master of
the TV, so a **non-Steam** library game left it running and the panel showed live Game Mode for the
whole stream — the 2026-08-24 Nobara field report. `exclusive` is now stated as a second,
independent reason: `free_box_session_for_exclusive` is pure and unit-tested over all four
Steam × Exclusive combinations, including that `extend` and the `SharedDesktop` preset must still
leave the box strictly alone.
A related comment had rotted: the one authorising the DPMS skip on this file's other routes ("its
takeover already stopped the desktop") outlived the stop it named by three commits — `c2f5e91b`
replaced the managed takeover's display-manager STOP with an idle drop-in — so the gap read as
intentional. It is replaced by the two reasons that actually hold, written at the returns they
belong to.
### Hyprland: the dpms dispatcher is a toggle, and the classic argv does not parse under Lua
Verified on the NixOS VM (125, Hyprland 0.55.4), and both halves of the arm as first shipped were
wrong — which is why it went on glass.
`hyprctl dispatch dpms off <name>` **dies on the Lua config manager**: `dispatch` is shorthand for
`hl.dispatch(...)`, so the bare words are parsed as a Lua expression —
error: [string "return hl.dispatch(dpms off HDMI-A-1)"]:1:
')' expected near 'off'
The Lua spelling is `hl.dsp.dpms("off", "<name>")`. A hyprlang box wants the classic form, there is
no stable probe for which manager is loaded, and `hyprctl_dispatch` already catches the exit-0
rejections both produce — so: try classic, then Lua, and report both failures if neither lands.
And the dispatcher is a **toggle that ignores the state word**. Measured, both spellings, positional
and table:
On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked ON, got OFF
Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked OFF, got ON
So a blind "send off, later send on" would **light** an already-dark head at stream start and
**darken** a lit one at teardown — the operator's screen left off after the stream, precisely the
failure this policy exists to prevent. `dpms_one` is therefore read → act only if the state differs
→ verify, via `hyprctl -j monitors all`'s `dpmsStatus` (measured to track the connector's sysfs
`dpms` exactly in both states; a dark monitor stays listed). That shape is also correct where the
call really is a set, so it is not conditional on detecting the manager. It returns whether it
**changed** anything, and `dpms_other_heads` records only those.
### The sleep inhibitor is held only while the stream is quiet
Since `b7a00137` the host held a logind `sleep:idle` inhibitor in **BLOCK** mode for the whole
length of a stream, so a passive video-only viewer could not have the box suspend out from under
them. A block lock on `sleep` refuses **every** suspend, though: "Sleep" in Steam's Big Picture
power menu reaches logind as the same `Suspend()` call and comes back as
Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".
Nothing in that UI surfaces a D-Bus error, so the entry simply did nothing for as long as anyone was
connected. Reproduced on a Bazzite host, 2026-08-24, by taking the same lock by hand.
The veto is now held only while the stream is quiet. Any client input on either plane drops it
**synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep press
cannot race it — and it is re-taken after 30 s of silence. That is the line the original
justification already drew: a person choosing Sleep is by definition sending input, and a passive
viewer never does. Both planes stamp the clock at their single input entry point: the native input
thread's channel `recv` (**before** the grant tests — a denied event still means a person is there)
and the GameStream control plane's three decode arms, **past** the keepalive gate, because a
keepalive is the one thing a passive viewer does send.
### polkit: three power actions for a session-less takeover
The other half of "the power menu does nothing during a stream", and an independent fault. logind
ships `power-off`/`reboot`/`suspend` as `allow_active: yes`. polkit decides "active" from the
caller's own logind session and, for a caller that has none — every `systemd --user` unit, which is
exactly what the managed gamescope session is — falls back to the user's elected DISPLAY session.
logind elects that only from `user`/`greeter` class sessions, never from the user manager's own, so
a takeover that **stops** the display manager removes the last candidate and all three actions drop
to `auth_admin_keep`: an interactive password prompt, put to a non-interactive caller, on a screen
that is switched off.
On SteamOS-like boxes that is the call being refused. Steam does not ask logind for "Shut Down" at
all — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus` runs a
plain `poweroff` once Steam is gone. During a stream that wrapper is ours, in the session-less
transient unit.
Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a `systemd --user` unit answers
authorized with sddm up, `auth_admin_keep` with sddm stopped, and authorized again in that same
stopped state with this rule installed.
`packaging/linux/49-punktfunk-power.rules`, installed by the deb, rpm and arch packagings, scoped to
the (shipped-empty) `punktfunk` group — the same group the takeover's own root helper
(`io.unom.punktfunk.dm-helper`) authorizes on, so it grants to exactly the population the fault
reaches. The `-multiple-sessions` and `-ignore-inhibit` variants are deliberately **not** granted.
### The encode down-driver: frame-budget thresholds and a re-armable stand-down
A 1440p60 Lutris session ratcheted **57 → 5 Mbps over ten minutes** on a link that never dropped a
packet — no keyframe asks, no flushes, a flat decoder, and the host granting every rate it was
asked for. Two causes, both client-side.
**The thresholds were absolute durations calibrated at 120 Hz** ("~half a frame budget",
"≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup
cleared the SEVERE tier and took the immediate ×0.7, where the same hiccup at 120 Hz (8.3 ms) never
reached it — the exact asymmetry the field log shows against 1440p120 sessions on the same host and
client. They are now sized in the session's own frame budget, plumbed from the negotiated refresh
and re-read on a mode switch.
**And the signal fired for a cause the rate cannot fix.** It exists to find the encoder's compute
knee, where cutting the rate cuts the work; when encode time is held up by a game saturating the
GPU that premise is false, the backoff changes nothing, and `on_ack`'s baseline re-seed erases the
evidence that nothing improved. Each encode-attributed backoff now remembers the level it fired at:
two in a row that fire no lower mean the rate is not the lever, and the signal stands down. Same
shape as `NOOP_CLOCK_FLUSHES_TO_DISARM`. Loss, OWD, decode and keyframe signals keep their full
power throughout, and the host's own climb refusal stays the backstop for a genuine knee.
**The stand-down re-probes rather than lasting the session.** Nothing else this controller learns
from evidence is permanent — both learned caps re-probe on the `CAP_REPROBE_WINDOWS_MIN` ladder, and
the clock-flush detector was itself changed from "off for the rest of the session" to re-armable for
exactly this reason. What the stand-down answers is transient by nature (contention ends when the
game exits to a menu, when the shader storm finishes, when the second app on the card closes), and
what it silences is the only signal that can descend when the encoder is genuinely past its knee on
a link that shows nothing else. A clean run re-arms it, and the interval doubles each time the
silence is immediately re-earned, so standing contention settles into a slow poll rather than
thrashing. The asymmetry is the argument: a too-eager re-arm costs one ×0.7 and stands down again; a
too-permanent silence costs the protection outright.
### The web console serves the native identity; the tray's probe drops its pin
The host has kept two identities since the identity split (`crate::identity`): `native-cert.pem` /
`native-key.pem` (P-256, real SANs — what the native QUIC plane, the mgmt API and every native
client pin) and the legacy `cert.pem` / `key.pem` (RSA, `CN=punktfunk`, **no SAN**, kept byte-stable
for Moonlight). The web console never followed the split. Every launcher — the systemd unit, the
NixOS module, the Windows service supervisor, `web-run.cmd`, the Steam Deck installer — names the
**legacy** pair, and none of them *can* choose: `Environment=` has no "this file, else that one". So
the console served a certificate with no SAN at all, which costs twice over: browsers reject a
CN-only cert outright (`ERR_CERT_COMMON_NAME_INVALID`), and the tray's loopback liveness probe
reused the agent **pinned** to the mgmt identity, so rustls refused the handshake and a healthy
console was labelled "not responding" — next to a tooltip reading "idle", which is the proof, since
the same agent reached mgmt fine on the very same tick.
The entry (`web/nitro-entry/tls-paths.mjs`) is the one place every launcher routes through, so the
choice is made there: prefer the native sibling pair when both files exist, **as a pair or not at
all** (a native cert with the legacy key completes no handshake with anyone). It is a **suffix
test**, not `node:path`: `node:path` resolves per-runtime, so a POSIX CI runner reads
`C:\ProgramData\punktfunk\cert.pem` as one long filename and never swaps — and Windows, where
`windows/service.rs` hands us exactly that, is the platform the CI job can never exercise. The
suffix test gives the same answer everywhere, leaves the prefix **verbatim** (where
`join(dirname(p), …)` normalised `/a/b/../cert.pem` into a different directory the moment `b` was a
symlink), and cannot hand back a mismatched pair. Existence is not enough:
`pf_paths::write_secret_file` is create+truncate+write rather than temp+rename, so a console
starting mid-write could adopt a 0-byte cert and leave `Bun.serve` throwing on every restart (the
Steam Deck unit is `Restart=on-failure` under the default rate limit, i.e. permanently dead) — the
check is a **non-empty** stat, mirroring the host's own `!c.trim().is_empty()`.
`serve` now resolves the native identity **before** minting the legacy one. That closes a first-run
window where the console (which waits on `cert.pem`) could start between the two writes and serve
the SAN-less cert for the rest of the boot, and a second latent fault: with `cert.pem` missing but
native clients paired, the old order let `load_or_create` mint a brand-new `cert.pem` that
`load_or_adopt` then adopted while logging that it was preserving their pins.
The tray's console probe **loses** its pin rather than gaining a second one. It is a different
server and there is no rule that it presents the mgmt certificate — an operator fronting the console
with their own LAN-CA cert would have hit this just as squarely. The probe sends no credentials,
reads no body, and decides only a menu label (plus whether a tray-icon single click opens the
console, which `win.rs` gates on `console_up`). On Windows it was never pinned to begin with:
`punktfunk_config_dir` is `None` off Linux, so `load_pin` already returned `None`.
`web/`'s test scope grows from `server/` to `server/ nitro-entry/`; 18 tests, including a win32
case.
### Apple: the mic tap installs with `format: nil` and follows the device
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the bus and raises an
Objective-C exception on **any** mismatch. Swift cannot catch that, so it reached the terminate
handler and aborted the process — SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`, crashing macOS
0.31.0 at **session start**, not at launch.
The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath it in that window: a
device switch, a clock/rate change, or the `kAudioOutputUnitProperty_CurrentDevice` swap
`startCapture` itself performs two lines before. The existing guard only rejected the
0 Hz / 0-channel case, which is a different failure — a device that is absent, not one that changed.
Installing with `format: nil` is the documented "use the bus's own format" and makes the mismatch
unrepresentable rather than merely unlikely. The tap then has to follow the real format, so the
rate-dependent pieces (mono bus, resampler, both scratch buffers) move into a `MicChain` the tap
rebuilds when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would resample by
the wrong ratio and pitch-shift the mic. That rebuild subsumes the old grow-on-larger-quantum
branch; the steady state still allocates nothing. Tests cover the sizing arithmetic, including that
`staging` fits the **upward** ratio, which silently truncates every packet on any device below
48 kHz if it is sized for the input rate instead.
### The console's bitrate row: 30 rungs, a typed rate, and neighbour stepping
The gamepad shell's Bitrate picker had been seven rungs ending at **80 Mbps** since the console
shipped — a ceiling a user ran into — while the GTK dialog beside it has always gone to
3000 Mbit/s, so the two surfaces disagreed about what the machine may ask for and the console was
the smaller. Three changes, one row:
- **30 rungs, 1 Mbps to 2 Gbps.** Tight at the bottom (1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25 …)
where one rung decides whether a thin link is watchable, coarse at the top where a rung is noise.
Rates at or above a gigabit read as Gbps.
- **Y opens a typed rate on that row** — four digits, through the tray keyboard (or SDL text input,
and Steam's own keyboard on a Deck) exactly like the add-host and pair fields. **A** goes on
cycling the ladder everywhere, so the console's grammar is unchanged.
- **A rate that is not a rung steps to its neighbour.** The generic picker snaps an unrecognised
value to its first option, which on this row is Automatic: one nudge threw away a rate typed here
or set by the desktop spinner.
`Screen::edit_key` now takes the context, because this is the first field that commits into the
settings store when it closes rather than holding text for a later action row. The desktop dialog
gets the same complaint's other half: its spinner steps 1 Mbit/s instead of 5, so 3, 4 and 6 are
reachable without typing.
### Linux client: a front door to the console UI
The gamepad console has shipped since the arch split, but on Linux the only way in was
`punktfunk-session --browse` (or `punktfunk-client --browse`, which execs it) on a command line. The
WinUI shell has had both doors for a while; this is the GTK half. Three entry points, one
destination: a gamepad button in the hosts header (`input-gaming-symbolic`, left of the hamburger —
the placement WinUI gives it); a "Console UI" main-menu entry so the mode has a searchable name;
and `io.unom.Punktfunk.Console.desktop`, a second launcher shipped by the deb, rpm, arch and flatpak
packagings, which is what an app-grid search finds and what gets added to Steam as a non-Steam game.
Its `Exec=punktfunk-client --browse --fullscreen` deliberately goes through the shell binary — that
argv already execs the session, and it is the command flatpak's `Exec` rewrite expects.
The in-shell launch is a `gio::Subprocess`: `wait_check_async` lands the child's exit on the GTK
main loop with no thread and no channel, releases `busy`, refreshes the host list (the console can
pair hosts), and banners a non-zero exit — which is also how a session built without its `ui`
feature surfaces ("--browse needs the console UI", exit non-zero).
**The Nix build does not install the new desktop entry**: it compiles the session
`--no-default-features`, so that launcher could only ever print an error. Noted in
`packaging/nix/README.md` next to the existing Skia caveat.
### Dependencies
**None.** No lockfile in the repo moves — not the workspace `Cargo.lock`, not the four under
`tools/` and `packaging/windows/`, and no `package.json` under `web/`, `sdk/` or `plugin-kit/`.
### CI
`ci.yml`'s web test step widens from `bun test server/` to `bun test server/ nitro-entry/`, so the
identity-selection gate runs where the origin-isolation gate already did. Both have the same
property: a failure mode only a browser would catch.
---
## v0.31.3
41 commits since v0.31.2 (26 non-merge), counted at the tip this was cut from.
Generated
+36 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.3"
version = "0.31.4"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.3"
version = "0.31.4"
[[package]]
name = "pf-inject"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.3"
version = "0.31.4"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.3"
version = "0.31.4"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.3"
"version": "0.31.4"
},
"paths": {
"/api/v1/client-logs": {
+1 -1
View File
@@ -40,7 +40,7 @@ const SCROLL_HORIZONTAL: u32 = 1;
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
/// as `pf_vdisplay`'s `kwin_dpms`.
/// as `pf_vdisplay`'s `panel_dpms`.
const WL_OUTPUT_MAX: u32 = 4;
/// One `wl_output` the compositor has advertised.
+18 -7
View File
@@ -867,14 +867,25 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
// Consumed by `gamescope` (best-effort, with kscreen fallback).
// DPMS control of the box's own physical panels — how a gamescope session (which owns no output on
// the box's desktop) honors `Topology::Exclusive`. Dispatches per desktop: KDE over
// org_kde_kwin_dpms, sway and Hyprland over their own IPC, and `drm_dpms` for a box with no
// desktop at all. GNOME is the one it cannot serve — Mutter exposes no DPMS to clients.
// The desktop's outputs can't be *disabled* the way the desktop backends do it (KWin refuses zero
// enabled outputs, and no output there is ours to keep), so DPMS-off is the honest translation:
// the desk is untouched, the panels just go dark. Refcounted across concurrent spawns; consumed by
// `gamescope` on both its owning routes, best-effort throughout.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_dpms.rs"]
mod kwin_dpms;
#[path = "vdisplay/linux/panel_dpms.rs"]
mod panel_dpms;
// The compositor-independent half of the same policy: turn the CRTCs off over DRM directly, for a
// box with no desktop to ask (Game Mode runs gamescope and no KWin, and is exactly where the
// operator's TV is lit by the box itself). Reached from `panel_dpms`'s "not KDE" arm, which is what
// owns the refcount and the hold.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/drm_dpms.rs"]
mod drm_dpms;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
@@ -0,0 +1,368 @@
//! Compositor-independent panel darkening over DRM — how a box with **no desktop compositor**
//! honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! [`crate::panel_dpms`] asks KWin to turn the panels off, which is the right answer whenever there
//! is a KDE desktop to ask. There often isn't. A box sitting in **Game Mode** runs gamescope and no
//! KWin at all, so that path declines — and Game Mode is precisely the deployment where the
//! operator's TV is lit by the box itself. Measured on the Nobara VM (2026-08-24): after the
//! takeover idles the box's gaming session, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On`
//! indefinitely. Nothing blanks on its own — when no client holds DRM master the kernel simply
//! keeps the CRTC configured, and fbcon owns it.
//!
//! So ask the kernel directly. The sequence, all of it measured on that box:
//!
//! 1. `open("/dev/dri/cardN")` — permitted for the ordinary session user, because logind puts a
//! **uaccess ACL** on the node for whoever holds the active seat (`crw-rw----+`). No root, no
//! polkit, no group: this is the same access every local compositor gets.
//! 2. `DRM_IOCTL_SET_MASTER` — succeeds while no one else is master, which is exactly the state the
//! takeover has just produced by idling the box's session. If it FAILS, someone else is driving
//! that card (a live compositor, a foreign gamescope) and we decline: darkening a panel out from
//! under its owner is not ours to do, and on the Attach route it would darken the very picture
//! being streamed.
//! 3. `DRM_IOCTL_MODE_GETRESOURCES` (count pass, then data pass) for the CRTC ids, and
//! `DRM_IOCTL_MODE_SETCRTC` with `fb_id = 0, mode_valid = 0, count_connectors = 0` on each one
//! that is actually driving something. That is a modeset to "off": the connector goes
//! `enabled=disabled dpms=Off`, which is the same end state `kscreen-doctor --dpms off` reaches
//! through KWin.
//! 4. `DRM_IOCTL_DROP_MASTER`, and **keep the fd open**.
//!
//! Step 4 is the part worth reading twice. The darkness **survives dropping master** (measured), so
//! we hand mastering rights straight back — the box's own gamescope must be able to take the card
//! when the restore relaunches its session, and a host still holding master would starve it. What
//! holds the panel dark is the open fd, not the mastership.
//!
//! **The re-light is `close(fd)`, and that is the whole of it.** The kernel's last-close handling
//! restores the console and the panel comes back lit (measured: `enabled=enabled dpms=On` within
//! 2 s of the close). There is no saved mode to replay and no restore that can half-fail — which
//! also means **crash safety comes free**, the same property [`crate::panel_dpms`] gets from DPMS
//! being non-persistent: a host that dies holding this has its fds closed by the kernel, and the
//! box lights up. Nothing to journal, nothing to sweep at startup. (Contrast the Windows
//! `pnp_disable_monitors` path, which needs a recovery journal precisely because its disable
//! survives everything.)
//!
//! Best-effort throughout, like every other arm of this policy: a box with no `/dev/dri` at all, a
//! card whose master is held by someone else, or a card with nothing lit simply contributes
//! nothing and the stream proceeds.
use std::fs::File;
use std::os::fd::AsRawFd;
use std::path::Path;
// ---------------------------------------------------------------- the kernel ABI
//
// `include/uapi/drm/drm.h` and `drm_mode.h`. Hand-declared rather than pulled from a crate: this is
// four ioctls and three plain-old-data structs, and the const asserts below pin every layout that
// could drift. `_IO('d', nr)` / `_IOWR('d', nr, T)` encoded by hand — the sizes are in the names.
/// `DRM_IOCTL_SET_MASTER` — `_IO('d', 0x1e)`.
const DRM_IOCTL_SET_MASTER: libc::c_ulong = 0x641e;
/// `DRM_IOCTL_DROP_MASTER` — `_IO('d', 0x1f)`.
const DRM_IOCTL_DROP_MASTER: libc::c_ulong = 0x641f;
/// `DRM_IOCTL_MODE_GETRESOURCES` — `_IOWR('d', 0xA0, drm_mode_card_res)`, 64-byte payload.
const DRM_IOCTL_MODE_GETRESOURCES: libc::c_ulong = 0xC040_64A0;
/// `DRM_IOCTL_MODE_GETCRTC` — `_IOWR('d', 0xA1, drm_mode_crtc)`, 104-byte payload.
const DRM_IOCTL_MODE_GETCRTC: libc::c_ulong = 0xC068_64A1;
/// `DRM_IOCTL_MODE_SETCRTC` — `_IOWR('d', 0xA2, drm_mode_crtc)`, 104-byte payload.
const DRM_IOCTL_MODE_SETCRTC: libc::c_ulong = 0xC068_64A2;
#[repr(C)]
#[derive(Default)]
struct DrmModeCardRes {
fb_id_ptr: u64,
crtc_id_ptr: u64,
connector_id_ptr: u64,
encoder_id_ptr: u64,
count_fbs: u32,
count_crtcs: u32,
count_connectors: u32,
count_encoders: u32,
min_width: u32,
max_width: u32,
min_height: u32,
max_height: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct DrmModeModeinfo {
clock: u32,
hdisplay: u16,
hsync_start: u16,
hsync_end: u16,
htotal: u16,
hskew: u16,
vdisplay: u16,
vsync_start: u16,
vsync_end: u16,
vtotal: u16,
vscan: u16,
vrefresh: u32,
flags: u32,
type_: u32,
name: [u8; 32],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct DrmModeCrtc {
set_connectors_ptr: u64,
count_connectors: u32,
crtc_id: u32,
fb_id: u32,
x: u32,
y: u32,
gamma_size: u32,
mode_valid: u32,
mode: DrmModeModeinfo,
}
// The ioctl numbers above encode their payload size (0x40 = 64, 0x68 = 104). If a struct here ever
// disagrees with that, the kernel reads or writes the wrong number of bytes — so pin it at compile
// time rather than discovering it as a corrupted modeset on someone's TV.
const _: () = assert!(std::mem::size_of::<DrmModeCardRes>() == 0x40);
const _: () = assert!(std::mem::size_of::<DrmModeModeinfo>() == 68);
const _: () = assert!(std::mem::size_of::<DrmModeCrtc>() == 0x68);
impl Default for DrmModeCrtc {
fn default() -> Self {
// SAFETY: both structs are `repr(C)` plain old data — integers and a `[u8; 32]`, no
// padding invariants, no pointers that must be valid, and no `Drop`. An all-zero value is
// a legal instance, and is exactly what the ioctls want for "no connectors, no mode".
unsafe { std::mem::zeroed() }
}
}
/// One card we have darkened: the open fd is the hold. Dropping this closes it, and the kernel
/// re-lights — see the module docs.
pub struct DrmDarken {
/// Kept solely for its `Drop`. The panel stays dark exactly as long as these are open.
_cards: Vec<File>,
/// Which `/dev/dri/cardN` we actually turned something off on — logging only.
pub darkened: Vec<String>,
}
/// `ioctl(fd, req, &mut arg)` for the modeset structs, returning the raw `errno` on failure.
///
/// Split out so each call site is one line and there is exactly one `unsafe` block to justify
/// instead of five near-identical ones.
fn ioctl<T>(fd: libc::c_int, req: libc::c_ulong, arg: &mut T) -> std::io::Result<()> {
// SAFETY: `fd` is an open DRM node owned by the caller for the whole call; `req` is one of the
// five `_IO`/`_IOWR` codes declared above, each paired with the `T` its size field names (the
// const asserts pin that); and `arg` is a live, uniquely-borrowed, `repr(C)` value of that
// exact type, so the kernel's read/write of `size_of::<T>()` bytes stays inside it.
let rc = unsafe { libc::ioctl(fd, req, arg as *mut T) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
/// Turn every lit CRTC on every DRM card off, and hold them off. `None` when nothing was darkened
/// — no cards, none masterable, or none lit — and therefore nothing to restore.
pub fn darken() -> Option<DrmDarken> {
let mut cards = Vec::new();
let mut darkened = Vec::new();
for entry in std::fs::read_dir("/dev/dri").ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
// `cardN` only: `renderD*` is the render node (no modesetting at all) and `by-path/` is a
// directory of symlinks to the same nodes.
if !name.starts_with("card") {
continue;
}
match darken_card(&path) {
// Masterable, but nothing on this card was lit. Its fd is dropped here, which is
// correct: we changed nothing, so there is nothing to hold.
Ok((_, 0)) => {}
Ok((card, n)) => {
tracing::debug!(card = name, crtcs = n, "DRM: CRTCs off");
darkened.push(name.to_string());
// ⚠ HOLD THE FD THAT DID THE WORK. Closing it and re-opening does not survive the
// round trip: the close is the kernel's LAST close on that device, which restores
// the console and re-lights the panel — the fresh fd then holds nothing. Measured
// on the Nobara VM 2026-08-24, where exactly that shape reported `darkened
// cards: ["card0"]` while the connector sat at `enabled=enabled dpms=On`.
cards.push(card);
}
Err(why) => tracing::debug!(card = name, %why, "DRM: not ours to darken"),
}
}
if darkened.is_empty() {
return None;
}
Some(DrmDarken {
_cards: cards,
darkened,
})
}
/// Darken one card, returning the open fd **and** how many CRTCs were actually turned off.
///
/// The fd comes back with the count because the caller MUST keep this exact one to hold the panel
/// dark: closing it is the kernel's last close on the device, which restores the console. A card
/// that reports 0 can have its fd dropped freely — nothing was changed to undo.
fn darken_card(path: &Path) -> std::io::Result<(File, usize)> {
let card = File::options().read(true).write(true).open(path)?;
let fd = card.as_raw_fd();
// Someone else driving this card (a live compositor, a foreign gamescope) ⇒ not ours. This is
// also what keeps the Attach route honest without needing to know about it here.
ioctl(fd, DRM_IOCTL_SET_MASTER, &mut 0u64)?;
// Count pass: every pointer NULL, the kernel fills in the counts.
let mut res = DrmModeCardRes::default();
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
let n = res.count_crtcs as usize;
if n == 0 {
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
return Ok((card, 0));
}
// Data pass: hand back a buffer sized by that count and ask again.
let mut ids = vec![0u32; n];
let mut res = DrmModeCardRes {
crtc_id_ptr: ids.as_mut_ptr() as u64,
count_crtcs: n as u32,
..Default::default()
};
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
// The kernel may report FEWER than the count pass promised (a hotplug between the two); it
// never reports more than the buffer we sized, so trust the second count.
ids.truncate(res.count_crtcs as usize);
let mut off = 0usize;
for id in ids {
let mut crtc = DrmModeCrtc {
crtc_id: id,
..Default::default()
};
if ioctl(fd, DRM_IOCTL_MODE_GETCRTC, &mut crtc).is_err() {
continue;
}
// Only touch a CRTC that is actually driving a display. Disabling an already-dark one is a
// harmless no-op, but counting it would make the log claim a panel went off that never was
// on — and that verdict is the whole point of reporting a count at all.
if crtc.mode_valid == 0 && crtc.fb_id == 0 {
continue;
}
// The modeset to "off": no framebuffer, no mode, no connectors.
let mut disable = DrmModeCrtc {
crtc_id: id,
..Default::default()
};
if ioctl(fd, DRM_IOCTL_MODE_SETCRTC, &mut disable).is_ok() {
off += 1;
}
}
// Hand mastering back immediately: the darkness does not depend on holding it (measured), and
// the box's own gamescope needs to be able to take this card when the restore relaunches its
// session. Keeping it would turn a dark panel into a session that cannot start.
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
Ok((card, off))
}
#[cfg(test)]
mod tests {
use super::{DrmModeCardRes, DrmModeCrtc, DrmModeModeinfo};
/// The layouts the ioctl numbers encode. The `const` asserts above already fail the BUILD on
/// drift; this restates them as a test so the reason is greppable from a failure, and pins the
/// two field offsets the count/data-pass dance actually depends on.
#[test]
fn the_abi_structs_match_the_ioctl_payload_sizes() {
assert_eq!(std::mem::size_of::<DrmModeCardRes>(), 0x40, "_IOWR 0x40");
assert_eq!(std::mem::size_of::<DrmModeModeinfo>(), 68);
assert_eq!(std::mem::size_of::<DrmModeCrtc>(), 0x68, "_IOWR 0x68");
// `crtc_id_ptr` is the second u64 — the field the data pass points at its id buffer. A
// reorder here would hand the kernel the framebuffer-id pointer instead.
assert_eq!(std::mem::offset_of!(DrmModeCardRes, crtc_id_ptr), 8);
assert_eq!(std::mem::offset_of!(DrmModeCardRes, count_crtcs), 36);
// `mode` must sit right after the seven u32s, or SETCRTC reads a mode we never wrote.
assert_eq!(std::mem::offset_of!(DrmModeCrtc, mode), 36);
}
/// ON GLASS. Darken this box's panels for real and read the verdict back out of sysfs.
///
/// Run it on a box with a **connected head and no compositor holding the card** — i.e. exactly
/// the takeover state this module exists for. On the Nobara VM:
///
/// ```sh
/// # idle the box's gaming session first (what stop_autologin_sessions does), then:
/// ./pf_vdisplay-<hash> --ignored --nocapture drm_dpms
/// ```
///
/// Skips itself (rather than failing) when nothing was ours to darken, because that is the
/// honest outcome on a dev box with a live desktop — the card is already mastered.
#[test]
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
fn live_the_panels_go_dark_and_come_back() {
fn connectors() -> Vec<(String, String, String)> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
return v;
};
for e in rd.flatten() {
let p = e.path();
let rd = |f: &str| {
std::fs::read_to_string(p.join(f))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
if rd("status") == "connected" {
v.push((
e.file_name().to_string_lossy().into_owned(),
rd("enabled"),
rd("dpms"),
));
}
}
v.sort();
v
}
let before = connectors();
println!("before: {before:?}");
assert!(
!before.is_empty(),
"no connected head — this test needs one to mean anything"
);
let Some(hold) = super::darken() else {
println!("nothing was ours to darken (card already mastered?) — skipping");
return;
};
println!("darkened cards: {:?}", hold.darkened);
std::thread::sleep(std::time::Duration::from_secs(2));
let during = connectors();
println!("during: {during:?}");
drop(hold);
std::thread::sleep(std::time::Duration::from_secs(2));
let after = connectors();
println!("after: {after:?}");
// The claim: every head that was lit went dark, and every one of them came back.
for (name, en, dpms) in &during {
assert_eq!(dpms, "Off", "{name} should be DPMS-off while held ({en})");
}
assert_eq!(
after, before,
"dropping the hold must restore exactly the state we found"
);
}
/// A zeroed `DrmModeCrtc` IS the disable request — that is the only thing `Default` is for
/// here, so a change that made it non-zero would silently stop disabling anything.
#[test]
fn the_default_crtc_is_the_disable_request() {
let c = DrmModeCrtc::default();
assert_eq!(c.fb_id, 0, "a framebuffer would keep the CRTC lit");
assert_eq!(
c.mode_valid, 0,
"a valid mode would re-modeset, not disable"
);
assert_eq!(c.count_connectors, 0);
assert_eq!(c.set_connectors_ptr, 0);
}
}
@@ -71,7 +71,7 @@ pub struct GamescopeDisplay {
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
/// teardown (§6.1) and never before.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
@@ -176,6 +176,63 @@ const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// Whether the MANAGED route currently holds a [`crate::panel_dpms`] darken hold for
/// `Topology::Exclusive`.
///
/// The managed route cannot register its release the way a bare spawn does. A spawn reports
/// `DisplayOwnership::Owned`, so `registry::acquire` picks its `take_topology_restore()` up and
/// runs it at teardown; managed reports `SessionManaged`, and that function returns for anything
/// not `Owned` **above** the pickup — deliberately, because this module owns the managed
/// lifecycle instead. So this module owns the release too: [`do_restore_tv_session`], the one
/// teardown every managed path funnels through.
///
/// A plain bool rather than a count because the managed SESSION is what is darkened, not each
/// connect: it survives client disconnects (that is the whole point of [`MANAGED_SESSION`]), and a
/// same-mode reconnect reuses it warm without a relaunch. Acquiring per connect would ratchet
/// `panel_dpms`'s refcount up with no matching releases and pin the panel dark for the host's life.
static MANAGED_DARKEN_HELD: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// The 0→1 edge: should this call actually take a `panel_dpms` hold? Pure, and split from
/// [`managed_darken_acquire`] so the balance rule is testable without a live compositor — the same
/// shape as `panel_dpms::Holds::acquire_edge`, and for the same reason.
fn managed_darken_acquire_edge(held: &mut bool, exclusive: bool) -> bool {
if !exclusive || *held {
return false;
}
*held = true;
true
}
/// The 1→0 edge: should this call actually release one?
fn managed_darken_release_edge(held: &mut bool) -> bool {
if !*held {
return false;
}
*held = false;
true
}
/// Take the managed route's darken hold, once, if `exclusive` and we don't already hold one.
fn managed_darken_acquire(exclusive: bool) {
let mut held = MANAGED_DARKEN_HELD
.lock()
.unwrap_or_else(|e| e.into_inner());
if managed_darken_acquire_edge(&mut held, exclusive) {
crate::panel_dpms::acquire_stream_darken();
}
}
/// Drop it, if held. Idempotent, because it is called unconditionally from the restore — which is
/// exactly what makes it safe to put above every early return there.
fn managed_darken_release() {
let mut held = MANAGED_DARKEN_HELD
.lock()
.unwrap_or_else(|e| e.into_inner());
if managed_darken_release_edge(&mut held) {
crate::panel_dpms::release_stream_darken();
}
}
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -537,7 +594,7 @@ impl VirtualDisplay for GamescopeDisplay {
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
// only). The registry stores it on this display's entry and runs it at teardown — which,
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
// cross-session ordering lives in `panel_dpms`'s refcount, not in the group float.
self.pending_restore.take()
}
@@ -583,8 +640,32 @@ impl VirtualDisplay for GamescopeDisplay {
// also what the ladder's own default arm picks.
None => (None, None),
};
// `Topology::Exclusive` means the operator asked for the box's own screens to go dark for
// the stream. Resolved ONCE, above every route's return, so the managed hold below and the
// "free the box's session" decision and the bare spawn's darken at the end of this
// function can never disagree within a single create.
let exclusive = crate::effective_topology() == crate::policy::Topology::Exclusive;
if let Some(client) = session_env {
return create_managed_session(&client, mode, self.hdr);
let out = create_managed_session(&client, mode, self.hdr)?;
// Managed is the route that matters most here: it is the recommended one, it gives the
// best experience, and it is the ONLY way to serve a client its own virtual output at
// its own mode. So `exclusive` has to mean something on it.
//
// Its takeover idles the box's autologin session, which stops that session DRIVING the
// panel — but measured on the Nobara VM (2026-08-24), that alone leaves the connector
// at `enabled=enabled dpms=On` indefinitely: with no DRM master the kernel just keeps
// the CRTC configured. Turning it off is [`crate::panel_dpms`]'s job, and on a Game Mode
// box (no KWin) that lands in its DRM arm — which needs no compositor and no privilege.
//
// The hold canNOT ride `self.pending_restore` the way the bare spawn's does: this
// route reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for
// anything not `Owned` ABOVE its `take_topology_restore()` pickup, so that hold would
// never be released — and a panel dark after every stream is worse than one left lit.
// Hence [`managed_darken_acquire`] / [`managed_darken_release`], balanced against
// [`do_restore_tv_session`] instead: the one teardown every managed path funnels
// through, and the same place the drop-in sweep lives for the same reason.
managed_darken_acquire(exclusive);
return Ok(out);
}
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
// of spawning our own: capture its node AND inject into its EIS socket.
@@ -601,6 +682,10 @@ impl VirtualDisplay for GamescopeDisplay {
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
};
point_injector_at_eis();
// ⚠ NO darken hold here either, and this one is policy: attach MIRRORS a gamescope
// that may itself be lighting the physical panel, so honoring `exclusive` by
// darkening it would darken the very picture being streamed. `exclusive` cannot be
// served on this route; the operator's lever is to pick a model that owns a display.
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
return Ok(VirtualOutput {
@@ -631,7 +716,8 @@ impl VirtualDisplay for GamescopeDisplay {
// NO instance free — and then collided with the box's own autologin/desktop Steam, which
// is precisely the collision this block exists to prevent.
let app = resolved_spawn_app(self.cmd.as_deref());
if app.as_deref().is_some_and(is_steam_launch) {
let steam = app.as_deref().is_some_and(is_steam_launch);
if steam {
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
stop_autologin_sessions()
@@ -639,6 +725,24 @@ impl VirtualDisplay for GamescopeDisplay {
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
free_desktop_steam()?;
} else if free_box_session_for_exclusive(steam, exclusive) {
// B1c: a NON-Steam launch has no single instance to free, and used to leave the box's
// gaming session completely untouched. On a Game Mode box that session IS the DRM
// master of the TV (`gamescope/heads.rs`), so under Exclusive it went on lighting the
// panel with live Game Mode for the whole stream — the loudest half of the Nobara field
// report, and never a 0.31.0 regression: this path has always been Steam-gated.
//
// Best-effort, unlike the Steam arm above: freeing the session is what MAKES the panel
// dark here, not what makes the launch possible, so a box that refuses costs the
// operator their dark screen and not their game. The restore is the same machinery
// either way (`STOPPED_AUTOLOGIN` → `schedule_restore_tv_session`).
if let Err(why) = stop_autologin_sessions() {
tracing::warn!(
%why,
"exclusive topology: could not free the box's gaming session, so its own \
display keeps whatever it is showing for this stream"
);
}
}
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
@@ -685,16 +789,19 @@ impl VirtualDisplay for GamescopeDisplay {
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
// quietly inside `panel_dpms`). Placed AFTER the spawn succeeded, so a failed create never
// blanks the user's screen. The hold is refcounted in `panel_dpms` rather than floated
// through the registry's group restore, because every gamescope spawn is its own group
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
// be driving the physical panel) — both returned earlier in this function.
if crate::effective_topology() == crate::policy::Topology::Exclusive {
crate::kwin_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
// concurrent spawns ends, under the second's still-live stream. Managed takes the same
// hold at its own return above, through [`managed_darken_acquire`] rather than this field
// (its display is not registry-owned, so there is no `take_topology_restore` pickup to
// ride). Only Attach still skips, and for a reason that survives: it mirrors a gamescope
// that may itself be driving the physical panel, so darkening it would darken the very
// picture being streamed.
if exclusive {
crate::panel_dpms::acquire_stream_darken();
self.pending_restore = Some(Box::new(crate::panel_dpms::release_stream_darken));
}
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
@@ -3728,6 +3835,17 @@ fn handback_watch(units: &[String]) {
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
/// cancelled+reconnected window keeps the list for a later real restore.
fn do_restore_tv_session(verify: bool) {
// Give the box its screens back FIRST, above every early return below — including the SteamOS
// ones, which is why this sits at the very top rather than beside the drop-in sweep that
// follows the same "must not leak past a return" rule. The managed route's `exclusive` darken
// has no registry restore to ride (`DisplayOwnership::SessionManaged` returns above
// `take_topology_restore`), so this call is its ONLY release — leaking it would leave the
// operator's panel dark for the rest of the host's life.
//
// Safe this early: releasing re-lights, and every path below either hands the box back or
// deliberately keeps a headless session on a box with no connected display (nothing lit to
// darken there anyway). Idempotent, so the paths that reach the restore twice cost nothing.
managed_darken_release();
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
@@ -5192,6 +5310,20 @@ fn is_steam_launch(cmd: &str) -> bool {
cmd.split_whitespace().next() == Some("steam")
}
/// Should a bare-spawn launch free the box's own gaming session when it is NOT a Steam launch?
///
/// Two different requirements reach the same call. A **Steam** launch frees it because it must —
/// the single instance is not shareable — and that arm fails the create when it can't. **Exclusive
/// topology** frees it because the operator asked for the box's screens to go dark, and on a Game
/// Mode box that session is the DRM master of the physical panel; that arm is best-effort.
///
/// Pure so the gate is testable without systemd: the bug it closes was a policy question
/// (`is_steam_launch` standing in for "does the box's session need to get out of the way"), not a
/// systemd one.
fn free_box_session_for_exclusive(steam: bool, exclusive: bool) -> bool {
!steam && exclusive
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
@@ -5564,8 +5696,9 @@ mod tests {
use super::{
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
free_box_session_for_exclusive, game_hz, gamescope_output_size, hdr_args, idle_dropin_body,
idle_dropin_path, install_idle_dropin, is_steam_launch, managed_darken_acquire_edge,
managed_darken_release_edge, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
@@ -5899,6 +6032,126 @@ mod tests {
assert!(!ran.contains("reinstall"), "{ran}");
}
/// ON GLASS. The MANAGED route's hold, driven against the real `panel_dpms`/`drm_dpms` stack —
/// the wiring the pure edge test above cannot see. Run it in the takeover state (the box's
/// gaming session idled, so nothing holds DRM master), on a box with a connected head:
///
/// ```sh
/// ./pf_vdisplay-<hash> --ignored --nocapture the_managed_hold_darkens_a_real_panel
/// ```
#[test]
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
fn live_the_managed_hold_darkens_a_real_panel() {
fn lit() -> Vec<(String, String)> {
let mut v = Vec::new();
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
return v;
};
for e in rd.flatten() {
let p = e.path();
let f = |n: &str| {
std::fs::read_to_string(p.join(n))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
if f("status") == "connected" {
v.push((e.file_name().to_string_lossy().into_owned(), f("dpms")));
}
}
v.sort();
v
}
let before = lit();
println!("before: {before:?}");
assert!(
!before.is_empty(),
"needs a connected head to mean anything"
);
super::managed_darken_acquire(true);
std::thread::sleep(std::time::Duration::from_secs(2));
let during = lit();
println!("during: {during:?}");
// A reconnect must not take a second hold — if it did, the release below would leave the
// panel dark. This is the failure the pure test models; here it is against the real
// refcount.
super::managed_darken_acquire(true);
super::managed_darken_release();
std::thread::sleep(std::time::Duration::from_secs(2));
let after = lit();
println!("after: {after:?}");
let went_dark: Vec<&String> = during
.iter()
.zip(&before)
.filter(|((_, now), (_, was))| was == "On" && now == "Off")
.map(|((n, _), _)| n)
.collect();
if went_dark.is_empty() {
println!("nothing was ours to darken (card already mastered?) — skipping");
return;
}
// Deliberately "at least one went dark", not "all did": a box can carry a connected head
// the live compositor does not manage. The Hyprland VM has a virtio `Virtual-1` beside the
// real `HDMI-A-1`, and only the latter is Hyprland's to darken — asserting all of them
// would fail on a difference that is not a defect. What must hold is that the mechanism
// darkened something real, and that the release put every head back exactly as found.
println!("went dark: {went_dark:?}");
assert_eq!(after, before, "the release must restore what we found");
}
#[test]
fn the_managed_darken_hold_is_taken_once_and_released_once() {
// The managed SESSION is what gets darkened, not each connect — it outlives client
// disconnects and a same-mode reconnect reuses it warm. So a reconnect must NOT take a
// second hold: `panel_dpms`'s refcount would ratchet up with no matching release and pin
// the operator's panel dark for the rest of the host's life.
let mut held = false;
assert!(managed_darken_acquire_edge(&mut held, true), "0→1 darkens");
assert!(!managed_darken_acquire_edge(&mut held, true), "reconnect");
assert!(!managed_darken_acquire_edge(&mut held, true));
// The restore calls the release unconditionally, above every early return — so it has to
// be idempotent, or a path that reaches the restore twice would release a hold it does
// not have and drop someone else's.
assert!(managed_darken_release_edge(&mut held), "1→0 re-lights");
assert!(!managed_darken_release_edge(&mut held), "already released");
assert!(!managed_darken_release_edge(&mut held));
// And it re-arms: a later stream on the same host lifetime darkens again.
assert!(managed_darken_acquire_edge(&mut held, true));
assert!(managed_darken_release_edge(&mut held));
// Not exclusive ⇒ never a hold, so the restore's unconditional release stays a no-op.
// This is what makes `extend` / `SharedDesktop` ("never blank the real monitors") mean
// what they say on the managed route.
let mut held = false;
assert!(!managed_darken_acquire_edge(&mut held, false));
assert!(!held);
assert!(!managed_darken_release_edge(&mut held));
}
#[test]
fn exclusive_frees_the_box_session_for_a_non_steam_launch_too() {
// The bug: `is_steam_launch` was standing in for "does the box's own session need to get
// out of the way", and those are two different questions. A non-Steam library game under
// `exclusive` left the box's Game Mode gamescope holding DRM master on the TV, so the
// operator's screen showed live Game Mode for the whole stream (Nobara, 2026-08-24).
assert!(free_box_session_for_exclusive(false, true));
// A Steam launch is already handled by the arm above this one — and that arm is the
// FAILING one (the single instance is not optional), so this gate must not also fire and
// free the session a second time.
assert!(!free_box_session_for_exclusive(true, true));
// Not exclusive: the operator did not ask for their screens to go dark, so a non-Steam
// launch must keep leaving the box's session strictly alone. This is what makes `extend`
// and the `SharedDesktop` preset ("never blank the real monitors") mean what they say.
assert!(!free_box_session_for_exclusive(false, false));
assert!(!free_box_session_for_exclusive(true, false));
}
#[test]
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
@@ -539,6 +539,123 @@ fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> V
.collect()
}
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
///
/// Distinct from [`disable_other_heads`], which is what the *Hyprland backend's own* exclusive
/// topology does, and deliberately so on this compositor above all: disabling a Hyprland head is
/// the operation whose only known undo is re-reading the operator's whole config
/// ([`restore_heads`]), dropping every runtime override they set by hand. DPMS is a separate axis
/// — this module's own notes record `dispatch dpms on <name>` failing to re-enable a *disabled*
/// head for exactly that reason — so off/on round-trips cleanly and touches nothing else.
///
/// A gamescope spawn owns no Hyprland output, hence the empty `ours`; a concurrent session's
/// `HEADLESS-*` is still spared by [`heads_to_disable`]'s `managed` filter.
///
/// Returns the heads actually changed, so the re-light undoes exactly those.
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
let Ok(heads) = list_monitors() else {
return Vec::new();
};
let mut changed = Vec::new();
for name in heads_to_disable(&heads, "") {
match dpms_one(&name, on) {
// Only a head THIS call moved is recorded: one already in the wanted state was left
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
// changed would have the re-light toggle a head we never darkened.
Ok(true) => changed.push(name),
Ok(false) => {}
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"hyprland: could not DPMS this monitor for `topology: exclusive`"
),
}
}
changed
}
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
///
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
/// [`dpms_one`] is built around.
fn monitor_dpms(name: &str) -> Option<bool> {
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed
.as_array()?
.iter()
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
.get("dpmsStatus")?
.as_bool()
}
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
///
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
/// single most important fact in this function. It ignores the state word entirely:
///
/// ```text
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
/// ```
///
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
/// the operator's screen left off after the stream, which is the failure this whole policy exists
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
/// config manager where the call really is a set, so it is not conditional on detecting which.
///
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
/// Lua, and report both failures if neither lands.
///
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
/// with a name it is at least addressed at the head we mean.
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
if monitor_dpms(name) == Some(want_on) {
return Ok(false); // already where we want it — toggling would break it
}
let classic =
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
Ok(()) => None,
Err(e) => {
let lua = lua_dpms_expr(name, want_on);
match hyprctl_dispatch(&["dispatch", &lua]) {
Ok(()) => None,
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
}
}
};
if let Some(why) = classic {
bail!("neither dispatch form was accepted for {name} — {why}");
}
// Verify, because a toggle that fired against a state we misread is worse than one that did
// not fire at all.
match monitor_dpms(name) {
Some(now) if now == want_on => Ok(true),
Some(now) => bail!(
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
wanted {want_on} (the dispatcher toggles the readback disagreed with reality)"
),
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
}
}
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
/// wrong on that manager.
fn lua_dpms_expr(name: &str, on: bool) -> String {
format!(
"hl.dsp.dpms(\"{}\", \"{name}\")",
if on { "on" } else { "off" }
)
}
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
/// that screen, not the session.
@@ -1388,6 +1505,22 @@ fn portal_thread(
mod tests {
use super::*;
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
/// entire difference between working and silently doing nothing.
#[test]
fn the_lua_dpms_expression_quotes_both_arguments() {
assert_eq!(
lua_dpms_expr("HDMI-A-1", false),
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
);
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
// which would flip a just-restored head back off.
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
}
#[test]
fn version_tag_parses_release_and_dev_builds() {
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
@@ -1,23 +1,54 @@
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//! Turning the box's OWN physical panels off — how a gamescope session honors
//! [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
//!
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
//! does not pass through KWin.
//! A gamescope session is its own compositor: nothing on either owning route (bare spawn, managed
//! takeover) touches the desktop the box is showing, so the physical panel keeps displaying the
//! (idle) desktop for the whole stream — while the same `exclusive` policy on a *desktop* backend
//! turns the physicals off outright. That backend's mechanism is closed to us here: a compositor
//! refuses an output configuration with ZERO enabled outputs, and a gamescope session has no
//! output of its own on that desktop to leave enabled.
//!
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
//! exactly as before this module existed.
//! DPMS is the honest translation. The desk stays exactly where it is — no topology churn, no
//! workspace moves, no window re-homing — the panels just go dark, and any LOCAL input wakes them,
//! which is the right answer for a desktop someone can walk up to. Stream input never wakes them:
//! it is injected into the nested gamescope's own EIS socket and never reaches the desktop.
//!
//! **There is no cross-compositor DPMS protocol**, so this module is a dispatcher. In order, each
//! arm self-gating so a box only pays for the one that answers:
//!
//! | desktop | mechanism |
//! |---|---|
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
//! | GNOME / Mutter | **cannot be served** — see below |
//!
//! KDE is driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the
//! same stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a
//! separate libkscreen/KDED layer that can be wedged while KWin itself answers fine). sway and
//! Hyprland are driven through their own native IPC, which is how [`crate::wlroots`] and
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
//! is warranted.
//!
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
//! re-light moves exactly those and never a head it did not darken.
//!
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
//!
//! ⚠ **GNOME is the one gap, and it is structural.** Mutter exposes no DPMS to clients at all, and
//! its `exclusive` mechanism (an `ApplyMonitorsConfig` that omits the physicals) needs a virtual
//! output of its own to keep enabled — which a gamescope session, being its own compositor, does
//! not have. The DRM floor cannot cover it either: Mutter holds DRM master, so `SET_MASTER` is
//! refused. [`darken`] says so at `warn!` rather than failing silently.
//!
//! This module owns the refcount and the hold for every arm — see [`Darkened`] for how each is
//! undone.
//!
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
@@ -433,6 +464,16 @@ enum Darkened {
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
/// re-light is the symmetric `--dpms on`).
Kscreen,
/// sway (wlroots) turned these outputs off — `swaymsg output <name> dpms off`. Addressed by
/// connector name, so the re-light undoes exactly the heads we changed and never a sibling's.
Sway(Vec<String>),
/// Hyprland turned these monitors off — `hyprctl dispatch dpms off <name>`. Same per-name
/// discipline as [`Darkened::Sway`], and the same reason.
Hyprland(Vec<String>),
/// No desktop to ask, so [`crate::drm_dpms`] turned the CRTCs off over DRM directly. The
/// re-light is a `drop` — the hold IS a set of open `/dev/dri/cardN` fds, and the kernel
/// re-lights on last close. Nothing to replay, and crash-safe for the same reason.
Drm(crate::drm_dpms::DrmDarken),
}
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
@@ -505,9 +546,42 @@ pub fn release_stream_darken() {
}
}
/// The non-KDE desktops we can ask, in preference order. Each self-gates on its own IPC being
/// reachable — `wlroots::dpms_other_heads` shells out to `swaymsg`, which needs `SWAYSOCK`;
/// Hyprland's needs `HYPRLAND_INSTANCE_SIGNATURE` — so a box only ever pays for the one that
/// answers, and a box running neither falls straight through.
///
/// Both address heads BY NAME and report back the ones they actually changed, so the re-light
/// undoes exactly those and never a concurrent session's headless output.
///
/// **GNOME is absent on purpose.** Mutter exposes no DPMS to clients at all, and its `exclusive`
/// mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its own to
/// keep enabled — which a gamescope spawn, being its own compositor, does not have. There is
/// nothing to call; the `warn!` at the end of [`darken`] names it rather than failing silently.
fn non_kde_desktop_darken() -> Option<Darkened> {
let sway = crate::wlroots::dpms_other_heads(false);
if !sway.is_empty() {
tracing::info!(
outputs = ?sway,
"sway: desktop outputs off for the exclusive gamescope stream"
);
return Some(Darkened::Sway(sway));
}
let hypr = crate::hyprland::dpms_other_heads(false);
if !hypr.is_empty() {
tracing::info!(
outputs = ?hypr,
"hyprland: desktop monitors off for the exclusive gamescope stream"
);
return Some(Darkened::Hyprland(hypr));
}
None
}
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
/// off, or every arm declined) — and therefore nothing to restore.
/// wedged-compositor fallback, then the other desktops, then DRM. `None` = nothing was darkened
/// (no desktop that answers, panels already off, or every arm declined) — and therefore nothing to
/// restore.
fn darken() -> Option<Darkened> {
match Session::open("darken") {
Ok(mut s) => {
@@ -526,8 +600,49 @@ fn darken() -> Option<Darkened> {
}
}
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
// the same KDE-only machinery), so decline quietly — already logged by `open`.
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
// the same KDE-only machinery). Declining is still right — but NOT quietly. [`darken`] is
// only ever reached because the operator selected `Topology::Exclusive`, so every decline
// here is "you asked for your screens off and they stayed on", which is a verdict and not
// a routine state. It sat at `debug!` in `open`, and that silence is what made the Nobara
// field report (2026-08-24) undiagnosable: no line anywhere named the panel. Same
// discipline as [`relight`], which has always said so when it gave up — a lit panel under
// `exclusive` deserves the honesty a dark one already got.
Err(e @ (OpenFailure::NoDpmsGlobal | OpenFailure::Connect(_))) => {
// Not KDE. Try the other desktops we drive, then the compositor-independent floor.
// Each arm self-gates on its own IPC being reachable, so the order is just preference
// and a box only ever pays for the ones that answer.
if let Some(d) = non_kde_desktop_darken() {
return Some(d);
}
match crate::drm_dpms::darken() {
Some(d) => {
tracing::info!(
cards = ?d.darkened,
"DRM: the box's own CRTCs are off for the exclusive gamescope stream (no \
desktop compositor to ask a session in Game Mode has none)"
);
Some(Darkened::Drm(d))
}
// Nothing on this box was ours to darken: no desktop that answers, and then no
// `/dev/dri` card that was ours either — every one already mastered by someone
// else (a live compositor, including the gamescope an Attach route is mirroring,
// which must NOT be darkened), or nothing lit. Say so: `darken` is only ever
// reached because the operator selected `Topology::Exclusive`, so this is "you
// asked for your screens off and they stayed on" — a verdict, not a routine
// state. It sat at `debug!` in `open`, and that silence is what made the Nobara
// field report (2026-08-24) undiagnosable: no line anywhere named the panel.
None => {
tracing::warn!(
%e,
"exclusive topology asked for the box's own screens to go dark: no \
desktop compositor on this box could be asked (GNOME/Mutter exposes no \
DPMS to clients), and no DRM card was ours to turn off either the \
panel stays as it is for this stream"
);
None
}
}
}
// A live session that stopped answering: the standalone tool rides a different stack
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
// kscreen fallbacks, honest-verdict discipline included.
@@ -594,6 +709,41 @@ fn relight(d: Darkened) {
);
}
}
// Per-name, so exactly the heads we darkened come back and a sibling's headless output is
// never switched on by us. A head the operator unplugged meanwhile just fails its one
// command and says so — the others still re-light.
Darkened::Sway(outputs) => {
let back = crate::wlroots::dpms_other_heads(true);
if back.is_empty() {
tracing::error!(
?outputs,
"sway: could NOT re-light the desktop outputs — they stay dark until local \
input or `swaymsg output '*' dpms on`"
);
} else {
tracing::info!(outputs = ?back, "sway: desktop outputs back on");
}
}
Darkened::Hyprland(outputs) => {
let back = crate::hyprland::dpms_other_heads(true);
if back.is_empty() {
tracing::error!(
?outputs,
"hyprland: could NOT re-light the desktop monitors — they stay dark until \
local input or `hyprctl dispatch dpms on`"
);
} else {
tracing::info!(outputs = ?back, "hyprland: desktop monitors back on");
}
}
// The one arm that cannot fail: the hold IS the open fds, so dropping it closes them and
// the kernel's last-close restores the console. No ioctl to be refused, no saved mode to
// replay — which is why this path needs no "could NOT re-light" line of its own.
Darkened::Drm(d) => {
let cards = d.darkened.clone();
drop(d);
tracing::info!(?cards, "DRM: the box's own CRTCs released — panel back on");
}
}
}
@@ -491,6 +491,45 @@ fn disable_argv(name: &str) -> [&str; 3] {
["output", name, "disable"]
}
/// The `swaymsg` argv that DPMS-es `name` off or on. Same noun-first shape as [`disable_argv`],
/// and a different axis from it: `dpms off` leaves the output enabled and configured (its
/// workspaces do not move, no window is re-homed) and merely stops driving the panel.
fn dpms_argv(name: &str, on: bool) -> [&str; 4] {
["output", name, "dpms", if on { "on" } else { "off" }]
}
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
///
/// Distinct from [`disable_other_heads`], which is what the *wlroots backend's own* exclusive
/// topology does. A gamescope spawn is its own compositor and owns no sway output, so there is
/// nothing here to promote to "the desk" and nothing to focus — and disabling the operator's
/// outputs would move their workspaces around for a stream that is not even on this compositor.
/// DPMS is the honest translation: the desk stays exactly as it is, the panels just go dark.
///
/// Reuses [`heads_to_disable`]'s filter with an empty `ours`, so a concurrent wlroots session's
/// `HEADLESS-*` output is spared for the same reason it is there — blanking it would black out
/// that client's stream.
///
/// Returns the heads actually changed, so the re-light can undo exactly those. Best-effort per
/// head, like its neighbour: one that refuses costs a lit screen, not the stream.
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
let Ok(heads) = list_monitors() else {
return Vec::new();
};
let mut changed = Vec::new();
for name in heads_to_disable(&heads, "") {
match swaymsg(&dpms_argv(&name, on)) {
Ok(_) => changed.push(name),
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"wlroots: could not DPMS this output for `topology: exclusive`"
),
}
}
changed
}
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
/// way the Hyprland twin's `reload` does.
@@ -1060,6 +1099,30 @@ mod tests {
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
}
/// `dpms` is a different sway verb from `disable`, and the difference is the whole point of
/// the gamescope arm: `disable` moves workspaces and re-homes windows on the operator's desk,
/// `dpms off` leaves the desk alone and only stops driving the panel. Four tokens, not three —
/// sway spells it `output <name> dpms on|off`.
#[test]
fn dpms_is_a_separate_verb_from_disable() {
assert_eq!(dpms_argv("DP-1", false), ["output", "DP-1", "dpms", "off"]);
assert_eq!(dpms_argv("DP-1", true), ["output", "DP-1", "dpms", "on"]);
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
}
/// The gamescope DPMS arm reuses the disable filter with an EMPTY `ours`: a gamescope spawn
/// owns no sway output, so nothing of ours needs sparing — but a concurrent wlroots session's
/// `HEADLESS-*` still must be, or darkening would black out that client's stream.
#[test]
fn the_gamescope_dpms_arm_still_spares_a_sibling_headless() {
let heads = [
head("DP-1", true),
head("HEADLESS-1", true),
head("DP-3", false),
];
assert_eq!(heads_to_disable(&heads, ""), vec!["DP-1"]);
}
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
/// restore is prepared and teardown touches nothing.
#[test]
+23 -10
View File
@@ -419,6 +419,29 @@ pub fn serve(
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists.
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
//
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
// leaves a first-run window where the console starts, finds no native pair, and serves the
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
// (It does not write one on an upgraded host whose native clients pinned the legacy cert —
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
// (native clients paired, but the cert they pinned is gone from disk) the old order let
// `load_or_create` mint a BRAND-NEW cert.pem that `load_or_adopt` then adopted while logging
// that it was preserving their pins — stranding them silently. Reading the dir first means
// that case reaches the branch written for it.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
#[cfg(feature = "gamestream")]
let state = {
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
@@ -426,20 +449,10 @@ pub fn serve(
};
#[cfg(not(feature = "gamestream"))]
let state = Arc::new(AppState::new(host, stats.clone()));
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists.
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
let _ = state.access.set(np.clone());
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
+4 -1
View File
@@ -1091,7 +1091,10 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
// accepted only inside the proxy code (per-request TLS), never process-wide.
("PUNKTFUNK_MGMT_URL", mgmt_url),
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure. Names the
// LEGACY pair — the console prefers the native sibling when it exists
// (web/nitro-entry/tls-paths.mjs), which is also what the gate above ends up waiting for:
// `serve` resolves the native identity before minting this one.
(
"PUNKTFUNK_UI_TLS_CERT",
data.join("cert.pem").to_string_lossy().into_owned(),
+20 -3
View File
@@ -208,7 +208,24 @@ fn poll_loop(
// that proves the server is answering, and the agent below refuses redirects so the probe is
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
let console_url = format!("https://127.0.0.1:{web_port}/login");
let agent = agent(load_pin());
// Named, not `agent`: shadowing the fn (as this did while there was only one agent) would make
// the second call below resolve to this binding instead.
let mgmt_agent = agent(load_pin());
// The console probe gets its OWN, UNPINNED agent. It is a different server from the mgmt API
// and there is no rule that it presents the same certificate: it served the legacy `cert.pem`
// while mgmt served the native one (the identity split), so the pinned agent refused the
// handshake and every identity-split host showed "Open web console (not responding)" over a
// perfectly healthy console — next to a tooltip reading "idle", because the same agent reached
// mgmt fine (field report 2026-08-24). An operator fronting the console with their own LAN-CA
// cert would have hit it just as squarely, so the coupling goes rather than the symptom.
//
// Nothing is lost by dropping the pin: this probe sends no credentials, reads no body, and
// decides only presentation — the menu entry's label, plus whether a tray-icon click opens
// the console or the menu (win.rs). A port-squatter could flip that, but the entry itself is
// unconditional and opens the same URL either way, and no browser ever pinned this cert. On
// Windows the probe was never pinned to begin with: `punktfunk_config_dir` returns None there,
// so `load_pin` was already None.
let console_agent = agent(None);
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
@@ -220,7 +237,7 @@ fn poll_loop(
loop {
let svc = probe_service();
let summary = if svc == ServiceState::Running {
let s = fetch_summary(&agent, &summary_url());
let s = fetch_summary(&mgmt_agent, &summary_url());
match s {
Some(_) => unreachable_since = None,
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
@@ -233,7 +250,7 @@ fn poll_loop(
};
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired);
let console_up = if probe_console(&agent, &console_url) {
let console_up = if probe_console(&console_agent, &console_url) {
console_misses = 0;
true
} else {
+24 -1
View File
@@ -62,6 +62,10 @@ desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
> sudo usermod -aG punktfunk "$USER" # then log out and back in
> ```
>
> It also keeps the box's own power menu working: on a takeover flavour that *stops* the display
> manager, the box has no active local session for the length of the stream, and Shut Down /
> Restart / Sleep are refused unless you are in this group.
>
> Your package created the group at install time and put **nobody** in it, on purpose: it also
> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present
> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust.
@@ -136,7 +140,26 @@ PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
The host then attaches to the session's own composited output: nothing is stopped, nothing is
relaunched, no mode is imposed, and what you see is exactly what is on the TV. That is the
difference from **managed**, which deliberately takes the session over and blanks the panel.
difference from **managed**, which deliberately takes the session over and relaunches it headless,
so the box's own session stops driving that panel.
Whether the panel then goes *dark* is the **Topology** setting's job, not the model's — see
[Virtual displays](/docs/virtual-displays#topology). What `exclusive` can do differs by model:
- **Managed** and **bare spawn** — the box's own gaming session is moved out of the way so it
stops driving the panel, and then the panel is actually turned **off** for the stream and
restored at teardown. The host asks whichever desktop is running: KDE goes through KWin's DPMS,
sway and Hyprland through their own; a box already in Game Mode has no desktop to ask, so the
host turns the CRTCs off over DRM itself. None of these needs root — the DRM path rides the same
seat access every local compositor gets.
- **Attach** — nothing is darkened, and cannot be: this model streams the panel the box is
driving, so turning it off would turn off the picture.
Under `extend` or `primary` none of this happens and your screens are left alone. If `exclusive`
asked for a dark screen and the host could not deliver one — **GNOME** is the case that cannot be
served, because Mutter offers apps no way to turn a screen off and holds the graphics card itself
so the DRM route is refused — it says so in the log rather than leaving you guessing at a lit
screen.
Only the one head the session drives is listed — a nested or headless gamescope (including the
per-session ones the host spawns itself) has none of its own, so the picker is empty there. Full
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.3"
"version": "0.31.4"
},
"paths": {
"/api/v1/client-logs": {
+48
View File
@@ -0,0 +1,48 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release about the machine you stream *from* behaving itself while you are not in the room with it. Asking for your screens off during a stream only ever worked on a box running a KDE desktop — a Steam Deck, or anything else sitting in Game Mode, kept its television lit for the whole session, playing your game to an empty room. That works now on every kind of Linux box, including ones with no desktop at all. Putting that box to sleep or shutting it down from Steam's own power menu had stopped working during a stream, in two separate ways, and works again. On the client side, a long session on a connection that was never the problem could slide from 57 Mbps down to 5 and stay there; the Mac app could quit outright the moment a stream started; and the web console served a certificate browsers refuse, so the page you are told to open did not load, while on Linux the tray insisted the console was dead the whole time it was running perfectly. There is a little new work too: the gamepad console's bitrate reaches 2 Gbps and takes a number you type, and on Linux there is finally a way into that console that is not a command line.
## TL;DR
- **"Turn my screens off while I stream" now actually turns them off.** It only ever worked on a KDE desktop. A Steam Deck or any other box in Game Mode ignored it silently, which is the deployment where you most want it. It works there, and on sway and Hyprland desktops too. GNOME is the one that still cannot be served, and the host now says so instead of leaving you guessing.
- **Steam's power menu did nothing during a stream. Sleep, Restart and Shut Down all work again.** Two unrelated faults with one symptom: your host was refusing every suspend request for the length of a stream, including yours, and on some setups the box lost the permission to power itself off at all while it was streaming.
- **A long session could slide to the worst picture quality on a perfectly good connection.** One 1440p session walked from 57 Mbps down to 5 over ten minutes without dropping a single packet, and never recovered. Both causes are fixed.
- **Mac: the app could quit the instant a stream started.** Not at launch — at the moment you connected, which made it look like the host's fault.
- **The web console would not open in a browser**, and on Linux the tray labelled it "not responding" while it was serving fine.
- **New:** the gamepad console's bitrate goes to 2 Gbps, steps finely at the low end, and takes a typed number; on Linux the gamepad console gets a button, a menu entry and its own app-grid launcher.
## Before you update
- **Only if your host takes the box over by stopping its login screen, and you want Steam's power menu to work:** be in the punktfunk group. That is the same group the takeover already needs to do its job, so if the takeover works for you today you are already in it and there is nothing to do. If you set that up some other way, join it — the docs page titled Steam / gamescope carries the one-line command — because the permission this release adds is granted to that group and nobody else, deliberately.
## New
- **Bitrate in the gamepad console reaches 2 Gbps, and you can type a rate.** The picker had seven rungs stopping at 80 Mbps since the console shipped, which someone ran straight into — and the desktop app beside it has always gone far higher, so the two disagreed about what your own machine was allowed to ask for. There are 30 rungs now, from 1 Mbps to 2 Gbps: tight at the bottom, where a single step decides whether a thin connection is watchable, and coarse at the top, where a step is noise. Press Y on that row to type a rate instead, using the same on-screen keyboard as the add-host and pairing fields, including Steam's own keyboard on a Deck. And a rate that is not one of the rungs — typed here, or set from the desktop app — now steps to its neighbour instead of being thrown away and snapped back to Automatic by a single nudge. The desktop app gets the other half of the same complaint: its bitrate box steps 1 Mbps at a time instead of 5, so 3, 4 and 6 are reachable without typing.
- **Linux: a front door to the gamepad console.** The couch interface has shipped for a while, but on Linux the only way to reach it was a command-line switch, which is not something you find by looking. There are three ways in now: a gamepad button in the app's host list, a "Console UI" entry in its menu, and a separate launcher in your applications grid that you can also add to Steam as a non-Steam game so it opens straight from the couch. All three land in the same place. This comes with the packaged builds; a from-source Nix build still does not include the separate launcher, because that build leaves the console interface out.
## Improved
- **The adaptive bitrate control no longer gives up on a whole session.** Part of the picture-quality fix below: when your client decided the host's encoder was the bottleneck and stopped listening to that signal, it stopped listening for the rest of the session. But the thing that provokes it is temporary by nature — a game hammering the graphics card, a shader storm, a second program on the same card — and all of those end. It now starts listening again after a clean stretch, and if the noise comes straight back it backs off for longer each time rather than flapping.
- **When your host cannot turn a screen off, it says so.** Every one of those refusals means "you asked for your screens off and they stayed on", and the most common one was being recorded at a level nobody reads. It is now a warning, with the reason.
## Fixed
- **Your screens stayed lit through the whole stream, on every Linux box except a KDE desktop.** Reported on Nobara: launching a game from the library with screens-off selected left the operator's monitor showing live Game Mode for the entire session. Three separate things were wrong. The setting was implemented by asking the desktop to turn its screens off — right when there *is* a desktop to ask, and a box in Game Mode has none, which is precisely the box whose television you want dark. That case is now handled by switching the display off through the graphics hardware directly, which needs no extra privileges and cannot leave a screen dark if the host crashes, because the screen comes back the moment the host lets go. A sway or Hyprland desktop was not served either, and now is, through each one's own controls, taking care to leave any screen it did not darken alone rather than "fixing" one that was already off. And separately, the part of the host that moves the box's own gaming session out of the way only did so for a Steam launch, because freeing Steam was the only reason anyone had ever needed it — so a non-Steam game from your library left that session running and driving the television regardless. Wanting your screens off is now a second, independent reason to move it aside. **GNOME remains a genuine gap, not an oversight:** it offers applications no way to turn a screen off, and holds the graphics card itself so the direct route is refused. The host warns instead of pretending.
- **The recommended way of streaming a Game Mode box ignored the setting entirely.** The route that gives the best experience — and the only one that can serve your client its own screen at its own resolution — was the one route that never darkened anything, because there was nowhere safe to undo it and a screen left dark after every stream is worse than one left lit. The undo now lives in the single teardown every one of those sessions passes through, so it is honoured. A reconnect does not darken twice, and a session that keeps running between connections stays dark rather than flickering.
- **Steam's power menu did nothing during a stream, and neither did Sleep.** Two independent faults, one symptom, both on Linux. Your host holds a lock for the length of a stream so the box cannot suspend out from under someone who is only watching and never touching anything — but that lock refuses *every* suspend, including the one you asked for by pressing Sleep, and nothing in Steam's interface shows the refusal, so the entry simply did nothing for as long as anyone was connected. The lock is now held only while the stream is genuinely quiet: any input from any client drops it immediately, so a Sleep press cannot lose the race, and it comes back after 30 seconds of silence. The case it was written for is unchanged — a passive viewer sends nothing, and the box still will not sleep under them. The second fault is Shut Down and Restart, on the takeover setups that stop the box's login screen: doing that leaves the box with no signed-in session, and the system's rules only allow powering off from one. All three entries quietly became a password prompt — asked of something that cannot answer, on a screen that is switched off. The packages now ship a rule granting exactly those three actions, to the punktfunk group and to nobody else, and only those three: a box with a second person logged in still asks first.
- **A long session slid to the lowest picture quality on a connection that never lost a packet.** Reported from a 1440p60 session that ratcheted 57 Mbps down to 5 over ten minutes, with the host granting every rate it was asked for and nothing at all going wrong on the link. Two causes, both in the part of your client that watches how long the host is taking to compress each frame. Its thresholds were tuned on a 120-frames-a-second session, where one frame is 8 milliseconds; on a 60-frame session a frame is twice that, so an ordinary one-frame hiccup cleared the "something is badly wrong" bar that the identical hiccup at 120 never reached. They are now measured against the session's own frame budget. And the signal was firing for something cutting the rate cannot fix: it exists to find the point where the encoder runs out of compute, where less rate means less work — but when a game is saturating the graphics card that premise is false, the reduction changes nothing, and it would fire again a moment later. Two reductions in a row that buy no improvement now stand the signal down. Everything else your client watches — packet loss, delay, decoding, and the host's own refusal to climb — keeps its full authority throughout, so a real encoder limit is still caught.
- **Mac: the app could quit outright the moment a stream started.** Not at launch, which is what made it look like a host problem. Setting up your microphone takes a snapshot of the input device's audio format and then hands that snapshot back a moment later, and on a Mac the input can change underneath in that window — you switch devices, something changes the sample rate, or the app itself switches to the device you chose two lines earlier. Any mismatch was fatal and could not be caught. It now asks the system to use whatever format the device actually has, so there is no snapshot to go stale, and follows the device if it changes rate mid-stream instead of resampling by the wrong ratio and pitch-shifting your voice.
- **The web console would not load in a browser, and the tray called it dead.** A Linux operator saw "Open web console (not responding)" in the tray, beside a tooltip that said the host was idle — which was the clue, because that tooltip proved the host was answering perfectly on the very same check. Your host has kept two identities for a while now: a modern one that everything current uses, and an older one kept unchanged so Moonlight-compatible clients keep working. The web console never followed that split, and none of the things that start it could choose — so it served the old identity, which no browser will accept, and the page you were told to open failed to load. Meanwhile the tray was checking on the console using the *modern* identity, so its check was refused and a completely healthy console was labelled as not responding. The console now serves the modern identity whenever the host has one, and the tray's check no longer insists on any particular one, because it reads nothing, sends nothing, and decides only what a menu entry says. A host that predates the split, or one where you supplied your own certificate, is unaffected and behaves exactly as before.
- **Hyprland: the request to turn a screen off is a toggle, and would have turned a dark screen back on.** Found on a live Hyprland box rather than in review, and worth saying because the first version of this fix was wrong in both directions: the command ignores whether you asked for on or off and simply flips whatever the screen is currently doing. So "turn it off now, turn it back on later" would have lit a screen that was already dark at the start of a stream, and darkened a lit one at the end — the operator's screen left off after streaming, which is the exact failure this whole feature exists to avoid. Your host now reads the screen's real state first, acts only if it differs, and checks afterwards that it worked. A screen already in the state you wanted is left completely alone.
## Thanks
Every fix above started as somebody saying exactly what they saw. The monitor still showing Game Mode through a whole stream, and which kind of game they had launched. The power menu that did nothing, on which box. The 1440p session whose quality walked downhill for ten minutes while every other number stayed flat — that report came with the measurements that separated the two causes, which is the hard half. The bitrate ceiling somebody hit and pointed out was lower than the app right next to it. The Mac that quit on connect. And the tray that said "not responding" beside a tooltip reading "idle" — noticing that those two things contradicted each other is what found the bug. Thank you.
One caveat we would rather write down than let you discover: the sway arm of the screens-off work has not been run against a real sway session — there is no sway machine in the lab. Hyprland, KDE, and the no-desktop route were all measured on real hardware. If you stream from sway, we would like to hear how it goes.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.4/CHANGELOG.md).
The short version: **nothing versioned moves, at all.** The streaming protocol, the embedding interface, the driver protocol, the gamepad channel, the add-on contract and the management API are exactly where 0.31.3 left them — the embedding header is byte-identical for the third release running, and the API description changes only its stamped version number. No header, package, plugin or integration needs rebuilding, re-pairing or re-publishing in any direction, and the add-on toolkit and TypeScript package are both untouched. Two behaviour changes are worth knowing about if you integrate: the host's sleep veto is now conditional on stream quiet rather than held for the whole session, and the client's encode-attributed backoff both re-arms after standing down and sizes its thresholds from the negotiated refresh rather than in absolute milliseconds.
+3
View File
@@ -0,0 +1,3 @@
• Fixes a long stream sliding down to the worst picture quality on a connection that was never the problem — and staying there. One session walked from 57 Mbps to 5 over ten minutes without losing a packet.
• Bitrate now reaches 2 Gbps instead of stopping at 80 Mbps, and steps finely at the low end where one step decides whether a thin connection is watchable.
• Press Y on the Bitrate row to type an exact rate. A rate that isn't a preset step no longer snaps back to Automatic.
+5
View File
@@ -667,6 +667,11 @@ in
HOST = "0.0.0.0";
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
# mark the session cookie Secure. The host's `serve` writes these PEMs.
#
# These name the LEGACY pair; the server prefers the native sibling
# (native-cert.pem/native-key.pem) when it exists, because a generated unit cannot express
# "this file, else that one" any more than the hand-written one can. The choice is made in
# web/nitro-entry/tls-paths.mjs — keep this in step with scripts/punktfunk-web.service.
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
PUNKTFUNK_UI_SECURE = "1";
+8 -1
View File
@@ -2,7 +2,8 @@
#
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
# with the host's own identity cert (~/.config/punktfunk/native-{cert,key}.pem, falling back to the
# legacy {cert,key}.pem — see the PUNKTFUNK_UI_TLS_CERT note below), and points the /api proxy
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# Enable per user:
@@ -39,6 +40,12 @@ Environment=HOST=0.0.0.0
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
#
# These name the LEGACY pair and the server prefers the native sibling
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
# else that one", so the choice is made in web/nitro-entry/tls-paths.mjs, which is the one place
# every launcher routes through. Don't "fix" these to the native names: a host that never took the
# identity split has no native pair, and the fallback lives on the other side of this handoff.
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
Environment=PUNKTFUNK_UI_SECURE=1
+2
View File
@@ -58,6 +58,8 @@ PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# Naming cert.pem/key.pem serves native-cert.pem/native-key.pem instead when both sit beside them
# (the identity split — nitro-entry/tls-paths.mjs); the legacy pair is the fallback, not the target.
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# See .env.example.
+21 -8
View File
@@ -8,8 +8,9 @@
//
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
// and the host identity is SELF-SIGNED whichever pair we serve — the native one carries real SANs, so
// a browser gets past the name check, but never past the untrusted issuer (and the legacy fallback is
// CN-only with no SAN, which fails both). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
@@ -17,14 +18,16 @@
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
//
// Env (set by the launchers / the systemd unit — see web.env.example):
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
// Unset ⇒ plain HTTP (local dev only).
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem — the native
// sibling pair is preferred when present, see tls-paths.mjs).
// BOTH set ⇒ HTTPS. Unset ⇒ plain HTTP (local dev only).
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
import "#nitro-internal-pollyfills";
import wsAdapter from "crossws/adapters/bun";
import { useNitroApp } from "nitropack/runtime";
import { startScheduleRunner } from "nitropack/runtime/internal";
import { resolveUiTlsPaths } from "./tls-paths.mjs";
const nitroApp = useNitroApp();
const ws = import.meta._websocket
@@ -75,8 +78,15 @@ const PEER_IP_HEADER = "x-pf-peer-ip";
const LISTENER_HEADER = "x-pf-listener";
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
//
// The launchers all name the LEGACY cert.pem/key.pem pair and cannot express a fallback, so the
// choice between the host's two identities is made here — see tls-paths.mjs for why the native
// pair is the right one to serve (SANs a browser accepts; the cert the tray and native clients
// already pin).
const { cert: certPath, key: keyPath } = resolveUiTlsPaths(
process.env.PUNKTFUNK_UI_TLS_CERT,
process.env.PUNKTFUNK_UI_TLS_KEY,
);
const tls =
certPath && keyPath
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
@@ -126,7 +136,8 @@ const listenerOptions = (lane) => ({
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
// makes the memory cost of an unauthenticated request negligible.
maxRequestBodySize:
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) ||
4 * 1024 * 1024,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
@@ -167,7 +178,9 @@ console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
// explanation instead of a frame, and everything else about the console keeps working.
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
const pluginPort = Number(
process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1,
);
let pluginServer;
try {
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
+92
View File
@@ -0,0 +1,92 @@
// Which of the host's two identities the console serves — resolved HERE because this entry is the
// one place every launcher routes through.
//
// The host keeps two identities side by side (crate::identity, the "identity split"):
//
// native-cert.pem / native-key.pem ECDSA P-256, with real SANs (the machine hostname,
// localhost, 127.0.0.1, ::1). This is what the native QUIC
// plane and the management API present, and what native
// clients pin.
// cert.pem / key.pem the legacy RSA GameStream identity: CN=punktfunk and NO SAN
// at all (gamestream::cert::generate passes rcgen an empty SAN
// list), kept byte-stable because Moonlight pins it and the
// pairing hashes bind its X.509 signature bytes.
//
// Every launcher names the LEGACY pair — scripts/punktfunk-web.service, the NixOS module, the
// Windows service supervisor, web-run.cmd, the Steam Deck installer — because they were written
// before the split, and none of them CAN choose: systemd `Environment=` has no "this file, else
// that one". Serving the legacy pair costs twice:
//
// * a CN-only, SAN-less cert is rejected outright by every current browser
// (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN), so the console the operator was
// told to open does not load;
// * the tray's loopback liveness probe pins whatever the mgmt API serves — the NATIVE cert — so
// the handshake is refused and a perfectly healthy console is labelled "Open web console (not
// responding)" while the host beside it reads "idle" (field report 2026-08-24).
//
// So prefer the native sibling. It is also the smaller secret to hand a bundled bun: on a default
// build key.pem is the Moonlight PAIRING SIGNING key, native-key.pem is only a TLS key.
//
// Swapped as a PAIR or not at all — a native cert with the legacy key is a server that cannot
// complete a handshake with anyone, so both halves must be present AND must come from the same
// directory. A host that never took the split (upgraded, native clients still pinning the RSA cert,
// so `load_or_adopt` keeps serving it) has no native pair on disk and falls through unchanged, as
// does a cert an operator supplied under any other name.
import { statSync } from "node:fs";
/**
* The directory prefix (separator included) of a path ending in `base`, or null if it does not.
*
* Deliberately NOT `node:path`: that resolves per-RUNTIME, so a POSIX build reads
* `C:\ProgramData\punktfunk\cert.pem` as one long filename and Windows, where the service
* supervisor hands us exactly that (windows/service.rs), is the platform CI can never exercise.
* A suffix test gives the same answer everywhere. It also leaves the prefix VERBATIM, where
* `join(dirname(p), …)` would normalise `/a/b/../cert.pem` to a different directory than the one
* the operator named which matters the moment `b` is a symlink.
*
* @param {string} p
* @param {string} base
* @returns {string | null}
*/
function dirPrefix(p, base) {
if (p === base) return ""; // bare relative name
if (!p.endsWith(base)) return null;
const sep = p[p.length - base.length - 1];
return sep === "/" || sep === "\\" ? p.slice(0, -base.length) : null;
}
/**
* A readable, NON-EMPTY file. Emptiness matters: `pf_paths::write_secret_file` is
* create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise
* adopt a 0-byte cert and leave `Bun.serve` throwing on every restart and not every launcher
* retries forever (the Steam Deck unit is `Restart=on-failure` under the default rate limit).
*
* @param {string} p
*/
function usable(p) {
try {
return statSync(p).size > 0;
} catch {
return false;
}
}
/**
* @param {string | undefined} cert PUNKTFUNK_UI_TLS_CERT, verbatim.
* @param {string | undefined} key PUNKTFUNK_UI_TLS_KEY, verbatim.
* @param {(p: string) => boolean} [exists] injected by the test; defaults to a real stat.
* @returns {{cert: string | undefined, key: string | undefined}}
*/
export function resolveUiTlsPaths(cert, key, exists = usable) {
// Half-configured TLS is the caller's error to report (it refuses to start); don't mask it by
// resolving one half of a pair that isn't there.
if (!cert || !key) return { cert, key };
const dir = dirPrefix(cert, "cert.pem");
// Same directory, or we are not looking at a pair — see the PAIR note above.
if (dir === null || dir !== dirPrefix(key, "key.pem")) return { cert, key };
const nativeCert = `${dir}native-cert.pem`;
const nativeKey = `${dir}native-key.pem`;
return exists(nativeCert) && exists(nativeKey)
? { cert: nativeCert, key: nativeKey }
: { cert, key };
}
+103
View File
@@ -0,0 +1,103 @@
// The pair swap is all-or-nothing, and the fallbacks are what keep legacy and custom-cert hosts
// serving. A native cert with the legacy key would be a console nobody can handshake with, so the
// mixed cases are the ones worth pinning down — including the Windows shape, which the resolver
// must get right without a win32 runtime to ask (see dirPrefix in tls-paths.mjs).
import { describe, expect, it } from "bun:test";
import { resolveUiTlsPaths } from "./tls-paths.mjs";
const DIR = "/home/you/.config/punktfunk";
const legacy = [`${DIR}/cert.pem`, `${DIR}/key.pem`] as const;
const native = [`${DIR}/native-cert.pem`, `${DIR}/native-key.pem`] as const;
/** `exists` over a fixed set of usable files on disk. */
const on =
(...files: string[]) =>
(p: string) =>
files.includes(p);
describe("resolveUiTlsPaths", () => {
it("prefers the native pair when both files are there", () => {
expect(resolveUiTlsPaths(...legacy, on(...legacy, ...native))).toEqual({
cert: native[0],
key: native[1],
});
});
it("keeps the legacy pair on a host that never took the identity split", () => {
expect(resolveUiTlsPaths(...legacy, on(...legacy))).toEqual({
cert: legacy[0],
key: legacy[1],
});
});
it("never mixes halves when only one native file is usable", () => {
for (const half of native) {
expect(resolveUiTlsPaths(...legacy, on(...legacy, half))).toEqual({
cert: legacy[0],
key: legacy[1],
});
}
});
// The Windows service supervisor hands us backslash paths (windows/service.rs); node:path on a
// POSIX CI runner would read the whole thing as one filename and silently never swap.
it("resolves Windows paths without a win32 runtime", () => {
const win = ["C:\\ProgramData\\punktfunk", "D:\\pf"] as const;
for (const d of win) {
expect(
resolveUiTlsPaths(`${d}\\cert.pem`, `${d}\\key.pem`, () => true),
).toEqual({
cert: `${d}\\native-cert.pem`,
key: `${d}\\native-key.pem`,
});
}
});
it("refuses to pair halves from two different directories", () => {
expect(resolveUiTlsPaths("/a/cert.pem", "/b/key.pem", () => true)).toEqual({
cert: "/a/cert.pem",
key: "/b/key.pem",
});
});
it("leaves the prefix verbatim rather than normalising it away", () => {
// `join(dirname(p), …)` would collapse this to /a/native-cert.pem — a different directory
// the moment `b` is a symlink.
expect(
resolveUiTlsPaths("/a/b/../cert.pem", "/a/b/../key.pem", () => true),
).toEqual({
cert: "/a/b/../native-cert.pem",
key: "/a/b/../native-key.pem",
});
});
it("leaves an operator's own cert alone, native pair present or not", () => {
// Also covers the endsWith trap: "mycert.pem" ends with "cert.pem" but is not one.
for (const own of [
[`${DIR}/lan-ca.pem`, `${DIR}/lan-ca.key`],
[`${DIR}/mycert.pem`, `${DIR}/mykey.pem`],
] as const) {
expect(resolveUiTlsPaths(...own, on(...own, ...native))).toEqual({
cert: own[0],
key: own[1],
});
}
});
it("does not re-swap a pair that already names the native files", () => {
expect(resolveUiTlsPaths(...native, () => true)).toEqual({
cert: native[0],
key: native[1],
});
});
it("passes a half-configured pair through for the entry to refuse", () => {
expect(resolveUiTlsPaths(legacy[0], undefined, on(...native))).toEqual({
cert: legacy[0],
key: undefined,
});
expect(resolveUiTlsPaths(undefined, undefined, on(...native))).toEqual({
cert: undefined,
key: undefined,
});
});
});
+1 -1
View File
@@ -15,7 +15,7 @@
"start": "bun run .output/server/index.mjs",
"api:gen": "orval --config orval.config.ts",
"lint": "tsc --noEmit",
"test": "bun test server/",
"test": "bun test server/ nitro-entry/",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs",
+1 -1
View File
@@ -193,7 +193,7 @@ export default defineConfig({
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
// browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime
// browser won't speak against a self-signed host cert.) Bun is the runtime
// everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb
// vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.)
preset: "bun",
+3
View File
@@ -51,6 +51,9 @@ if exist "%ENDPOINTFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%ENDPOIN
rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
rem These name the LEGACY pair; the server prefers native-cert.pem/native-key.pem beside them when
rem both exist (the identity split - web\nitro-entry\tls-paths.mjs). Don't "fix" them to the native
rem names: a host that never took the split has no native pair, and the fallback lives in there.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
set "PUNKTFUNK_UI_SECURE=1"
+3 -1
View File
@@ -18,7 +18,9 @@ HOST=0.0.0.0
# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths
# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak
# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.)
# HTTP/3/QUIC against a self-signed host cert — so HTTP/1.1 over TLS is what's offered.)
# Name the LEGACY pair below: the server prefers native-cert.pem/native-key.pem beside it when both
# exist (nitro-entry/tls-paths.mjs), and falls back to these on a host that never took the split.
PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
# Mark the session cookie Secure (required once served over TLS):