Compare commits

..
Author SHA1 Message Date
enricobuehler 96f75f4e52 style: rustfmt the abandoned-devnode sweep
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-drift (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 3m33s
android / android (pull_request) Successful in 10m5s
ci / rust (pull_request) Successful in 9m45s
2026-08-23 09:49:49 +02:00
enricobuehler 4c5b97cfe4 fix(host,audio): the registry stamp route reached for the Render hive even for capture endpoints
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 2m50s
ci / web (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m7s
ci / docs-drift (pull_request) Successful in 23s
android / android (pull_request) Canceled after 7m19s
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.

Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.

The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
2026-08-23 09:40:01 +02:00
enricobuehler 4beee17953 fix(host,audio): merge the abandoned-devnode tests into the existing module
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Failing after 1m44s
ci / rust-arm64 (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 5m48s
2026-08-23 09:08:57 +02:00
enricobuehler 4ad0055416 fix(host,audio): a host that died mid-mint left an orphan devnode, and the next start minted a duplicate
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.

Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.

* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
  carrying the role's Steam hardware id is re-marked and reused, so the
  endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
  uninstall instead of outliving the product.

The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
2026-08-23 09:03:52 +02:00
enricobuehler db9cd40079 The hand-back never checked that the panel came back, and a crashed host left game mode asleep (#375)
ci / bun-nix (push) Successful in 37s
ci / rust-arm64 (push) Successful in 1m31s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 1m48s
deb / build-publish-gamescope (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 23s
deb / build-publish-host (push) Successful in 5m35s
ci / rust (push) Successful in 7m21s
docker / deploy-docs (push) Successful in 51s
android / android (push) Successful in 10m16s
arch / build-publish (push) Successful in 10m38s
deb / build-publish (push) Successful in 5m18s
deb / smoke-install (push) Successful in 4m30s
windows-host / package (push) Successful in 16m42s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m22s
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.

The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
2026-08-22 23:38:23 +00:00
enricobuehler c63e8cee39 fix(gamescope): the hand-back never checked that the panel came back, and a crashed host left game mode asleep
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m50s
ci / rust-arm64 (pull_request) Successful in 2m7s
ci / rust (pull_request) Successful in 7m5s
android / android (pull_request) Successful in 7m35s
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own
physical screen stays black.

I could not reproduce it (PR #375 has the full negative write-up: five scenarios
across both distro families on the real VMs, all recovering cleanly, and the
mechanism I first proposed disproved on glass). So this does not guess at the
trigger. It closes the gap that lets ANY trigger end as a dark panel, and fixes
the one black-screen path I could prove.

## The restore never checked its own work

`do_restore_tv_session` issues a lifecycle verb and logs what systemd said about
the JOB. "The job succeeded" and "the box shows a picture" are different
questions, and nothing in this file has ever asked the second one — the restore
walks away the moment the verb returns, so every way the box can end up dark
looks identical to success in the log.

So measure it. After the hand-back a detached watcher polls
`detect_active_session()`, whose `None` means no compositor of our uid is running
at all — exactly the symptom. If the box is still dark 25 s later it climbs a
ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara
f44, 2026-08-22):

1. STOP the autologin unit. Its login session's script is parked on
   `systemctl --user --wait start <unit>` on both images, so a stop releases that
   wait, the session exits, and `Relogin=true` logs back in — starting the unit
   inside a session with a seat. `stop`, not `restart`: a restart does NOT
   release the parked waiter (measured), which is why it cannot rescue a box the
   ordinary restart already failed to bring back.
2. Restart the display manager — what the pre-0.31.0 takeover did on every
   disconnect, and proven on the Bazzite VM to return the box to game mode.
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has
   to run.

Detached, and that is load-bearing: the restore holds `RESTORE_FLIGHT`, which a
reconnecting client must take before it can re-take the box, so watching for up
to a minute while holding it would put that wait in front of every reconnect.
The watcher also stands down the instant `takeover_live()` says a new takeover
armed — the box belongs to that stream now, and a remedy fired into it would be
a fresh bug. It runs after `clear_takeover()` so that check means "a client
reconnected" and not "our own takeover has not been filed yet".

Skipped on the shutdown path: `restore_takeover_now` runs inside `native.rs`'s
20 s `SHUTDOWN_RESTORE_GRACE`, and spending that grace watching would cost the
hand-back rather than check it. What covers a shutdown that left the box dark is
the next host start — which this commit also makes true.

## A crashed host left the box's game mode asleep, provably

`restore_takeover_on_startup` sweeps a leftover idle drop-in off the box and logs
that the box's "own Game Mode session would have started and then done nothing".
Removing the FILE does not touch the unit RUNNING under it: its `ExecStart` is
still the sleep, so it sits `active` drawing nothing. Nothing below that sweep
restarts it either — the takeover file may be absent, unparseable, or fail
`takeover_state_is_live`, and all three exits leave the box on a dark panel with
its game mode "running". Any host killed mid-takeover (SIGKILL, OOM, a yanked
update) lands exactly there, and it survives until someone reboots.

`hand_back_idled_units_after_crash` restarts those units, gated on the box
actually being dark so a user already in game mode or on a desktop is never
bounced, and only for ACTIVE instances — under a just-removed idle drop-in,
active means "running the sleep".

## Not changed

The `restart` verb on the ordinary restore path. It works on both distros
(measured), and 0.31.0 chose it deliberately for the idled unit. The `stop` idea
survives only as escalation rung 1, where it runs after the proven path has
already failed.

`listed_autologin_units` is factored out of `stop_autologin_sessions` so both
callers share it, and its column parsing — which decides whether a live gaming
session can be told from a dead leftover — finally has a test against real
`--plain` output from both images.
2026-08-23 01:02:27 +02:00
enricobuehler b670b5d844 Merge pull request 'A TV negotiated the refresh its menu pinned, not the one it outputs' (#378) from worktree-tv-refresh-mismatch into main
ci / web (push) Successful in 2m11s
ci / docs-site (push) Successful in 2m1s
ci / rust-arm64 (push) Successful in 2m45s
ci / docs-drift (push) Successful in 1m27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 31s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
ci / bun-nix (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 5m41s
android / android (push) Successful in 9m51s
2026-08-22 22:47:19 +00:00
enricobuehler 064ea3de7d fix(android): a TV negotiated the refresh its MENU pinned, not the one it outputs
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 39s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / rust (pull_request) Successful in 5m7s
android / android (pull_request) Successful in 6m22s
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
     console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
     included. That pin exists for phone refresh governors (Nothing OS's LTPO
     logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, `nativeDisplayMode` resolves "Native" refresh from
     `display.mode` — which now reports the mode the MENU pinned, not the TV's
     real HDMI output. So the session negotiates (say) 120.
  3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
     own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
     panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.

Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.

Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
2026-08-23 00:21:50 +02:00
enricobuehler ec278c0478 Merge pull request 'Floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm' (#377) from worktree-hevc-idr-storm-coalesce into main
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m17s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 31s
ci / rust-arm64 (push) Successful in 2m3s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-gamescope (push) Successful in 1m5s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
ci / rust (push) Successful in 7m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 18s
deb / build-publish (push) Successful in 5m17s
android / android (push) Successful in 9m45s
deb / build-publish-host (push) Successful in 5m48s
arch / build-publish (push) Successful in 9m20s
deb / smoke-install (push) Successful in 2m36s
windows-host / package (push) Successful in 13m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
2026-08-22 22:00:55 +00:00
enricobuehler 5d91176500 fix(host): floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / web (pull_request) Successful in 2m2s
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 7m33s
ci / docs-site (pull_request) Successful in 1m57s
android / android (pull_request) Successful in 8m14s
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.

Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.

The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.

Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
2026-08-22 23:49:33 +02:00
enricobuehler 551d0c3294 Merge pull request 'The encoder follows a game-driven display mode change' (#373) from worktree-encoder-follow-mode-change into main
ci / bun-nix (push) Successful in 17s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m21s
ci / docs-drift (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
deb / build-publish (push) Successful in 5m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 5m50s
docker / deploy-docs (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 7m5s
arch / build-publish (push) Successful in 10m22s
android / android (push) Successful in 11m5s
deb / build-publish-gamescope (push) Failing after 11m3s
windows-host / package (push) Successful in 13m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
deb / smoke-install (push) Successful in 5m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 22m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 22m23s
Reviewed-on: #373
2026-08-22 21:33:42 +00:00
enricobuehler 7f77fa68af Merge pull request 'Stop the flatpak build updating runtimes it already has' (#370) from worktree-flatpak-deps-no-update into main
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 31s
ci / docs-site (push) Successful in 1m5s
ci / web (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 1m28s
apple / swift (push) Successful in 2m10s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish-client-arm64 (push) Successful in 1m43s
decky / build-publish (push) Successful in 1m7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
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 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 6m31s
docker / deploy-docs (push) Successful in 45s
android / android (push) Successful in 9m2s
docker / builders-arm64cross (push) Successful in 15s
flatpak / build-publish (push) Successful in 5m15s
arch / build-publish (push) Successful in 10m36s
deb / smoke-install (push) Successful in 5m41s
apple / distribute (push) Successful in 13m29s
ci / rust (push) Canceled after 16m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m57s
apple / screenshots (push) Successful in 10m36s
Reviewed-on: #370
2026-08-22 21:16:05 +00:00
enricobuehler ece8b16a78 fix(gamestream): the encoder follows a game-driven display mode change
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 5m16s
ci / rust (pull_request) Successful in 6m20s
The GameStream twin of the native fix. A fullscreen game can mode-set the
virtual display mid-stream; the IDD-push capturer re-opens its ring at the
new mode, and `try_latest` then hands this loop a frame the encoder cannot
accept. Every submit fails, the submit ladder rebuilds the encoder IN
PLACE at the same configured size — which cannot converge on a size the
source has already left — and after five resets the stream ends, costing
the Moonlight client a full disconnect/reconnect.

Reopen at the delivered size instead, with the same bookkeeping the
capture-loss rebuild in this loop already does (ring depth, RFI caps,
forced IDR, in-flight numbering restart). A failed reopen spends the
shared `encoder_resets` budget at the existing exponential pace rather
than ending the stream on the first try — a mode-set leaves the driver
settling, which is what that backoff exists for.

`gs_bit_depth(frame.format)` is derived per open, so an HDR flip that
recreates the ring at P010 now re-opens at the right depth too.

The client is NOT told: GameStream has no mid-stream mode-change message,
so Moonlight decodes a bitstream that disagrees with the resolution it
configured its decoder from. That is the same bargain the first open in
this function already takes whenever the captured size differs from the
negotiated one (the monitor-mirror case, §7.3) — tolerant decoders re-init
off the SPS and scale; a strict one (Media Foundation on Xbox) may stall
and drop the session. Taking it here too is strictly better than the
alternative, which is ending every stream the moment a game changes mode.
The guard carries that note.
2026-08-22 22:25:09 +02:00
enricobuehler 2b91339cb8 Merge pull request 'A paired Moonlight device can be given a name, and the pad-silence theory is measured and dropped' (#374) from worktree-gamestream-pad-heartbeat into main
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 44s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
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 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
deb / build-publish-host (push) Successful in 4m44s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
arch / build-publish (push) Successful in 9m0s
android / android (push) Failing after 9m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 43s
deb / build-publish (push) Successful in 4m52s
deb / smoke-install (push) Successful in 2m52s
windows-host / package (push) Successful in 18m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 18m30s
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
2026-08-22 19:22:01 +00:00
enricobuehler ffa4577793 style(host): keep the IDR-anchor comment off the trailing position
ci / rust-arm64 (pull_request) Successful in 2m46s
ci / web (pull_request) Successful in 1m35s
ci / bun-nix (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 39s
android / android (pull_request) Successful in 6m46s
ci / rust (pull_request) Successful in 20m2s
A trailing comment that long makes rustfmt treat the two comment lines
that follow it as a continuation of the same block and reflow them into a
hanging indent past column 60, which fails `cargo fmt --all --check`.
Put it on its own line above the statement instead.
2026-08-22 20:58:00 +02:00
enricobuehler 4a32c8fb36 test(mgmt): one config-dir override for the file, not one per test
ci / web (pull_request) Successful in 2m6s
ci / bun-nix (pull_request) Successful in 37s
ci / docs-site (pull_request) Successful in 1m48s
ci / docs-drift (pull_request) Successful in 4m18s
android / android (pull_request) Successful in 6m0s
ci / rust (pull_request) Successful in 8m49s
ci / rust-arm64 (pull_request) Successful in 13m14s
`ci / rust` failed the unsafe-hygiene gate: mgmt/tests.rs went to 6 process-global-API
mentions against a baseline of 3. The new rename test had copy-pasted the existing
`EnvGuard` + CONFIG_DIR_TEST_LOCK + tempdir dance, which is exactly the duplication gate C
exists to catch — its advice is to fix the call site rather than raise the baseline.

So there is now ONE `ConfigDirOverride` both tests use. It also makes the pairing harder to
get wrong than the copies were: the lock is a FIELD rather than a separate `_serial` binding
a test could forget, and since `Drop::drop` runs before any field drops, the environment is
restored while the guard still holds the lock.

Back to 3 mentions, and `sh scripts/ci/check-unsafe-hygiene.sh` reports all three gates clean.
Note the last one is a PROSE mention: the grep counts comments too (deliberately — "keep it
dumb and stable"), so the doc comment had to stop naming the function it warns about.

Not re-run on .173: the box went off-network mid-change. It does not need to be — this is
`mgmt/tests.rs`, which is not Windows-gated, so Linux CI compiles and runs it. The Windows-only
verification (clippy over the `cfg(target_os = "windows")` devtest change) was already done and
that file is untouched here.
2026-08-22 20:57:05 +02:00
enricobuehler 9bb8d84f12 fix(host): retry the mode-follow encoder reopen instead of ending the session
ci / rust-arm64 (pull_request) Successful in 2m26s
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 26s
ci / web (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 9m29s
ci / rust (pull_request) Canceled after 4m46s
The reopen added in the previous commit bailed the session on the FIRST
failed `open_video`. That is worse than what it replaced: the mode-set
that triggers the reopen is exactly the kind of event that leaves the
driver settling, which is the transient the submit path's backoff already
exists for ("NVENC session open failing after a codec switch", 2026-07 —
no 8 ms retry could outlive it).

Spend the shared `encoder_resets` budget on it at the same exponential
pace (100 ms → 1.6 s), re-entering the follow-the-source guard each round.
The old encoder stays installed and mismatched meanwhile, so it simply
keeps failing submit until an open succeeds or the budget runs out — the
same ~3 s ceiling as before, but now every round is a real attempt at the
new mode instead of an in-place re-init that cannot converge.

Also tag the exhausted path accurately: it is an encoder REOPEN failure,
not a submit failure, and the session-end log prints that context.
2026-08-22 20:47:49 +02:00
enricobuehler f42aca690f fix(host): the encoder follows a game-driven display mode change
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 24s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Canceled after 5m5s
A fullscreen game can mode-set the virtual display mid-session with no
client Reconfigure. The IDD-push capturer already handles that — it
re-opens its ring at the new mode on a confirmed descriptor change — but
nothing re-opened the ENCODER, which is the one component that cannot
follow a resolution change in place.

Every submit then failed with "captured frame 1920x1080 != encoder
3840x2160", and the submit-error path only rebuilds the encoder IN PLACE
(Terminate + re-Init at the SAME configured size), which cannot fix a
size the source has already left. All five resets burned on it and the
video session ended ~3 s later, with audio still running — the client
sees a frozen picture and has to reconnect.

Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):

  IDD push: display descriptor changed — recreating the ring at the new
    mode target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
  encoder submit failed — encoder rebuilt in place, forcing an IDR
    error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
  ... reset=5 max=5
  encoder did not recover after repeated in-place rebuilds — ending the
    video session ... resets=6

Track what the encoder was opened against and, when the source delivers
something else, re-open at the delivered size through the same
`open_video` path the client-initiated resize uses — then publish the new
mode to the client exactly as an accepted resize does, so its mode slot,
stats and aspect follow. PyroWave's Automatic rate is re-resolved for the
new mode (it is a per-mode bpp pin); H.26x rates stay with ABR.

Also covers a mid-session frame-format change (an HDR flip re-creates the
ring at a new format), which failed the same way.

The GameStream/Moonlight loop has the identical gap, left alone here: that
protocol has no mid-stream mode-change message, so following the source
there needs its own decision.
2026-08-22 20:35:25 +02:00
enricobuehler 34a02fdac5 Merge pull request 'Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch' (#372) from worktree-steam-prelaunch-latch into main
ci / bun-nix (push) Successful in 51s
ci / web (push) Successful in 1m28s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m59s
deb / build-publish-gamescope (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m34s
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 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m51s
ci / rust (push) Successful in 8m36s
arch / build-publish (push) Successful in 9m41s
android / android (push) Successful in 10m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 42s
deb / smoke-install (push) Successful in 7m27s
windows-host / package (push) Successful in 17m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m11s
2026-08-22 18:32:30 +00:00
enricobuehler 8670b412c7 fix(host): Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.

`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.

Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)

Two layers, because only one of them can be certain:

* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
  `fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
  This is the rule already applied to a spawned child ("a launcher about to
  hand off looks exactly like the game for its first few seconds"); the scan
  side never had it. It bounds the pre-launch trees nobody has named yet, at
  the cost of a few seconds of GameRunning latency. Exit detection is
  untouched, and a provider report still latches immediately — that is the
  launcher's own statement, not an inference from a lookalike.

The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
2026-08-22 20:12:36 +02:00
enricobuehler 539ac2f2a5 feat(host,web): name a paired Moonlight device, because its certificate never will
android / android (pull_request) Successful in 9m22s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
ci / docs-drift (pull_request) Canceled after 0s
Reported from the field: "is there a possibility of renaming the moonlight paired
devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a
display bug — every moonlight-common-c client self-signs with that same fixed subject,
so the certificate carries no device identity at all. Until now the console listed that
string for every Moonlight row, which means a user with a phone, a TV and a Switch saw
three identical rows and had nothing but a fingerprint prefix to tell them apart — most
sharply when deciding which one to unpair.

The name is an operator-supplied label, stored host-side keyed by fingerprint:

  * `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it.
    `paired.json` is a bare `Vec<Vec<u8>>` of DERs, so giving it a shape would be a
    migration on the one file that decides who may connect — and a label is not part of
    that trust decision, so a corrupt or missing label file must never be able to lock
    anyone out. Same atomic temp-file + rename as `save_paired`.
  * `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a
    `label`. A whitespace-only body clears rather than storing a blank name, and only an
    already-paired fingerprint may be named (a label for an unknown one would be
    invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow
    without bound and a re-pairing of the same certificate starts unnamed.
  * Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second
    one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64.
    That is not cosmetic here — the label is the ONLY thing distinguishing two paired
    devices in the console, so an unscrubbed one could dress a stranger's device up as
    the operator's TV and be spared an unpair on that basis. For the same reason the new
    route takes the plugin/cert lanes of the DELETE beside it (neither may reach it),
    not the roster GET's read permission; the lane test now pins that.
  * Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded
    with the current label (not the `CN=…` fallback, or every rename would start by
    deleting boilerplate). Native rows keep their pairing-supplied name and get no
    pencil.

Test: one round trip through the API — name it, see it in the list, watch the bidi
override and the whitespace collapse get scrubbed, clear it two ways, reject a
malformed and an unpaired fingerprint, and assert the unpair forgot it on disk.

VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS):
`cargo test -p punktfunk-host mgmt::` → 58 passed, including the new
`client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that
caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes`
and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean.

Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed:
  * `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the
    UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy
    had drifted from its own pinned generator — nothing in CI regenerates or verifies
    it. This lands the clean regeneration rather than hand-patching generated code.
  * `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally
    before committing: the only differences are `PATCH /clients/{fingerprint}`, the
    `RenameClient` schema and `PairedClient.label` — no OS-driven drift.

Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor`
fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[],
error=null`, and the test's escape hatches only cover gamescope, an absent compositor or
an error. Either the test needs a Windows arm or Windows display enumeration is returning
nothing it should — that is a real question, so it is left for someone to answer rather
than papered over here.
2026-08-22 19:31:43 +02:00
enricobuehler f5a75d9edc test(devtest): drive a Windows HID pad through silence and back, to test what a Moonlight client actually does
Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on
Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd
moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified
by `cargo check` + `clippy` only, so nothing about it had ever run.

The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose
own doc says a UMDF pad "treats a multi-second input silence as an unplugged
controller", the native plane calls it every tick, and `SessionPads::pump_rumble`
does not. That asymmetry looked decisive because the two planes differ in exactly
the way that would expose it: punktfunk's own client re-sends every live pad's
snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native
pad is never silent, while moonlight-common-c sends a controller packet only on
CHANGE — an untouched pad emits nothing at all.

`--idle-after N` stops the state frames while still pumping; `--resume-after M`
starts them again, because enumeration surviving a silence proves nothing on its
own (a pad can stay listed and deliver no input) — what matters is whether a report
written after the silence still lands.

MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid
--idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with
`SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child
still present the whole time. So the heartbeat gap is NOT the field bug, and the
one-line "add a heartbeat to the GameStream arm" fix this was going to justify is
not warranted — which is the point of landing the probe rather than the guess.

Also measured with the same binary, and worth recording because it IS real:
  * two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault::
    IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
    mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one
    name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never
    collide — so the hazard is new, even if it is not what the reporter hit.
  * a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and
    3 s gaps all created their pad, so an ordinary client reconnect is not the trigger.

Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18
declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the
field (the Windows updater is a full Inno Setup run that re-runs `driver install
--gamepad`), and access grants (a Moonlight fingerprint has no grants record, which
`control.rs` reads as GRANT_ALL).

Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e.
GameStream is switched OFF, so this box has never exercised the plane dfcffcdd
changed. That is how a compile-only fix reached users unexercised, and it is the
first thing to change before the next attempt.
2026-08-22 18:58:57 +02:00
enricobuehler 430499bdab ci(flatpak): trigger on the deps-check script too
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m17s
ci / docs-drift (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m59s
android / android (pull_request) Successful in 5m18s
ci / rust (pull_request) Successful in 7m49s
flatpak-deps-present.sh decides whether the job talks to Flathub at all, and
a push-paths filter that ignores it means a change to that decision ships
untested until the next unrelated client commit happens to rebuild. Same
reason .gitea/workflows/flatpak.yml is already listed.
2026-08-22 03:35:26 +02:00
enricobuehler 92578803c2 fix(ci): stop the flatpak build updating runtimes it already has
The flatpak job died on every attempt with

    Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
    Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While
      pulling ... .filez: Server returned HTTP 404

dl.flathub.org was serving a 404 for one object of the then-current
rust-stable//25.08 commit. retry.sh burned all 10 attempts (~9 min) on the
same object, and flatpak-builder segfaulted on its own error path (rc=139),
so the wrapper could not tell a dead end from a load blip either.

Root cause is ours, not Flathub's: `--install-deps-only` does not install
what is missing, it UPDATES what is present. builder_manifest_install_dep()
branches on `flatpak info --show-commit <ref>` succeeding and runs
`flatpak update` for every already-installed dep, with no fallback to a
plain install when that update fails. ci/flatpak-ci.Dockerfile bakes the
entire runtime set, so that update was a pure no-op on a healthy run while
making every build depend on Flathub's health at that minute. Nothing wanted
the newer commit — the manifest pins a runtime VERSION, not a commit.

So ask first, and reach for Flathub only on a real miss. The check is
scripts/ci/flatpak-deps-present.sh (runtime + SDK at the manifest's exact
runtime-version, sdk-extensions by presence, since their version comes from
the SDK's metadata and any bump that moves them moves runtime-version too).
It fails OPEN: anything it cannot parse takes the full install path. Its
--self-test stubs `flatpak` and covers baked / cold / each dep missing /
wrong version / unreadable manifest.

Also drop --install-deps-from=flathub from the build step. Its comment
called it "a no-op safety net"; builder-main.c calls
builder_manifest_install_deps() whenever that flag is set, and
--install-deps-only only decides whether it exits afterwards, so the step
billed as offline was re-running the same update — and could only ever fire
if the prefetch step had already failed the job.

packaging/flatpak/build-flatpak.sh keeps --install-deps-from: a dev box
genuinely wants deps installed, and it has no baked image.
2026-08-22 03:34:51 +02:00
enricobuehler ca2ff7093a Merge pull request '0.31.2 — the address the host used, from three directions' (#369) from worktree-release-0312-prep into main
audit / docs-site-audit (push) Successful in 23s
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 26s
audit / pnpm-audit (push) Successful in 19s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / cargo-audit (push) Failing after 40s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m13s
ci / bun-nix (push) Successful in 16s
ci / docs-drift (push) Successful in 21s
audit / license-gate (push) Successful in 4m57s
audit / miri (push) Successful in 6m17s
audit / c-abi-asan (push) Successful in 6m24s
android-screenshots / screenshots (push) Successful in 1m54s
ci / rust-arm64 (push) Successful in 1m38s
ci / rust (push) Successful in 8m29s
android / android (push) Successful in 11m48s
arch / build-publish (push) Successful in 9m23s
linux-client-screenshots / screenshots (push) Successful in 4m53s
sbom / sbom (push) Successful in 33s
web-screenshots / screenshots (push) Successful in 6m46s
decky / build-publish (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
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 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 17s
nix / flake (push) Successful in 22m13s
deb / smoke-install (push) Successful in 2m52s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 5m39s
deb / build-publish-gamescope (push) Successful in 1m5s
deb / build-publish-client-arm64 (push) Successful in 2m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
apple / distribute (push) Successful in 13m6s
apple / swift (push) Successful in 2m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m22s
apple / screenshots (push) Successful in 9m40s
windows-host / package (push) Successful in 13m30s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 28s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m45s
flatpak / build-publish (push) Failing after 8m56s
2026-08-21 20:50:22 +00:00
enricobuehler a2dc011200 release: 0.31.2 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 6m30s
nix / flake (pull_request) Successful in 6m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m5s
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 16m10s
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).

THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.

The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.

api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.

That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.

The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).

Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).

Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).

Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).

One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
2026-08-21 20:04:23 +02:00
enricobuehler 48eeae7527 Merge pull request 'The fixed-port firewall rules were open to every program on the machine' (#368) from worktree-firewall-program-scoped-rules into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 21s
ci / rust-arm64 (push) Successful in 1m28s
ci / web (push) Successful in 1m41s
ci / docs-site (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 4m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 27s
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 21s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 7m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m58s
ci / rust (push) Successful in 8m7s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 2m35s
android / android (push) Successful in 10m56s
windows-host / package (push) Successful in 13m48s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m39s
2026-08-21 14:14:10 +00:00
35 changed files with 2505 additions and 203 deletions
+53 -9
View File
@@ -50,7 +50,10 @@ on:
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
# Both halves of this job's correctness, not of the bundle's content: a change to either
# can only be proven by a real run, and there is no other trigger that would give it one.
- '.gitea/workflows/flatpak.yml'
- 'scripts/ci/flatpak-deps-present.sh'
tags: ['v*']
workflow_dispatch:
@@ -270,12 +273,13 @@ jobs:
- name: Prefetch deps + sources (retried — the network phase, split off the build)
run: |
set -euo pipefail
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
# genuinely MISSING; see the block below. (No codec extension: the client links no
# FFmpeg — see the manifest header.)
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
@@ -288,9 +292,40 @@ jobs:
# for the mechanism.
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
#
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
# that flag does not install what is missing, it UPDATES what is present.
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
# and runs `flatpak update` for every dep already installed — with no fallback to a
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
# .filez: Server returned HTTP 404
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
# saw a crash rather than a clean "this will never work" either. The build never wanted
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
# one satisfies it. Updating bought nothing and imported an upstream outage.
#
# So: assert what the image already has, and reach for Flathub only on a real miss —
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
# touching it): a bug in it that reports "satisfied" when it is not would build against
# whatever runtime happened to be lying around, which is worth more than an inline
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
# install path below.
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
echo "deps satisfied by the baked image — not touching Flathub"
flatpak list --user --columns=ref
else
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
fi
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only --disable-updates \
"$PWD/build-dir" "$MANIFEST"
@@ -298,7 +333,17 @@ jobs:
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
# step needs no network.
#
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
# design is to be offline, and it could only ever fire if the prefetch step above had
# already failed the job. Dropped: the prefetch step is the one place that talks to
# Flathub, and it is the one place with retries.
#
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
@@ -326,7 +371,6 @@ jobs:
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--disable-updates \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
+193
View File
@@ -12,6 +12,199 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.31.2
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
**Nothing versioned moves, and this time nothing versioned even changes shape.** `WIRE_VERSION`
stays **2**, the C ABI stays **25**, and so do the driver protocol, the gamepad channel, the plugin
index schema and the host event schema. `include/punktfunk_core.h` is **byte-identical to the
v0.31.1 tag** — unlike the last two releases, which each added a `#define` — and `pf-driver-proto`
shows no diff either. No route is added or removed, no `#[repr(C)]` struct moves, and neither
`@punktfunk/host` (0.1.5) nor `@punktfunk/plugin-kit` (0.4.4) is re-cut. An embedder can take this
release without recompiling anything, and a packager has one thing to notice: the Windows firewall
rules below.
`api/openapi.json` changes in **documentation only** — two `description` strings on `HostInfo`, no
route, schema, field or type — plus the `info.version` stamp. That documentation change is
load-bearing, though, because it records a behaviour change: `local_ip` is now read per request.
The release is entirely fix-shaped. Three of the six non-merge commits are the same class of fault —
the host using the wrong local address — reached from three directions: the data socket's source
address (#367), the advertised address after a cold boot (#366), and the firewall rules that
admitted anyone to the ports those addresses point at (#368). The fourth is an Android regression
from v0.31.1 (#365); the remaining two are a refactor and a test in support of #366.
### Versions
| | v0.31.1 | v0.31.2 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped; `DeliveryReport` (`0x0B`) from v0.31.1 is the most recent addition and is untouched |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.1 tag — not even a constant |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.1 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged; no file under the gamepad backends is touched by this release |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.31.1 | **0.31.2** | **description-only**, plus the stamp (`info.version` is `CARGO_PKG_VERSION`). The two `HostInfo` strings that change are quoted under **`Host::local_ip`** below; no route, schema, required-field or type differs. Re-stamped here, not regenerated — `punktfunk-host` does not build on macOS; the document itself was regenerated in #366 on a runner where `openapi_document_is_complete_and_checked_in` executes. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, and `packaging/gamescope/PKGBUILD` still declares `pfhdr8` after the v0.31.1 correction |
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
| `@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, no
API-surface change. Every 0.31.x host, client, driver and plugin keeps interoperating in both
directions with no re-pairing and no rebuild.
Two **behaviour** changes that break no build but change what a machine does:
- **Windows `service install` now scopes every inbound rule to a program.** The five fixed-port
rules gain `program=<exe>` while keeping their `localport=`. If you provision firewall rules
yourself rather than letting `service install` do it, the equivalent is `program=` on each; if you
do nothing, `service install` re-runs on every upgrade and rewrites them for you. **Externally
visible:** 5353 is punktfunk's alone now, so anything else on the machine that was reachable on
mDNS through punktfunk's any-program rule needs its own rule.
- **`HostInfo.local_ip` is no longer static for the life of the process.** It was a field
snapshotted at `Host::detect()`; it is a method that re-reads on every request. A consumer that
cached it once at startup was caching a value that could be `127.0.0.1` forever (see below) and
should poll instead. The two `description` strings in `api/openapi.json` say so.
### Windows: the fixed-port firewall rules admitted any program on the machine
`service install` added `dir=in action=allow` rules carrying only `localport=`. A rule of that shape
admits **any process** that binds the port — GameStream (47984/47989/47998-48010/48010), the native
plane (9777), mgmt (47990), mDNS (5353) and the console pair (47992/47993). Binding a high port on
Windows requires no elevation, so an unprivileged program could take any of them and become
LAN-reachable simply by binding first, and **silently**: our rule is precisely what suppresses the
"Allow this app to communicate on…" prompt that would otherwise be the only way in.
Every rule is now scoped to the executable that actually listens on it, keeping the ports — program
**and** port is strictly tighter than either alone. The host rules name the host exe (resolved once
via `current_exe()` and shared with the data-plane rule, which already worked this way and is the
pattern the rest now follow); the console rules name the bundled `<app>/bun/bun.exe` the supervisor
spawns. `fw_add_rule_args` is the new single constructor for the whole shape.
The old argument for leaving them unscoped — "an install whose recorded exe path later moves still
has its fixed ports open" — does not hold: `service install` re-runs the whole remove-then-add on
every upgrade, so the path is refreshed rather than left stale.
Fallbacks are deliberate and **asymmetric**. A fixed-port rule whose program cannot be resolved
falls back to the old any-program form, because a looser rule still streams and no rule is a black
screen. The data-plane rule instead **skips**: it has no `localport=` to fall back to, so a
program-less version of it would not be a looser rule but an open host. The installer prints the
5353 note only when the scoping actually happened — claiming it while the rules are still wide open
would be worse than saying nothing.
Reported by a user on 2026-08-21, immediately after the source-IP fix below cleared their black
screen.
### The data socket binds the address the control plane arrived on
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source address from the routing
table, **independently of the address the client's control connection actually arrived on**. The
client's data socket is `connect`ed to the host IP it dialled, so its kernel drops every datagram
from any other source — in the kernel, before userspace, where nothing counts it.
On a host with two live paths to the client (Ethernet and Wi-Fi both up on the same LAN; a
VPN/overlay adapter claiming the route) that is a permanent black screen with every gauge green: the
hole-punch still arrives so the host logs `punched=true`, `loss_ppm` stays 0 because there are no
packets to see gaps in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documented the mirror of this assumption for
the *client's* source IP; the host side was never checked.
The socket now binds `Connection::local_ip()` (unmapping an IPv4-mapped v6 address so it can still
`connect` to a v4 peer), falling back to the wildcard **loudly** when that is unavailable.
Two diagnostics changed with it, because the field session's log could not answer the question:
- the `data plane bound` line carries the socket's post-`connect` `local=` address — the source the
kernel will actually stamp — and WARNs when it differs from the address the control plane arrived
on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not decode" and no longer names
`punched=false` as *the* fingerprint. It fired with `punched=true` in the field, contradicting its
own advice and sending an investigation to the firewall. It now branches on what the bring-up line
says, and admits its counter is incremented after decrypt and replay checks, so a session whose
every datagram failed to open reports the same zero as one that received nothing.
### `Host::local_ip` re-reads, and mDNS adverts follow it
`Host::detect()` snapshotted the LAN address once at process start and every consumer read that
frozen field forever. On a cold boot the host wins the race against the network — the Windows
service is registered `AutoStart` with no dependencies — so `primary_local_ip()`'s route probe to
8.8.8.8 failed with `ENETUNREACH` and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround users found.
Four surfaces broke together off that one field: both mDNS adverts (`_punktfunk._udp`,
`_nvstream._tcp`) published `127.0.0.1` as their A record; `session_url_xml()` handed Moonlight
`rtsp://127.0.0.1:48010` after `/launch`; `wol::wake_macs()` found no interface for loopback and
dropped the `mac` TXT record, silently disabling Wake-on-LAN; and `HostInfo.local_ip` reported
loopback to the web console.
Fixed at the choke point rather than per caller:
- `primary_local_ip()` never returns loopback or the unspecified address. When the route probe
fails it falls back to `first_lan_ipv4` — the first non-loopback interface address, which exists
as soon as the NIC is configured even if the default route is not installed yet, the common shape
of the boot race. It is split out so a test can assert the one thing that matters: it never hands
back the loopback `get_if_addrs` also reports.
- `Host::local_ip` becomes a method that re-reads instead of a field that freezes. A `connect(2)` on
an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is
serialized into.
- mDNS records are **pushed, not polled**: a live advert re-registers when the routed address
changes (`discovery::advertise_live`, shared by both service types). It polls the routed address
rather than subscribing to the daemon's `IpAdd` events because the boot race usually resolves
without one — the NIC often has its address before we register, and only the route lands late.
This also covers the sibling cases that were never reported: DHCP handing out a different lease, and
a host moved between Wi-Fi and Ethernet.
The re-announce loop's stop signal is now the `mpsc` channel it already sleeps on, rather than an
`Arc<AtomicBool>` plus a `Drop` impl: the `Advert` dropping its sender wakes the thread immediately
instead of leaving it to notice a flag up to `IP_RECHECK` (10 s) later.
### Android: the button correction is gated on named triggers, not declared keys
v0.31.1 corrected button positions for pads Android has no key layout for, and gated it on
`hasKeys(BUTTON_C, BUTTON_Z)`. That gate answers for what a device **declares**, not what it
reports: `hid-input` allocates `BTN_A + n` straight through for every button in the descriptor, so
`BTN_C` (`0x132`) and `BTN_Z` (`0x135`) are set on **any** pad declaring six or more buttons —
including a standard-layout pad that never presses either. The signal was therefore identical on the
pad that needs correcting and the pad that does not, and no tightening of it could have separated
them.
Two field reports on 2026-08-21 (a GameSir G8+ and an "Xbox Wireless Controller" over Bluetooth) had
X answering Y, Y answering LB, and both shoulders answering menu buttons — exactly what
`GENERIC_XBOX` does to scancodes `0x133`/`0x134`/`0x136`/`0x137`. It is the same pad model on both
sides of the bug: an Elite Series 2 needed the correction on a Fire TV, and another was broken by it
here.
What separates them is the **axes**. A HID gamepad describes its triggers either as the
Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names Android has words for — or as
two generic axes on `ABS_Z`/`ABS_RZ`, which it does not. A descriptor well-formed enough to name its
triggers puts its buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller reports GAS/BRAKE after its firmware update and Z/Rz before
it, and only the older one was ever wrong.
`padButtons` now takes `namedTriggers` and answers `NATIVE` whenever it is set — no correction of
any kind, on buttons or axes, for a pad Android already reads. `padMap` computed that fact one line
below and only ever spent it on the axes; it now decides both. `hasKeys` stays for the narrower
question it can answer — *which* straight-through order, once the axes have established there is
one — where a false positive costs nothing. This is the same discriminator Moonlight uses
(`ControllerHandler`, `gasRange == null` beside the `"Xbox Wireless Controller"` name); v0.31.1
cited its tables and then replaced its discriminator, which is where this came in.
`PadButtonsTest` is at 16 cases, 3 new: the gate holds for every vendor/declaration combination, the
four reported buttons stay themselves, and the report-order choice past the gate is unchanged.
**Not fixed here:** a DualSense report filed alongside these, with Triangle dead in both the client
UI and the stream. A button reaching neither is one `buttonBit` maps to nothing, which no branch of
the correction produces for Triangle.
---
## v0.31.1
30 commits since v0.31.0 (19 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.1"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.1"
version = "0.31.2"
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.1"
version = "0.31.2"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.1"
version = "0.31.2"
[[package]]
name = "pf-inject"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.1"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.1"
version = "0.31.2"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+95 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.1"
"version": "0.31.2"
},
"paths": {
"/api/v1/client-logs": {
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
else -> KeyEvent.KEYCODE_DPAD_RIGHT
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
/**
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
*
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
* the host is already serving 120. Every frame then waits out that mismatch, which is the
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
*/
private fun resolveHighRefreshMode() {
if (isTvDevice(this)) return
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
@@ -478,7 +478,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
// describe the same panel and must not disagree.
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
return Triple(maxOf(w, h), minOf(w, h), hz)
}
@@ -367,6 +367,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|| state.forced_screen_env
}
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
/// a host that died holding one ([`restore_takeover_on_startup`]).
///
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
fn hand_back_idled_units_after_crash() {
if box_session_live() {
return; // something is already drawing — the drop-in was inert
}
let units: Vec<String> = listed_autologin_units()
.into_iter()
.filter(|(_, active)| active == "active")
.map(|(unit, _)| unit)
.collect();
if units.is_empty() {
return;
}
tracing::warn!(
?units,
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
is dark restarting it"
);
for unit in &units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
tracing::error!(unit, status = %why, "gamescope: could not restart it");
}
}
ensure_box_session_or_escalate(&units);
}
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
@@ -399,6 +431,13 @@ pub fn restore_takeover_on_startup() {
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing"
);
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
// — and all three of those exits used to leave the box sitting on a dark panel with its
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
hand_back_idled_units_after_crash();
}
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
@@ -2984,6 +3023,48 @@ fn replay_switch_under_restored_dm(dm: &str) {
}
}
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
///
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
/// takeover then frees nothing rather than killing a session it could not see properly, and the
/// crash hand-back restarts nothing rather than bouncing one.
fn listed_autologin_units() -> Vec<(String, String)> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Vec::new();
};
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
}
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
/// holding the instance our own launch then collides with, and a dead one read as live idles a
/// session nobody was in.
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
stdout
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect()
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
@@ -3011,33 +3092,9 @@ fn replay_switch_under_restored_dm(dm: &str) {
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
fn stop_autologin_sessions() -> Result<()> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Ok(());
};
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect();
let listed = listed_autologin_units();
if listed.is_empty() {
return Ok(()); // nothing autologged in — Steam is already free
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
}
let dm = display_manager_unit();
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
@@ -3439,7 +3496,13 @@ pub fn restore_takeover_now() {
}
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
do_restore_tv_session();
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
// The next host start is what covers a shutdown that left the box dark
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
// drop-in off it).
do_restore_tv_session(false);
}
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
@@ -3503,11 +3566,168 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
})
}
/// How long a hand-back waits for the box to show something on its own panel before it starts
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
/// the ordinary start has already had its full grace and not delivered.
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
/// Poll slice for the two waits above.
const HANDBACK_POLL: Duration = Duration::from_millis(500);
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
///
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
/// gamescope cannot still be answering for the box.
fn box_session_live() -> bool {
super::detect_active_session().kind != super::ActiveKind::None
}
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
/// and every remedy below would now be fighting a live stream for the box's session.
enum HandbackWait {
Live,
Superseded,
TimedOut,
}
fn wait_for_box_session(grace: Duration) -> HandbackWait {
let deadline = Instant::now() + grace;
loop {
if takeover_live() {
return HandbackWait::Superseded;
}
if box_session_live() {
return HandbackWait::Live;
}
if Instant::now() >= deadline {
return HandbackWait::TimedOut;
}
std::thread::sleep(HANDBACK_POLL);
}
}
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
/// checks whether the restore it just performed actually WORKED.
///
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
/// not the same question as "does the box show a picture again", and the gap between the two is
/// where every "my screen stays black after disconnecting" report lives — including ones whose
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
/// f44, 2026-08-22), and say loudly at every rung what is happening.
///
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
/// ordinary restart already failed to bring back.
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
///
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
/// new one and stands down immediately.
///
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
fn ensure_box_session_or_escalate(units: &[String]) {
let units: Vec<String> = units.to_vec();
std::thread::spawn(move || handback_watch(&units));
}
fn handback_watch(units: &[String]) {
match wait_for_box_session(HANDBACK_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the box is driving its own panel again — hand-back complete"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
tracing::warn!(
secs = HANDBACK_GRACE.as_secs(),
units = ?units,
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
session with a seat",
HANDBACK_GRACE.as_secs()
);
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
for unit in units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
}
}
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the display manager relogged the box into its own session — panel back"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
// Rung 2 — put the display manager itself through a restart.
if let Some(dm) = display_manager_unit() {
tracing::warn!(
%dm,
"gamescope: the box is still dark — restarting its display manager"
);
match restore_display_manager(&dm) {
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(%dm, "gamescope: the display manager brought the box back");
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
},
Err(why) => tracing::warn!(
%dm,
shape = why.shape(),
reason = %why,
"gamescope: could not restart the display manager"
),
}
}
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
if crate::try_recover_session() {
tracing::warn!(
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
);
return;
}
tracing::error!(
units = ?units,
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
these, or `sudo systemctl restart display-manager.service`. Set \
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
);
}
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
/// [`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() {
fn do_restore_tv_session(verify: bool) {
// 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.
@@ -3574,6 +3794,9 @@ fn do_restore_tv_session() {
),
}
clear_takeover(); // A3: consumed — after the restart, not before it
if verify {
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
}
return;
}
}
@@ -3699,14 +3922,14 @@ fn do_restore_tv_session() {
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
for unit in &units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb(&["restart", &unit]) {
match issue_restore_verb(&["restart", unit]) {
RestoreVerb::Done => tracing::info!(
unit,
"restored the TV's autologin gaming session (debounce elapsed, no client)"
@@ -3731,6 +3954,12 @@ fn do_restore_tv_session() {
}
}
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
if verify {
ensure_box_session_or_escalate(&units);
}
}
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
@@ -3767,7 +3996,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
}
};
if still_due {
do_restore_tv_session();
// The disconnect restore: verified. This is the path the field reports
// are about, it is on a worker thread with no deadline over it, and a box
// left dark here stays dark until someone walks up to it.
do_restore_tv_session(true);
}
}
}
@@ -5334,12 +5566,12 @@ mod tests {
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,
nested_wrapper_script, our_wsi_layer_dir, 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, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
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,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE,
RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
use std::time::{Duration, Instant};
@@ -5538,6 +5770,39 @@ mod tests {
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
/// list-units` output from both distro families. Read the wrong column and a live gaming
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
#[test]
fn listed_units_take_the_active_column_not_the_load_column() {
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
assert_eq!(
parse_listed_units(out),
vec![
(
"gamescope-session-plus@ogui-steam.service".to_string(),
"active".to_string()
),
(
"gamescope-session-plus@steam.service".to_string(),
"inactive".to_string()
),
]
);
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
// off-by-one this pins.
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
// Anything that is not one of our template's instances is not ours to touch.
assert!(
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
.is_empty()
);
assert!(parse_listed_units("").is_empty());
}
#[test]
fn idle_dropin_replaces_exec_start_rather_than_appending() {
let body = idle_dropin_body("/usr/bin/sleep");
@@ -26,17 +26,26 @@
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pub(crate) const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
const MINTED_HWIDS: [&str; 2] = [
"ROOT\\SteamStreamingSpeakers",
"ROOT\\SteamStreamingMicrophone",
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
continue;
}
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
// all — a devnode registered by a host that died before the marker write landed. It is
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
//
// The instance prefix is what makes this safe, and it is NOT redundant with
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
if is_abandoned_mint(
&inst,
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
) {
out.push(inst);
}
}
Ok(out)
}
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
instance_id
.to_ascii_uppercase()
.starts_with("ROOT\\MEDIA\\")
&& MINTED_HWIDS
.iter()
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
}
#[cfg(test)]
mod abandoned_tests {
use super::is_abandoned_mint;
fn hw(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn adopts_our_own_unmarked_devnodes() {
// What a host that died mid-mint leaves behind, either role.
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0004",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0002",
&hw(r"ROOT\SteamStreamingSpeakers")
));
// PnP casing is not guaranteed on either half.
assert!(is_abandoned_mint(
r"root\media\0009",
&hw(r"root\steamstreamingspeakers")
));
}
#[test]
fn never_matches_valves_own_devices() {
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
// enumerated too — only the instance prefix separates them from ours.
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
&hw(r"ROOT\SteamStreamingSpeakers")
));
}
#[test]
fn never_matches_other_vendors_or_real_hardware() {
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
assert!(!is_abandoned_mint(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
&hw(r"ROOT\SteamStreamingSpeakers")
));
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
}
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
let devnode = match find_role_devnode(role)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
None => match adopt_orphan_devnode(role, &hwid)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
},
};
pe::bind_driver(&hwid, &inf)?;
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
Ok(None)
}
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
///
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
///
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
/// stick across restarts.
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
let set = pe::media_class_devs()?;
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
break; // ERROR_NO_MORE_ITEMS
}
let Some(inst) = pe::instance_id(&set, &did) else {
continue;
};
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
continue;
}
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
.iter()
.any(|h| h.eq_ignore_ascii_case(hwid))
{
continue;
}
if super::devnode_cleanup::OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
continue;
}
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
tracing::warn!(
role = role.label(),
devnode = %inst,
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
);
return Ok(Some(inst));
}
Ok(None)
}
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
result
}
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
/// non-capture id resolves to, and the pad program only ever has render endpoints.
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
MMDEV_CAPTURE_PATH
} else {
MMDEV_RENDER_PATH
}
}
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let guid = endpoint_guid_part(endpoint_id)?;
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
// the property store was denied — silently, since the caller degrades to "keeps the
// driver's default name".
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
grant_system_full_control(&path)
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
mod tests {
use super::*;
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
#[test]
fn registry_stamp_hive_follows_the_endpoint_direction() {
assert_eq!(
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
MMDEV_CAPTURE_PATH,
"the minted microphone's capture endpoint records under Capture"
);
assert_eq!(
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
MMDEV_RENDER_PATH,
);
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
}
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
/// value, and byte 23 must be the pad index.
#[test]
+47 -1
View File
@@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge");
let deck = args.iter().any(|a| a == "--deck");
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
// pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on
// CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this
// because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the
// `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit
// can look healthy on one plane and die on the other.
let idle_after: u64 = args
.iter()
.skip_while(|a| *a != "--idle-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--resume-after M` ends the silence at M seconds and drives again. That is the half that
// actually answers the question: enumeration surviving a silence proves nothing, because a pad
// can stay listed and still deliver no input. What matters is whether a report written AFTER
// the silence still reaches a consumer — check it with `win-input-matrix --watch` while this
// runs, and watch whether the timestamps start advancing again.
let resume_after: u64 = args
.iter()
.skip_while(|a| *a != "--resume-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
@@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
$label
);
let deadline = Instant::now() + Duration::from_secs(secs);
let started = Instant::now();
let mut announced_silence = false;
let mut announced_resume = false;
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
@@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
let el = started.elapsed();
let resumed =
resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after));
let silent =
idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed;
if silent && !announced_silence {
announced_silence = true;
println!(
" --- going SILENT (no more state frames, still pumping) at {}s ---",
idle_after
);
}
if resumed && !announced_resume {
announced_resume = true;
println!(
" --- RESUMING state frames at {}s (after {}s of silence) ---",
resume_after,
resume_after.saturating_sub(idle_after)
);
}
if !silent && last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
+106 -2
View File
@@ -590,6 +590,9 @@ fn watch(
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
// How long the scan has *continuously* seen something for this title — the scan-side twin of
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
let mut seen_since: Option<Instant> = None;
loop {
if cancelled() {
return;
@@ -713,12 +716,39 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
// chain of process trees, and the ones that run before the game carry the signals the game
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
// a tree that was never the game.
//
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
// flips the lease from the first to the second, permanently; when that tree then exits with
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
// League session 10 s after launch, while Steam was still compiling its shaders, and the
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
//
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
// by name); this bounds what no signal caught.
let scan_settled = if live.is_empty() {
seen_since = None;
false
} else {
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
};
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
// gets there sooner than the scan would. Not gated by the window above: a report is the
// launcher's own statement about the game, not an inference from a process that resembles
// it, so there is nothing to wait out.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
if scan_settled || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -731,6 +761,8 @@ fn watch(
title = %shared.game.title,
kind = kind.as_str(),
procs = live.len(),
// Which processes, not just how many: see [`crate::procscan::names`].
names = ?crate::procscan::names(&live),
"the launched game is running"
);
break;
@@ -2019,6 +2051,78 @@ mod tests {
);
}
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
/// game.
///
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
/// latched on that single sighting: the lease left the start phase immediately, and when the
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
/// to launch the game twice.
///
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
///
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
use std::sync::atomic::AtomicUsize;
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
// this test for entirely the wrong reason. (Same trap as the live matcher test in
// [`crate::procscan`].)
let td = tempfile::tempdir().expect("tempdir");
let stand_in = td.path().join("sleep");
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
let launch_stamp = launch_clock();
// Alive for less than the shim window — Steam's shader job, in miniature.
let mut child = std::process::Command::new(&stand_in)
.arg("3")
.spawn()
.expect("spawn the fake pre-launch tree");
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
// so the scan would call it alive forever and the exit under test never happen.
std::thread::spawn(move || {
let _ = child.wait();
});
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
PRE_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
launch_stamp,
// No child and no pid: the scan is the only signal, which is the field-report shape
// (`steam steam://rungameid/…` had already handed off and exited).
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
},
Box::new(|| {
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Matched));
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
assert_eq!(
PRE_EXITS.load(Ordering::SeqCst),
0,
"a tree that ran before the game must not end the session when it exits — this is the \
field report"
);
assert_ne!(
shared.state(),
GameState::Exited,
"the game never started, so nothing of it can have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
+100
View File
@@ -760,6 +760,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
}
}
/// Where the operator's per-client display labels persist, keyed by certificate fingerprint.
///
/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a
/// bare `Vec<Vec<u8>>` of certificate DERs — giving it a shape would be a migration on the one file
/// that decides who may connect — and a label is not part of that trust decision, so a corrupt or
/// missing label file must never be able to lock anybody out. Losing this file loses names, nothing
/// else.
///
/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME
/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity
/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical
/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint.
fn labels_path() -> Option<std::path::PathBuf> {
Some(pf_paths::config_dir().join("client-labels.json"))
}
/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would
/// otherwise race on a whole-file rewrite and silently drop one of the two names.
static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a
/// label is cosmetic, so every failure degrades to "no names" and never to an error).
pub(crate) fn load_client_labels() -> std::collections::BTreeMap<String, String> {
let Some(path) = labels_path() else {
return Default::default();
};
let Ok(raw) = std::fs::read(&path) else {
return Default::default();
};
serde_json::from_slice(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names");
Default::default()
})
}
/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored
/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree
/// regardless of how the caller cased the path parameter.
pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option<String> {
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let fp = fp_hex.to_ascii_lowercase();
let mut labels = load_client_labels();
let stored = match label {
Some(l) => {
let clean = crate::native_pairing::sanitize_device_name(l, &fp);
labels.insert(fp, clean.clone());
Some(clean)
}
None => {
labels.remove(&fp);
None
}
};
save_client_labels(&labels);
stored
}
/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the
/// file cannot grow without bound as devices come and go, and so a re-pairing of the same
/// certificate starts unnamed rather than inheriting a stranger's name.
pub(crate) fn retain_client_labels(still_paired: &[Vec<u8>]) {
use sha2::{Digest, Sha256};
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let live: std::collections::BTreeSet<String> = still_paired
.iter()
.map(|der| hex::encode(Sha256::digest(der)))
.collect();
let mut labels = load_client_labels();
let before = labels.len();
labels.retain(|fp, _| live.contains(fp));
if labels.len() != before {
save_client_labels(&labels);
}
}
/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write
/// cannot truncate it.
fn save_client_labels(labels: &std::collections::BTreeMap<String, String>) {
let Some(path) = labels_path() else { return };
if let Some(dir) = path.parent() {
let _ = pf_paths::create_private_dir(dir);
}
let bytes = match serde_json::to_vec(labels) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "serializing client labels failed");
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) {
tracing::warn!(error = %e, "persisting client labels failed (temp write)");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(error = %e, "persisting client labels failed (rename)");
let _ = std::fs::remove_file(&tmp);
}
}
#[cfg(test)]
mod host_name_tests {
use super::sanitize_display_name;
+121 -3
View File
@@ -1111,6 +1111,21 @@ fn spawn_sender(
use crate::send_pacing::percentile;
/// How long to ignore further keyframe requests after emitting one.
///
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
/// on a saturated link.
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
(frame_interval * 2).max(Duration::from_millis(100))
}
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
#[allow(clippy::too_many_arguments)]
@@ -1194,6 +1209,11 @@ fn stream_body(
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
// and a deeper ring is free pipelining the fallback would forfeit.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
// Both sites that swap `enc` re-bind `frame` with it, so this is always
// `(frame.format, frame.width, frame.height)` right after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
@@ -1273,9 +1293,9 @@ fn stream_body(
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = frame_interval * 2;
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
let mut last_keyframe: Option<Instant> = None;
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
@@ -1362,6 +1382,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc_src = (frame.format, frame.width, frame.height);
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
@@ -1375,6 +1396,82 @@ fn stream_body(
}
}
let t_cap = tick.elapsed();
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
// the virtual display, or an HDR flip changing the format), and the encoder is the one
// component that cannot follow a resolution change in place. Every `submit` below then
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
//
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
// That is the same bargain the first open above already takes whenever the captured size
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
// Taking it here too is strictly better than the alternative, which is ending every stream
// the moment a game changes mode.
if enc_src != (frame.format, frame.width, frame.height) {
match encode::open_video(
cfg.codec,
frame.format,
frame.width,
frame.height,
cfg.fps,
cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(),
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
cursor_blend, // same capture cursor mode — see the first open
cfg.slices, // client slicing ceiling — see the first open
) {
Ok(e) => {
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
negotiated = ?(cfg.width, cfg.height),
"gamestream: the capture source changed mode mid-stream — reopened the \
encoder at the delivered size (the client is not told; a strict decoder \
may not follow see the note at this guard)"
);
enc = e;
enc_src = (frame.format, frame.width, frame.height);
// A rebuilt encoder starts unconfigured — same reasons as the first open.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
// The old encoder died with its in-flight submissions — their AUs will never
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
// the capture rebuild above). Restart the stall clock for the fresh encoder and
// give it the full reset budget.
enc_inflight = 0;
encoder_resets = 0;
last_au_at = Instant::now();
}
Err(e) => {
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
// this is exactly the kind of event that leaves the driver settling, which is
// what the submit ladder's backoff exists for. Spend the shared reset budget at
// the same exponential pace, re-entering this guard each round — the old encoder
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("reopen encoder at the source's new mode");
}
let backoff = frame_interval
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"gamestream: reopening the encoder at the source's new mode failed — retrying");
next_frame = Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
}
}
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
@@ -1716,6 +1813,27 @@ mod tests {
assert_eq!(t.game.title, "/opt/game/run");
}
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
/// arrives far slower than two frame intervals and so passes the gate every time.
#[test]
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
// pre-floor window was 16.7 ms, so every request became a full IDR.
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
assert!(
at_120 >= Duration::from_millis(100),
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
);
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
assert_eq!(
keyframe_coalesce_window(Duration::from_millis(200)),
Duration::from_millis(400)
);
}
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
/// byte-identical, at a peer socket via the paced sendmmsg path.
#[test]
+8 -1
View File
@@ -55,7 +55,14 @@ pub struct DetectSpec {
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
/// sharpest signal available: Steam wraps every launch — native or Proton — in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
/// `reaper SteamLaunch AppId=<appid>`.
///
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
/// and only the last of them is the game. Reading the first as the game is what dropped a
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
/// window before believing any of them.
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker.
pub env_marker: Option<EnvMarker>,
+2 -1
View File
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
clients::list_paired_clients,
clients::unpair_all_clients
))
.routes(routes!(clients::unpair_client));
// DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above.
.routes(routes!(clients::unpair_client, clients::rename_client));
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
// build's API (and its OpenAPI document) simply has no such endpoints.
#[cfg(feature = "gamestream")]
+104 -4
View File
@@ -11,7 +11,17 @@ pub(crate) struct PairedClient {
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
///
/// Do not display this as a device name. Every moonlight-common-c client self-signs with that
/// same fixed subject, so it identifies the *protocol*, not the device — a list of paired
/// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show.
subject: Option<String>,
/// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).
///
/// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because
/// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device.
#[schema(example = "Living Room TV")]
label: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
@@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients(
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
Json(ders.iter().map(|der| client_info(der)).collect())
// One read of the label sidecar for the whole list, not one per row.
let labels = crate::gamestream::load_client_labels();
Json(ders.iter().map(|der| client_info(der, &labels)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
pub(crate) fn client_info(
der: &[u8],
labels: &std::collections::BTreeMap<String, String>,
) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
let label = labels.get(&fingerprint).cloned();
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
label,
fingerprint,
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
label,
fingerprint,
},
}
}
/// Body of `PATCH /clients/{fingerprint}` — the device's display name.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameClient {
/// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and
/// the device goes back to being listed by fingerprint alone.
///
/// Scrubbed before storage by the same sanitizer the native plane runs on device names:
/// control characters and Unicode bidi overrides are stripped (they could make one paired
/// device impersonate another in this very list), whitespace collapsed, and the result capped
/// at 64 characters.
#[schema(example = "Living Room TV")]
label: Option<String>,
}
/// Rename a paired client
///
/// Sets or clears the operator-visible display name for one paired Moonlight client. This is
/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to
/// tell paired devices apart: every moonlight-common-c client self-signs with the identical
/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable
/// only by fingerprint. The name is stored beside the pairing store and survives host restarts;
/// unpairing the device forgets it.
#[utoipa::path(
patch,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "renameClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
request_body = RenameClient,
responses(
(status = OK, description = "The client as it now reads", body = PairedClient),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn rename_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
Json(body): Json<RenameClient>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
// Only name a device that is actually paired: a label for an unknown fingerprint would be
// invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup
// only ever removes labels whose device WAS paired.
let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
let Some(der) = paired
.iter()
.find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint))
.cloned()
else {
return api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
);
};
drop(paired);
// An all-whitespace name is a cleared name, not a device called " ": the sanitizer would
// otherwise turn it into the "device <fp8>" fallback and the row would look renamed.
let wanted = body
.label
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty());
crate::gamestream::set_client_label(&fingerprint, wanted);
let labels = crate::gamestream::load_client_labels();
(StatusCode::OK, Json(client_info(&der, &labels))).into_response()
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
@@ -119,6 +214,9 @@ pub(crate) async fn unpair_client(
// restart, which now also matters below: a resurrected pairing would silently
// re-open the control port.
crate::gamestream::save_paired(&paired);
// Forget this device's display name with it, so the file can't grow without bound and a
// later re-pairing of the same certificate starts unnamed.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
@@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Resp
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
// silently re-open the control port.
crate::gamestream::save_paired(&paired);
// Nothing is paired any more, so no label can still belong to anyone.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
+186 -20
View File
@@ -819,6 +819,54 @@ async fn status_reflects_runtime_state() {
assert!(!body.to_string().contains("gcm"));
}
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
/// value back on drop even if an assertion panics.
///
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
/// while this still holds the lock.
struct ConfigDirOverride {
tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
_serial: std::sync::MutexGuard<'static, ()>,
}
impl ConfigDirOverride {
fn new() -> ConfigDirOverride {
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
// that reads or writes this variable.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
ConfigDirOverride { tmp, prev, _serial }
}
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
/// subdirectory appended.
fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
impl Drop for ConfigDirOverride {
fn drop(&mut self) {
match self.prev.take() {
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
// runs under the same serialization as the `set_var` in `new`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
// need the executor while we hold it.
@@ -828,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
// The guard restores the previous value even if an assertion below panics.
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
// serializes every test that writes or reads this variable in the binary.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
// writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
@@ -1001,6 +1030,137 @@ async fn paired_clients_list_and_unpair() {
assert_eq!(body["unpaired"], 0);
}
/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup.
///
/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight
/// devices — their certificates all carry the same subject — so "the name silently didn't stick"
/// is indistinguishable from "the device is the other one" in the console.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() {
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
let stand_in = crate::identity::ephemeral().unwrap();
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
let der = pem.contents.clone();
let fingerprint = hex::encode(Sha256::digest(&der));
{
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der.clone());
}
let patch = |fp: String, body: serde_json::Value| {
axum::http::Request::patch(format!("/api/v1/clients/{fp}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Unnamed until somebody names it — the field is absent, not an empty string.
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert!(body[0]["label"].is_null());
// Name it (uppercase fingerprint must match too — the path is documented case-insensitive).
let (status, body) = send(
&app,
patch(
fingerprint.to_uppercase(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["label"], "Living Room TV");
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body[0]["label"], "Living Room TV");
// The scrub runs: a bidi override could make one paired device read like another in the very
// list an operator uses to decide what to unpair, and the whitespace collapse keeps the name
// one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.)
let (_, body) = send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }),
),
)
.await;
assert_eq!(body["label"], "Deckevil x");
// Whitespace-only clears rather than storing a device called " " (or the sanitizer's
// "device <fp8>" fallback, which would look like a successful rename).
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": " " })),
)
.await;
assert!(body["label"].is_null());
// …and an explicit null clears too.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Bedroom" }),
),
)
.await;
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": null })),
)
.await;
assert!(body["label"].is_null());
// Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not
// paired would write a label nothing can ever list or clean up).
assert_eq!(
send(
&app,
patch("zz".into(), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
send(
&app,
patch("aa".repeat(32), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::NOT_FOUND
);
// Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of
// the same certificate.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}"))
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let on_disk: std::collections::BTreeMap<String, String> =
std::fs::read(tmp.path().join("client-labels.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
assert!(
!on_disk.contains_key(&fingerprint),
"unpair must forget the device's label, got {on_disk:?}"
);
}
#[cfg(feature = "gamestream")]
#[tokio::test]
async fn submit_pin_validates_and_requires_pending_pairing() {
@@ -1378,6 +1538,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// roster's read permission must never carry over to emptying it.
("DELETE", "/api/v1/clients", false, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
// Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than
// the roster's read permission: the label is the only thing distinguishing one paired
// Moonlight device from another in the console, so anything that could set it could dress
// its own device up as the operator's TV — and be trusted, or spared an unpair, on that
// basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway.
("PATCH", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
("DELETE", "/api/v1/native/clients", false, false),
(
+111
View File
@@ -1875,6 +1875,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
mut cur_display_gen,
built_bitrate,
) = pipe;
// What `enc` was opened against. The capture source can change format/size UNDER this loop with
// no client `Reconfigure` at all — the IDD-push capturer re-opens its ring on a confirmed
// display-descriptor change (a fullscreen game mode-setting the virtual display, an HDR flip) —
// and every backend's `submit` then refuses the frame. Tracked so the loop can FOLLOW the
// source (see the guard in the submit path) instead of dying against an error no in-place
// encoder reset can fix. Every site below that swaps `enc` re-binds `frame` with it, so this is
// always `(frame.format, frame.width, frame.height)` immediately after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// The display exists now, so the portal has answered: settle the cursor plan against what it
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
@@ -2613,6 +2621,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
cur_mode = new_mode;
next = std::time::Instant::now();
enc_src = (frame.format, frame.width, frame.height);
// H2/H3: the backend may have honored a different mode than requested — KWin caps
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
// running monitor doesn't advertise and the host falls back to the actual display
@@ -2695,6 +2704,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
trace.as_ref(),
true,
) {
enc_src = (frame.format, frame.width, frame.height);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
@@ -3388,6 +3398,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
interval = new_interval;
cur_node_id = new_node_id;
cur_display_gen = new_display_gen;
enc_src = (frame.format, frame.width, frame.height);
// The rebuild re-ran `create`, so the portal answered again — possibly a different
// backend's portal (the retarget above), possibly with a different verdict. Settle
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
@@ -3650,6 +3661,106 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// exactly that volume, so host apps already tone-mapped the content into it and the honest
// mastering description IS the client's panel. (The IDD capturer only knows the generic
// baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.)
// Follow an AUTONOMOUS source change — one no client `Reconfigure` announced. The IDD-push
// capturer re-opens its ring on a confirmed display-descriptor change: a fullscreen game
// mode-setting the virtual display (2026-08-22 field report: a 4K60 HEVC session, the game
// switched the display to 1080p mid-play), or an HDR flip changing the frame format. The
// encoder is the one component that cannot follow that in place (same note as
// `try_inplace_resize`), so every `submit` below refuses the frame — and the submit-error
// path only rebuilds the encoder IN PLACE, at the SAME configured size, which cannot fix a
// size the source has already left. All five resets burn on it and the session ends while
// audio keeps running. Reopen at what the source actually delivers instead; the client
// learns the new mode from the `Reconfigured` below and its decoder from the opening IDR.
if enc_src != (frame.format, frame.width, frame.height) {
let actual = delivered_mode(frame.width, frame.height, interval);
// Same per-mode pin the client-initiated resize re-resolves: PyroWave's Automatic rate
// IS a function of the mode, so carrying the old one across a source-driven mode change
// hands it the wrong operating point. H.26x rates are mode-independent (ABR owns them),
// and an explicit client rate is never second-guessed.
let src_kbps = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &actual, plan.chroma, plan.bit_depth)
} else {
bitrate_kbps
};
let opened = crate::encode::open_video(
plan.codec,
frame.format,
frame.width,
frame.height,
actual.refresh_hz,
src_kbps as u64 * 1000,
frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
)
.with_context(|| {
format!(
"the capture source changed to {}x{} {:?} mid-session and the encoder could not \
be reopened at it",
frame.width, frame.height, frame.format
)
});
let mut new_enc = match opened {
Ok(e) => e,
Err(e) => {
// Don't spend the session on the FIRST failed open. The mode-set that triggered
// this is exactly the kind of event that leaves the driver settling — the same
// transient the submit path's backoff exists for ("NVENC session open failing
// after a codec switch", 2026-07) — so spend the shared reset budget on it at
// the same exponential pace, re-entering this guard each round. The old encoder
// is still installed and still mismatched; it simply keeps failing submit until
// an open succeeds or the budget runs out.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("encoder reopen at the source's new mode");
}
let backoff = std::cmp::max(
interval,
std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)),
);
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"reopening the encoder at the source's new mode failed — retrying");
next = std::time::Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
};
if let Some(c) = plan.wire_chunk {
new_enc.set_wire_chunking(c);
}
// A rebuilt encoder starts with the ring bound unset — re-report it, as every other
// rebuild site does, or an in-place backend can encode a texture the capturer has
// already rotated and overwritten.
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
"the capture source changed mode mid-session with no client reconfigure — reopened \
the encoder at the delivered size"
);
enc = new_enc;
enc_src = (frame.format, frame.width, frame.height);
adopt_built_bitrate(&mut bitrate_kbps, src_kbps, &live_bitrate, &retarget_tx);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
// A fresh encoder opens on an IDR — anchor the cooldown.
last_forced_idr = Some(std::time::Instant::now());
// The client's mode slot still says the old size, and its stats/aspect follow it.
// Publish what it is really decoding now, exactly as an accepted resize does.
live_mode.store(
pack_mode(actual.width, actual.height, actual.refresh_hz),
Ordering::Relaxed,
);
let _ = reconfig_result_tx.send(Reconfigured {
accepted: true,
mode: actual,
});
}
let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m));
enc.set_hdr_meta(hdr_meta);
let mut resend_meta = hdr_meta != last_hdr_meta;
+20
View File
@@ -87,6 +87,26 @@ pub fn resolve(pid: u32) -> Option<ProcRef> {
}
}
/// Short names for the processes a lease adopted, in `procs` order.
///
/// Diagnostics only — nothing decides anything on these, and they are deliberately not part of
/// [`ProcRef`], which is compared for equality. They exist because a launch that adopted the game
/// and a launch that adopted a *pre-launch* tree logged identically (`procs=1`), which is what left
/// the 2026-08-22 field report unclosable from its log: the one question worth asking of that line
/// is which process the lease latched onto.
pub fn names(procs: &[ProcRef]) -> Vec<String> {
#[cfg(any(target_os = "linux", windows))]
{
let scanner = Scanner::system();
procs.iter().map(|p| scanner.name_of(*p)).collect()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = procs;
Vec::new()
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
+67 -1
View File
@@ -126,6 +126,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's `comm` — its short name, as `ps` shows it. Diagnostics only (see
/// [`super::names`]); `?` for a process that has already gone, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
std::fs::read_to_string(self.root.join(p.pid.to_string()).join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
@@ -180,16 +188,29 @@ impl Scanner {
if let Some(tok) = steam_tok {
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
//
// …with one exception, because the reaper is *not* only the game's: Steam wraps its
// shader pre-caching for a title in the same `SteamLaunch AppId=<appid>` reaper it
// wraps the game in, so that job satisfies this recipe exactly while the game has
// not started yet. Adopting it points the lease at a tree that exits when the
// compile finishes, which reads as the game exiting — on Linux that dropped a
// Rocket League stream 10 s into a launch, mid-"Processing Vulkan shaders", and the
// player had to launch a second time to get a session that stayed up (field report
// 2026-08-22). The payload names itself: `fossilize_replay` is Steam's replayer and
// is never a game.
let mut launch = false;
let mut appid = false;
let mut shader = false;
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == tok.as_bytes() {
appid = true;
} else if program_name(arg) == b"fossilize_replay" {
shader = true;
}
}
if launch && appid {
if launch && appid && !shader {
return true;
}
}
@@ -247,6 +268,15 @@ impl Scanner {
}
}
/// The last `/`-separated component of an argv entry — the program's own name, when the entry is a
/// path to one. Bytes rather than `str` because an argv entry is not required to be UTF-8.
fn program_name(arg: &[u8]) -> &[u8] {
match arg.iter().rposition(|&b| b == b'/') {
Some(i) => &arg[i + 1..],
None => arg,
}
}
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
/// or the file is unreadable — both routine during a scan.
fn read_capped(path: &Path) -> Option<Vec<u8>> {
@@ -472,6 +502,42 @@ mod tests {
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
}
/// The 2026-08-22 field report: Steam's **shader pre-caching** runs under the game's own
/// `SteamLaunch AppId=` reaper, so it satisfies the appid recipe while the game has not started.
///
/// Adopting it is what dropped a Rocket League stream 10 s into a launch — the lease called that
/// tree the game, and its exit (the compile finishing) the game exiting. The reaper's payload is
/// the whole tell, and it is only ever Steam's replayer.
#[test]
fn steam_shader_pre_caching_is_not_the_game() {
let td = fake_proc_root(
1000.0,
&[
// The shader job for this very appid — the game is still being brought up.
FakeProc::new(35, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/SteamLinuxRuntime/fossilize_replay",
"/home/p/.steam/steamapps/shadercache/252950/fozpipelinesv6/steamapprun_pipeline_cache.foz",
]),
// The game itself, same appid, same reaper. This one IS the game.
FakeProc::new(36, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/Proton/proton",
"waitforexitandrun",
"/home/p/.steam/steamapps/common/rocketleague/RocketLeague.exe",
]),
],
);
let s = scanner(td.path());
assert_eq!(pids(s.find(&DetectSpec::steam(252_950), None)), vec![36]);
}
#[test]
fn matches_env_marker_by_exact_value_or_presence() {
let td = fake_proc_root(
@@ -120,6 +120,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's image file name. Diagnostics only (see [`super::names`]); `?` for a process
/// that has already gone or cannot be opened, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
process_start_and_image(p.pid)
.and_then(|(_, image)| image.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
+15 -3
View File
@@ -17,9 +17,12 @@ list; the install guides quote the one or two lines that apply to each distro.
one](/docs/web-console#two-ports-not-one)).
- **`punktfunk-gamestream`** is needed only once you turn on Moonlight compat
(`PUNKTFUNK_GAMESTREAM=1` in `host.env` — [Moonlight](/docs/moonlight)).
- **Video needs nothing opened.** The data plane uses an ephemeral UDP port the *client* opens with a
hole-punch; the host streams back through the path the client opened, so only outbound UDP has to
be allowed (the default in both ufw and firewalld).
- **Video needs nothing opened on Linux.** The data plane uses an ephemeral UDP port the *client*
opens with a hole-punch; the host streams back through the path the client opened, so only
outbound UDP has to be allowed (the default in both ufw and firewalld). **Windows is the
exception** — it drops the client's hole-punch, which is why `service install` adds an inbound UDP
rule scoped to the host executable rather than to a port number (no fixed rule can cover a port
chosen fresh each session).
## Enabling the profiles
@@ -40,6 +43,15 @@ Stock Arch and Debian ship no firewall; Ubuntu installs ufw but leaves it inacti
and most Fedora-family spins run firewalld; CachyOS enables ufw. On **NixOS** the module's
`openFirewall = true` does all of this; on **Windows** the installer registers the rules.
<Callout type="warn">
**Windows: the rules are scoped to Punktfunk from 0.31.2 on.** Each rule names the executable that
listens on it as well as the port, so those ports are open to Punktfunk rather than to anything on
the machine that binds them first — before 0.31.2 they named only the port. The one thing this can
change for you is **5353**: if something else on that PC relied on Punktfunk's rule to answer
discovery, it now needs a rule of its own. `service install` re-applies the rules on every upgrade,
so a normal update is enough.
</Callout>
## Moving a port
Two are configurable, and both are how you share a machine with another streaming host — see
+95 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.1"
"version": "0.31.2"
},
"paths": {
"/api/v1/client-logs": {
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
+42
View File
@@ -0,0 +1,42 @@
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, and most of it continues the hunt 0.31.1 started: a stream that connects, reports itself perfectly healthy at every gauge, and shows you a black screen. Two more causes end here, and both are about which of your host's addresses it used — video that left the host by whichever network connection the machine happened to prefer rather than the one your client actually dialled, and a host that started up faster than its own network and then spent the rest of the day telling everyone to connect to an address that only ever means "this machine". The other half is Windows security: the firewall rules Punktfunk installs named ports but not programs, which left those ports open to anything running on that PC — they name Punktfunk now, and that is the one change here that can affect another program on the same machine. On Android, the controller fix from 0.31.1 turned out to be firing on controllers that never needed it, breaking buttons that had been correct all along.
## TL;DR
- **A host with two ways to reach your client streamed into a black screen.** Ethernet and Wi-Fi both connected, or a VPN adapter installed, was enough: the video left by whichever one the machine preferred, and your client discarded every packet of it. Nothing else about the session was wrong, which is exactly why it was so hard to see.
- **A host that started before its network was ready never recovered.** It advertised an address meaning "this machine", so clients listed it and could not connect to it, Moonlight-compatible sessions could not stream, and Wake-on-LAN silently stopped working. Restarting the host was the only cure; now there is nothing to cure.
- **Windows: Punktfunk's firewall rules were open to every program on your PC.** They named only port numbers, so any program — with no administrator rights and no prompt — could take one of those ports and be reachable from your network through a rule meant for Punktfunk. **Read *Before you update*: one thing on that machine may now need a rule of its own.**
- **Android: 0.31.1's controller fix broke controllers that were already correct.** On a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y, Y answered the left shoulder, and both shoulders answered menu buttons.
- **Your host now follows its own address when it changes** — a new lease from your router, or a machine moved between Wi-Fi and Ethernet — instead of announcing the address it had at startup forever.
## Before you update
- **Windows hosts: run the installer rather than replacing the program by hand.** The firewall fix rewrites the rules Punktfunk installs, and that happens in the host's own service-install step, which the installer runs for you on every update. If you run the host some other way, run `punktfunk-host service install` once as an administrator. Skipping it leaves the old wide-open rules in place; nothing on the client side needs doing.
- **Windows hosts: something else on that machine may need its own firewall rule now.** Punktfunk's rule for the discovery port (5353) used to be open to every program, so anything else on that PC that answers discovery — another streaming host, a media server, a printer or scanner utility — could have been reachable through Punktfunk's rule without ever having one of its own. That ends with this release. If something else on the machine stops being discoverable after you update, give it its own rule. The installer prints a note saying exactly this while it works.
## Improved
- **Your host keeps up with its own address instead of freezing it at startup.** The address a host publishes for clients to dial was worked out once, when the host process started, and then never looked at again. Now it is re-read as things change and the published address is updated to match — so a new lease from your router, or a laptop host carried from Wi-Fi to Ethernet, no longer leaves your clients dialling somewhere the host has not been for hours. This is the general form of the cold-boot fix below, and it covers the cases nobody had got round to reporting yet.
- **When a stream does go black, the host's log now tells you the truth about it.** The message it printed used to name a cause with real confidence — and was wrong often enough to send people to the wrong place entirely, including at least one investigation that went to the firewall while the actual fault was the network card. It no longer asserts a cause it cannot know, and it now records which network connection the video is actually leaving by, and says so plainly when that is not the one the client arrived on. That single line is what turns the black screen above from a mystery into something a log answers.
## Fixed
- **A host with more than one live path to your client sent the video down the wrong one, and you got a black screen with every indicator green.** Two network connections up on the same network — the very common Ethernet-and-Wi-Fi-both-on — or a VPN or overlay adapter that claims to know a better route, and the host let the machine choose which one the video left by. That choice was made with no reference at all to how your client had reached it. Your client only listens for video from the address it dialled, so it threw away everything arriving from the other one, in the part of the system that counts nothing and reports nothing. Meanwhile the connection was made, sound and controller input flowed perfectly, and the host's own loss figure sat at zero — because loss is measured over packets that arrived, and none did. The host now sends the video from the same address the client reached it on.
- **A host that started before its network was ready advertised itself as unreachable, and stayed that way until it was restarted.** Cold-booting a machine is a race, and the host wins it: it starts without waiting for the network, asks which address it should publish, gets no answer because there is no network yet, and falls back to the address that means "this machine and nothing else". Then it kept that answer for the entire life of the process. Everything that reads that address broke together — the host appeared in your client's list but could not be connected to, Moonlight-compatible sessions were handed the same useless address after launching a game, Wake-on-LAN quietly stopped working because the host could no longer identify the network hardware to record for it, and the web console displayed the wrong address to anyone who looked. Users found the workaround themselves, which was to restart the host once the machine had settled. The host now refuses that fallback answer entirely: if the usual method cannot say which address to use, it takes the first real network address it can find, which exists as soon as the network card is configured — well before the machine finishes working out how to route anything.
- **Windows: the firewall rules Punktfunk installs opened those ports to every program on the machine, not to Punktfunk.** Each rule named a port and nothing else, and a rule like that admits whatever is listening on that port — Punktfunk or otherwise. Nothing about it required administrator rights to exploit: taking a high-numbered port on Windows needs no privileges at all, so any program that started first could sit on one of Punktfunk's ports and be reachable from your whole network. Worse, it happened without any of the usual signs, because the pop-up asking whether to let a program communicate on your network is precisely what a matching rule suppresses — Punktfunk's rule was answering that question on another program's behalf. Every rule now names the program that is genuinely meant to be listening on it, and keeps the port restriction as well, so both must match. The affected ports were the streaming, discovery, management and console ports. If Punktfunk cannot work out its own location on disk it keeps the old broader rule rather than leaving you with no rule at all, since a rule that is too generous still streams and a missing one is a black screen.
- **Android: controllers that had always worked started pressing the wrong buttons.** This is a regression from 0.31.1, reported the same day on a GameSir G8+ and an Xbox Elite Series 2: X answered Y, Y answered the left shoulder, and the two shoulder buttons answered menu buttons, with everything else correct. 0.31.1 fixed controllers Android has no layout file for by reading each button's position in the controller's own report instead of trusting Android's guess — the right fix, applied to too many controllers. It decided which controllers needed it by asking what the device *claimed* to have, and that claim turns out to be true of any controller with six or more buttons, including every controller that was already perfectly correct. So it corrected pads that needed no correcting, and moved their buttons off the marks. It is now decided on the triggers instead: a controller that describes its triggers properly is one Android has a real layout for, and it is left completely alone — no correction to its buttons or its sticks. That is the same signal Moonlight uses for the same decision, and it matches the reports precisely, down to the fact that the very same model needed correcting on a Fire TV and was broken by it here: an Xbox Wireless Controller describes its triggers one way after a firmware update and the other way before it, and only the older one was ever wrong.
## Known issue
- **A DualSense with a dead Triangle button is not fixed here.** It was reported alongside the two controllers above and looks related, but it is not the same fault — Triangle reaching neither the stream nor Punktfunk's own controller display is a different failure from a button arriving as the wrong one, and nothing in the fix above produces it. The Connected controllers page prints exactly what each press reports; that line from an affected pad is what will pin it down.
## Thanks
Every fix in this release came from someone reporting what actually happened rather than what they assumed. Both black-screen causes were found in field logs from hosts that looked entirely healthy — and the firewall hole was reported by a user on the same day, immediately after the first of those fixes cleared their black screen and left them looking at the rules. The Android controller regression came back within a day of the release that caused it, from two people who named which button answered which, which is the difference between a report that can be fixed and one that can only be believed. Thank you.
## 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.2/CHANGELOG.md).
The short version: nothing versioned moves at all. The streaming protocol, the embedding interface, the driver protocol, the gamepad channel and the add-on contract are exactly where 0.31.1 left them, no message or function changed shape, and no header, package or plugin needs rebuilding, re-pairing or re-publishing in any direction. Two things worth knowing: on Windows the firewall rules provisioned at install are now scoped to the executable that listens on each port, which is the one change here that can affect another program on the same machine; and the host's reported address in the management API is now read fresh on every request instead of being fixed for the life of the process, so poll it rather than caching it.
+2
View File
@@ -0,0 +1,2 @@
• Fixes controllers pressing the wrong buttons after the last update — on a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y and both shoulders answered menu buttons.
• The button correction from the last release now applies only to controllers Android has no layout for, so a pad that worked before this update is left exactly as it was.
+111
View File
@@ -0,0 +1,111 @@
# shellcheck shell=bash
# Does this box already have every Flathub dep a flatpak manifest declares?
# bash scripts/ci/flatpak-deps-present.sh <manifest.yml> -> exit 0 = yes, 1 = no
# bash scripts/ci/flatpak-deps-present.sh --self-test -> run the asserts below
#
# WHY THIS EXISTS: flatpak.yml used to prefetch deps with `flatpak-builder --install-deps-only`,
# which does NOT mean "install what is missing". builder_manifest_install_dep() branches on
# `flatpak info --show-commit <ref>` succeeding and runs `flatpak update` for every dep that IS
# installed (a failed update is fatal there — it never falls back to install) — and
# ci/flatpak-ci.Dockerfile bakes the whole runtime set, so on a healthy run that flag did nothing
# except make the build depend on Flathub being up at that minute. On 2026-08-22 it took the job
# down: dl.flathub.org returned HTTP 404 for one .filez object of the then-current
# rust-stable//25.08 commit, identically on all 10 retry.sh attempts (~9 min), and flatpak-builder
# segfaulted on its own error path (rc=139) so the retry wrapper could not tell a dead end from a
# blip. Nothing about the build wanted that newer commit: the manifest pins a runtime VERSION, not
# a commit, and the baked one satisfies it.
#
# So the workflow asks this first and only reaches for Flathub on a real miss.
#
# FAILS OPEN, deliberately: an unreadable/unexpected manifest reports "not present" (1), so the
# caller does the full install. Silently skipping the install on a manifest we stopped
# understanding is how you build against the wrong runtime.
set -uo pipefail
deps_present() {
local manifest="$1" runtime rt_ver sdk exts e
runtime=$(sed -n 's/^runtime: *//p' "$manifest" | head -1)
rt_ver=$(sed -n 's/^runtime-version: *//p' "$manifest" | tr -d "\"'" | head -1)
sdk=$(sed -n 's/^sdk: *//p' "$manifest" | head -1)
exts=$(sed -n '/^sdk-extensions:/,/^[^ #-]/p' "$manifest" | sed -n 's/^ *- *//p')
[ -n "$runtime" ] && [ -n "$rt_ver" ] && [ -n "$sdk" ] && [ -n "$exts" ] || return 1
flatpak info --user "$runtime//$rt_ver" >/dev/null 2>&1 || return 1
flatpak info --user "$sdk//$rt_ver" >/dev/null 2>&1 || return 1
# Extensions are checked for PRESENCE, not version: flatpak-builder resolves their version from
# the SDK's own metadata (it prints "Dependency Extension: … 25.08"), never from the manifest.
# Any bump that moves them moves runtime-version too, which the two checks above already catch.
for e in $exts; do
flatpak info --user "$e" >/dev/null 2>&1 || return 1
done
}
self_test() {
local rc fails=0 full
# NOT `local`: the EXIT trap fires after this function has returned.
SELFTEST_TMP=$(mktemp -d) || return 1
trap 'rm -rf "$SELFTEST_TMP"' EXIT
local tmp="$SELFTEST_TMP"
cat > "$tmp/ok.yml" <<'YML'
runtime: org.gnome.Platform
runtime-version: '50'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: punktfunk-client
YML
# A manifest this script cannot read (the fail-open case).
printf 'app-id: io.unom.Punktfunk\n' > "$tmp/unparseable.yml"
# Stub `flatpak`: $INSTALLED is the newline-separated set of refs it admits to having.
mkdir -p "$tmp/bin"
cat > "$tmp/bin/flatpak" <<'STUB'
#!/usr/bin/env bash
# only `flatpak info --user <ref>` is exercised here
# args are: info --user <ref>
[ "$1" = info ] || exit 0
printf '%s\n' "$INSTALLED" | grep -qxF "$3"
STUB
chmod +x "$tmp/bin/flatpak"
PATH="$tmp/bin:$PATH"
check() { # <expected rc> <label> <installed set> <manifest>
INSTALLED="$3" deps_present "$4"; rc=$?
if [ "$rc" != "$1" ]; then
echo "FAIL: $2 (expected rc=$1, got $rc)" >&2; fails=$((fails + 1))
else
echo "ok: $2"
fi
}
full='org.gnome.Platform//50
org.gnome.Sdk//50
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20'
check 0 "everything baked -> skip Flathub" "$full" "$tmp/ok.yml"
check 1 "cold box -> install" "" "$tmp/ok.yml"
check 1 "runtime missing -> install" "${full/org.gnome.Platform\/\/50/x}" "$tmp/ok.yml"
check 1 "sdk missing -> install" "${full/org.gnome.Sdk\/\/50/x}" "$tmp/ok.yml"
# The regression that started all this: llvm20 fine, rust-stable not.
check 1 "one sdk-extension missing -> install" "${full/*.rust-stable/x}" "$tmp/ok.yml"
# A runtime installed at ANOTHER version must not pass just because the name matches.
check 1 "runtime at the wrong version" 'org.gnome.Platform//51
org.gnome.Sdk//51
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20' "$tmp/ok.yml"
check 1 "unreadable manifest -> fail open" "$full" "$tmp/unparseable.yml"
[ "$fails" = 0 ] || { echo "$fails check(s) failed" >&2; return 1; }
echo "all checks passed"
}
case "${1:---help}" in
--self-test) self_test ;;
--help|-h) sed -n '2,4p' "$0"; exit 2 ;;
*) deps_present "$1" ;;
esac
+379 -62
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -119,6 +119,7 @@
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"action_unpair_all": "Alle entkoppeln",
"action_rename": "Umbenennen",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
@@ -246,6 +247,10 @@
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_name": "Name",
"clients_fingerprint": "Fingerabdruck",
"clients_rename_title": "Gerät umbenennen",
"clients_rename_body": "Moonlight-Clients melden sich alle gleich, deshalb vergibst du diesen Namen selbst. Leer lassen, um ihn zu entfernen.",
"clients_rename_label": "Anzeigename",
"clients_rename_failed": "Gerät konnte nicht umbenannt werden",
"pairing_title": "Kopplung",
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
+5
View File
@@ -119,6 +119,7 @@
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"action_unpair_all": "Unpair all",
"action_rename": "Rename",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
@@ -246,6 +247,10 @@
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_name": "Name",
"clients_fingerprint": "Fingerprint",
"clients_rename_title": "Rename device",
"clients_rename_body": "Moonlight clients all identify themselves the same way, so this name is yours to set. Leave it empty to remove it.",
"clients_rename_label": "Display name",
"clients_rename_failed": "Could not rename the device",
"pairing_title": "Pairing",
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
+64 -4
View File
@@ -1,10 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { SlidersHorizontal, Trash2 } from "lucide-react";
import { Pencil, SlidersHorizontal, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useRenameClient,
useUnpairAllClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
@@ -40,8 +41,18 @@ export type PairedProtocol = "native" | "moonlight";
export interface PairedRow {
protocol: PairedProtocol;
fingerprint: string;
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
/**
* What to show in the Name column. Native devices carry a name from pairing; a Moonlight client
* shows its operator-given label if it has one, and otherwise falls back to its cert subject
* which is the same fixed string for every Moonlight client alive, hence [`label`].
*/
name: string;
/**
* The operator-assigned label, Moonlight rows only `null` when the device has never been
* named. Distinct from `name` because the rename dialog must open on the label alone: seeding
* it with the `CN=…` fallback would make every rename start by deleting boilerplate.
*/
label?: string | null;
/**
* Access fields native rows only, and only from hosts that have them (the console pairs
* against older hosts: all four stay `undefined` then, and the Access column shows "—").
@@ -67,13 +78,14 @@ const hasAccess = (r: PairedRow): boolean =>
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const { confirm, promptText } = useDialogs();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
const unpairMoonlight = useUnpairClient();
const unpairAllNative = useUnpairAllNativeClients();
const unpairAllMoonlight = useUnpairAllClients();
const renameMoonlight = useRenameClient();
const patchAccess = useUpdateNativeClientAccess();
// One clock for every countdown in the card AND the sheet — recomputed client-side from
// `expires_unix`, so the tick never refetches anything.
@@ -97,7 +109,8 @@ export const PairedDevicesSection: FC = () => {
(c): PairedRow => ({
protocol: "moonlight",
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}),
),
];
@@ -129,6 +142,32 @@ export const PairedDevicesSection: FC = () => {
}
};
/**
* Name a Moonlight device. Every Moonlight client presents the identical certificate subject,
* so without this the list is a column of `CN=NVIDIA GameStream Client` rows and the only way
* to tell a phone from a TV or to know which one you are about to unpair is the
* fingerprint. Submitting an empty field clears the name (the host reads that as "unnamed"),
* which is why cancel (`null`) and empty are handled differently here.
*/
const onRename = async (row: PairedRow) => {
const next = await promptText({
title: m.clients_rename_title(),
description: m.clients_rename_body(),
label: m.clients_rename_label(),
defaultValue: row.label ?? "",
confirmLabel: m.action_rename(),
});
if (next === null) return;
renameMoonlight.mutate(
{ fingerprint: row.fingerprint, data: { label: next.trim() || null } },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
onError: () => toast.error(m.clients_rename_failed()),
},
);
};
const savedAccess = () => {
setEditing(null);
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
@@ -218,6 +257,7 @@ export const PairedDevicesSection: FC = () => {
expiresUnix: r.expiresUnix,
})
}
onRename={onRename}
onUnpair={onUnpair}
onUnpairAll={onUnpairAll}
pendingFingerprint={pendingFingerprint}
@@ -246,6 +286,11 @@ export const PairedDevices: FC<{
nowUnix: number;
/** Open the access editor for a native row (only offered where `hasAccess`). */
onEditAccess: (row: PairedRow) => void;
/**
* Name a Moonlight row. Offered only on those: a native device already carries the name it gave
* at pairing, while a Moonlight certificate carries nothing that identifies the device at all.
*/
onRename: (row: PairedRow) => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
/** Unpair every row, behind one confirmation. */
onUnpairAll: () => void;
@@ -260,6 +305,7 @@ export const PairedDevices: FC<{
refetch,
nowUnix,
onEditAccess,
onRename,
onUnpair,
onUnpairAll,
pendingFingerprint,
@@ -342,6 +388,20 @@ export const PairedDevices: FC<{
</TableCell>
<TableCell>
<div className="flex justify-end">
{r.protocol === "moonlight" && (
<Button
variant="ghost"
size="icon"
aria-label={m.action_rename()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
}
onClick={() => onRename(r)}
>
<Pencil className="size-4" />
</Button>
)}
{hasAccess(r) && (
<Button
variant="ghost"
+3 -1
View File
@@ -29,7 +29,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page
@@ -84,6 +85,7 @@ export const Armed: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3 -1
View File
@@ -25,7 +25,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Per-client access states, separate from Pages/Pairing: these stories render single components
@@ -106,6 +107,7 @@ export const AccessColumn: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3
View File
@@ -120,6 +120,9 @@ export const pairedClients: PairedClient[] = [
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
// Named by the operator — the row that shows what a rename buys you next to a sibling that
// still reads as its (identical-for-everyone) certificate subject.
label: "Living Room TV",
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},