Compare commits

...
Author SHA1 Message Date
enricobuehler 5e30805490 Merge pull request '0.31.3 — the launch that dropped, the refresh a TV never output, and the probe that choked the link' (#386) from worktree-release-0313-recut into main
audit / bun-audit (web) (push) Successful in 22s
audit / docs-site-audit (push) Successful in 22s
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / cargo-audit (push) Successful in 42s
ci / rust-arm64 (push) Successful in 3m53s
audit / license-gate (push) Successful in 5m41s
audit / miri (push) Successful in 6m13s
ci / docs-site (push) Successful in 1m7s
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 2m7s
ci / docs-drift (push) Successful in 28s
audit / c-abi-asan (push) Successful in 8m9s
android-screenshots / screenshots (push) Successful in 2m26s
ci / rust (push) Successful in 9m55s
sdk-publish / publish (push) Successful in 49s
linux-client-screenshots / screenshots (push) Successful in 4m29s
arch / build-publish (push) Successful in 9m52s
sbom / sbom (push) Successful in 39s
web-screenshots / screenshots (push) Successful in 6m50s
decky / build-publish (push) Successful in 1m4s
docker / builders-arm64cross (push) Successful in 13s
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
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 12s
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 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / deploy-docs (push) Successful in 21s
flatpak / build-publish (push) Successful in 4m46s
deb / smoke-install (push) Successful in 2m36s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 7m27s
deb / build-publish-gamescope (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 2m34s
android / android (push) Successful in 13m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m1s
apple / swift (push) Successful in 2m8s
apple / screenshots (push) Successful in 9m31s
windows-host / package (push) Successful in 13m9s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 35s
apple / distribute (push) Successful in 12m30s
nix / flake (push) Successful in 20m9s
2026-08-23 10:32:42 +00:00
enricobuehler 7312f0ddba chore(sdk): cut 0.1.6 — the rename route's types cannot reach a plugin until they ship
ci / docs-site (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 2m9s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m57s
ci / bun-nix (pull_request) Successful in 34s
ci / docs-drift (pull_request) Successful in 33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m41s
ci / rust-arm64 (pull_request) Successful in 4m11s
android / android (pull_request) Successful in 7m10s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m9s
nix / flake (pull_request) Successful in 8m34s
ci / rust (pull_request) Successful in 18m27s
The v0.31.3 CHANGELOG recorded this cut as a decision left open, on the same
reasoning v0.31.0 used for 0.1.5: a plugin resolves `@punktfunk/host` from the
registry, so types sitting in `sdk/` reach nobody until a version carries them.
#374 added `PATCH /clients/{fingerprint}`, `RenameClient` and
`PairedClient.label` to the management API and regenerated the client for them —
so without this cut the route exists on every 0.31.3 host and no plugin can call
it in a typed way.

ONE FILE is the whole diff since sdk-v0.1.5: `sdk/src/gen/punktfunk.ts`. It is
larger than the feature because regenerating it from the UNCHANGED committed spec
already produced a ~700-line diff — the checked-in copy had drifted from its own
pinned generator, and nothing in CI regenerates or verifies it (unlike
api/openapi.json and include/punktfunk_core.h, which are both gated). #374 landed
the clean regeneration rather than hand-patching generated code, and this cut
publishes it.

`SDK_VERSION` moves with `package.json`. It is a hand-maintained constant — the
build sets `rootDir: "src"` so it cannot import the manifest, and the runner ships
as one bundled `runner-cli.js` with no manifest beside it — and the runner
compares it against the SDK installed in the plugins tree to decide whether to
reinstall. Shipping 0.1.6 with the constant still reading 0.1.5 would publish the
types and then never deliver them. `version.test.ts` gates exactly that, which is
also what sdk-publish.yml's "Tag matches package version" step re-checks against
the tag.

GATES, all four steps sdk-publish.yml runs, in order and locally:
`bun install --frozen-lockfile --ignore-scripts` clean, `bun run typecheck`
clean, `bun test` 83 pass / 0 fail / 191 expect() calls across 12 files (the same
83 the 0.1.5 cut reported), `bun run build` clean. Nothing but the two version
sites and the two release documents is touched — no dist/ or lockfile churn
reached the tree.

`@punktfunk/plugin-kit` is deliberately NOT re-cut: nothing under plugin-kit/ has
moved since 0.4.4, which stays the registry's `latest`.

Tag `sdk-v0.1.6` on the merge commit, alongside `v0.31.3`. The two version
independently by design — sdk-publish.yml triggers on `sdk-v*` and the app's `v*`
tags never republish the SDK — so the shared commit is a convenience, not a
coupling.
2026-08-23 12:13:08 +02:00
enricobuehler e7ebaf591c release: 0.31.3 — version bump, notes, CHANGELOG, Play notes
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m50s
ci / bun-nix (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-drift (pull_request) Successful in 1m10s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
ci / rust-arm64 (pull_request) Successful in 7m2s
android / android (pull_request) Canceled after 8m55s
ci / rust (pull_request) Canceled after 11m10s
nix / flake (pull_request) Canceled after 8m17s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 5m46s
41 commits since v0.31.2 (26 non-merge). Cut from origin/main f5931650 (#385
merged, main green).

THE NUMBER: a patch. One versioned surface moves and it moves additively — the
management API gains PATCH /clients/{fingerprint}, the RenameClient schema and
PairedClient.label, none of which existed before, so nothing that consumes the
API today changes shape. Everything else is where v0.31.2 left it: WIRE_VERSION
2, C ABI 25 with include/punktfunk_core.h showing NO diff against the v0.31.2 tag
(second release running), 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. Two feat commits (#374, #384), both additive; v0.31.1 carried two
feats as a patch on the same reasoning.

THE SHAPE: the faults share a family resemblance — a session degrading or ending
against something ordinary that nothing was checking. Steam's pre-launch trees
latching the game lease and their exit then read as the game's (#372); a
fullscreen game mode-setting the virtual display under both stream loops (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377);
an Android TV negotiating the refresh its own console pin installed rather than
what the panel outputs (#378); a startup capacity probe large enough to
black-hole the link it was measuring (#379); a hand-back that never verified the
panel came back (#375); a half-minted audio devnode nothing afterwards
recognised (#381); and a failed compositor build that unlinked the working one it
never replaced (#382). Plus two Android input/present fixes (#376, #380), the
console's per-frame cost and its new resolution switch (#384, #385), one feature
(#374), and CI (#370, #383).

TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
  * #375 ships WITHOUT a reproduction. Five scenarios across both distro families
    on real VMs all recovered cleanly and the first proposed mechanism was
    disproved on glass, so it closes the gap that lets any trigger end as a dark
    panel rather than guessing at one.
  * #380 is re-implemented from #371's diagnosis, and #371 is NOT merged. All
    three faults were real and correctly identified; each fix as sent reached
    further than the hardware that needed it. The notes credit the diagnosis.

DOCS FRESHNESS, per docs/releases/README.md step 1: #379, #380 and #384 carried
their own docs-site updates (configuration.md, input.md, client-settings.md). The
one fact left owed was naming a Moonlight device, whose canonical home is the
"Managing paired devices" section of docs-site/content/docs/pairing.md — a
paragraph goes there. No new PUNKTFUNK_* variable this cycle
(PUNKTFUNK_RECOVER_SESSION_CMD is pre-existing and already documented in
configuration.md and gamescope.md), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.

VERIFIED HERE: scripts/ci/check-docs-drift.sh clean; scripts/ci/check-docs-links.sh
clean; the android.yml Play notes gate run verbatim, 444/500 characters and unique
against every other release's file; both openapi copies cmp identical and stamped
0.31.3; cargo fmt --all --check clean; cargo audit clean over all five Rust
lockfiles (h2 fixed in the commit below this one); cargo about --fail clean on the
host workspace; git diff v0.31.2..HEAD on include/punktfunk_core.h and on
crates/pf-driver-proto both empty, which is the direct evidence for those two
version rows; Cargo.lock's 36 workspace version strings moved with Cargo.toml;
notes voice scan clean (zero backticked terms above ## For developers) and the
CHANGELOG link pinned to src/tag/v0.31.3.

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

LEFT AS A DECISION, not made here: sdk/src/gen/punktfunk.ts changed in #374 (a
clean regeneration that also absorbed ~700 lines of pre-existing drift) but
@punktfunk/host is not re-cut, so the registry's 0.1.5 has no types for the new
route. Cut sdk-v0.1.6 if anything outside this repo needs them.
2026-08-23 12:01:35 +02:00
enricobuehler 010949fead fix(deps): h2 0.4.15 -> 0.4.18, closing RUSTSEC-2026-0258
`cargo audit` on the root lockfile went red on 2026-08-17, when RUSTSEC-2026-0258
was disclosed against h2 <= 0.4.15 (unbounded empty DATA frames; fixed in
0.4.16). audit.yml's cargo-audit job is BLOCKING and fires on every Cargo.lock
change, so the 0.31.3 version bump in the next commit would have taken it red on
merge regardless of this advisory's own timing.

h2 is transitive — no manifest in the workspace declares it — so this is a
lockfile-only change.

MINIMAL ON PURPOSE. `cargo update -p h2` reports "Locking 1 package" but also
rewrote nine unrelated entries from `windows-sys 0.61.2` to 0.52.0/0.59.0,
gratuitous resolver drift that would have changed what the Windows builds compile
against for no reason. That was discarded; the two h2 lines are applied directly
instead, and `cargo metadata --locked` accepts the result with nothing else
moving — which is the proof the resolver needed none of the rest.

VERIFIED: `cargo audit` over all five Rust lockfiles. The root one is now clean;
the other four already were. The two lines cargo-audit still prints
(`audiopus_sys`, `paste`) are *unmaintained* warnings, already allowed via
.cargo/audit.toml, and do not fail the job.

NOT REGENERATED, deliberately: THIRD-PARTY-NOTICES.txt still records h2 0.4.15.
scripts/gen-third-party-notices.sh walks the dependency closure of the machine it
runs on, and on macOS it DROPS nine crates — the rusqlite / libsqlite3-sys /
fallible-iterator / hashlink cluster, 575 -> 566 — because they are gated to
platforms this Mac is not. Committing that would remove attributions a Linux or
Windows build genuinely links, which the script's own header calls a legal
regression rather than an untidiness. Regenerate on Linux. Nothing in
.gitea/workflows diffs the checked-in copy, and build-deb.sh /
pack-host-installer.ps1 / punktfunk.spec / pack-msix.ps1 each regenerate it on
their own platform, so the shipped packages are accurate and this is cosmetic
drift in the in-repo copy only.
2026-08-23 11:54:14 +02:00
enricobuehler f5931650e0 Merge pull request 'main is red: the Android-only row list never learned about the new resolution switch' (#385) from worktree-console-tv-perf-safe-fixes into main
ci / web (push) Successful in 1m6s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 27s
ci / rust-arm64 (push) Successful in 1m55s
ci / docs-site (push) Successful in 1m41s
deb / build-publish-gamescope (push) Successful in 49s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m47s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
deb / build-publish (push) Successful in 4m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m50s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m36s
deb / build-publish-host (push) Successful in 5m15s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Successful in 9m29s
android / android (push) Successful in 9m34s
deb / smoke-install (push) Successful in 3m19s
flatpak / build-publish (push) Successful in 6m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m39s
ci / rust (push) Successful in 19m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
Reviewed-on: #385
2026-08-23 09:51:00 +00:00
enricobuehler 519d004cab test(console-ui): the Android-only row list gains the new resolution switch
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m54s
ci / docs-drift (pull_request) Successful in 24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m37s
ci / rust (pull_request) Successful in 6m54s
android / android (pull_request) Successful in 7m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m11s
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact
ordered set of rows the desktop does not show, which is the point of it — a row
that silently changed platform is the regression it exists to catch. The new
switch is Android-only by design, so the expected list grows by one, between the
Controllers action row and the console-UI switch (it sits under Reduce motion,
earlier in the Interface tab than either).

Caught by CI on both the Linux and Windows legs, which run this crate's tests;
the row-COUNT assertion next to it was already updated and passed.
2026-08-23 11:43:10 +02:00
enricobuehler 46201fd9c3 Merge pull request 'The console redrew everything, every frame, at whatever resolution the panel handed it' (#384) from worktree-console-tv-perf-safe-fixes into main
ci / docs-drift (push) Successful in 31s
ci / web (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m32s
ci / bun-nix (push) Successful in 1m6s
deb / build-publish-gamescope (push) Successful in 2m14s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 29s
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 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
ci / rust (push) Failing after 5m31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m34s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m14s
docker / deploy-docs (push) Successful in 37s
arch / build-publish (push) Successful in 8m12s
android / android (push) Successful in 9m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m49s
deb / smoke-install (push) Successful in 4m35s
flatpak / build-publish (push) Successful in 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m51s
Reviewed-on: #384
2026-08-23 09:33:41 +00:00
enricobuehler f320f4b465 feat(clients/android): "Reduce interface resolution", for the 4K boxes the console is slow on
ci / docs-drift (pull_request) Successful in 1m7s
ci / bun-nix (pull_request) Successful in 1m8s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m53s
ci / rust (pull_request) Failing after 4m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m19s
android / android (pull_request) Successful in 5m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 7m36s
The console draws at whatever resolution the panel hands it, and on a 4K
television or projector that is four times the fragment work of 1080p on a
graphics chip built to decode and composite video rather than to draw a moving
interface. The reporter's two devices — a Fire TV Stick 4K Max and a Valerion
projector — are both premium products and both exactly this shape: the money
is in the light engine and the panel, and the SoC is a TV part. A premium 4K
box is MORE likely to want this than a cheap 1080p stick, which never had the
extra pixels to begin with.

So: an off-by-default switch in the controller-optimized settings, directly
under Reduce motion, because the two are the same kind of bargain — give up
some fidelity, get a smoother console. On, the buffer's long edge is capped at
1920 with `SurfaceHolder.setFixedSize` and the compositor scales it up for
free. Text goes a little softer. Nothing else changes.

Two things this had to get right, neither of which is obvious from the call:

`setFixedSize` shrinks the BUFFER and not the VIEW. Everything that speaks in
surface pixels therefore has to be scaled to match — the safe-area insets, the
design-unit scale, and the pointer coordinates, which a mouse still reports in
view pixels and which would otherwise land the cursor at twice its true
offset. The scale factor is one number applied to both axes, so the aspect
ratio survives exactly and no layout can stretch.

And the buffer is sized from the SurfaceView's own laid-out size, reported
back through `onSizeChanged`, rather than from `displayMetrics`. The two
normally agree, but `displayMetrics` has a long history of disagreeing with a
view's real size by a system bar depending on the version and on who is
hiding what, and a buffer whose aspect ratio does not match the rect it is
scaled into is a stretched interface. "Normally agree" is not something to
hang picture geometry on.

The pointer listeners are installed in `AndroidView`'s `factory`, which runs
once, so the factor reaches them through `rememberUpdatedState` — captured
directly it would freeze at its first-composition value (1, before any layout
has reported a size) and a mouse would be wrong for the rest of the session.
The same reason `platformUp` is already held that way.

⚠ This is the INTERFACE only and shares nothing with the stream. Picture size
is `effectiveMode`, off `Display.mode.physicalWidth` — a physical display
mode, not any surface's buffer — and picture scaling is the separate
`renderScale`. The two `SurfaceView`s are different views and this is the only
`setFixedSize` call in the client. The name keeps "interface" in it, and the
docs entry ends by pointing at Resolution and Bitrate, so that nobody turns
this on expecting a sharper stream.
2026-08-23 11:21:41 +02:00
enricobuehler 89eb031cd6 A dropped skia download read as a lint failure, and had no retry to survive on (#383)
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 48s
ci / docs-drift (push) Successful in 28s
apple / swift (push) Successful in 2m4s
ci / rust-arm64 (push) Successful in 2m20s
deb / build-publish-gamescope (push) Successful in 28s
decky / build-publish (push) Successful in 1m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
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 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
docker / builders-arm64cross (push) Successful in 14s
deb / build-publish-host (push) Successful in 5m20s
deb / build-publish (push) Failing after 5m20s
docker / deploy-docs (push) Successful in 41s
arch / build-publish (push) Successful in 9m10s
android / android (push) Successful in 9m35s
deb / smoke-install (push) Successful in 2m31s
apple / distribute (push) Successful in 11m16s
apple / screenshots (push) Successful in 9m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m43s
skia-bindings pulls ~19 MB of prebuilt Skia from inside its build script with a
bare curl and no retry, and swallows a failed download into a from-source Skia
build the CI containers cannot complete — so a dropped transfer surfaced as
"Clippy (Android target) failed" with the real cause 1,800 lines up.

A retrying curl shim first on PATH covers it (skia-bindings already resumes and
caches the part-file, so a retry continues the transfer). The prose rule in
android.yml's env block is now a gate that fails on STARTING A FULL BUILD.
2026-08-23 08:24:57 +00:00
enricobuehler 2991001fe4 perf(console-ui,clients/android): the console re-shaped every string and raised a no-op layer, every frame
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a
Valerion projector. The Skia shell is faster than the Compose one it replaced
per unit of work; it was doing far more work than anyone had counted, and all
of it on every frame whether or not anything had changed.

Four costs, none of which change a pixel:

`Fonts::paragraph` built a `ParagraphBuilder`, added its text and called
`layout()` on every call — the whole shaper, HarfBuzz and line breaking and
font fallback, for every string on screen, sixty times a second. It is now
built once per distinct (text, shape, weight, size, width, colour) and kept.
Position is deliberately not in the key, so a shelf that scrolls and a screen
that slides both re-use what they already shaped. Cold entries are dropped
once the map passes its ceiling, by the two frames that last drew them, so the
live set is what is on screen and paging a large library cannot grow it
forever. The loose `(TextAlign, Option<usize>)` pair became a `Para` tag on
the way past: those two were never independent, and it is half of a hash key
now.

`LayerEnv::paint` raised an unbounded `save_layer` unconditionally — including
on the settled path, where alpha is 1, the scale is 1 and the slide is 0. That
allocates an offscreen the size of the whole SURFACE and composites it back,
to apply an alpha of one, on every frame the console sat still. Skia does not
elide it: `SkCanvas::saveLayerAlphaf` forwards alpha >= 1 straight to
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K
panel that is a 33 MB render target per frame, against a Skia budget that is
64 MB on a 2 GB box — so it was evicting real work to do nothing. Dropping it
is pixel-identical rather than close: nothing in this crate draws with a blend
mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD
subpixel text to gain or lose an isolation. `screens::home` had already
learned this one tile-deep; this is the same fix one level up.

The toast's layer was unbounded too, for a 34 dp pill. Everything inside it is
inside the pill, so it takes the pill's rect and some slack for the hairline.

`draw_clipped` measured its ellipsis fit by allocating a `String` per
character, for every over-long title on screen, every frame. It measures out
of a stack buffer now. The controller chip's string stopped being rebuilt
sixty times a second to say the same thing.

On the Android host, the render thread now takes the same priority lift the
decode thread has taken all along (`-8`, a band below the stream's `-10`, so
the two do not compete when the console is up mid-session). At default nice, a
TV box's scheduler is free to park the console's frame loop on a little core
behind background work, which reads as a UI that lags the remote.

And the thing that made this hard to answer in the first place: the console
logged its GLES version and its cache budget and never its render resolution
or its frame cost, so "it feels sluggish" could not be triaged from a log
bundle at all. It now names the surface size when it wraps one, and reports
mean and peak draw time once a minute. The window is timed around the draw and
not the swap — `eglSwapBuffers` blocks on vsync, so wall-clock per iteration
is always the panel period and says nothing.

What is deliberately NOT here is the biggest single lever on a 4K box: capping
the console's render resolution. That is a real quality trade on a panel
someone bought for its resolution, and it is not this commit's to make.
2026-08-23 10:22:58 +02:00
enricobuehler 19df33e0f7 Merge pull request 'A failed gamescope rebuild took HDR from boxes whose compositor still worked' (#382) from worktree-gamescope-rebuild-keeps-hdr into main
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m52s
ci / rust-arm64 (push) Successful in 2m41s
ci / docs-drift (push) Successful in 21s
ci / bun-nix (push) Successful in 1m40s
deb / build-publish-gamescope (push) Successful in 3m44s
deb / build-publish (push) Successful in 4m38s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 1m36s
ci / rust (push) Successful in 8m32s
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 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-host (push) Successful in 6m5s
android / android (push) Successful in 10m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m20s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 43s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
deb / smoke-install (push) Successful in 3m36s
arch / build-publish (push) Successful in 16m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m30s
2026-08-23 08:06:53 +00:00
enricobuehler c920204184 Two audio-endpoint bugs found chasing the Sound Recording-tab hang (#381)
android / android (push) Canceled after 5s
arch / build-publish (push) Canceled after 6s
ci / rust (push) Canceled after 3s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 7s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m9s
windows-host / canary-manifest (push) Canceled after 0s
A host that died mid-mint left an orphan devnode and the next start minted a
duplicate; the registry stamp route reached for the Render hive even for capture
endpoints. Both reproduced and verified on the .173 Windows lab box.

The Recording-tab hang that prompted the investigation is NOT fixed — it did not
reproduce on .173, and nine candidate mechanisms were ruled out by direct
measurement. See the PR body for the disproof table.
2026-08-23 08:05:05 +00:00
enricobuehler 6f4613e146 fix(ci): a dropped skia download read as a lint failure, and had no retry to survive on
apple / swift (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 18s
ci / docs-drift (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 13m45s
android / android (pull_request) Successful in 5m52s
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).

Measured on main 2026-08-22, android job:

  DOWNLOAD AND INSTALL FAILED: curl error code: "18"
  curl stderr: "curl: (18) end of response with 17054400 bytes missing"

2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.

* A retrying curl shim first on PATH is the only lever that reaches inside a
  build script, and the cheapest correct one: skia-bindings already passes
  '-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
  the truncated transfer rather than restarting it. --retry-all-errors is
  load-bearing: a truncated transfer is not an HTTP status, so plain --retry
  would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
  skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
  DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
  STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
  failure again.
2026-08-23 10:00:21 +02:00
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 3b08da11ff fix(gamescope): pin libdisplay-info to the vendored subproject, like wlroots
ci / web (pull_request) Successful in 1m55s
ci / rust-arm64 (pull_request) Successful in 3m27s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 6m13s
ci / rust (pull_request) Successful in 6m44s
Caught on the SteamOS lab VM while verifying the previous commit end to end. With
libx11-xcb-dev added the build finally COMPLETED (652/652, banner "3.16.25-21-gb71a56c
+pfhdr8") — and then failed its on-glass check:

    punktfunk-gamescope: error while loading shared libraries:
    libdisplay-info.so.2: cannot open shared object file

Self-inflicted: the previous commit also took libdisplay-info-dev from the CI image's
list. gamescope vendors libdisplay-info as a submodule, but it is NOT in
force_fallback_for, so meson preferred the system lib the moment the build box had the
-dev package and linked it SHARED. SteamOS ships no libdisplay-info.so.2, so the binary
built, installed and printed its +pfhdr banner inside the distrobox and could not start
on the machine it exists for.

This is verbatim the wlroots trap the same comment block already documents ("starts fine
on the build host and dies with libwlroots-0.19.so ... anywhere else"), so it gets the
same remedy rather than a second one: libdisplay-info joins force_fallback_for. "Just
don't install the -dev package" does not hold — Debian, Fedora and Arch all have it and
anything can pull it in transitively, and the failure is silent right up to the on-glass
check that build-gamescope.sh happens to run.

Also drop libdisplay-info-dev from the Deck list (pointless once the fallback is pinned)
and record why that list must NOT be synced with ci/gamescope-trixie.Dockerfile: the CI
list targets a .deb that runs on Debian, this one cross-builds in trixie for SteamOS
glass. libx11-xcb-dev and libxkbcommon-x11-dev stay — SteamOS ships both sonames.

The on-glass check did its job here: it caught the bad binary, removed it and left the
box SDR rather than letting the host promise HDR it could not deliver.
2026-08-23 09:44:59 +02:00
enricobuehler 3ee88bb8cf Merge pull request 'acquireLatestImageAsync hands back a fence it already gave away' (#376) from worktree-asc-fdsan-acquire-fence into main
ci / rust-arm64 (push) Successful in 1m35s
ci / web (push) Successful in 1m11s
ci / bun-nix (push) Successful in 19s
ci / docs-site (push) Successful in 1m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
ci / docs-drift (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m6s
ci / rust (push) Successful in 8m31s
android / android (push) Successful in 9m5s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 32s
Reviewed-on: #376
2026-08-23 07:42:10 +00: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 773eea24d9 Merge pull request 'A DualSense's buttons all reach the stream, and its Mute button works' (#380) from worktree-dualsense-buttons-and-pad-routing into main
android / android (push) Canceled after 2m33s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 1m53s
ci / web (push) Canceled after 1m11s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
2026-08-23 07:39:02 +00:00
enricobuehler 3b5c95959b Merge pull request 'The startup capacity probe stops black-holing constrained links' (#379) from worktree-abr-probe-target-from-stream-cap into main
android / android (push) Canceled after 20s
ci / rust (push) Canceled after 3s
ci / docs-drift (push) Canceled after 18s
ci / rust-arm64 (push) Canceled after 19s
ci / web (push) Canceled after 18s
ci / bun-nix (push) Canceled after 19s
ci / docs-site (push) Canceled after 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 2m11s
deb / build-publish-gamescope (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m12s
deb / build-publish (push) Successful in 4m10s
deb / build-publish-host (push) Successful in 5m5s
arch / build-publish (push) Successful in 8m14s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 10m53s
flatpak / build-publish (push) Successful in 9m0s
apple / distribute (push) Successful in 12m33s
deb / smoke-install (push) Successful in 2m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m28s
apple / screenshots (push) Successful in 9m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 23m0s
windows-host / package (push) Canceled after 9m47s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-23 07:38:49 +00:00
enricobuehler a2bc9a2bdc fix(steamdeck): a failed gamescope rebuild took HDR from boxes whose compositor still worked
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck source install.
Two defects, one symptom.

1. scripts/steamdeck/build-gamescope.sh has been UNBUILDABLE since 2026-08-13, when
   3ac4548c turned `-Denable_gamescope_wsi_layer=true` on. The layer needs x11-xcb, which
   Debian splits into its own libx11-xcb-dev; the distrobox apt list — last touched
   2026-07-31 — never got it. MEASURED on debian:trixie against that list verbatim,
   gamescope at the pinned 5fb8dce4:

       Run-time dependency x11-xcb found: NO (tried pkgconfig and cmake)
       src/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found

   `meson setup` exits 1 with the list as it was and 0 with libx11-xcb-dev added, and
   build-punktfunk-gamescope.sh treats a missing layer as a hard error, so the whole build
   fails. ci/gamescope-trixie.Dockerfile walked into the identical trap one release later
   (1b28a7f7, v0.28.1) and asserts x11-xcb at image build; this list never got the same
   fix. Debian-family only: Arch's libx11 and Fedora's libX11-devel carry x11-xcb.pc.
   xkbcommon-x11 and libdisplay-info measured absent too, and are added with it.

2. The build-failure branch then called `unwire`, deleting PUNKTFUNK_GAMESCOPE_BIN from
   host.env. A failed build REPLACED NOTHING — the previously installed binary is still on
   disk and still passes `verifies`. So a rebuild that never landed took HDR away from a
   box that had been streaming it minutes earlier. The script warns into a log nobody reads
   and exits 0, the update reports success, and the host then resolves the distro's stock
   /usr/bin/gamescope at patch level 0 and fixes the session at 8-bit SDR in the Welcome —
   which the punktfunk/1 handshake cannot take back.

   A verifying binary now stays wired (and a box a previous run of this bug unwired gets
   re-wired). `unwire` happens only where the binary itself fails its on-glass check, which
   is the branch that also removes it.

Also promote the "no +pfhdr marker" line from DEBUG to INFO. The handshake already reports
capture_supports_hdr=false at INFO while the one line saying WHY sat a level below it —
that asymmetry is what made this field report expensive to answer.

Verified: the meson reproduction above (exit 1 -> exit 0); all three added package names
resolve on trixie and satisfy their pkg-config modules; the four states of the changed
branch exercised in isolation (working binary stays wired, stock/missing binary unwired,
previously-unwired box re-wired). Not verified on real SteamOS glass — the lab VM was
unreachable from this machine.
2026-08-23 09:28:56 +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 cb07a8f983 fix(clients/android): a DualSense's buttons all reach the stream, and its Mute button works
ci / web (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-drift (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 5m51s
ci / rust (pull_request) Successful in 5m57s
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.

1. Some buttons never reach the stream. Fire OS is reported to tag certain
   DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
   BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
   drops them. The event's source class is the platform's per-event guess; the
   DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
   ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
   safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
   remote's way out of the stream, and both share their keycodes with a pad.
   `Gamepad.isPad` is untouched (source-class only) and no vendor-id or
   device-name matching is added anywhere — the field report records both pads
   being IDENTIFIED correctly; only their button positions were wrong.

2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
   BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
   borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
   GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
   L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
   only inside the straight-through report order a driverless pad uses. A row in
   SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
   Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
   `correct()`'s `genericKeyCode` guard stays exactly as it was.

3. Mute toggles the mic — once per press, and only on a pad that has one.
   Edge-triggered through the existing `completesChord` as the one-button chord
   it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
   unguarded check would flap the mic for as long as the button is held. Gated
   on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
   and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
   would mute the microphone on every QAM press. Resolved at slot open from what
   each path knows: the report order for an InputDevice, the declared kind for a
   capture link. Under the "local" system-button policy a real mute button is
   exempt from the early return (that policy means the press stays with this
   device, which is exactly what the toggle does) and loses only its wire send;
   every other system button behaves as before.

Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.

Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
2026-08-23 01:07:59 +02: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 11abff5343 fix(client): size the capacity probe from the session, and re-anchor if the burst eats the video
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 7m31s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m21s
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / rust (pull_request) Successful in 5m47s
The startup link-capacity probe burst at a flat 2 Gbps on the reasoning that it
must measure the link and not itself. That reasoning is obsolete: the ABR
already clamps the measured ceiling to `stream_cap_kbps` (what this session's
mode + codec could plausibly use), so every bit measured above `cap / 0.7` is
discarded the moment it lands. The height bought a number nothing reads, and
paid bufferbloat for it — a constrained Wi-Fi link can black-hole under it
(measured on webOS: a 6 s probe timeout delaying first video to 14 s, and a
"successful" probe still reporting send_dropped=20211; the same shape is now
reported on a Fire TV Stick 4K Max).

Derive the target instead: `stream_cap_kbps × 2`, capped at the old 2 Gbps.
×2 is the smallest multiplier that can still prove the cap (the ceiling is
`delivered × 0.7`, so proving it needs `delivered ≥ cap × 1.43`), so this can
never cap anyone — a session whose mode justifies a high ceiling asks for a
high target by itself, and a mode `stream_ceiling_kbps` declines to size still
gets 2 Gbps. Deliberately not a platform `cfg!`: the constraint is the
session's, not Android's, and webOS has the same bug.

Second half of the black screen: if the burst takes the first keyframe down
with it, nothing re-requests one and the client sits on black until an
unrelated recovery path happens to fire. Ask for a keyframe at probe end when
no frame completed across the burst — compared against the count snapshotted
at the burst's leading edge rather than against 0, so it also covers a
mid-session embedder speed test that kills a running stream. One request per
probe, through the control task's coalescer, so it cannot IDR-storm.

`PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are unchanged.
2026-08-23 00:45:42 +02: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 cf7baf3ba8 fix(client/android): acquireLatestImageAsync hands back a fence it already gave away
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m10s
ci / rust (pull_request) Successful in 7m18s
android / android (pull_request) Successful in 8m4s
Every pf-decode SIGABRT on the Shield is fdsan catching a double-close of the
acquire fence the ASC presenter passes to ASurfaceTransaction_setBuffer, in
three shapes: inside Fence::Fence(int) under setBuffer when the number had
already been re-owned ("fd N is owned by unique_fd, was expected to be
unowned"), at the end of Transaction::apply when the layer state is torn down,
and in Parcel::freeDataNoInit once the number churns.

The fence is not ours to give. AImageReader::acquireLatestImage drains with a
single int* out-param it overwrites per image, then releases each dropped image
with whatever that out-param currently holds — the successor's fence — and
returns the last value written. So as soon as a burst gives it two images to
collapse, the caller receives an fd the reader has already adopted and closed,
plus one leaked fd per extra drop. This is unfixed as of AOSP main, so the
newest-wins collapse has to happen on our side.

Drain both present intents with acquireNextImageAsync, whose fence is always a
fresh dup we exclusively own, and let latency pick the newest itself — the loop
the smoothing FIFO already ran. Superseded candidates drop as before: image back
to the pool, its own acquire fence closed. Reader drops now show up in `skipped`
instead of vanishing inside the reader lock.
2026-08-22 23:41:05 +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
enricobuehler 0df4ca957f fix(host,windows): the fixed-port firewall rules were open to every program on the machine
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 6m1s
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — 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
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule 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 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.

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 this 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 can't
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.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
2026-08-21 16:05:00 +02:00
enricobuehler 13aa11355e Merge pull request 'Video egressed from whichever interface routing picked, not the one the client dialed' (#367) from worktree-blackscreen-data-plane-source-ip into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m2s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m13s
ci / rust-arm64 (push) Successful in 1m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish-gamescope (push) Successful in 49s
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 18s
deb / build-publish-client-arm64 (push) Successful in 1m11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 36s
deb / build-publish-host (push) Successful in 7m0s
ci / rust (push) Successful in 7m59s
arch / build-publish (push) Successful in 10m16s
android / android (push) Successful in 12m19s
windows-host / package (push) Successful in 13m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
deb / build-publish (push) Failing after 13m45s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
2026-08-21 12:31:44 +00:00
enricobuehler e989d7457f fix(host): video egressed from whichever interface routing picked, not the one the client dialed
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 6m26s
ci / rust (pull_request) Successful in 15m39s
`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 dialed, so its kernel drops every datagram from
any other source — 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 documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now 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, which contradicts its own advice and sent
  an investigation at 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.
2026-08-21 14:06:48 +02:00
enricobuehler b05bb1dd48 A cold-booted host advertised 127.0.0.1 and never recovered (#366)
ci / docs-drift (push) Successful in 38s
ci / bun-nix (push) Successful in 1m14s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 2m2s
deb / build-publish-gamescope (push) Successful in 45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
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 14s
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 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
deb / build-publish-client-arm64 (push) Successful in 5m40s
ci / rust (push) Successful in 8m16s
arch / build-publish (push) Successful in 10m47s
android / android (push) Successful in 10m59s
deb / build-publish (push) Successful in 10m26s
deb / build-publish-host (push) Successful in 11m17s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 1m11s
deb / smoke-install (push) Successful in 3m54s
windows-host / package (push) Successful in 17m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 20m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m6s
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.

Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.

Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
2026-08-21 12:04:15 +00:00
enricobuehler 2898f6b049 test(host): cover the interface fallback the boot race actually takes
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m48s
ci / rust (pull_request) Successful in 5m30s
android / android (pull_request) Successful in 6m48s
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
2026-08-21 13:34:44 +02:00
enricobuehler 4eb4e3465b refactor(host): end the mDNS re-announce loop with a channel, not a flag
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
2026-08-21 13:32:08 +02:00
enricobuehler 44cd5bfd81 Merge pull request 'The button correction fired on pads that never needed it' (#365) from worktree-android-pad-mapping-regression into main
ci / bun-nix (push) Successful in 31s
ci / docs-drift (push) Successful in 44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m34s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / builders-arm64cross (push) Successful in 39s
docker / deploy-docs (push) Successful in 1m5s
ci / rust (push) Successful in 7m35s
android / android (push) Successful in 8m29s
2026-08-21 11:24:03 +00:00
enricobuehler 8977228a4b fix(host): a cold-booted host advertised 127.0.0.1 and never recovered
`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, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `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 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.
  * `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, so 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.
2026-08-21 13:21:52 +02:00
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
ci / docs-drift (pull_request) Successful in 50s
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 5m27s
android / android (pull_request) Successful in 6m41s
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
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 —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate 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 more 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 over Bluetooth 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.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 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), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
enricobuehler fc3b2d0328 Merge pull request '0.31.1 notes: fold in #362, which is what makes the console's off switch work' (#364) from worktree-release-0311-foldin into main
ci / docs-site (push) Successful in 1m7s
ci / bun-nix (push) Successful in 18s
apple / swift (push) Successful in 2m9s
ci / docs-drift (push) Successful in 19s
android-screenshots / screenshots (push) Successful in 1m55s
ci / web (push) Successful in 8m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m27s
ci / rust-arm64 (push) Successful in 10m14s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish (push) Successful in 4m33s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 2m33s
ci / rust (push) Successful in 14m38s
arch / build-publish (push) Successful in 9m40s
apple / distribute (push) Successful in 13m35s
sbom / sbom (push) Successful in 25s
android / android (push) Successful in 11m19s
deb / build-publish-host (push) Successful in 9m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m42s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
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 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
docker / deploy-docs (push) Successful in 20s
linux-client-screenshots / screenshots (push) Successful in 5m2s
deb / smoke-install (push) Successful in 2m40s
flatpak / build-publish (push) Successful in 6m39s
web-screenshots / screenshots (push) Successful in 6m25s
apple / screenshots (push) Successful in 9m53s
windows-host / package (push) Successful in 14m5s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-20 20:37:56 +00:00
enricobuehler 1c1fd7d9bc release: 0.31.1 — fold in #362, which makes the console's off switch work
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Successful in 14m33s
30 commits since v0.31.0 (19 non-merge). #362 merged onto main seconds
after the release merge (#363), so the notes as tagged would have been
wrong in the one way that matters: they lead `## New` with the console's
"Controller-optimized UI" switch, and cbd3d028 is the commit that makes
that switch do anything at all.

trust::Settings::extra is #[serde(flatten)], so the android.* keys are
TOP-LEVEL keys of the settings document. ConsoleJson wrote and read them
nested under an "extra" object, which serde filed under the literal key
"extra" — so no console row ever found android.gamepad_ui_enabled. Every
Android-only row rode the same dead path in 0.31.0: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.
That is a user-facing fix in its own right and is now in `## Fixed`, in
`## TL;DR`, and in the Play notes — it is the most useful 500 characters
an Android user gets from this release.

Also folded in: c9a76287, the focus halo and panel_highlight taking the
card's corner plus their own outset (a rect grown by d only stays
concentric if its radius grows by d too).

NOT folded in — deliberately: cargo audit is red on main with
RUSTSEC-2026-0258 (h2 0.4.15, unbounded empty DATA frames, fixed in
0.4.16, transitive via hyper). It predates this cut — the same job failed
on 669a1bc0 and on the v0.31.0 tag commit — so it is not a regression
here. `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven lock lines
but ALSO rewrites several windows-sys references downward (0.61.2 ->
0.59.0/0.52.0) on the pinned 1.96.0 toolchain. Re-resolving the graph for
the Windows build inside a version bump that cannot be compiled for
Windows on this host is the wrong trade; it wants its own commit and its
own CI. Recorded in the CHANGELOG's verification section.

Cargo.lock is untouched by this commit.

Gates re-run: cargo fmt --all --check clean; scripts/ci/check-docs-drift.sh
clean; cargo test -p punktfunk-core --lib 273 passed; the Play notes gate
run verbatim, 440/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 22:37:01 +02:00
enricobuehler 1c60e641b3 Merge pull request 'The console's own settings never left the console, and the layer under a card missed its corners' (#362) from worktree-android-console-extra-keys into main
ci / rust-arm64 (push) Successful in 2m1s
ci / docs-site (push) Successful in 1m10s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 2m19s
ci / docs-drift (push) Successful in 19s
android / android (push) Successful in 11m5s
arch / build-publish (push) Successful in 11m27s
deb / build-publish-host (push) Failing after 6m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
deb / build-publish-gamescope (push) Successful in 3m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 1m56s
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 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 9m24s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 34s
flatpak / build-publish (push) Successful in 4m55s
deb / smoke-install (push) Successful in 5m3s
ci / rust (push) Successful in 22m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m37s
2026-08-20 20:03:42 +00:00
enricobuehler eda4b7ebd2 Merge pull request '0.31.1 — the video data port, the pad games could not see, and the buttons Android guessed wrong' (#363) from worktree-release-0311-prep into main
audit / cargo-audit (push) Failing after 2m1s
apple / swift (push) Successful in 2m5s
android / android (push) Canceled after 3m20s
audit / bun-audit (sdk) (push) Successful in 32s
arch / build-publish (push) Canceled after 3m1s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
audit / pnpm-audit (push) Successful in 21s
audit / docs-site-audit (push) Successful in 25s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 47s
audit / bun-audit (plugin-kit) (push) Successful in 15s
audit / license-gate (push) Successful in 6m31s
audit / miri (push) Successful in 6m13s
audit / c-abi-asan (push) Successful in 7m41s
apple / distribute (push) Successful in 11m54s
audit / bun-audit (web) (push) Failing after 11m38s
windows-host / package (push) Successful in 14m11s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 38s
apple / screenshots (push) Successful in 11m12s
nix / flake (push) Successful in 26m2s
2026-08-20 20:00:14 +00:00
enricobuehler 730ac43169 release: 0.31.1 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
apple / swift (pull_request) Failing after 1m40s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 5m33s
android / android (pull_request) Successful in 7m19s
nix / flake (pull_request) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
ci / rust (pull_request) Successful in 25m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m47s
27 commits since v0.31.0 (17 non-merge). Cut from origin/main 2be444b3
(#361 merged).

THE NUMBER: a patch, on the user's call, and the version table does not
argue with it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h differing from the v0.31.0 tag by one `#define`,
driver protocol 6 / min 3 (pf-driver-proto has no diff against the tag),
gamepad channel 3, plugin index schema 1, host event schema 1, gamescope
+pfhdr8 with no new patch files, SDK 0.1.5 untouched. No `!` commit and
no breaking change of any kind.

Two surfaces grow, both by pure addition, and neither forces the minor:
the management API gains PUT /library/provider/{provider}/running (#361,
already regenerated into both openapi copies there — this commit moves
only the stamp), and plugin-kit goes 0.4.3 -> 0.4.4 to export
ProviderClient.reportRunning from the package root. plugin-kit-v0.4.4 is
tagged AND published — it is the registry's `latest`; 0.4.2 is still
skipped there. A plugin that adopts neither is unaffected, and a 404 from
an older host means "this host tracks games by scanning".

The cycle is otherwise fix-shaped: fifteen fixes and two small features
— the console's own switch for turning itself off (which exists because
0.31.0 made the console the only interface Android shows with a pad
attached) and the provider running-report above.

The new control message (DeliveryReport, type byte 0x0B) is a `#define`,
not an ABI step — the same shape PipelineGap (0x0A) had at v0.30.0, and
it takes a free byte in its block rather than lengthening LossReport,
which is length-checked exactly.

Two behaviour changes are called out in the CHANGELOG rather than left
to be discovered: Windows `service install` now provisions a
program-scoped inbound UDP rule for the host exe (the black-picture
fix — no `localport=` rule can ever cover an ephemeral data port), and
the GameStream plane's default virtual-Xbox backend flips from the XUSB
companion to the UMDF HID pad, which is the correction of a default that
was only ever a module-name coincidence. Both revert with
PUNKTFUNK_XBOX_BACKEND=xusb / by removing the rule.

Also in this commit, because a cut is when it bites:
packaging/gamescope/PKGBUILD moves pfhdr7 -> pfhdr8. The banner has said
+pfhdr8 since patch 0010 and the host probes it for >= 8, but pacman
compares pkgver-pkgrel, read 3.16.25.pfhdr7-1 on both v0.30.0 and
v0.31.0, and offered Arch/SteamOS hosts no upgrade at all — so the
keyboard-layout fix never reached them. deb and rpm derive the version
from the binary banner and moved by themselves; Arch is the only channel
that hardcodes it. The v0.31.0 table flagged this as pre-existing.

api/openapi.json is RE-STAMPED here, not regenerated: punktfunk-host
does not build on macOS. #361 regenerated the document itself, with the
new route and its three schemas, on a runner where
openapi_document_is_complete_and_checked_in actually executes. This
commit moves one line (info.version, which utoipa fills from
CARGO_PKG_VERSION), and "0.31.0" appears nowhere else in either copy.

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 —
it did NOT run on the v0.31.0 cut, so this is the first cut since ABI 25
where a C compiler has built the generated header;
scripts/ci/check-docs-drift.sh clean; the android.yml Play notes gate
run verbatim, 442/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 21:58:39 +02:00
enricobuehler c9a76287d8 fix(console-ui): the layer under a card takes the card's corner plus its own outset
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 5m34s
ci / rust (pull_request) Successful in 5m45s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
The focus halo grows the card's rect by 4 design units on every side but drew
it with the card's own corner radius. A shape grown by `d` keeps its corners
parallel to the original's only if its radius grows by `d` too — otherwise the
two arcs stop sharing a centre. So the halo came out squarer than the card it
sits under: clean along the edges, visibly misaligned at the four corners,
where it read as a badly drawn outline rather than as light spilling out.

Same rule applied to `panel_highlight`, which pulls in half a unit and kept the
full radius. `drop_shadow` only offsets, so its geometry was already right, and
the collections plate uses `RRect::with_outset`, which adjusts the radii itself.

Every card in the console goes through these two helpers — the home tiles, the
library grid, the coverflow, the collections deck.
2026-08-20 21:44:55 +02:00
enricobuehler cbd3d02817 fix(clients/android): the console's Android rows were nested where serde flattens them
Turning "Controller-optimized UI" off in the console did nothing: the console
stayed up, because the setting never left the console.

`trust::Settings::extra` is `#[serde(flatten)]`, so the `android.*` keys are
TOP-LEVEL keys of the settings document, beside `width` and `codec`.
`ConsoleJson` wrote and read them nested under an `"extra"` object instead.
Serde put that whole object into the map under the literal key `"extra"`, so
no console row ever found `android.gamepad_ui_enabled` — the row showed its
own default, and the value the console saved came back to Kotlin as the one
Kotlin had just sent. `applySettings` then saw no change, raised no callback,
and `App` never recomputed `gamepadUiActive`.

Every Android-only row rode the same broken path: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.

A store written by the nesting build carries the dead wrapper; it is dropped
on the next write rather than echoed for the life of the install.

The new test pins the shape from both sides. A round-trip alone could not have
caught this — both halves agreed on the same wrong nesting, which is exactly
how it survived review.
2026-08-20 21:44:46 +02:00
enricobuehler 2be444b329 Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main
ci / docs-site (push) Successful in 1m14s
ci / web (push) Successful in 1m19s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 27s
deb / build-publish-gamescope (push) Successful in 43s
ci / rust-arm64 (push) Successful in 3m26s
deb / build-publish-client-arm64 (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m7s
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 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m16s
nix / flake (push) Successful in 20m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
ci / rust (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m31s
deb / build-publish-host (push) Successful in 7m6s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 39s
plugin-kit-publish / publish (push) Successful in 41s
android / android (push) Successful in 12m41s
windows-host / package (push) Successful in 13m2s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 4m17s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m45s
Reviewed-on: #361
2026-08-20 19:22:10 +00:00
enricobuehler 669a1bc0ce The video data port was never open, and nothing could tell us (#360)
ci / docs-drift (push) Successful in 29s
ci / bun-nix (push) Successful in 53s
ci / docs-site (push) Successful in 1m13s
ci / web (push) Successful in 1m17s
apple / swift (push) Failing after 1m34s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
ci / rust-arm64 (push) Successful in 1m52s
deb / build-publish-gamescope (push) Successful in 1m9s
decky / build-publish (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
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 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 44s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
deb / build-publish-host (push) Successful in 6m4s
ci / rust (push) Successful in 7m23s
arch / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m14s
android / android (push) Successful in 10m46s
flatpak / build-publish (push) Successful in 7m15s
deb / build-publish (push) Successful in 5m6s
deb / smoke-install (push) Successful in 3m49s
windows-host / package (push) Successful in 13m18s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-host / canary-manifest (push) Successful in 27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.

Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
2026-08-20 18:48:28 +00:00
enricobuehler 8ff6fe6093 fix(host): regenerate the API spec, and stop the runstate tests colliding
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / docs-drift (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m54s
android / android (pull_request) Successful in 5m23s
ci / rust (pull_request) Successful in 7m17s
nix / flake (pull_request) Successful in 7m49s
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.

The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
2026-08-20 20:38:08 +02:00
enricobuehler 1758266bda chore(plugin-kit): export the running-report surface, and bump to 0.4.4
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.

Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
2026-08-20 20:38:08 +02:00
enricobuehler d5fb1e4479 feat(host): a provider plugin can report which of its titles are running
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.

New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.

`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.

The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.

plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
2026-08-20 20:38:08 +02:00
enricobuehler ea3c9e1202 ci(docs-drift): baseline the new control-message constant
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
apple / swift (pull_request) Failing after 1m35s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
2026-08-20 20:27:37 +02:00
enricobuehler 685c4bd99a Merge pull request 'A pad that was never there kept the console UI on, and the picture sat in the corner' (#359) from worktree-android-gamepad-ui-and-corner into main
ci / rust-arm64 (push) Successful in 2m19s
ci / web (push) Successful in 1m28s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
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 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
ci / docs-site (push) Successful in 2m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 25s
ci / rust (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 1m43s
android / android (push) Successful in 10m20s
2026-08-20 18:22:33 +00:00
enricobuehler 0519b057d5 fix(clients/android): a pad that was never there, and a picture in the corner
ci / rust-arm64 (pull_request) Successful in 3m37s
ci / web (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 8m52s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 2m25s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 11m0s
Two field reports from one Android user, with one shape between them: a
decision taken once, at a moment when the answer was still wrong, and never
revisited.

The console UI could not be dismissed. "With a controller" asks whether a pad
is attached, and the client answered that with `isPad` — does this device's
source class include gamepad or joystick. That is the right question for
ROUTING an event and the wrong one for presence: devices publish inputs that
claim the source class while being no such thing (OEM game-mode overlays, the
gaming-phone shoulder triggers), and one of them is enough to pin the console
UI on forever, because a pad that was never there can never disconnect.

`pads()` now filters on `looksLikeController`: the source claim AND hardware
behind it — a stick, a HAT, or the A/B face buttons — on a device the platform
did not synthesize itself. The claim is cheap; the hardware is not. `isPad`
keeps its looser meaning for the event lane, where it is correct. It is not a
complete defence (an OEM device that declares BTN_GAMEPAD and two axes is
indistinguishable from a pad at this layer), so the master switch stays the
guaranteed way out — and the Controllers screen still lists everything real in
one column or the other, which is where someone looks when the client's idea of
"a pad is attached" disagrees with the room.

The picture sat in the top-left corner. The ASurfaceControl layer composites
into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but
the stream screen hides the system bars and switches the window to draw into
the display cutout a frame or two later, and each of those grows the view under
a surface that is never recreated. The layer went on painting at the size it
started with, anchored at the origin. It passed on glass because a device whose
bars were already hidden when the surface arrived never sees the gap.

The size is now live: a packed atomic on the session handle, seeded by
`nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every
`surfaceChanged`, and read by the layer before each present. One atomic load per
frame, and rotation and multi-window come along for free.

Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and
`:app:` unit tests, and the native crate's own suite. The new JNI symbol is
exported in the built `.so`.
2026-08-20 20:07:57 +02:00
enricobuehler 33b029695f Merge pull request 'The gamescope prune deleted the compositor and shipped only its launcher' (#355) from gamescope-show-version-banner into main
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 24s
ci / docs-site (push) Successful in 1m23s
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 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
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 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 27s
ci / rust (push) Canceled after 16m22s
nix / flake (push) Successful in 28m29s
Reviewed-on: #355
2026-08-20 18:00:07 +00:00
enricobuehler 1f6f01cb76 Merge pull request 'The in-stream "Switch to Desktop" went unwatched on Nobara, and both distro families leaked an idled Game Mode' (#358) from worktree-nobara-gamemode-switch into main
ci / web (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 2m36s
ci / bun-nix (push) Successful in 23s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 26s
ci / rust (push) Canceled after 5m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 1m30s
deb / build-publish (push) Successful in 4m43s
deb / build-publish-host (push) Successful in 5m38s
arch / build-publish (push) Successful in 9m37s
android / android (push) Successful in 11m1s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 45s
deb / smoke-install (push) Successful in 5m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m6s
2026-08-20 17:51:44 +00:00
enricobuehler fade2f7af3 Merge remote-tracking branch 'origin/main' into worktree-nobara-gamemode-switch
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 48s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 7m59s
ci / rust (pull_request) Successful in 16m58s
2026-08-20 19:48:15 +02:00
enricobuehler 6e4cc335c5 Merge pull request 'The console can switch itself off, and a TV remote can reach everything it offers' (#354) from worktree-console-ui-off-toggle into main
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Successful in 31s
deb / build-publish (push) Canceled after 46s
ci / web (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 2m47s
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 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
android / android (push) Canceled after 5m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
arch / build-publish (push) Canceled after 5m27s
ci / rust (push) Canceled after 5m33s
deb / build-publish-host (push) Canceled after 4m21s
deb / build-publish-gamescope (push) Canceled after 19s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 12m41s
flatpak / build-publish (push) Successful in 7m18s
Reviewed-on: #354
2026-08-20 17:46:35 +00:00
enricobuehler d9662c010d fix(gamescope): 0.31.0's idled takeover left nothing watching for "Switch to Desktop"
The managed takeover stopped stopping the display manager and started idling the
box's autologin session instead (c2f5e91b). That commit also deleted the two lines
the old DM stop carried — `record_session_select_baseline()` and
`STOPPED_DM = Some(dm)` — and 38a0f54b then removed every remaining writer of that
static. The in-stream switch gate in `create_managed_session` still reads it, so
`honor_session_select_switch` became unreachable code on every box.

Bazzite never noticed, for two reasons measured on `.41`: its `os-session-select`
is a thin wrapper over `steamosctl` D-Bus calls and writes NO sentinel (verified —
`~/.config/steamos-session-select` does not exist before or after a completed
switch), and `is_steam_htpc_platform()` defaults the mid-stream session watcher ON
for bazzite/steamos, which follows the switch by itself.

Nobara gets neither. `ID=nobara` matches no HTPC default, so no watcher; and its
ChimeraOS-layout `os-session-select` DOES write the sentinel, which was the only
thing the host had to see the switch by. So on Nobara the user's "Switch to
Desktop" went entirely unhandled: the capture loss it causes drives a rebuild, the
rebuild relaunches game mode over the booting desktop, and the stream is thrown
straight back in — the 2026-07-24 field report, reopened by a user 2026-08-20.

Arm the gate on the takeover that actually exists now. `takeover_idled()` reads
IDLE_DROPIN_ARMED, the idle drop-in re-baselines the sentinel when it goes in, and
STOPPED_DM is documented as what it has become: adoption-only state for a takeover
stranded by a pre-0.31.0 host. The hand-back keeps that host's DM restore + switch
replay (now `replay_switch_under_restored_dm`) but runs it only for such an adopted
takeover — a 0.31.0 takeover leaves the DM up precisely so the OS does that work
itself, and on the VM it does: `Updated user selected session to plasma`, then
Plasma.

Both hand-back paths also give the box its own Game Mode back, which neither did.
The takeover replaces that session's ExecStart with a sleep, and a mid-stream
switch is the one exit that leaked it — the disconnect restore sweeps it, but a
switch is not a disconnect. Measured on Bazzite `.41`: after a completed switch to
KDE the unit still carried `ExecStart=/usr/bin/sleep infinity`, so "Return to
Gaming Mode" would have started a unit that does nothing. That is the same barred
way back `release_autologin_mask` already exists to prevent for the mask this
drop-in replaced, so it goes there — covering the watcher-driven distros — and in
`honor_session_select_switch`, covering the sentinel-driven ones.

Verified on the Nobara VM (123 on home-node-3) by driving the real path against
live systemd: takeover -> real `steamos-session-select plasma` -> rebuild. The
rebuild refuses to relaunch game mode, the idle drop-in is gone, and the box
arrives in KDE. Backed out to the 0.31.0 shape on the same box it fails at "the
switch did not advance the sentinel" — the blindness itself. The extended
`the_mask_comes_off_only_when_the_box_takes_itself_back` covers the watcher path
against a live user manager, and fails without the hand-back.
2026-08-20 19:33:19 +02:00
enricobuehler 2b0913cf53 Merge pull request 'A DualSense and an Elite Series 2 on a Fire TV pressed each other's buttons — Android reads the scancode now' (#357) from worktree-android-pad-hid-remap into main
docker / builders-arm64cross (push) Successful in 13s
ci / docs-drift (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 2m31s
ci / bun-nix (push) Successful in 2m18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / deploy-docs (push) Successful in 43s
ci / rust (push) Successful in 6m35s
android / android (push) Successful in 8m48s
2026-08-20 17:18:46 +00:00
enricobuehler 1280f697be fix(host,core): the video data port was never open, and nothing could tell
apple / swift (pull_request) Successful in 2m16s
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 3m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 1m29s
ci / docs-site (pull_request) Successful in 58s
ci / docs-drift (pull_request) Failing after 28s
ci / bun-nix (pull_request) Successful in 56s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Canceled after 7m48s
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
2026-08-20 19:14:49 +02:00
enricobuehler 3b39710a5a fix(clients/android): controllers Android has no key layout for stop landing on the wrong buttons
ci / web (pull_request) Successful in 1m37s
ci / docs-site (pull_request) Successful in 1m43s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Successful in 7m23s
Reported from a Fire TV Stick 4K Max: a DualSense and an Xbox Elite Series 2,
both over Bluetooth, both identified correctly but with buttons on the wrong
actions ("L1 being L2"). Both work in Moonlight on the same box.

Android names a pad's buttons through a KEY LAYOUT file matched on VID/PID. A
pad with no matching file falls back to AOSP's `Generic.kl`, which assigns
keycodes by SCANCODE POSITION — 0x130→BUTTON_A, 0x131→BUTTON_B, 0x132→BUTTON_C
and up. That is only right if the pad's buttons sit where the file assumes, and
a HID gamepad with no kernel driver numbers its buttons 1..n straight through in
its OWN report order, so every keycode past the first divergence is somebody
else's button. Neither pad has a layout there: AOSP ships none for the Elite
Series 2 over Bluetooth (045e:0b05) on any version, and the DualSense's
(054c:0ce6) both postdates Fire OS and carries `requires_kernel_config
CONFIG_HID_PLAYSTATION`, which a Fire TV kernel has not. A DualSense reporting
straight through puts L2 on 0x136, which `Generic.kl` calls BUTTON_L1 — the
reported symptom exactly, and Circle and R1 (BUTTON_C / BUTTON_Z) mapped to
nothing at all and were dropped.

This client read `KeyEvent.keyCode` only, so it inherited every one of those
errors — in the stream, in the console shell's navigation, and in the
Controllers screen's own tester, which is why the tester agreed with the bug.

Resolve buttons from the SCANCODE instead, which is the pad's own report
position and immune to the layout file — the same reason `Keymap.toVk` reads
`scanCode` for keyboards. Two things keep it off pads that already work:

  1. the correction applies ONLY where the delivered keycode is what
     `Generic.kl` would have said, so a device-specific layout always wins;
  2. which report order to read is decided from what the DEVICE declares —
     a pad numbering straight through claims BUTTON_C and BUTTON_Z, keycodes
     no real controller has a button for — never from a model table.

`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and every pad
reader now goes through it: the streaming branch, the Skia console shell's
probe, the older Compose navigation, and the Controllers tester. The console and
the game must not disagree about which button a user pressed.

Axes get the same treatment: a pad declaring none of LTRIGGER/RTRIGGER,
BRAKE/GAS or BRAKE/THROTTLE is one Android never mapped, and its triggers are on
raw axes. A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry, so
only the triggers (RX/RY) were missed; every other such pad puts the right stick
on Rx/Ry and the triggers on Z/Rz — the shape that makes pulling a trigger swing
the right stick. Whether those idle at −1 is measured off the device's own range
rather than assumed, so a pad reporting an honest 0..1 is not rescaled to a
permanent half-pull.

Also: the Controllers screen now reports each press as its raw scancode and
keycode plus the correction, names the resolved layout on the pad's card, and
reads axes through the same map the stream does — so it can be trusted again,
and so an unmapped pad can be pinned from a field report without the hardware.
The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the
identity table; without them an Elite streamed under the Xbox 360 identity.

Moonlight carries the same two report-order tables (`ControllerHandler`'s
`isNonStandardDualShock4` / `isNonStandardXboxBtController`), which is why both
pads work there on the same box.

Verified: :kit:testDebugUnitTest and :app:testDebugUnitTest both green (13 new
cases in PadButtonsTest covering the scancode tables and the axis resolution),
:app:compileDebugKotlin clean. NOT yet tested on glass — the reporter's Fire TV
is the confirmation this needs. The Xbox pad's Guide button stays unreachable
either way: it arrives as KEY_HOMEPAGE, and Android never delivers KEYCODE_HOME
to an app.
2026-08-20 18:46:56 +02:00
enricobuehler 19243c30b4 Merge pull request 'GameStream sessions built the XUSB pad that Steam cannot see — Xbox backend parity with the native plane' (#356) from worktree-gamestream-xbox-hid-parity into main
deb / build-publish-host (push) Successful in 5m28s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 50s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
deb / build-publish (push) Successful in 5m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
ci / rust (push) Successful in 7m27s
android / android (push) Successful in 10m54s
deb / smoke-install (push) Successful in 3m58s
arch / build-publish (push) Successful in 11m11s
windows-host / package (push) Successful in 14m38s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
2026-08-20 16:33:41 +00:00
enricobuehler dfcffcdd50 fix(gamestream/windows): Moonlight sessions built the XUSB pad that Steam cannot see
ci / bun-nix (pull_request) Successful in 36s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 1m39s
ci / rust (pull_request) Successful in 5m36s
android / android (pull_request) Successful in 5m51s
Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.

On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "a non-HID UMDF2 driver", Class =
System), so Steam's hidapi enumeration, SDL, RawInput, DirectInput, joy.cpl and
WGI/GameInput cannot see it at all — only classic XInputGetState can. The native plane
moved to the real HID pad as its DEFAULT on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.

The GameStream plane never got that change. It has bound `crate::inject::gamepad`
since the first gamepad commit, when that name meant uinput and Windows had no second
backend; Windows later gave the same name the XUSB companion, so this plane inherited
it by module-name coincidence rather than by any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.

There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.

- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
  pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
  no existing caller. Its doc now records that both planes read it, and why being
  `pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
  backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
  and on every other platform the single backend that exists.
  `PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.

The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.

No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.

VERIFIED
  * ON WINDOWS (.133, the only box where this arm compiles at all):
    `cargo check -p punktfunk-host` clean in 57s, then
    `cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
    features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
    `Hid` are Windows types alike.
    Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
    tree plus this box's lagging clock and a warm shared target dir otherwise yield a
    fresh `Finished` having compiled nothing), and both passes logged
    `Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
    header-write notice — benign and pre-existing.
  * `cargo fmt --all --check` clean.

NOT VERIFIED — owed
  * Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
    down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
    the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
    the identical method surface and just compiled — and ci.yml DOES run on
    pull_request, so opening a PR closes this.
  * On glass: no Moonlight session has driven the HID pad through this plane yet. That
    is the real acceptance test, and the log line to look for is
    "virtual Xbox pad created (Windows UMDF HID)" where it used to say
    "virtual Xbox 360 created (Windows XUSB companion)".
  * windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
    workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
    above is deliberately standing in for it.
2026-08-20 18:19:19 +02:00
enricobuehler f2b5b3e567 Merge pull request '0.31.0 release notes, re-cut on current main — and the bun install CI flake' (#351) from worktree-release-next-prep into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 29s
ci / docs-site (push) Successful in 1m22s
ci / web (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m5s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust (push) Successful in 8m26s
decky / build-publish (push) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m37s
android / android (push) Successful in 9m57s
linux-client-screenshots / screenshots (push) Successful in 2m32s
sbom / sbom (push) Successful in 26s
arch / build-publish (push) Successful in 10m14s
flatpak / build-publish (push) Successful in 5m2s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
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 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 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / deploy-docs (push) Successful in 17s
deb / smoke-install (push) Successful in 4m40s
deb / build-publish (push) Successful in 3m24s
deb / build-publish-host (push) Successful in 7m31s
deb / build-publish-gamescope (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m11s
web-screenshots / screenshots (push) Successful in 6m19s
windows-host / package (push) Successful in 13m57s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m43s
apple / swift (push) Failing after 18s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
2026-08-20 07:50:58 +00:00
enricobuehler 6d7e6f71c0 release: 0.31.0 — verification pass before the tag
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 5m4s
Re-measured every version-table row against the tree rather than
trusting the earlier cut: ABI 25, wire 2, driver 6/min 3, gamepad 3,
index schema 1, event schema 1, edition 2024, MSRV 1.85, 27 crate dirs,
39 workspace members, SDK 0.1.5, plugin-kit 0.4.3, openapi 0.31.0 with
both copies byte-identical, gamescope +pfhdr8 with 10 patch files and
no diff against the tag. All match what the table claims. The C header
diff against v0.30.0 removes exactly two things — the old ABI define and
the ex11 doc paragraph that stated the superseded hi-res rule — so
'addition only' holds.

Three fixes to the text itself:

  * The commit count is now taken at the tip being released (170/113 at
    d7fa5847) instead of at this branch, where it moved every time I
    committed to it.

  * The two NixOS items in Before-you-update are one item. They were
    separated by three unrelated bullets, so the audience that has to
    act on both — builds are failing AND there is now a cache — had to
    find them in two places.

  * A TL;DR line read 'it repaired itself on no retry', which says the
    opposite of what it means.

Checked and left alone: the Gaming Mode 'Switch to Desktop' entry is
presented as a bug users hit, and that is right — v0.30.0's takeover
does stop the display manager on mask-fragile flavors
(gamescope.rs:259 at the tag), so it shipped. Only the mask-vs-stop
detail moved inside this cycle.

Gates: fmt clean, punktfunk-core --lib 273 passed, cargo metadata ok,
Play notes 456/500 and unique, voice scan finds no internal names in the
notes body.
2026-08-20 09:42:02 +02:00
enricobuehler 9e3fba10c1 release: 0.31.0 — fold in the four commits that landed during the re-cut
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m43s
ci / docs-site (pull_request) Successful in 1m52s
ci / rust-arm64 (pull_request) Successful in 2m27s
ci / rust (pull_request) Canceled after 5m1s
170 -> 173 commits since v0.30.0 (115 non-merge, counted on this branch).

One is user-facing and is in the notes: the web console's Virtual
displays page rendered the Streamed-screen and session-lifetime cards
below the tab shell, so both tabs showed them and the Live tab read as
a duplicate of Configuration. They are policy surfaces and now sit
inside Configuration.

The other three are CHANGELOG-only: the guides trim's second round, the
fifth get-started screenshot (a client's host list), and the workflow
change that made it reachable — linux-client-screenshots now publishes
its PNGs to the generic registry as well, because the v3 artifact store
is browser-only and nothing could pull the shots out of it.

The CI bullet also records this branch's own retry.sh wrapping, since a
reader hitting 'Fail extracting tarball' deserves to find the diagnosis
rather than repeat it.

Gates re-run on this tip: fmt clean, punktfunk-core --lib 273 passed,
Play notes 456/500, both openapi copies identical at 0.31.0.
2026-08-20 09:36:58 +02:00
enricobuehler fd4f032d20 ci: retry bun install — a truncated tarball reads as a corrupt package
ci / bun-nix (pull_request) Successful in 44s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m14s
ci / rust (pull_request) Canceled after 2m3s
ci / rust-arm64 (pull_request) Canceled after 2m2s
ci / docs-site (pull_request) Canceled after 2m1s
docs-site died on `error: Fail extracting tarball for
"@rolldown/binding-linux-x64-gnu"` (run 19630, 2026-08-20). The message
points at the package; the package is fine.

MEASURED, because the message invites the wrong fix:
  * The tarball's sha512 matches docs-site/bun.lock exactly, and it is
    an ordinary 3-entry npm tgz — same gzip framing, same modes, no pax
    headers — as the 1.2.0 one that installs fine. Only the payload
    differs in size (20.6 MB vs 19.0 MB of .node).
  * bun 1.3.13 AND 1.3.14 both extract that exact tarball from disk in
    under 80 ms. So it is not the bun bump the floating oven/bun:1 tag
    brought in, and not a format bun stopped accepting.
  * In the SAME run, the web job installed the same registry over the
    same network and passed — it was 25 s ahead of docs-site.
  * Run 19632, seven minutes later, installed the identical lockfile
    and passed.

So: a transient truncation, not a bad package. bun streams
download-and-extract, so a tarball cut off mid-stream surfaces at the
extract step and names the package it was reading — which is why this
looks like `@rolldown/binding-linux-x64-gnu` is broken and why the
obvious fixes (bump rolldown, pin bun, refresh the lockfile) would all
have "worked" by changing which bytes were in flight, and none of them
would have fixed anything.

scripts/ci/retry.sh already exists for precisely this and its header
already diagnosed it: "the runner box executes many jobs in parallel and
its network drops packets under that load … Wrap every single-shot
network command in CI with this instead." `bun install` is a single-shot
network command and was the one class still unwrapped, so it is wrapped
now at all nine Linux sites — ci.yml (web, docs-site), arch, deb, rpm,
web-screenshots, sdk-publish and plugin-kit-publish (both installs).

3 attempts, not retry.sh's usual 5: a genuinely stale lockfile fails
deterministically under --frozen-lockfile, and 10s+20s of backoff is
enough to outlive a load burst without making that honest failure wait
a minute and a half.

The two windows-host.yml installs are left alone: pwsh, and a Windows
box that is not the contended runner.

Verified: all seven workflows still parse; the helper resolves from
web/, docs-site/ and sdk/ (the three working-directory shapes used);
the wrapper recovers a command that fails once and succeeds on the
retry; and `bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile
--ignore-scripts` in docs-site installs all 1138 packages, so the
lockfile is sound and the wrapper does not change the command.

Not done, deliberately: docs-site's lockfile still pins rolldown 1.1.2
where web has 1.2.0. That difference is real but it is not this bug,
and refreshing a lockfile to chase a network flake would have buried it.
2026-08-20 09:34:50 +02:00
enricobuehler 892e683f0e Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-20 09:24:01 +02:00
enricobuehler 7af6c323d0 Merge remote-tracking branch 'origin/main' into worktree-console-ui-off-toggle
android / android (pull_request) Successful in 7m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m8s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 31s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m34s
# Conflicts:
#	docs-site/content/docs/client-settings.md
2026-08-20 09:13:10 +02:00
enricobuehler 76e6618b84 fix(console-ui): Settings and the library are reachable without face buttons, for a TV remote
ci / docs-drift (pull_request) Successful in 36s
ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m29s
android / android (pull_request) Canceled after 1m54s
ci / rust (pull_request) Canceled after 1m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1m59s
An Android TV's only input is its remote — a D-pad, OK and Back — and the
console bound both Settings and the game library to face buttons that
remote does not have. X opened Settings, Y opened the shelf, and neither
event could be produced: the Kotlin bridge emits Move/Confirm/Back for a
remote and nothing else, the carousel's only action tiles are Add Host and
Rescan, and the hint chips that also carry those actions need a finger on
a touchscreen. So on a TV the two were simply unreachable. This is the
gap the deleted Compose home used to paper over with its own "no face
buttons" mapping, and the same wall Apple hit on the Siri Remote.

Down on the carousel now opens Settings — the other free direction beside
up, which already opens the host menu. It is a second route on a pad and
the only route on a remote, so the hint bar names the one the device in
hand actually has: ▼ with no pad attached, X with one. That is the whole
extent of the legend change; Y still opens the library and still says so.

The shelf gains a Library row on the host's own options menu, which up
reaches, offered on exactly the terms Y offers it (saved AND paired). It
replaces the menu rather than stacking on it, so Back from the shelf lands
on the carousel and not on a menu about the host just left. This is also
the route `docs/game-library.md` has been describing all along — "open its
options and choose Library" — which until now did not exist.

▼ is a new hint glyph: the ▲ triangle stood on its head rather than a
second drawing routine, and pressable in the hint bar exactly as ▲ is.
2026-08-20 09:06:05 +02:00
enricobuehler 7d2a8778d1 fix(nix): the prune deleted the compositor and shipped only its launcher
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 1m0s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / rust (pull_request) Successful in 5m16s
nix / flake (pull_request) Successful in 12m29s
ROOT CAUSE of every gamescope symptom chased today. nixpkgs WRAPS this package:
makeWrapper leaves the real compositor ELF at bin/.gamescope-wrapped and
installs a small launcher at bin/gamescope that sets PATH (xwininfo) before
exec'ing it. Our prune kept only `gamescope`:

    find $out/bin -mindepth 1 ! -name gamescope -delete

so it deleted the compositor and shipped the launcher pointing at a path that
no longer existed. MEASURED (run 19622): $out/bin held one 16 KB file, and
`strings` on it showed the PATH prologue and '/bin/.gamescope-wrapped'.

That single line explains all of it:
  * `--version` printed NOTHING — the launcher exec'd a deleted target. I had
    attributed this to the build sandbox, and to upstream's PrintVersion()
    landing after 3.16.25. Both were wrong.
  * the +pfhdr marker was 'missing from the binary' — a wrapper carries no
    version string, so it never could have been there.

Keep the target through the prune, and assert on the WRAPPED ELF rather than
the launcher (which would otherwise pass or fail by accident). The launcher
references its target by absolute path, so renaming the launcher stays safe.

The guards were right on every run; they were reporting a genuinely broken
artifact, not a flaky check.
2026-08-20 07:02:22 +00:00
enricobuehler 6007bc42cd docs(client-settings): the console-off switch is in both places on Android, and neither row is offered on a TV
The Interface section owned two facts this changes: which surfaces carry
"Gamepad-optimized browsing" (the Android console home carries it now, so
that home can be left from inside it) and what an Android TV sees — the
mode row used to be described as present-but-inert there, and both rows are
simply absent now.
2026-08-20 08:40:59 +02:00
enricobuehler 16d54b73a1 feat(console-ui): the console can switch itself off, where there is a touch UI to switch off to
The Android console's settings screen had a picker for WHEN it fronts the
app but no switch for WHETHER it does — that lived only in the touch
settings, which a user already inside the console has no way to reach.
The console UI became the only UI on Android, so the way back out was a
setting you could only change from the thing you were trying to get to.

Adds the "Controller-optimized UI" row over the same `gamepadUiEnabled`
the touch switch writes, folded through `extra` under
`android.gamepad_ui_enabled` like the other Android-only rows. Off
returns to the touch home immediately: the console's save bumps the
snapshot generation, the host emits its settings event, and Kotlin's
`applySettings` now folds the flag back into the App state that
`gamepadUiActive` reads.

Shown only where "off" has somewhere to land. `ConsoleOptions.fallback_ui`
(new, threaded to `Ctx` beside `deck`) is true only for the Android touch
shell — false on a TV and for the desktop session, where this console IS
the interface and an off switch would strand the user in nothing.

The mode row follows the same rule and moves under it: it decides nothing
on a TV (the tv term satisfies `gamepadUiActive`'s OR on its own) or while
the switch is off, so it is hidden in both cases rather than offered as a
control that changes nothing. Renamed to the touch screen's own word for
it, "Show it" — "Controller UI" directly under "Controller-optimized UI"
was two rows told apart by their tails.
2026-08-20 08:40:59 +02:00
enricobuehler d60b1dda29 fix(nix): split the +pfhdr failure into its two possible stages
The marker is not in the installed ELF, and patch 0005 applies cleanly to a
src/meson.build that still carries the exact line it rewrites. Those two facts
cannot both be reasoned from the log as it stands, so stop guessing and
instrument the two stages separately:

  * postPatch now asserts +pfhdr is in src/meson.build after our vcs_tag
    substitution — the same gate build-punktfunk-gamescope.sh applies. If it
    fires, patch 0005 or the substitution lost the marker, and it prints the
    version block as patched.
  * the installCheck failure branch now prints evidence instead of an
    assertion: $out/bin, anything under $out mentioning pfhdr, and the
    version-shaped strings actually present in the binary.

Whichever fires, the next run says which stage drops it. Each guess here costs
a full compositor build, and I have now spent three on this one symptom.
2026-08-20 06:39:28 +00:00
enricobuehler d7fa5847f1 Merge pull request 'Virtual displays: the Streamed-screen and session⇄game cards showed on both tabs — they join the Configuration tab' (#353) from worktree-displays-tab-dup into main
ci / bun-nix (push) Successful in 39s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m38s
ci / docs-drift (push) Successful in 1m4s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-gamescope (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
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 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish (push) Successful in 5m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m49s
arch / build-publish (push) Successful in 8m38s
deb / build-publish-host (push) Successful in 6m58s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 3m7s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
ci / rust (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
2026-08-20 06:38:08 +00:00
enricobuehler e473a4be7b fix(web): the Virtual displays page's trailing cards join the Configuration tab
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Failing after 11m15s
ci / rust (pull_request) Successful in 20m0s
The Streamed screen and session-game lifetime cards sat BELOW the tab
shell, so they rendered on both tabs — from the Live displays tab the
page read as the Configuration tab's content duplicated (reported the
other way round, same sight). Both are policy surfaces, so they ride
inside the Configuration tab as siblings of the config card; the Live
tab is now only the live list + arrangement.
2026-08-20 08:34:05 +02:00
enricobuehler 675030935a fix(nix): assert +pfhdr in the installed binary, not by running --version
`--version` produces EMPTY output under nix's build sandbox — measured on BOTH
nixpkgs' 3.16.25 and the pinned 5fb8dce4 (runs 19551 / 19573 / 19594). It is a
sandbox property, not a defect: gamescope calls PrintVersion() before the getopt
loop (src/main.cpp:721), so `gamescope --version` does print the banner on a
real system, which is what the host's capability probe reads. My earlier claim
that the probe itself was broken was wrong.

packaging/gamescope/build-punktfunk-gamescope.sh already avoids running the
binary, asserting on src/meson.build instead. Grepping the installed ELF is
strictly stronger than either: the version string reaches .rodata via
GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
patching, meson configuration and compilation into the artifact we ship — and it
cannot be defeated by the binary being unable to start.

The src pin from the previous commit stays. It is still right for its own
reason: the patches are authored against 5fb8dce4, nixpkgs' 3.16.24 could not
take patch 0009 at all, and every other channel ships this exact commit.
2026-08-20 06:21:37 +00:00
enricobuehler 21d9190324 Merge pull request 'The fifth get-started screenshot — a client's host list — and the workflow change that made it reachable' (#352) from docs-host-list-shot into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m17s
ci / docs-drift (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m17s
ci / rust (push) Successful in 13m27s
2026-08-20 06:19:35 +00:00
enricobuehler 7ae8866a5c Merge pull request 'Guides trim round 2: the five biggest pages each shed one prose category — −16%, no facts, anchors stable' (#350) from docs-guides-trim-round2 into main
ci / rust (push) Canceled after 24s
ci / docs-site (push) Canceled after 23s
ci / web (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 23s
ci / bun-nix (push) Canceled after 24s
ci / docs-drift (push) Canceled after 24s
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 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m36s
docker / deploy-docs (push) Successful in 1m1s
2026-08-20 06:19:20 +00:00
enricobuehlerandClaude Fable 5 a8099e0f5b docs(quickstart): the client host list — the fifth get-started screenshot, pulled from the pipeline the previous commit teaches to publish
ci / web (pull_request) Successful in 3m38s
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 5m53s
ci / docs-drift (pull_request) Successful in 50s
ci / rust (pull_request) Successful in 7m46s
Saved hosts with their pairing state + an unpaired host found on the network, from
linux-client-screenshots run 19593 via the new generic-package publish (fixed 'ci' version,
anonymous GET), cropped to the cards. Completes the handoff's screenshot list: console login,
Waiting for approval + PIN, the Approve dialog, live status, and a client's host list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:25:44 +02:00
enricobuehler b4b24f8b57 release: 0.31.0 — re-cut on d801cb72, folding in the 41 commits since #320
ci / rust-arm64 (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m27s
ci / docs-drift (pull_request) Successful in 30s
ci / rust (pull_request) Successful in 16m35s
#320 merged but v0.31.0 was never tagged, and 69 more commits (41
non-merge) landed on top of it. The version stays 0.31.0 and Cargo.toml
does not move; the notes, Play notes and CHANGELOG section are
re-measured on the new tip.

THE NUMBER IS NOW FORCED, where the first cut's was a judgement call.
The C ABI moves 24 -> 25: punktfunk_set_log_callback plus the
PunktfunkLogCb typedef (#338's core half), so an embedder can hear the
core's tracing lines. Addition only — no declaration moved, no repr(C)
struct grew a field, and an embedder that never calls it is byte-
compatible with v24. include/punktfunk_core.h is therefore NO LONGER
byte-identical to the v0.30.0 tag, which the first cut's table and both
"nothing versioned moves" paragraphs claimed.

Table re-measured on d801cb72. Two rows changed since the first cut and
two were already corrected in-tree by their own commits:
  * C ABI 24 -> 25 (above).
  * api/openapi.json 0.29.0 -> 0.31.0 — the STAMP only. No management
    surface moved; the file had been left two versions stale and #337's
    regenerate-and-diff caught it. Both copies cmp identical.
  * SDK 0.1.5 and plugin-kit 0.4.3 were cut during the cycle (#348,
    #334), so the rows the first cut wrote as "owed" are now past tense.
    The registry still skips 0.4.2 on purpose.
Everything else holds: wire 2, driver protocol 6 / min 3, gamepad
channel 3, plugin index schema 1, host event schema 1, edition 2024,
MSRV 1.85, 27 crate dirs, gamescope +pfhdr8 with no new patches. The
PKGBUILD pfhdr7 discrepancy is unchanged and still pre-existing.

REWRITTEN RATHER THAN APPENDED TO, because the intervening work never
shipped and a user must not read a within-cycle correction as a second
bug:
  * The Gaming Mode takeover section. The first cut described "stops the
    login manager first, then steps in" — which #342 replaced before
    shipping, after a field report that a stopped display manager leaves
    the box unable to service Steam's own Switch to Desktop (it hung
    until reboot, and could not even be detected around: every trace of
    that switch is written by the component we had stopped). End state:
    idle the autologin over the gamescope-session-plus@ template, leave
    the DM running. #343 then deleted the whole 142-line stop/linger
    chain the old approach needed. Two shipped facts became false with
    it — the takeover no longer stops the DM and no longer needs the
    punktfunk group (that group still gates the usbip pad nodes) — and
    the notes' Before-you-update says so for Deck/Bazzite operators.
  * The Android ASC entries. HDR seeded from the codec's echo, untagged
    SDR raising blacks to grey, and the console's dispose re-showing the
    system bars over the stream are all regressions against THIS cycle's
    unreleased presenter, so they are folded into the Android story, not
    listed as fixes users hit.

NEW USER-FACING WORK, in rough order of how much it changes:
  * Windows client (#349): the default download becomes a signed
    per-user Inno Setup installer + portable zip; MSIX stays for the
    Store. This is the Steam fix — MSIX puts the exe under WindowsApps,
    which Steam's picker cannot browse, and alias activation defeats
    overlay injection. Before-you-update carries the one action in the
    release: an MSIX user who wants it must Remove-AppxPackage first and
    will re-pair once, because pairing identity lives with the package.
  * install.sh (#345/#346): one command from nothing to a pairable Linux
    host, running platforms.json's lines verbatim, with Sunshine
    coexistence, --dry-run, --uninstall and env twins. Shipped PREVIEW
    on purpose and the notes say so.
  * Send logs to host completes on Apple (#338) and Android (#339) — the
    two legs 0.30's notes named as follow-ups.
  * KWin 6.6 (#344/#331): it refuses to stream an output it created
    disabled, in the session's language, logged nowhere, and persists
    the disable against the stable per-client name so it repeats
    forever. Enable and retry; and a translated refusal no longer burns
    all 8 retries.
  * Deck Wake-on-LAN (#347, closes #322), portable Playnite covers
    (#330), plugin-kit republish on startup/manual (#334), console touch
    gestures and idle gates (#333), cancel-connect (#329), NixOS host
    builds failing outright (#328 + #332 + #336).
  * Docs overhaul (#337/#340/#343/#346): get-started track, Switching
    from Sunshine, screenshots, and data/platforms.json as the single
    source for install facts with seven CI drift gates over it.

Gates on this tree: cargo fmt --all --check clean (no drift this time —
the first cut needed a whitespace commit, which is already in main);
cargo metadata --offline ok; cargo test -p punktfunk-core --lib 273
passed; Play notes gate verbatim 456/500 and unique; voice scan clean
outside For developers; both openapi copies cmp identical at 0.31.0.

⚠ The C ABI harness still did not run here — no libopus on this MacBook
— and this time the header DID change, so ABI 25's C compile is owed to
CI. Named in Verification status rather than left to be found.

⚠ Owed outside this repo: platforms.json changed (the Windows download),
so punktfunk-website needs `bun run sync-platforms` committed — the step
#346 added to docs/releases/README.md, and this is its first exercise.
2026-08-20 01:24:15 +02:00
enricobuehlerandClaude Fable 5 c23fc84bef docs(guides): trim round 2 — each page sheds one prose category, no fact rows, headings or commands touched
ci / rust-arm64 (pull_request) Successful in 1m44s
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 12m33s
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
  default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
  paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
  operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
  asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
  ('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
  end, so only a light pass was honest.

15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:17:16 +02:00
enricobuehler 0e5a059098 fix(nix): pin gamescope's src to 5fb8dce4, like every other channel
nix was the ONLY channel not pinning the compositor — the RPM spec, the
PKGBUILD, build-punktfunk-gamescope.sh and packaging/gamescope/README.md all
ship 5fb8dce4 (3.16.25-11), while gamescope.nix patched whatever version
nixpkgs happened to carry. That produced two failures in two days, both the
same bug:

  * nixpkgs shipped 3.16.24, where patch 0009's context does not exist, so the
    build died at patchPhase. host.gamescopeHdr defaults true, so every
    `services.punktfunk.host.enable = true` failed the same way.
  * bumping the lock to 3.16.25 fixed the patches, and then --version printed
    NOTHING. Upstream's `gamescope::PrintVersion()` landed AFTER the 3.16.25
    tag; in that tag `--version` is `return 0;` with the comment "We always
    print the version to stderr anyway". The host reads that banner to decide a
    session's bit depth and cursor compositing BEFORE the virtual display
    exists, so a silent banner is a silent fall back to SDR.

The installCheck was right to fail — this was not a check problem.

Also in this commit: the +pfhdr guard now prints the banner it read. That is
how the empty output was identified at all; without it the failure is
indistinguishable from the binary not starting, at ~15 min per guess.

vcs_tag now substitutes the PINNED version (old.version is the pre-override
attr and would still say 3.16.25).

nix-instantiate --parse clean. Hash from nix-prefetch-git --fetch-submodules.
2026-08-19 23:16:18 +00:00
enricobuehlerandClaude Fable 5 674b16d8eb linux-client-screenshots: publish the PNGs to the generic package registry too — the v3 artifact is browser-only, which blocked reusing the shots for the docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:08:31 +02:00
enricobuehler d801cb72f2 Merge pull request 'Windows client: per-user installer + portable zip as the default download — a stable exe path Steam can launch (overlay, Big Picture); MSIX stays for the Store' (#349) from worktree-win-client-installer into main
ci / rust-arm64 (push) Successful in 2m26s
ci / web (push) Successful in 1m53s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / docs-site (push) Successful in 2m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
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 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust (push) Successful in 7m57s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
installer-smoke / smoke (arch) (push) Successful in 45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
installer-smoke / smoke (fedora-44) (push) Successful in 1m18s
installer-smoke / smoke (debian-13) (push) Successful in 2m26s
docker / builders-arm64cross (push) Successful in 31s
docker / deploy-docs (push) Successful in 32s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m48s
2026-08-19 23:05:32 +00:00
enricobuehler ace01f06a2 Merge pull request 'Cut SDK 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships' (#348) from release-sdk-0.1.5 into main
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 1m42s
ci / docs-drift (push) Successful in 23s
ci / docs-site (push) Successful in 2m49s
ci / web (push) Successful in 2m51s
deb / build-publish-gamescope (push) Successful in 47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
deb / build-publish-client-arm64 (push) Successful in 1m41s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
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 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
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 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
sdk-publish / publish (push) Successful in 42s
deb / build-publish (push) Successful in 5m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish-host (push) Successful in 4m57s
arch / build-publish (push) Successful in 11m7s
docker / builders-arm64cross (push) Successful in 11s
ci / rust (push) Canceled after 40s
docker / deploy-docs (push) Canceled after 0s
deb / smoke-install (push) Successful in 3m9s
windows-host / package (push) Successful in 17m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 36s
nix / flake (push) Failing after 24m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 27m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m36s
2026-08-19 22:51:11 +00:00
enricobuehler e4ec4cec31 chore(sdk): cut 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 2m7s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 7m25s
nix / flake (pull_request) Successful in 15m37s
v0.31.0's changelog recorded this cut as owed and said why: plugins resolve
`@punktfunk/host` from the registry, so a fix sitting in `sdk/` reaches nobody
until a version carries it. Four commits have touched the SDK since sdk-v0.1.4,
the headline one being the mgmt-port fix — a moved `PUNKTFUNK_MGMT_BIND` left
every plugin and the tray dialing 47990.

`SDK_VERSION` moves with it. It is a hand-maintained constant (the bundled
runner has no package.json to read at runtime), and the runner compares it
against the SDK actually installed in the plugins tree to decide whether to
reinstall — so shipping 0.1.5 with the constant still reading 0.1.4 would have
published the fix and then never delivered it. `version.test.ts` exists for
exactly this and caught it.

Gates: 83/83 SDK tests pass.
2026-08-20 00:50:46 +02:00
enricobuehler 245173a731 fix(nix): make the +pfhdr guard show the banner it actually read
The memory raise got gamescope building, and it now reaches installCheckPhase
and fails there:

    punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take

Patch 0005 applied cleanly to src/meson.build in that same run, so the message
is misleading: the patch DID take. Two very different causes are
indistinguishable from the log as written —

  * the binary never ran (shrunk RPATH, missing loader dep), so --version
    printed nothing at all; or
  * it ran and printed a version without +pfhdrN, meaning upstream no longer
    builds the banner from VCS_TAG.

A guard that reports 'missing' without showing what it read cannot be acted on,
and each guess costs a full compositor build. Capture the output and print it on
failure, with a note on how to read the two cases apart.

No behaviour change: same assertion, same exit. Also verified in this run: the
chmod fix works (prune, rename and layer install all completed) and the WSI
layer is installed.
2026-08-19 22:46:07 +00:00
enricobuehler 230d253b06 Merge pull request 'The Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all' (#347) from worktree-deck-wol-learn-mac into main
ci / web (push) Successful in 1m37s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 35s
ci / rust-arm64 (push) Successful in 2m42s
ci / docs-site (push) Successful in 2m30s
deb / build-publish-gamescope (push) Successful in 1m40s
deb / build-publish-client-arm64 (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 19s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
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
ci / rust (push) Successful in 7m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
deb / build-publish (push) Successful in 5m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
arch / build-publish (push) Canceled after 8m19s
deb / build-publish-host (push) Canceled after 5m10s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m43s
android / android (push) Successful in 11m48s
flatpak / build-publish (push) Successful in 4m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m14s
Reviewed-on: #347
2026-08-19 22:41:32 +00:00
enricobuehler def215ae8e Merge pull request 'Installer bedding-in (--uninstall, NVIDIA silent-failure checks, detection matrix as gate 7) and screenshots for the get-started track' (#346) from worktree-docs-phase2 into main
android / android (push) Canceled after 44s
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 37s
ci / web (push) Canceled after 36s
ci / docs-site (push) Canceled after 35s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
decky / build-publish (push) Successful in 47s
installer-smoke / smoke (arch) (push) Successful in 42s
apple / swift (push) Successful in 2m13s
installer-smoke / smoke (debian-13) (push) Successful in 1m2s
installer-smoke / smoke (fedora-44) (push) Successful in 2m15s
apple / distribute (push) Successful in 11m38s
apple / screenshots (push) Successful in 10m38s
2026-08-19 22:40:55 +00:00
enricobuehlerandClaude Fable 5 dfcc530ee7 installer bedding-in: --uninstall, NVIDIA silent-failure checks, the detection matrix as CI gate 7 — and the get-started track gets its screenshots
ci / docs-drift (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
installer-smoke / smoke (debian-13) (pull_request) Successful in 58s
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 43s
ci / bun-nix (pull_request) Successful in 30s
ci / rust-arm64 (pull_request) Successful in 3m6s
installer-smoke / smoke (arch) (pull_request) Successful in 1m34s
ci / rust (pull_request) Successful in 7m54s
android / android (pull_request) Successful in 8m7s
Phase 2 of the docs-and-onboarding overhaul (items 1-partial, 2 and 4 of the handoff):

install.sh: --uninstall reverses step 1 + step 6 per family (user units off first, only the
punktfunk packages actually installed, then the repo; config/groups/firewall stay, as
/docs/uninstall states) — smoke-tested as a new installer-smoke step on all three families.
The end-of-run check now catches the two NVIDIA silent failures on every family: no driver at
all, and a module the kernel refused to load (Secure Boot) via an nvidia-smi probe pointing at
the troubleshooting anchor; the Fedora ffmpeg-libs/NVENC warning folds into the same block.

check-docs-drift.sh gate 7: the manual 16-file os-release matrix PR #345 was verified with,
committed — every family's detection, its install line, its removal line and the four
unsupported pointers run through the real script under --dry-run on every push (docs-drift's
container gains curl, the script's own prerequisite).

Screenshots (RFC: "screenshots over prose"): four console shots captured from the same
storybook-fixture pipeline web-screenshots.yml runs — login and the armed Pairing page into
quickstart.md, the Approve dialog (access level + expiry + guest fast-path) into pairing.md
replacing the prose that described it, live status into web-console.md. Files under
docs-site/public/img/, dark-theme, bundled+preloaded by the docs build (verified served).
Still missing: a client host-list shot — linux-client-screenshots run 19546 built it, but its
artifact isn't API-downloadable; add it when a browser session can fetch the zip.

WP5 rider: the release-flow docs-freshness step now includes the website content look-over.

NOT flipped: installer stays preview — the handoff gates the default flip on real-box mileage
(Bazzite above all), which a Mac can't provide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:56:19 +02:00
enricobuehler 2b81bd286f fix(clients): the Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 1m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 5m19s
android / android (pull_request) Successful in 7m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m43s
Every wake gate in the codebase reads `!host.mac.is_empty()` against the saved
record — `ConnectPlan::wake`, the console's `can_wake`, `punktfunk wake`. That MAC
only ever reached the store through `trust::learn_mac`, and `learn_mac` had exactly
two callers: the GTK hosts page and the WinUI one.

Neither runs on a Steam Deck. Gaming Mode has only the Decky panel (which drives the
headless CLI) and the console home — and those learned the management port alone,
never the MAC. So a Deck's records stayed MAC-less forever, every wake gate stayed
false, and Wake-on-LAN was skipped silently: no packet, no error, nothing to see.
It worked on desktop purely because those two hosts pages learn on each discovery
tick. (#322)

Rather than add the missing call twice, collapse the three per-field learners
(`learn_mac`, `learn_os`, `learn_mgmt_port` — three `pub fn`s, three load/save
cycles) into one `learn_from_advert`, and call it at every site where an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's
`discover`. Remembering one call is not a thing a front-end can half-do; remembering
three is what produced this. It takes the three fields rather than a `DiscoveredHost`
because there are two of those — core's and the WinUI shell's verbatim port.

`discover` is where the panel-only flow is fixed: it is the one verb the Decky panel
runs that ever sees an advert. It keeps `KnownHosts::read()`, so it still mints no
ids and cannot join the race that comment warns about, and `learn_from_advert` writes
only when an advert genuinely taught the record something — a steady-state panel
refresh touches no disk.

Two things fall out of the same root cause: the console home now persists the OS
chain too, so a Deck host's icon stops vanishing the moment mDNS goes quiet; and
`punktfunk wake`'s "connect to it once while it's awake" is replaced, since a MAC
comes from an advert and never from a connect — that wording sent this diagnosis
looking in the wrong place.

The magic-packet sender itself was never at fault (`punktfunk-core::wol` passes its
7 tests) and neither was the flatpak sandbox (`--share=network`). Nothing reached
them.

Closes #322
2026-08-19 14:16:14 +02:00
159 changed files with 9257 additions and 1512 deletions
+24 -1
View File
@@ -171,6 +171,13 @@ jobs:
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# Must precede every cargo step below: skia-bindings' ~19 MB prebuilt download runs inside
# a build script with no retry, and a truncated transfer here does not surface as a network
# error — it silently becomes a from-source Skia build that dies in the container. See the
# script for the measured failure.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
@@ -209,9 +216,25 @@ jobs:
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
# both pointer widths are load-bearing and why the environment must not be duplicated here.
# The `STARTING A FULL BUILD` check turns the manual rule in this workflow's `env:` block
# ("Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED") into something that fails the
# job by itself. Without it a missed prebuilt reads as a Gradle stack trace with the real
# cause ~1,800 lines up — which is exactly how 2026-08-22 spent a week looking like a lint
# failure. This is the first cargo step in the job, so it catches the drop earliest.
#
# No pipefail: the runner is dash. Capture, then decide.
- name: Clippy (Android target, deny warnings)
working-directory: clients/android
run: ./gradlew :kit:cargoNdkClippy --stacktrace
run: |
set -e
rc=0
./gradlew :kit:cargoNdkClippy --stacktrace > /tmp/android-clippy.log 2>&1 || rc=$?
cat /tmp/android-clippy.log
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
exit 1
fi
exit $rc
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
+3 -1
View File
@@ -248,7 +248,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
+27 -3
View File
@@ -107,6 +107,12 @@ jobs:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -270,6 +276,12 @@ jobs:
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
# `pf-console-ui` pulls skia-safe, so a target-cache miss makes this job download a prebuilt
# Skia from the same no-retry build-script fetch that took the android job out on
# 2026-08-22, over the same load-shedding runner network. Cheap insurance; see the script.
- name: curl with retries (skia-bindings' prebuilt fetch has none)
run: sh scripts/ci/install-retrying-curl.sh
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -339,8 +351,19 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
# 30s is enough to ride out a load burst without making that wait a minute and a half.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: it generates the orval API client + paraglide messages that
# typechecking imports.
- name: Build
@@ -368,8 +391,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
- name: Build
run: bun run build
@@ -417,7 +441,7 @@ jobs:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
+3 -1
View File
@@ -231,7 +231,9 @@ jobs:
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
+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"
+6
View File
@@ -61,3 +61,9 @@ jobs:
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
! command -v punktfunk-host
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
@@ -111,3 +111,20 @@ jobs:
name: punktfunk-linux-client-screenshots
path: clients/linux/screenshots
retention-days: 30
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
# blocked reusing these shots for the docs. Publish them to the generic package registry
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
# public repo:
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
- name: Publish screenshots to the package registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
for f in clients/linux/screenshots/*.png; do
name=$(basename "$f")
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
echo "published $BASE/$name"
done
+5 -2
View File
@@ -37,15 +37,18 @@ jobs:
- uses: actions/checkout@v4
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
# is the worst place to lose to a dropped packet — the tag is already pushed.
- name: Build the SDK (file:../sdk dependency source)
working-directory: sdk
run: |
bun install --frozen-lockfile --ignore-scripts
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Install dependencies
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
+3 -1
View File
@@ -176,7 +176,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
+4 -1
View File
@@ -39,8 +39,11 @@ jobs:
- uses: actions/checkout@v4
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
# worst place to lose to a dropped packet — the tag is already pushed.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
+3 -1
View File
@@ -40,8 +40,10 @@ jobs:
- uses: actions/checkout@v4
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Generate API client + i18n messages
run: bun run codegen
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
+1384 -50
View File
File diff suppressed because it is too large Load Diff
Generated
+38 -38
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1959,9 +1959,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.0"
version = "0.31.3"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.0"
version = "0.31.3"
[[package]]
name = "pf-inject"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.0"
version = "0.31.3"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.0"
version = "0.31.3"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+222 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.0"
"version": "0.31.3"
},
"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": {
@@ -1860,6 +1931,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -6625,7 +6759,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6671,7 +6805,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -7312,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",
@@ -7333,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."
}
}
},
@@ -7792,6 +7934,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +8028,42 @@
}
}
},
"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.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { !it.isVirtual && !Gamepad.isPad(it) }
// Everything real that is NOT counted as a controller — including a device that claims
// a pad source with no pad hardware behind it, which the Gamepads list above now
// rejects. One list or the other, never neither: this screen is where someone looks
// when the client's idea of "a pad is attached" disagrees with the room.
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
}
DisposableEffect(Unit) {
val im = context.getSystemService(InputManager::class.java)
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
// The CORRECTED keycode, so this screen shows the button the stream will send and not
// the one Android guessed for a pad it has no key layout for — the two differ on every
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
// stream is worse than no tester. The raw pair is still reported in "Last input".
val code = Gamepad.padKeyCode(event)
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
held[code] = true
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
held[code] = false
if (code == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
}
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
// field report needs to pin an unmapped pad's report order without the device in hand.
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
event.scanCode,
raw,
if (code != event.keyCode) "$fixed" else "",
)
consume
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
// streaming — on a pad Android has no key layout for, the right stick and the triggers
// are not on the axes their names suggest.
val map = Gamepad.padMap(event.device)
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
axes["LT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
axes["RT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
axes["RX"] = event.getAxisValue(map.rightStickX)
axes["RY"] = event.getAxisValue(map.rightStickY)
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
} else {
map.level(event.getAxisValue(map.leftTrigger))
}
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
} else {
map.level(event.getAxisValue(map.rightTrigger))
}
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only when a correction is actually in force: on a pad Android has a key layout for
// there is nothing to say, and a line that says "normal" on every device teaches
// nobody anything. Named rather than merely flagged, so a field report can quote it.
padButtonsNote(info.buttons)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
@@ -784,6 +823,12 @@ internal data class PadInfo(
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
/**
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
* the pad Android already knows, which is what a screenshot scene wants and what the note
* under the card stays silent about.
*/
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
val dev: InputDevice? = null,
)
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
canRumble = deviceHasVibrator(dev),
dev = dev,
)
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
}
/** Identity line: VID:PID + the source classes Android assigned. */
/**
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
* has no key layout for it — null for a pad it does know, which needs no explanation.
*/
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
Gamepad.PadButtons.NATIVE -> null
Gamepad.PadButtons.GENERIC_SONY ->
"Android has no button layout for this controller — read as a PlayStation pad"
Gamepad.PadButtons.GENERIC_XBOX ->
"Android has no button layout for this controller — read as an Xbox pad"
Gamepad.PadButtons.SONY_MODERN ->
"Android has no button layout for this controller — face buttons corrected"
}
private fun deviceDetail(dev: InputDevice): String =
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
@@ -872,6 +932,10 @@ private val TEST_BUTTONS = listOf(
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
"Start" to KeyEvent.KEYCODE_BUTTON_START,
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
"" to KeyEvent.KEYCODE_DPAD_UP,
"" to KeyEvent.KEYCODE_DPAD_DOWN,
"" to KeyEvent.KEYCODE_DPAD_LEFT,
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.abs
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
@@ -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(
@@ -615,8 +628,8 @@ class MainActivity : ComponentActivity() {
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
if (fromPad(event)) {
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
@@ -697,7 +710,7 @@ class MainActivity : ComponentActivity() {
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
lastPadIsGamepad = fromPad(event)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
@@ -705,11 +718,13 @@ class MainActivity : ComponentActivity() {
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched.
when (event.keyCode) {
// already move focus on their own, so they fall through to super untouched. Read
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
// menus on the right buttons too, not only the stream.
when (Gamepad.padKeyCode(event)) {
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
@@ -726,6 +741,32 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* Did this key event come from a controller — the question every pad branch here actually
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
*
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
*
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
* keep leaving the stream, which for a device with no pad on it is the documented way out
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
* branch and breaks them.
*
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
* and both the raw and the corrected keycode are in this block for every button concerned.
*/
private fun fromPad(event: KeyEvent): Boolean =
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
@@ -114,6 +114,22 @@ data class Settings(
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* Draw the console UI at 1080p and let the display scale it up, instead of at the panel's own
* resolution. Off by default — this is a deliberate sharpness-for-smoothness trade, not
* something to impose on a device that does not need it.
*
* It exists for 4K TVs and projectors. Their graphics chips are chosen to decode and composite
* video, not to shade a UI, and are far slower than a phone's; at 4K every pass the console
* draws — the mesh backdrop above all — costs four times what it does at 1080p on hardware
* that is nowhere near four times faster. A "premium" 4K box is MORE likely to want this than
* a cheap 1080p stick, which never had the extra pixels to begin with.
*
* Read by [io.unom.punktfunk.console.SkiaConsoleShell], which applies it with
* `SurfaceHolder.setFixedSize` — the compositor then scales the smaller buffer up for free.
* The stream is untouched; that has its own `renderScale`.
*/
val reduceUiResolution: Boolean = false,
/**
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
@@ -329,6 +345,7 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
reduceUiResolution = prefs.getBoolean(K_REDUCE_UI_RES, false),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
@@ -373,6 +390,7 @@ class SettingsStore(context: Context) {
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_REDUCE_UI_RES, s.reduceUiResolution)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
@@ -415,6 +433,7 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_REDUCE_UI_RES = "reduce_ui_resolution"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -478,7 +497,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)
}
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// The view's CURRENT pixel size, for the ASurfaceControl layer's
// destination rect. It is reported here and not only at
// surfaceCreated because the view grows a frame or two after the
// stream screen appears — hiding the system bars and switching on
// cutout drawing both resize it, and neither recreates the surface.
// A layer left on the start-up rect paints the picture small, in the
// top-left corner. The view's own size, not the buffer geometry in
// `width`/`height`: the layer composites in the view's space.
NativeBridge.nativeVideoSurfaceSize(
handle, this@apply.width, this@apply.height,
)
// Re-assert the frame-rate vote: a buffer-geometry change can reset
// the surface's frame-rate setting on some OEM builds, silently
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
@@ -317,15 +317,21 @@ internal object ConsoleJson {
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
val extra = j.optJSONObject("extra") ?: JSONObject()
extra.put("android.low_latency", s.lowLatencyMode)
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
extra.put("android.gyro_on_phone", s.gyroOnPhone)
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("extra", extra)
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
// whole object into the map under the literal key "extra", where no console row could
// read it and every value the console wrote came straight back as the one we had sent.
j.put("android.low_latency", s.lowLatencyMode)
j.put("android.rumble_on_phone", s.rumbleOnPhone)
j.put("android.gyro_on_phone", s.gyroOnPhone)
j.put("android.sc2_capture", s.sc2Capture)
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("android.reduce_ui_resolution", s.reduceUiResolution)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
return j
}
@@ -335,7 +341,8 @@ internal object ConsoleJson {
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
val extra = j.optJSONObject("extra") ?: JSONObject()
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
@@ -372,13 +379,15 @@ internal object ConsoleJson {
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
reduceUiResolution = j.optBoolean("android.reduce_ui_resolution", s.reduceUiResolution),
)
}
}
@@ -159,6 +159,9 @@ object SkiaConsole {
val opts = JSONObject()
.put("device_name", deviceName(app))
.put("gpu_cache_bytes", gpuCacheBytes(app))
// The touch shell exists as a fallback on phones/tablets but not on a TV —
// gates the console's own "Controller-optimized UI" off switch.
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
.put("settings", ConsoleJson.settings(initial, base))
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -137,13 +138,53 @@ fun SkiaConsoleShell(
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
val scale = if (tv) 0f else {
val dm = context.resources.displayMetrics
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
// The SurfaceView's own laid-out size, fed back by `onSizeChanged` below — deliberately not
// `displayMetrics`. The reduced buffer's aspect ratio has to match the RECT it is scaled into
// or the compositor stretches the whole interface, and while those two normally agree,
// `displayMetrics` has a long history of disagreeing with a view's real size by a system bar
// depending on the version and on who is currently hiding what. "Normally agree" is not
// something to hang picture geometry on. Zero until the first layout, which is exactly what
// `render` wants: the surface comes up at its natural size and is re-fixed a frame later.
var viewW by remember { mutableStateOf(0) }
var viewH by remember { mutableStateOf(0) }
// "Reduce interface resolution" (`Settings.reduceUiResolution`): cap the console's BUFFER at
// 1920 on its long edge and let the compositor scale it up to the panel. 1 means "draw at the
// panel's own resolution" — the setting is off, or the display is already at or under 1080p
// and there is nothing to give back.
//
// ONE factor on both axes, so the aspect ratio survives exactly and no layout can stretch.
// Everything else in this function that speaks in SURFACE pixels multiplies by it — the insets
// and design-unit scale just below, the pointer coordinates further down — because
// `setFixedSize` shrinks the buffer WITHOUT shrinking the view: a mouse still reports its
// position in view pixels, and handing those straight to a half-size surface would land the
// cursor at twice its true offset.
val render = if (!settings.reduceUiResolution) 1f else {
val long = maxOf(viewW, viewH)
if (long > 1920) 1920f / long else 1f
}
LaunchedEffect(handle, left, top, right, bottom, scale) {
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
// The pointer listeners below are installed in `factory`, which runs ONCE — capturing `render`
// directly would freeze them at its first-composition value (1, before the first layout has
// reported a size), and a mouse would keep reporting view pixels into a half-size surface for
// the rest of the session. Same reason `platformUp` is held this way.
val currentRender by rememberUpdatedState(render)
val dm = context.resources.displayMetrics
val scale = if (tv) 0f else {
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
// `render` too: the design-unit scale is in SURFACE pixels, so shrinking the buffer without
// shrinking this would draw the type larger on screen than the same phone draws it today.
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f) * render
}
LaunchedEffect(handle, left, top, right, bottom, scale, render) {
if (handle != 0L) {
NativeBridge.nativeConsoleSetViewport(
handle,
left * render,
top * render,
right * render,
bottom * render,
scale,
)
}
}
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
@@ -159,7 +200,12 @@ fun SkiaConsoleShell(
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (fromPad) {
val bit = when (ev.keyCode) {
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
// under other buttons' names, so read raw this console answered ✕ with whatever
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
// and the game must not disagree about which button a user pressed.
val code = Gamepad.padKeyCode(ev)
val bit = when (code) {
KeyEvent.KEYCODE_BUTTON_A -> 0
KeyEvent.KEYCODE_BUTTON_B -> 1
KeyEvent.KEYCODE_BUTTON_X -> 2
@@ -179,7 +225,7 @@ fun SkiaConsoleShell(
}
return@probe true
}
val dbit = when (ev.keyCode) {
val dbit = when (code) {
KeyEvent.KEYCODE_DPAD_UP -> 0
KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT -> 2
@@ -191,7 +237,7 @@ fun SkiaConsoleShell(
padState.push(handle)
return@probe true
}
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
return@probe true
}
@@ -267,7 +313,9 @@ fun SkiaConsoleShell(
Box(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.onSizeChanged { viewW = it.width; viewH = it.height },
factory = { ctx ->
SurfaceView(ctx).apply {
// The console draws opaque, edge to edge; Compose overlays sit above it.
@@ -300,7 +348,8 @@ fun SkiaConsoleShell(
MotionEvent.ACTION_CANCEL -> 5
else -> return@setOnTouchListener false
}
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
// View pixels → SURFACE pixels (see `render` above).
NativeBridge.nativeConsolePointer(handle, kind, ev.x * currentRender, ev.y * currentRender, 0f)
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
true
}
@@ -308,13 +357,27 @@ fun SkiaConsoleShell(
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
) {
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
NativeBridge.nativeConsolePointer(handle, 4, ev.x * currentRender, ev.y * currentRender, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
true
} else false
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
},
// Applied here rather than in `factory` so flipping the setting takes effect without
// leaving the console: `setFixedSize` re-creates the buffer and the render thread
// re-wraps it through the ordinary surfaceChanged path. `setSizeFromLayout` is the
// documented way back to "the view's own size" when the setting goes off again.
update = { view ->
if (render < 1f) {
view.holder.setFixedSize(
(viewW * render).roundToInt().coerceAtLeast(1),
(viewH * render).roundToInt().coerceAtLeast(1),
)
} else {
view.holder.setSizeFromLayout()
}
},
)
when (platformScreen) {
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import io.unom.punktfunk.console.ConsoleJson
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
*
* They were written and read nested under an `"extra"` object instead. Serde put that whole
* object into the map under the literal key `"extra"`, so no console row ever found
* `android.gamepad_ui_enabled` and the value the console saved came back to Kotlin as the one
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
* with nothing happening: the console stayed up, because the setting never moved.
*/
class ConsoleSettingsExtraTest {
@Test
fun androidKeysAreWrittenFlat() {
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.low_latency"))
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
}
/** A store written by the nesting build must not keep echoing its dead wrapper. */
@Test
fun aStaleNestedWrapperIsDropped() {
val base = JSONObject().put(
"extra",
JSONObject().put("android.gamepad_ui_enabled", true),
)
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
}
@Test
fun theConsolesOwnSaveIsReadBack() {
val saved = JSONObject()
.put("android.gamepad_ui_enabled", false)
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
.put("android.ds_capture", false)
val next = ConsoleJson.applySettings(Settings(), saved)
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
assertFalse(next.dsCapture)
}
/** Both halves against each other — the shape only holds if they agree. */
@Test
fun theRoundTripKeepsEveryAndroidRow() {
val want = Settings(
gamepadUiEnabled = false,
gamepadUiMode = GAMEPAD_UI_ALWAYS,
lowLatencyMode = false,
rumbleOnPhone = true,
gyroOnPhone = true,
sc2Capture = false,
dsCapture = false,
)
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
assertEquals(want.sc2Capture, got.sc2Capture)
assertEquals(want.dsCapture, got.dsCapture)
}
}
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
/**
@@ -127,8 +128,12 @@ object Gamepad {
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
)
/**
@@ -188,9 +193,53 @@ object Gamepad {
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
}
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
fun pads(): List<InputDevice> =
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
/**
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
* REAL device carrying real pad hardware a stick, a HAT, or the A/B face buttons.
*
* [isPad] alone answers "did this event come from a pad source", which is the right question
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing OEM
* game-mode overlays and the gaming-phone shoulder triggers among them and one of those is
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
* "With a controller" has no way back to the touch UI.
*
* The capability probe is what separates them: a source class is a claim, a stick or a face
* button is hardware. It is not a complete defence an OEM device that declares `BTN_GAMEPAD`
* and a pair of axes is indistinguishable from a pad at this layer so the master switch stays
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
* device), which is worth excluding but catches none of the above.
*/
fun looksLikeController(dev: InputDevice?): Boolean {
val d = dev ?: return false
return looksLikeController(
padSource = isPad(d),
virtual = d.isVirtual,
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
// a d-pad-only pad) still counts.
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
.any { it },
)
}
/** [looksLikeController]'s decision, over plain facts the seam its truth table is tested at
* (an [InputDevice] cannot be built off a device). */
fun looksLikeController(
padSource: Boolean,
virtual: Boolean,
hasStick: Boolean,
hasFaceButtons: Boolean,
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
/**
* All connected controllers, in system enumeration order the devices that answer "is a pad
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
*/
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { looksLikeController(it) }
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
@@ -233,6 +282,17 @@ object Gamepad {
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
* (a keyboard's arrow keys share these keycodes and belong to the VK path) see MainActivity.
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
*
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
* see there. This table is global, so a device that genuinely presses one of those two
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
* takes a pad that declares fifteen. The residual case a fifteen-button HOTAS whose button
* 16 also toggles the client's mic is the one this leaves on the table; narrowing it
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
*/
fun buttonBit(keyCode: Int): Int = when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
@@ -246,6 +306,8 @@ object Gamepad {
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
@@ -293,6 +355,353 @@ object Gamepad {
else -> BTN_BACK
}
// ---------------------------------------------------------------------------------------
// Controllers Android has no key layout for
//
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
// ORDER — so every keycode after the first divergence is somebody else's button.
//
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
// symptom exactly.
//
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
/**
* The report order a controller's buttons are numbered in, and with it which scancode carries
* which physical button. Resolved once per device by [padButtons] from what the device
* declares; [correct] then maps one scancode to the keycode it should have produced.
*/
enum class PadButtons {
/**
* The keycode Android delivered is already right a device-specific key layout is in
* force, or the generic one happens to agree. [correct] changes nothing.
*/
NATIVE,
/**
* A Sony pad numbering straight through with no kernel driver behind it: L1 R1
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` in that order. The
* analog trigger value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold
* to keycodes [buttonBit] deliberately drops the wire carries the axis, never both.
*
* This order and ONLY this order is where `0x13d`/`0x13e` mean the touchpad click and
* the mute button. Everywhere else they are L3/R3.
*/
GENERIC_SONY,
/**
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
* clones the Xbox layout the same assumption [styleFor] makes for its glyphs.
*/
GENERIC_XBOX,
/**
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
* layout the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH () and `0x134` is
* BTN_WEST (); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
* face pair comes out swapped and nothing else is wrong.
*/
SONY_MODERN,
;
/**
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
*
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
* said for [scan] anything else is a device-specific layout's answer, which outranks
* this table. That guard is what makes the correction idempotent and safe to run on
* every pad: it can only ever fire where Android was guessing in the first place.
*/
fun correct(scan: Int, keyCode: Int): Int {
if (this == NATIVE) return keyCode
if (keyCode != genericKeyCode(scan)) return keyCode
val fixed = when (this) {
GENERIC_SONY -> when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
//
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
// mute purely because a driverless DualSense enumerates its buttons straight
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
// DualSense on the couch. There is no scancode that means the same button on
// all pads; that is the entire reason this enum exists.
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
// safe answer if that ever changes.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
0x132 -> KeyEvent.KEYCODE_BUTTON_X
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> keyCode // 0x130 A / 0x131 B already agree
}
// Only the face pair; every other row of Generic.kl is right for these codes.
SONY_MODERN -> when (scan) {
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
else -> keyCode
}
NATIVE -> keyCode
}
return fixed
}
}
/**
* AOSP `Generic.kl`'s gamepad rows the layout Android falls back to when no device-specific
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
* events alone.
*/
fun genericKeyCode(scan: Int): Int = when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_A
0x131 -> KeyEvent.KEYCODE_BUTTON_B
0x132 -> KeyEvent.KEYCODE_BUTTON_C
0x133 -> KeyEvent.KEYCODE_BUTTON_X
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
0x13b -> KeyEvent.KEYCODE_BUTTON_START
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> KeyEvent.KEYCODE_UNKNOWN
}
/**
* How one controller must be read: its button report order plus the axes its right stick and
* analog triggers actually arrive on. Resolved once per device by [padMap].
*/
class PadMap(
val buttons: PadButtons,
val rightStickX: Int = MotionEvent.AXIS_Z,
val rightStickY: Int = MotionEvent.AXIS_RZ,
/**
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on that case
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
* report one pair, the other, or both have always needed.
*/
val leftTrigger: Int = AXIS_NONE,
val rightTrigger: Int = AXIS_NONE,
/** Those trigger axes rest at 1 rather than 0, measured off the device's own range. */
val triggersSigned: Boolean = false,
) {
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
}
/** The map every pad with a key layout uses: Android's own names, unchanged. */
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
/**
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] the device's stable identity
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
* Nothing here depends on a live connection, so entries never need evicting.
*/
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` 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 a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer WHICH straight-through order, once `namedTriggers` has established
* there is one where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
* The [PadMap] for [dev] its button report order and the axes its right stick and triggers
* arrive on, resolved once per device model and cached.
*
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
* raw axes under the names the HID report gave them. Which two depends on the same report
* order the buttons did:
*
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
* stick, then the triggers so the right stick is already right and only the triggers
* (`AXIS_RX`/`AXIS_RY`) are missed;
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
* the shape that makes pulling a trigger swing the right stick.
*
* Whether those axes idle at 1 is MEASURED from the device's own range rather than assumed,
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
*/
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
(rx?.min ?: 0f) < -0.5f
} else {
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
}
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
padMaps[dev.descriptor] = map
return map
}
/**
* The axis half of [padMap], decided from four facts about the device so it can be pinned
* without one see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
* whichever axis pair the fallback picks, never assumed.
*/
fun padMap(
buttons: PadButtons,
namedTriggers: Boolean,
hasRxRy: Boolean,
restsNegative: Boolean,
): PadMap = when {
namedTriggers || !hasRxRy -> PadMap(buttons)
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
buttons == PadButtons.GENERIC_SONY -> PadMap(
buttons,
leftTrigger = MotionEvent.AXIS_RX,
rightTrigger = MotionEvent.AXIS_RY,
triggersSigned = restsNegative,
)
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
// right stick makes pulling a trigger swing it.
else -> PadMap(
buttons,
rightStickX = MotionEvent.AXIS_RX,
rightStickY = MotionEvent.AXIS_RY,
leftTrigger = MotionEvent.AXIS_Z,
rightTrigger = MotionEvent.AXIS_RZ,
triggersSigned = restsNegative,
)
}
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
/**
* The keycode [event] should have carried, given the controller it came from [event]'s own
* keycode for every pad Android has a key layout for, and the scancode's true button for one
* it does not (see the block comment above [PadButtons]).
*
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
* pad is fixed in the menus and in the game at once. Events from anything that is not a
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
* untouched.
*/
fun padKeyCode(event: KeyEvent): Int {
val dev = event.device ?: return event.keyCode
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
}
/**
* Maps one controller's joystick MotionEvents to axis (+ HATdpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -306,7 +715,12 @@ object Gamepad {
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* folds the event straight in without re-qualifying it.
*/
class AxisMapper(private val handle: Long, private val pad: Int) {
class AxisMapper(
private val handle: Long,
private val pad: Int,
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
private val map: PadMap = NATIVE_MAP,
) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1
@@ -317,30 +731,18 @@ object Gamepad {
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
// with max, the same fold as the Controllers screen probe, so a pad that reports
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
// reporting NONE of those names is one Android has no key layout for, and [map]
// carries the raw axes its triggers really landed on instead.
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
sendAxis(AXIS_LT, trigger(lt))
sendAxis(AXIS_RT, trigger(rt))
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
// tap (press+release inside one batch window) lives only in the historical samples — the
@@ -383,6 +785,17 @@ object Gamepad {
hatY = 0
}
/**
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
* else the max of the two names Android gives a trigger it does know.
*/
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
if (resolvedAxis == AXIS_NONE) {
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
} else {
map.level(event.getAxisValue(resolvedAxis))
}
private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return
last[id] = v
@@ -101,6 +101,18 @@ class GamepadRouter(
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
*/
val motionReaches: Boolean = true,
/**
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad the one bit
* whose physical meaning differs per controller, and the gate on the mic toggle in
* [slotButton].
*
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
* fact each path actually knows: the report order for an [InputDevice] (only
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
* capture link.
*/
val hasMuteButton: Boolean = false,
) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
@@ -160,7 +172,8 @@ class GamepadRouter(
/**
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
* a pad the couch equivalent of the stream's on-screen mute button, which a gamepad user
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] a DualSense's) is pressed the
* couch equivalent of the stream's on-screen mute button, which a gamepad user
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
* indicator makes an accidental toggle self-evident. The buttons still go to the host the
@@ -234,15 +247,40 @@ class GamepadRouter(
}
}
/**
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
*
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
* policy. It is tracked the mic toggle in [slotButton] is edge-triggered off held state
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
* flush, or a mute held across a disconnect would put a release on the wire for a press that
* never went out. Every other system button under that policy leaves [slotButton] at the top
* and never reaches a send at all.
*/
private fun localOnly(slot: Slot, bit: Int): Boolean =
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
/**
* One button transition on [slot] the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
//
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: that policy's own
// words are "keeps them entirely with this device", and toggling this device's microphone
// is precisely what a mute button does with itself. Returning here would have left the
// button present and silently dead under `local`, for a reason nobody would ever find. It
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
if (!systemForward &&
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
) {
return
}
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
@@ -258,7 +296,7 @@ class GamepadRouter(
}
flushPendingSelect(slot)
}
if (send && forwarding) {
if (send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
@@ -268,11 +306,26 @@ class GamepadRouter(
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
// face button. Both leave the press on the wire: the game still gets its buttons.
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
//
// A pad's own mute button is a second trigger for the SAME toggle, not a new
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
// button is held down.
//
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
if (completesChord(wasHeld, bit, MIC_CHORD) ||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
) {
onMicChord?.invoke()
}
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
} else {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
if (!owned && send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -543,7 +596,15 @@ class GamepadRouter(
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
// The declared kind separates them (a DualShock 4 has no mute button either).
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
slots[syntheticId] = Slot(
index,
Gamepad.AxisMapper(handle, index),
hasMuteButton = hasMute,
)
return ExternalPad(syntheticId, index, motionReaches)
}
@@ -603,10 +664,15 @@ class GamepadRouter(
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
// which under Automatic answers for whichever pad happened to be active at dial time. Held
// for the slot's life; the sensor path reads it on every sample.
val map = Gamepad.padMap(dev)
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index),
Gamepad.AxisMapper(handle, index, map),
NativeBridge.nativePadMotionReaches(handle, pref),
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
// report order IS the answer — and unlike `pref` it survives the user pinning every
// pad to one type, which would otherwise cost a DualSense its mute button.
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
)
slots[dev.id] = slot
// After the table holds the slot, so a listener that sends on this device the moment it is
@@ -652,7 +718,9 @@ class GamepadRouter(
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
bits = bits and bit.inv()
}
slot.held = 0
@@ -298,6 +298,18 @@ object NativeBridge {
surfaceH: Int,
)
/**
* Re-report the video SurfaceView's on-screen pixel size call it from every `surfaceChanged`.
*
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
* switches the window to draw into the display cutout a frame or two later, and neither
* recreates the surface. Without this the layer keeps painting at its start-up size in the
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
* cheap (one atomic store), UI-safe.
*/
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long)
@@ -1,6 +1,7 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -155,6 +156,44 @@ class GamepadChordTest {
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
}
/**
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
*
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
* `bit == BTN_MISC1` would toggle the mic on every repeat hold the button and the mic
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
*
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
*
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here
* the edge rule below is the half a unit test can hold.
*/
@Test
fun `the mute button toggles the mic once per press`() {
fun fires(wasHeld: Int, bit: Int) =
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
assertTrue(
"a press while other buttons are held is still a fresh press",
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
)
for (other in listOf(
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
)) {
assertFalse("$other toggled the mic", fires(0, other))
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
}
}
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
@Test
fun `chord masks are wire button bits`() {
@@ -0,0 +1,310 @@
package io.unom.punktfunk.kit
import android.view.KeyEvent
import android.view.MotionEvent
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [Gamepad.PadButtons.correct] the scancode resolution for controllers Android
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
*
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
* landing on the wrong actions "L1 being L2". Neither pad has a key layout on that box (AOSP
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
* after the first divergence belongs to a different button.
*
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
* right; the assertions read it back the other way.
*/
class PadButtonsTest {
private fun sony(scan: Int) =
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
private fun xbox(scan: Int) =
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
/**
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
* BUTTON_L1 so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
* read as a face button.
*/
@Test
fun `a DualSense's shoulders stop being each other's buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
}
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
@Test
fun `a DualSense's face buttons land on their Xbox positions`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
}
/**
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
* which took the exit, mic and stats chords with it every one of them is built on Select.
*/
@Test
fun `a DualSense's menu buttons and stick clicks are themselves`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/**
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
* and BUTTON_16 to carry them into [Gamepad.buttonBit] the keycode is an implementation
* detail of that hop, the BIT is the contract, so both halves are pinned here.
*/
@Test
fun `a DualSense's touchpad and mute reach their wire buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
}
/**
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
*
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` L3 and R3 in the standard Linux/AOSP
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
* `hid-playstation` DualSense loses both stick clicks and R3 starts toggling the microphone.
*/
@Test
fun `every other pad keeps L3 and R3 on those scancodes`() {
for (p in listOf(
Gamepad.PadButtons.NATIVE,
Gamepad.PadButtons.GENERIC_XBOX,
Gamepad.PadButtons.SONY_MODERN,
)) {
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
}
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@Test
fun `an Xbox pad numbering straight through keeps its own layout`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
}
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
@Test
fun `a driver-backed Sony pad has only its face pair corrected`() {
val m = Gamepad.PadButtons.SONY_MODERN
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
}
}
/**
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
* `Generic.kl` would have said came from a device-specific key layout, which knows this
* controller better than any table here. Correcting it would break a pad that works.
*/
@Test
fun `a keycode a device layout already resolved is never second-guessed`() {
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
for (p in Gamepad.PadButtons.entries) {
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
}
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
assertEquals(
KeyEvent.KEYCODE_BACK,
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
)
}
/**
* The guard's NEGATIVE path the half that decides anything.
*
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
* transparent in every one of them and the assertions would hold with it deleted. These are
* the ones that fail without it: a device-specific key layout answering something the table
* disagrees with, on a scancode the table has an opinion about. The layout wins it knows
* this controller, and the table is only ever a guess about a pad nothing knew.
*/
@Test
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
}
// And the two rows added for the touchpad and mute are no different: a pad whose layout
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
assertEquals(
KeyEvent.KEYCODE_BUTTON_1,
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
for (p in Gamepad.PadButtons.entries) {
for (scan in 0x130..0x13e) {
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
assertEquals(once, p.correct(scan, once))
}
}
}
/**
* The axis half. A pad that names its triggers something Android knows is read exactly as it
* always was this is the branch that must NOT fire on the pads that already work.
*/
@Test
fun `a pad that names its triggers is read unchanged`() {
for (p in Gamepad.PadButtons.entries) {
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
}
// Same when there is no Rx/Ry to fall back to in the first place.
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
}
/**
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry left stick, right
* stick, then the triggers. Only the triggers were being missed; the sticks already read
* right and must be left alone.
*/
@Test
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
}
/**
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it so the two
* pairs must never be mixed up, which is the whole point of pinning them.
*/
@Test
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
}
}
/**
* A trigger axis that idles at 1 is rescaled; one that idles at 0 must NOT be, or it would
* read as a permanent half-pull. Which it is gets measured off the device, never assumed
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
*/
@Test
fun `only a trigger that idles negative is rescaled`() {
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
assertEquals(0f, signed.level(-1f), 1e-6f)
assertEquals(0.5f, signed.level(0f), 1e-6f)
assertEquals(1f, signed.level(1f), 1e-6f)
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(0f, unsigned.level(0f), 1e-6f)
assertEquals(1f, unsigned.level(1f), 1e-6f)
}
/** A pad Android does know is untouched, which is most of them. */
@Test
fun `a pad with a key layout is left alone`() {
for (scan in 0x130..0x13e) {
val generic = Gamepad.genericKeyCode(scan)
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
@@ -0,0 +1,67 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The truth table behind "is a controller attached" the question the console UI's
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
* dismiss it, because the phantom pad never disconnects.
*/
class PadPresenceTest {
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
@Test
fun realPadsCount() {
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
// An arcade stick / d-pad-only pad — buttons, no analog stick.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
),
)
// A wheel or flight stick — axes, no A/B.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
),
)
}
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
* gamepad source class. This is the field report the console UI that could not be dismissed. */
@Test
fun virtualDevicesAreNotControllers() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
),
)
}
/** A device that claims a pad source with nothing behind it is not a pad either. */
@Test
fun aSourceClaimWithoutHardwareIsNotAController() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
),
)
}
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
@Test
fun nonPadSourcesNeverCount() {
assertFalse(
Gamepad.looksLikeController(
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
}
}
@@ -223,6 +223,7 @@ impl ConsoleHost {
let thread = std::thread::Builder::new()
.name("pf-console".into())
.spawn(move || {
boost_thread_priority();
let run = || -> Result<()> {
let console = Console::new(opts, entry, &thread_handles)?;
render_loop(console, thread_shared.clone(), thread_store)
@@ -249,6 +250,34 @@ impl ConsoleHost {
}
}
/// Best-effort: lift the console's render thread off the default nice band, the same way
/// `decode::setup::boost_thread_priority` lifts the decode thread. This thread IS the console's
/// frame loop — every menu press waits on it — and at default priority a TV box's scheduler is
/// free to park it on a little core behind whatever else the system is doing, which reads as a
/// UI that lags the remote. `-8` rather than the decode path's `-10`: a stream's frames are the
/// harder deadline, and the two should not compete when the console is up during a session.
///
/// Non-fatal if the platform refuses (the exact floor a foreground app may set is policy).
fn boost_thread_priority() {
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls; PRIO_PROCESS
// with a TID targets that one task on Linux — the idiom `Process.setThreadPriority` uses.
unsafe {
let tid = libc::gettid();
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
log::debug!(
"console: setpriority(-8) failed (non-fatal): {}",
std::io::Error::last_os_error()
);
}
}
}
/// How often the render loop reports what a frame is costing it. Nothing in a bug report from a
/// TV said whether the console was drawing at 4K or at 60 Hz, so "it feels sluggish" could not be
/// triaged from a log bundle at all — this is that missing line. One line a minute is cheap
/// enough to leave on for everyone, and the answer is only useful from the box that is slow.
const FRAME_REPORT: Duration = Duration::from_secs(60);
/// No input for this long = the console is being looked at, not used — halve the redraw
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
/// afterglow at full smoothness and only calms a genuinely parked screen.
@@ -283,6 +312,9 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
let mut gl_failures = 0u32;
const GL_FAILURE_LIMIT: u32 = 3;
// What a frame is costing, reported once a `FRAME_REPORT` window (see there).
let (mut frames, mut frame_time, mut frame_peak) = (0u32, Duration::ZERO, Duration::ZERO);
let mut report_at = Instant::now();
loop {
// Take everything queued. With no surface up, block until something arrives.
@@ -446,8 +478,17 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
skia = None;
match g.wrap_window(&egl, w, h) {
Ok(surf) => {
// The console's real render resolution — the one number a bug report
// from a TV never carried. A 4K panel is 4× the fragment work of 1080p
// for every pass the shell draws.
log::info!("console: drawing at {w}×{h}");
skia = Some((surf, w, h));
gl_failures = 0;
// Start the frame window here, not at loop entry: the console parks
// with no surface while a stream is up, and a window that had been
// open across that would report its first frame as "1 frame in 20 min".
(frames, frame_time, frame_peak, report_at) =
(0, Duration::ZERO, Duration::ZERO, Instant::now());
}
Err(e) => {
log::error!("console: {e:#}");
@@ -462,6 +503,11 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
insets,
scale,
};
// Around the DRAW only, not the swap: `eglSwapBuffers` blocks on vsync, so
// wall-clock per iteration is always ~the panel period and says nothing. What
// matters is how much of that period the shell spends building the frame —
// once that passes the period, the console is missing vsyncs.
let drew = Instant::now();
console.frame(
surf.canvas(),
&viewport,
@@ -470,6 +516,20 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
&pads,
);
g.context.flush_and_submit();
let cost = drew.elapsed();
frame_time += cost;
frame_peak = frame_peak.max(cost);
frames += 1;
if report_at.elapsed() >= FRAME_REPORT {
log::info!(
"console: {w}×{h}, {frames} frames in {:?} — {:.1} ms/frame mean, {:.1} ms peak",
report_at.elapsed(),
frame_time.as_secs_f64() * 1000.0 / f64::from(frames),
frame_peak.as_secs_f64() * 1000.0,
);
(frames, frame_time, frame_peak, report_at) =
(0, Duration::ZERO, Duration::ZERO, Instant::now());
}
if let Err(e) = s.swap() {
// The window went away under us; wait for the next surface.
log::warn!("console: {e:#} — dropping the surface");
@@ -45,6 +45,10 @@ struct CreateOptions {
device_name: String,
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
gpu_cache_bytes: usize,
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
/// gates the console-off settings row. Default false: absent means don't offer it.
#[serde(default)]
fallback_ui: bool,
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
settings: pf_client_core::trust::Settings,
/// The profile catalog as `[[id, name], …]`.
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
let console_opts = ConsoleOptions {
device_name: opts.device_name,
deck: false,
fallback_ui: opts.fallback_ui,
store: Some(store.clone()),
platform: Platform::Android,
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
@@ -21,6 +21,25 @@
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
//! auto-fallback with the residual risk being visual, not a crash.
//!
//! **The acquire fence must come from `acquireNextImageAsync`, never `acquireLatestImageAsync`.**
//! `AImageReader::acquireLatestImage` (`NdkImageReader.cpp`, unfixed as of AOSP main) drains with
//! one `int*` out-param it overwrites per image, then releases each dropped image with whatever the
//! out-param currently holds — the *successor's* fence:
//!
//! ```text
//! acquireImageLocked(&prev, fd) → *fd = F1 (prev = img1)
//! acquireImageLocked(&next, fd) → *fd = F2 (next = img2; F1 overwritten and leaked)
//! prev->close(*fd) → reader adopts F2 as img1's release fence, then closes it
//! acquireImageLocked(&next, fd) → no buffer; leaves *fd alone
//! returns img2 with *fd = F2 ← already given away and closed
//! ```
//!
//! So the moment a burst gives it two images to collapse, the caller is handed a stale fd plus one
//! leaked fd per extra drop. Passing that stale fd to `setBuffer` transfers it to SurfaceFlinger,
//! which closes it again — an `fdsan` `SIGABRT` on the decode thread, either at `Fence::Fence(int)`
//! inside `setBuffer` (the number was already re-owned) or at the end of `Transaction::apply` when
//! the layer state is torn down. `AscBackend::drain_reader` therefore does newest-wins itself.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
@@ -142,21 +161,21 @@ pub(super) struct AscBackend {
impl AscBackend {
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
/// `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
src_w: i32,
src_h: i32,
surface_w: i32,
surface_h: i32,
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
panel_hz: i32,
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_w, surface_h)?;
let layer = Layer::create(window, surface_size)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let reader = match ImageReader::new_with_usage(
@@ -376,19 +395,24 @@ impl AscBackend {
true
}
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older ones
/// drop back to the pool as they are superseded); smooth keeps order up to capacity.
///
/// Both modes drain with `acquireNextImageAsync`, one image at a time. `acquireLatestImageAsync`
/// is the obvious newest-wins call and is NOT usable — see the acquire-fence note at the top of
/// this module.
fn drain_reader(&mut self) {
if self.fifo_capacity == 0 {
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
if let Some(acq) = self.acquire(true) {
// Newest-wins: collapse the burst to the freshest buffer ourselves. Each superseded
// candidate drops here — its image returns to the pool, its own acquire fence closes.
while let Some(acq) = self.acquire() {
if self.candidate.replace(acq).is_some() {
self.skipped += 1; // an un-presented candidate was superseded
}
}
} else {
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
while let Some(acq) = self.acquire(false) {
while let Some(acq) = self.acquire() {
self.fifo.push_back(acq);
while self.fifo.len() > self.fifo_capacity {
self.fifo.pop_front();
@@ -398,19 +422,13 @@ impl AscBackend {
}
}
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
/// `None` when the reader is empty or a transient acquire error occurs.
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
/// Acquire the next image and pair its decode stamps + cadence due. `None` when the reader is
/// empty or a transient acquire error occurs.
fn acquire(&mut self) -> Option<Acquired> {
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
// acquire requires.
let res = unsafe {
if latest {
self.reader.acquire_latest_image_async()
} else {
self.reader.acquire_next_image_async()
}
};
let res = unsafe { self.reader.acquire_next_image_async() };
let (image, fence) = match res {
Ok(AcquireResult::Image(pair)) => pair,
Ok(_) => return None, // no buffer available / max acquired
@@ -96,8 +96,7 @@ pub(super) fn run_async(
present_priority,
smooth_buffer,
panel_hz,
surface_w,
surface_h,
surface_size,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -199,8 +198,7 @@ pub(super) fn run_async(
&window,
mode.width as i32,
mode.height as i32,
surface_w,
surface_h,
surface_size,
panel_hz,
initial_ds,
mode.refresh_hz,
+14 -7
View File
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
///
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
/// couldn't read it yet, and the backend falls back to the window buffer size.
pub surface_w: i32,
pub surface_h: i32,
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
/// yet, and the backend falls back to the window buffer size.
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
use ndk::native_window::NativeWindow;
use std::ffi::c_void;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use super::async_loop::DecodeEvent;
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
pub(super) struct Layer {
api: Api,
sc: Arc<ScHandle>,
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
dest_w: i32,
dest_h: i32,
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
/// because the view resizes under a surface that is never recreated (see `dest`).
surface_size: Arc<AtomicU64>,
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
/// view when video started): the window's own buffer geometry, the best remaining guess.
fallback_w: i32,
fallback_h: i32,
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
configured: bool,
}
@@ -287,13 +293,16 @@ impl Layer {
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
///
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
/// yet) falls back to that buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
/// using it shrank the picture to the top-left corner. It is read fresh on every present
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
/// screen hides the system bars and switches on cutout drawing a frame or two after
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
/// view yet) falls back to the buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
let api = Api::resolve()?;
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
// static NUL-terminated string; the call returns null on failure (checked).
@@ -303,20 +312,11 @@ impl Layer {
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
return None;
}
let dest_w = if dest_w > 0 {
dest_w
} else {
window.width().max(1)
};
let dest_h = if dest_h > 0 {
dest_h
} else {
window.height().max(1)
};
let fallback_w = window.width().max(1);
let fallback_h = window.height().max(1);
log::info!(
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
window.width(),
window.height(),
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
);
Some(Layer {
sc: Arc::new(ScHandle {
@@ -324,12 +324,20 @@ impl Layer {
release: api.ac_release,
}),
api,
dest_w,
dest_h,
surface_size,
fallback_w,
fallback_h,
configured: false,
})
}
/// The destination rectangle for this present: the live view size, or the window's buffer
/// geometry while Kotlin has reported nothing.
fn dest(&self) -> (i32, i32) {
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
.unwrap_or((self.fallback_w, self.fallback_h))
}
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
@@ -370,11 +378,12 @@ impl Layer {
right: src_w.max(1),
bottom: src_h.max(1),
};
let (dest_w, dest_h) = self.dest();
let dst = ARect {
left: 0,
top: 0,
right: self.dest_w,
bottom: self.dest_h,
right: dest_w,
bottom: dest_h,
};
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
if dataspace != 0 {
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
panel_hz: _,
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
// SurfaceView, so it never needs the view's on-screen size.
surface_w: _,
surface_h: _,
surface_size: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
access_seq: std::sync::atomic::AtomicU32::new(0),
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
Box::into_raw(Box::new(handle)) as jlong
}
+58 -1
View File
@@ -26,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
/// sends, read by the ASurfaceControl presenter before each present.
///
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
/// that is never recreated: hiding the system bars and switching the window to
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
/// and each one grows the video view. A destination rect captured once at creation then keeps
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
/// falls back to the window's buffer geometry.
pub surface_size: Arc<AtomicU64>,
}
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
/// single atomic load and can never see a torn pair (a new width against an old height).
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
if w <= 0 || h <= 0 {
return 0;
}
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
}
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
if packed == 0 {
return None;
}
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
}
struct VideoThread {
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::{pack_surface_size, unpack_surface_size};
/// The pair the presenter reads as one atomic load must survive the round trip — including a
/// size wider than a signed 16-bit value, which every panel this runs on now is.
#[test]
fn surface_size_round_trips() {
assert_eq!(
unpack_surface_size(pack_surface_size(2800, 1260)),
Some((2800, 1260))
);
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
}
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
/// to the window's buffer geometry rather than composing into an empty rectangle.
#[test]
fn non_positive_sizes_are_the_sentinel() {
assert_eq!(pack_surface_size(0, 0), 0);
assert_eq!(pack_surface_size(1920, 0), 0);
assert_eq!(pack_surface_size(-1, 1080), 0);
assert_eq!(unpack_surface_size(0), None);
}
}
+39 -2
View File
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
// current from here on (the bars hide and the cutout mode changes AFTER this call).
h.surface_size.store(
super::pack_surface_size(surface_w, surface_h),
std::sync::atomic::Ordering::Relaxed,
);
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority,
smooth_buffer,
panel_hz: panel_fps,
surface_w,
surface_h,
surface_size: h.surface_size.clone(),
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
/// on-screen pixel size, re-reported on every `surfaceChanged`.
///
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
/// + an atomic store, so it links on the host build too.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
width: jni::sys::jint,
height: jni::sys::jint,
) {
jni_guard((), || {
let packed = super::pack_surface_size(width, height);
if handle == 0 || packed == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.surface_size
.store(packed, std::sync::atomic::Ordering::Relaxed);
})
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
@@ -317,6 +317,12 @@ impl ImageReader {
/// If the returned file descriptor is not [`None`], it must be awaited before attempting to
/// access the [`Image`] returned.
///
/// **The returned fence is unsound whenever the platform actually drops an older image.**
/// `AImageReader::acquireLatestImage` reuses one out-param across the drain and releases each
/// dropped image with the *successor's* fence fd, so the fd handed back has already been given
/// to the reader (and closed by it) — adopting it here yields a double close and an `fdsan`
/// abort. Drain with [`ImageReader::acquire_next_image_async()`] and pick the newest yourself.
///
/// <https://developer.android.com/ndk/reference/group/media#aimagereader_acquirelatestimageasync>
#[cfg(feature = "api-level-26")]
#[doc(alias = "AImageReader_acquireLatestImageAsync")]
+18 -5
View File
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
// `read`, not `load`: this verb never hands a record's id back, so it has no business
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
// both together) — after which the ids one of them already handed out no longer resolve.
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
// The one write this verb does make, and why it doesn't contradict the above: an advert
// is the only place a host's wake MAC is ever published, and this verb is the only one
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
// taught the record something new — so a steady-state panel refresh touches no disk.
for (d, saved) in &rows {
if let Some(k) = saved {
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
}
}
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
};
let host = &known.hosts[i];
if host.mac.is_empty() {
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
return UNRESOLVED;
}
if !has(args, "--wait") {
+14 -27
View File
@@ -1087,33 +1087,20 @@ impl HostsPage {
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
let online = self.adverts.values().any(|a| matches(k, a))
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.mac.is_empty())
{
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the icon then survives the host going offline.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.os.is_empty())
{
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — and this one is not cosmetic: without it a host
// that moved off 47990 loses its library the moment mDNS is unavailable, because
// the advert was the only place the real port ever lived.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && a.mgmt_port.is_some())
{
if let Some(p) = a.mgmt_port {
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
}
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
// its OS chain (so the icon survives it going offline), and its management port
// — the last one not cosmetic, since a host that moved off 47990 loses its
// library the moment mDNS is unavailable and the advert is the only place the
// real port ever lived.
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
saved.push_back(HostCard {
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
+30 -3
View File
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
Welcome,
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
let mut ls = send;
let lp = loss_ppm.clone();
let df = dropped_frames.clone();
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
// sentinel — which is exactly what makes it usable for testing that path.
let rxp = rx_wire_packets.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut last_report = std::time::Instant::now();
let mut last_dropped = 0u64;
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
// window while it is zero, once when the first packets land, then stop — so a host that
// predates the message is not flooded with "unknown control message" on a good session.
let mut delivery_confirmed = false;
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let d = df.load(Relaxed);
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
last_report = std::time::Instant::now();
let v = lp.swap(u32::MAX, Relaxed);
// Independent of whether there is a fresh loss sample: "no fresh sample" is
// exactly the shape a dead data plane has, so gating it on one would silence
// it in the state it exists to report.
let received = rxp.load(Relaxed);
if received == 0 || !delivery_confirmed {
delivery_confirmed = received > 0;
if io::write_msg(
&mut ls,
&DeliveryReport {
packets_received: received,
}
.encode(),
)
.await
.is_err()
{
break; // control stream gone
}
}
if v != u32::MAX
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
.await
+14 -4
View File
@@ -800,11 +800,21 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
// Write the advertised mgmt port down while the host is visible, so this console
// keeps working against a moved port once it is not. No-op (and no disk write)
// Write down everything the advert teaches while the host is visible: the mgmt
// port (so this console keeps working against a moved one once it is not), the
// OS chain, and the wake MAC — which matters most here, because this console and
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
// record that never learned a MAC can never be woken. No-op (and no disk write)
// when unchanged, so this is safe on every refresh tick.
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
if let Some(a) = advert {
pf_client_core::trust::learn_from_advert(
&h.fp_hex,
&h.addr,
h.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let row = HostRow {
key: key.clone(),
+15 -23
View File
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
// it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& !h.mac.is_empty()
}) {
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the tile's mark then survives the host going offline.
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
}) {
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — load-bearing, unlike the two above: a host moved off
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
if let Some(p) = hosts
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
// offline), and its management port — the last load-bearing rather than cosmetic, as
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
// the port down. No-op, and no disk write, when unchanged.
if let Some(a) = hosts
.iter()
.find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& h.mgmt_port.is_some()
})
.and_then(|h| h.mgmt_port)
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
{
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let can_wake = !online && !k.mac.is_empty();
let menu = {
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
/// persisted like `mac`. Empty if absent (older host).
pub os: String,
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
pub mgmt_port: Option<u16>,
+2 -2
View File
@@ -8,6 +8,6 @@
//! still load via a serde alias in core.
pub use pf_client_core::trust::{
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
parse_hex32, KnownHost, KnownHosts, Settings,
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
KnownHosts, Settings,
};
+82 -54
View File
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
}
}
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
/// data the live host advertised, purely because it came earlier in the file.
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
/// hand a stale record at the same address the data the live host advertised, purely because it
/// came earlier in the file.
fn learn_target<'a>(
known: &'a mut KnownHosts,
fp_hex: &str,
@@ -692,61 +692,62 @@ fn learn_target<'a>(
known.hosts.get_mut(i)
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
if mac.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mac == mac {
return;
}
h.mac = mac.to_vec();
let _ = known.save();
}
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
if os.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.os == os {
return;
}
h.os = os.to_string();
let _ = known.save();
}
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
/// management port — and report whether anything actually moved, so the caller writes only when
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
///
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
if mgmt_port == 0 {
return;
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
/// TXT, and forgetting a MAC already learned would cost the user their wake.
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
let mut changed = false;
if !mac.is_empty() && h.mac != mac {
h.mac = mac.to_vec();
changed = true;
}
let mut known = KnownHosts::load();
if !os.is_empty() && h.os != os {
h.os = os.to_string();
changed = true;
}
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
h.mgmt_port = mgmt_port;
changed = true;
}
changed
}
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
/// the record already says all three, so a surface can call this on every discovery tick.
///
/// ONE call rather than three. Each field used to be learned by its own function, which meant
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
///
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
/// a MAC is no reason to become one.
///
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
pub fn learn_from_advert(
fp_hex: &str,
addr: &str,
port: u16,
mac: &[String],
os: &str,
mgmt_port: Option<u16>,
) {
let mut known = KnownHosts::read();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mgmt_port == Some(mgmt_port) {
return;
if apply_advert(h, mac, os, mgmt_port) {
let _ = known.save();
}
h.mgmt_port = Some(mgmt_port);
let _ = known.save();
}
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
/// fingerprint alone — the identity a just-connected client is certain of.
///
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
/// where an advert is visible, whereas this fires on any successful connect, including a host
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
@@ -2293,6 +2294,33 @@ mod tests {
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
}
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
/// the same advert reports no change — which is what lets every surface call this on every
/// discovery tick without churning the store.
#[test]
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
let mut h = KnownHost::default();
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// The same advert a tick later: nothing moved, so there is nothing to persist.
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
// would cost the user their wake, so an absent field must never overwrite a known one.
assert!(!apply_advert(&mut h, &[], "", None));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
assert!(!apply_advert(&mut h, &[], "", Some(0)));
assert_eq!(h.mgmt_port, Some(47991));
// A host that genuinely moved: the new value wins.
assert!(apply_advert(&mut h, &[], "", Some(47992)));
assert_eq!(h.mgmt_port, Some(47992));
}
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
/// presentation state, so a dangling one is never an error surface.
#[test]
+24 -9
View File
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
/// there isn't (the library grid spends up on rows) the same menu hangs off
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
Up,
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
Down,
Key(&'static str),
}
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Up => BADGE_D * k,
Resolved::Up | Resolved::Down => BADGE_D * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
}
@@ -294,6 +298,9 @@ enum Resolved {
/// The d-pad's up — drawn the same in every style, because it is a direction rather
/// than a button whose label changes with the pad.
Up,
/// The d-pad's down — the same triangle stood on its head, and style-free for the
/// same reason [`Resolved::Up`] is.
Down,
Key(&'static str),
}
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Shoulders => Resolved::Key("Tab"),
HintKey::Adjust => Resolved::Adjust,
HintKey::Up => Resolved::Up,
HintKey::Down => Resolved::Down,
HintKey::Key(t) => Resolved::Key(t),
};
}
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Secondary => Resolved::Badge(Face::Y),
HintKey::Shoulders => Resolved::Shoulders,
HintKey::Adjust => Resolved::Adjust,
HintKey::Down => Resolved::Down,
HintKey::Up => Resolved::Up,
HintKey::Key(t) => Resolved::Key(t),
}
@@ -394,17 +403,23 @@ fn draw_glyph(
pen += w + 3.0 * k;
}
}
Resolved::Up => {
// ▲ — one solid triangle in a badge-sized slot.
g @ (Resolved::Up | Resolved::Down) => {
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
// way up: apex toward the direction it names, base at the other end.
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
let mut up = PathBuilder::new();
up.move_to((cx, cyf - th));
up.line_to((cx - tw, cyf + th));
up.line_to((cx + tw, cyf + th));
up.close();
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
let (apex, base) = if matches!(g, Resolved::Down) {
(cyf + th, cyf - th)
} else {
(cyf - th, cyf + th)
};
let mut tri = PathBuilder::new();
tri.move_to((cx, apex));
tri.line_to((cx - tw, base));
tri.line_to((cx + tw, base));
tri.close();
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
+4
View File
@@ -63,6 +63,10 @@ pub(crate) struct Ctx<'a> {
pub pads: &'a [PadInfo],
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
pub deck: bool,
/// The host app has another interface to fall back to when the console is switched
/// off (an Android phone/tablet's touch shell) — see
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
pub fallback_ui: bool,
/// The name the HOST stores this client under when pairing (the machine's
/// hostname, resolved by the binary).
pub device_name: &'a str,
@@ -396,6 +396,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads,
deck,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
@@ -255,6 +255,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -301,6 +302,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -323,6 +325,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -941,6 +941,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -378,6 +378,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -429,6 +430,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
+84 -2
View File
@@ -206,7 +206,17 @@ impl HomeScreen {
}
_ => Some(MenuPulse::Boundary),
},
MenuEvent::Move(_) => None,
// Down is Settings — the same screen X opens. The carousel is horizontal, so
// down is the other free direction, and it is the only route to Settings on a
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
// answered it by moving rows out to the ordinary Settings app.)
MenuEvent::Move(MenuDir::Down) => {
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
ctx.store,
)));
Some(MenuPulse::Confirm)
}
}
}
@@ -279,7 +289,15 @@ impl HomeScreen {
{
hints.push(Hint::new(HintKey::Up, "Options"));
}
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
// Name the route this device actually has. With no pad attached the legend is
// already speaking keyboard, and the one input that reaches here with neither a
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
// Down opens Settings for everyone; only the advertisement changes.
hints.push(if ctx.pads.is_empty() {
Hint::new(HintKey::Down, "Settings")
} else {
Hint::new(HintKey::Tertiary, "Settings")
});
hints.push(Hint::new(HintKey::Back, "Quit"));
hints
}
@@ -859,6 +877,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -885,6 +904,67 @@ mod tests {
));
}
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
/// legend names the direction rather than X when nothing is plugged in.
#[test]
fn a_remote_reaches_settings_and_options_without_face_buttons() {
let mut settings = ctx_settings();
let hosts = [host("paired", true, true, false)];
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Android,
pads: &pads,
deck: false,
fallback_ui: true,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
// Down opens the same screen X opens.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
"down must open Settings"
);
// Up still opens the host's own menu — the library hangs off that menu now.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
"up must open the host options menu"
);
// With no pad the legend advertises the direction, not a button that isn't there.
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
"a padless device is told about down"
);
// With a pad it goes back to naming X, which is faster to press.
let pads = vec![pf_client_core::menu_nav::PadInfo {
name: "Pad".into(),
key: "045e:028e:Pad".into(),
pref: punktfunk_core::config::GamepadPref::Xbox360,
steam_virtual: false,
battery: None,
detail: "045E:028E · gamepad".into(),
forwarded: true,
rumble: false,
}];
ctx.pads = &pads;
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
"a pad is told about X"
);
}
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
/// connecting takeover says which settings are coming (§5.2a).
#[test]
@@ -908,6 +988,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -932,6 +1013,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -2218,6 +2218,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
}
+92 -8
View File
@@ -34,6 +34,10 @@ use skia_safe::{Canvas, Rect};
enum Action {
Wake,
SendLogs,
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
/// exactly like that Y (an unpaired host has no shelf to fetch).
Library,
CopyLink,
Edit,
/// Choose the profile the host's primary tile connects with (opens the
@@ -154,6 +158,12 @@ impl OptionsScreen {
if host.paired && host.online {
a.push(Action::SendLogs);
}
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
// because it is the one row here that goes somewhere rather than acting on the
// host — and on a remote-only device it is the ONLY way to the library.
if host.paired && host.saved {
a.push(Action::Library);
}
a.extend([
Action::CopyLink,
Action::Edit,
@@ -171,6 +181,7 @@ impl OptionsScreen {
match a {
Action::Wake => "Wake host".into(),
Action::SendLogs => "Send logs to host".into(),
Action::Library => "Library".into(),
Action::CopyLink => "Copy link".into(),
Action::Edit => "Edit\u{2026}".into(),
Action::BindProfile => "Default profile\u{2026}".into(),
@@ -234,7 +245,7 @@ impl OptionsScreen {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
ListMsg::Activate => {
self.run(action, ctx.store, fx);
self.run(action, ctx, fx);
pulse
}
}
@@ -257,7 +268,8 @@ impl OptionsScreen {
}
}
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
let store = ctx.store;
let key = self.host_key().to_string();
match action {
Action::Wake => {
@@ -289,6 +301,24 @@ impl OptionsScreen {
}
fx.pop();
}
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
// on the epoch read BEFORE the command drains, so the screen can tell its own
// fetch's titles from the ones already in the model. `replace`, not push — the
// menu has said its piece, and Back from the shelf belongs on the carousel
// rather than on a menu about the host you just left.
Action::Library => {
let host = self.host();
fx.cmds.push(ConsoleCmd::FetchLibrary {
addr: host.addr.clone(),
mgmt: host.mgmt_port,
fp_hex: host.fp_hex.clone(),
});
let epoch = ctx.library.fetch_epoch();
fx.replace(Screen::Library(super::library::LibraryScreen::new(
self.host(),
epoch,
)));
}
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
self.host(),
))),
@@ -407,6 +437,27 @@ mod tests {
use crate::model::ProfileChip;
use crate::screens::Nav;
/// Activate one row. `run` reads the store, and — for Library — the shared library's
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
/// enough for every action test here.
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
let mut settings = pf_client_core::trust::Settings::default();
let library = crate::library::LibraryShared::default();
let ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
s.run(action, &ctx, fx);
}
fn host() -> HostRow {
HostRow {
key: "aa".into(),
@@ -510,6 +561,38 @@ mod tests {
assert_eq!(s.host_key(), "aa");
}
/// The shelf is on this menu, which is the only route to it that survives a device with
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
/// the shelf lands on the carousel rather than on a menu about the host just left.
#[test]
fn the_library_hangs_off_the_menu_for_a_padless_device() {
let mut s = OptionsScreen::for_host(&host());
assert!(s
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
let mut fx = Outbox::default();
run_action(&mut s, Action::Library, &mut fx);
assert!(
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
"opening the shelf asks for it first"
);
match fx.nav {
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
_ => panic!("expected the shelf to replace the menu"),
}
// An unpaired host has no shelf to fetch — the row is absent, not inert.
let unpaired = OptionsScreen::for_host(&HostRow {
paired: false,
..host()
});
assert!(!unpaired
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
}
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
/// the same reason — addressed to the HOST's plain key even from rows that carry a
/// composite one.
@@ -520,7 +603,7 @@ mod tests {
.actions(crate::platform::Platform::Desktop)
.contains(&Action::BindProfile));
let mut fx = Outbox::default();
s.run(Action::BindProfile, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::BindProfile, &mut fx);
match fx.nav {
Some(crate::screens::Nav::Replace(screen)) => match *screen {
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
@@ -537,7 +620,7 @@ mod tests {
let mut s = OptionsScreen::for_host(&host());
assert!(s.label(Action::Clipboard).ends_with("Off"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
@@ -551,7 +634,7 @@ mod tests {
});
assert!(s.label(Action::Clipboard).ends_with("On"));
let mut fx = Outbox::default();
s.run(Action::Clipboard, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
@@ -569,12 +652,12 @@ mod tests {
s.list.cursor = i;
let mut fx = Outbox::default();
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert!(fx.cmds.is_empty(), "the first press only arms");
assert!(s.armed);
assert!(s.label(Action::Forget).contains("press again"));
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
@@ -597,6 +680,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -659,7 +743,7 @@ mod tests {
OptionsScreen::for_game(&host(), &game()),
] {
let mut fx = Outbox::default();
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::CopyLink, &mut fx);
assert!(matches!(fx.nav, Some(Nav::Pop)));
assert!(fx.toast.is_some());
}
+2
View File
@@ -497,6 +497,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "living-room-deck",
t: 0.0,
};
@@ -537,6 +538,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "deck",
t: 0.0,
};
@@ -233,6 +233,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -274,6 +275,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
+121 -8
View File
@@ -80,6 +80,12 @@ enum RowId {
/// beside the palette row for the same reason it does: both are presentation, and the
/// effect of stepping this one is visible on the backdrop behind it.
ReduceMotion,
/// Draw the console at 1080p and let the display scale it up, instead of at the panel's
/// own resolution. Android-only, and beside [`RowId::ReduceMotion`] on purpose: both are
/// "give up some fidelity for a smoother console", and this is the one that matters on a
/// 4K TV or projector, where every pass the shell draws costs four times what it does at
/// 1080p on a GPU that is not four times faster.
ReduceUiResolution,
/// How the game library arranges its titles — see `library::LibraryView`. The library
/// changes it in place now, from the bar over its own field, which is where an
/// arrangement you want to SEE the effect of belongs; this row stays because both
@@ -104,6 +110,12 @@ enum RowId {
Sc2Passthrough,
/// DualSense raw-USB capture (touchpad, motion, adaptive triggers).
DsCapture,
/// Whether the console UI fronts the app at all — the touch settings' switch
/// (`Settings.gamepadUiEnabled`), reachable from inside the console it turns off.
/// Only offered where there is another interface to fall back to
/// ([`Ctx::fallback_ui`]): on a TV or the desktop session this console is the only
/// UI, and an off switch would strand the user in nothing.
GamepadUi,
/// When the console UI fronts the app: with a controller attached, or always.
GamepadUiMode,
/// The platform's connected-controllers view (an action row — opens a native screen).
@@ -121,6 +133,8 @@ mod android_keys {
pub const SC2: &str = "android.sc2_capture";
pub const DS_CAPTURE: &str = "android.ds_capture";
pub const GAMEPAD_UI_MODE: &str = "android.gamepad_ui_mode";
pub const GAMEPAD_UI: &str = "android.gamepad_ui_enabled";
pub const REDUCE_UI_RES: &str = "android.reduce_ui_resolution";
}
/// The Android console-UI mode's stored values (`GamepadUi.kt`).
@@ -240,11 +254,13 @@ const TABS: [(&str, &[RowId]); 7] = [
&[
RowId::Palette,
RowId::ReduceMotion,
RowId::ReduceUiResolution,
RowId::LibraryView,
RowId::LibraryCollections,
RowId::Stats,
RowId::Fullscreen,
RowId::AutoWake,
RowId::GamepadUi,
RowId::GamepadUiMode,
RowId::Licenses,
],
@@ -384,7 +400,7 @@ impl SettingsScreen {
.1
.iter()
.copied()
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx.settings))
.filter(|id| row_on(*id, ctx.platform) && row_applies(*id, ctx))
.collect();
}
if self.profiles.is_empty() {
@@ -675,7 +691,9 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
| RowId::PhoneGyro
| RowId::Sc2Passthrough
| RowId::DsCapture
| RowId::GamepadUi
| RowId::GamepadUiMode
| RowId::ReduceUiResolution
| RowId::Controllers
| RowId::Licenses
);
@@ -694,9 +712,22 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
}
}
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
fn row_applies(id: RowId, ctx: &Ctx) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
RowId::SmoothBuffer => ctx.settings.present_priority == "smooth",
// The console-off switch needs somewhere for "off" to land: only clients with a
// fallback interface (an Android phone/tablet's touch shell) get the row — on a TV
// this console is the only UI, and off would strand the user (the touch settings'
// subtitle even promises "A TV always uses it").
RowId::GamepadUi => ctx.fallback_ui,
// The same two conditions the mode decides anything under: a TV is in console mode
// whatever the mode says (`GamepadUi.kt`: the tv term alone satisfies the OR), and
// while the switch above is off nothing fronts the console at all. Hidden rather
// than dimmed, like the touch screen's picker, and it sits directly below the row
// that drops it so the cursor is never under anything that moves.
RowId::GamepadUiMode => {
ctx.fallback_ui && extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true)
}
_ => true,
}
}
@@ -914,6 +945,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// Phrased as the thing that is ON, not as the suppression, so "On" means the
// reduction is in effect — the same way every other toggle on this screen reads.
RowId::ReduceMotion => (None, "Reduce motion", on_off(s.reduce_motion).into()),
RowId::ReduceUiResolution => (
None,
"Reduce interface resolution",
on_off(extra_bool(s, android_keys::REDUCE_UI_RES, false)).into(),
),
RowId::LibraryView => (
None,
"Library view",
@@ -962,9 +998,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"DualSense over USB",
on_off(extra_bool(s, android_keys::DS_CAPTURE, true)).into(),
),
RowId::GamepadUi => (
None,
"Controller-optimized UI",
on_off(extra_bool(s, android_keys::GAMEPAD_UI, true)).into(),
),
RowId::GamepadUiMode => (
None,
"Controller UI",
// The touch screen's word for the same picker, which now sits under the same
// switch it does there — "Controller UI" beside "Controller-optimized UI"
// would be two rows a reader has to tell apart by their tails.
"Show it",
label_for(
&GAMEPAD_UI_MODES,
extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected"),
@@ -1101,6 +1145,12 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
fades. Also the gentler choice on an OLED, where a still field can sit for \
hours."
}
RowId::ReduceUiResolution => {
"Draws the menus at 1080p and lets the display scale them up. Text goes a \
little softer; the console gets much smoother on a 4K TV or projector, whose \
graphics chip is far slower than the panel in front of it. Nothing about a \
stream changes this is the interface only."
}
RowId::LibraryView => {
"Shelf shows one cover at a time, big. Grid shows about eighteen at once — \
for when you already know what you are looking for. The library's own bar \
@@ -1145,9 +1195,14 @@ fn detail(id: RowId, platform: crate::platform::Platform) -> &'static str {
"Capture a wired DualSense directly (touchpad, motion, adaptive triggers). \
Needs the USB grant when the pad is plugged in."
}
RowId::GamepadUi => {
"Front the app with this console instead of the touch interface. Off returns \
to the touch home immediately switch it back on there."
}
RowId::GamepadUiMode => {
"When this console fronts the app: whenever a controller is attached, or \
always. The touch settings' \"Controller-optimized UI\" switch turns it off."
always for a device that lives docked to a TV. The switch above turns it \
off altogether."
}
RowId::Controllers => "Connected controllers, their grants and a rumble/haptics test.",
RowId::Licenses => "The open-source licences this app ships under.",
@@ -1361,6 +1416,9 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
}
RowId::ReduceMotion => toggle(&mut s.reduce_motion, delta, wrap),
RowId::ReduceUiResolution => {
toggle_extra(s, android_keys::REDUCE_UI_RES, false, delta, wrap)
}
RowId::LibraryView => {
let all = &crate::library::LibraryView::ALL;
let cur = crate::library::LibraryView::parse(&s.library_view);
@@ -1375,6 +1433,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::PhoneGyro => toggle_extra(s, android_keys::PHONE_GYRO, false, delta, wrap),
RowId::Sc2Passthrough => toggle_extra(s, android_keys::SC2, true, delta, wrap),
RowId::DsCapture => toggle_extra(s, android_keys::DS_CAPTURE, true, delta, wrap),
RowId::GamepadUi => toggle_extra(s, android_keys::GAMEPAD_UI, true, delta, wrap),
RowId::GamepadUiMode => {
let mut v = extra_str(s, android_keys::GAMEPAD_UI_MODE, "connected").to_string();
step_str(&GAMEPAD_UI_MODES, &mut v, delta, wrap).map(|()| {
@@ -1504,6 +1563,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1538,6 +1598,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1603,6 +1664,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1652,6 +1714,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1686,6 +1749,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1727,6 +1791,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1757,6 +1822,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1793,6 +1859,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1867,6 +1934,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1900,6 +1968,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1931,6 +2000,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -1960,6 +2030,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2009,6 +2080,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2060,6 +2132,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2105,6 +2178,10 @@ pub(super) mod tests {
RowId::Sc2Passthrough,
RowId::DsCapture,
RowId::Controllers,
// Between the Input tab's rows and the rest of Interface: this one sits under
// Reduce motion, which is earlier in that tab than the console-UI switch.
RowId::ReduceUiResolution,
RowId::GamepadUi,
RowId::GamepadUiMode,
RowId::Licenses,
]
@@ -2147,6 +2224,9 @@ pub(super) mod tests {
extra_str(ctx.settings, android_keys::GAMEPAD_UI_MODE, "connected"),
"always"
);
assert!(extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
assert!(adjust(RowId::GamepadUi, 1, true, ctx));
assert!(!extra_bool(ctx.settings, android_keys::GAMEPAD_UI, true));
// Only `extra` moved.
let mut after = ctx.settings.clone();
after.extra = before.extra.clone();
@@ -2154,6 +2234,34 @@ pub(super) mod tests {
});
}
/// The console-off switch exists only where there is a fallback interface for "off"
/// to land in, and the mode row under it only where the mode decides anything: not on
/// a TV (always console, whatever the mode says) and not while the switch is off.
#[test]
fn console_off_switch_needs_a_fallback_ui() {
with_ctx(|ctx| {
ctx.platform = crate::platform::Platform::Android;
// A TV: no off switch (it would strand the user), and no mode row either —
// `gamepadUiActive`'s tv term satisfies the OR on its own.
assert!(
!row_applies(RowId::GamepadUi, ctx),
"a TV offers no off switch"
);
assert!(!row_applies(RowId::GamepadUiMode, ctx));
// A phone or tablet with the console on: both rows.
ctx.fallback_ui = true;
assert!(row_applies(RowId::GamepadUi, ctx));
assert!(row_applies(RowId::GamepadUiMode, ctx));
// Switched off: the switch stays (it is the way back), the mode row goes.
set_extra_bool(ctx.settings, android_keys::GAMEPAD_UI, false);
assert!(row_applies(RowId::GamepadUi, ctx));
assert!(
!row_applies(RowId::GamepadUiMode, ctx),
"the mode row decides nothing while the switch above it is off"
);
});
}
#[test]
fn every_row_has_exactly_one_tab() {
let mut seen: Vec<RowId> = Vec::new();
@@ -2168,11 +2276,12 @@ pub(super) mod tests {
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
// game-library toggle: this screen never read it, and the library is offered on any
// paired host now.
// 35 desktop rows + the eight Android-only ones (design android-skia-console-port.md
// D3): six `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 43, "{seen:?}");
// 35 desktop rows + the ten Android-only ones (design android-skia-console-port.md
// D3): eight `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 45, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
assert!(seen.contains(&RowId::ReduceUiResolution));
assert!(seen.contains(&RowId::AudioFormat));
// The catalog rows belong to the trailing tab, which builds them at render time.
assert!(TABS[PROFILES_TAB].1.is_empty());
@@ -2209,6 +2318,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2250,6 +2360,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2295,6 +2406,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -2373,6 +2485,7 @@ pub(super) mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
+15
View File
@@ -183,6 +183,11 @@ pub struct ConsoleOptions {
pub device_name: String,
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
pub deck: bool,
/// Whether the host app has another interface to fall back to when the console is
/// switched off — an Android phone/tablet's touch shell. Shows the console-off switch
/// on the settings screen; false where this console is the only UI there is (the
/// desktop session, an Android TV), where offering "off" would strand the user.
pub fallback_ui: bool,
/// Where settings persist and the profile catalog comes from. `None` = the desktop
/// file store (`pf_client_core::trust`), which is what the Vulkan session wants and the
/// only store there is on Linux/Windows; every other host must supply one.
@@ -203,6 +208,7 @@ impl ConsoleOptions {
ConsoleOptions {
device_name,
deck,
fallback_ui: false,
store: None,
platform: Platform::Desktop,
gpu_cache_bytes: DEFAULT_GPU_CACHE_BYTES,
@@ -247,6 +253,8 @@ pub(crate) struct Shell {
hosts_gen: u64,
device_name: String,
deck: bool,
/// See [`ConsoleOptions::fallback_ui`].
fallback_ui: bool,
pub(crate) in_stream: bool,
connecting: Option<Connecting>,
/// The last host title a connect was raised for, kept past the connect itself so
@@ -352,6 +360,7 @@ impl Shell {
hosts_gen: u64::MAX,
device_name: opts.device_name,
deck: opts.deck,
fallback_ui: opts.fallback_ui,
in_stream: false,
connecting: None,
last_connect_title: None,
@@ -888,6 +897,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
@@ -951,6 +961,10 @@ impl Shell {
// navigation but "open this tile's menu". Without this the context menu —
// and with it the only way to copy a host's link — is pad-only.
crate::glyphs::HintKey::Up => Some(MenuEvent::Move(MenuDir::Up)),
// ▼ is the same kind of hint: a direction that steers nothing, because
// the only screen publishing it is the home carousel, where down means
// "open Settings". A finger must be able to press what it advertises.
crate::glyphs::HintKey::Down => Some(MenuEvent::Move(MenuDir::Down)),
_ => None,
};
if let Some(ev) = ev {
@@ -970,6 +984,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t: self.t0.elapsed().as_secs_f64(),
};
+12 -1
View File
@@ -163,8 +163,19 @@ impl Shell {
let bw = lead + tw + pad_x;
let bx = (w - bw) / 2.0;
let by = h - BOTTOM_BAND * k - bh - 8.0 * k + (1.0 - slide) * 12.0 * k;
canvas.save_layer_alpha_f(None, alpha);
let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32);
// BOUNDED to the pill. Unbounded, `save_layer` allocates an offscreen the size of
// the whole SURFACE and composites it back — on a 4K TV that is a 33 MB render
// target raised and torn down every frame, for four seconds, to fade a 34 dp pill
// (and on a box whose whole Skia budget is 64 MB, it evicts real work to do it).
//
// Everything drawn inside is inside `rect`: the pill fill, `theme::panel`'s
// hairline ON that rect, the kind mark centred in it, and text that ends a `pad_x`
// short of its right edge. There is no blur to reach further, so the outset is
// slack for the stroke rather than a computed reach — `screens::home` needs 36 k
// for the same layer only because it wraps a σ = 10 k halo.
let bounds = rect.with_outset((12.0 * k as f32, 12.0 * k as f32));
canvas.save_layer_alpha_f(Some(bounds), alpha);
canvas.draw_rrect(
skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32),
&fill(crate::theme::shade(0.6)),
+34 -5
View File
@@ -67,6 +67,8 @@ impl Shell {
}
None => dt,
};
// The shaped-paragraph cache's clock, before anything asks it to draw.
fonts.begin_frame();
self.sync();
// Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in
// the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint
@@ -80,10 +82,14 @@ impl Shell {
crate::theme::set_reduce_motion(reduce);
self.pads = pads.to_vec();
self.glyphs = GlyphStyle::from_pref(pad_pref);
self.chip = Some(pad.map_or_else(
|| "No controller — keyboard works too".to_string(),
str::to_owned,
));
// Compared before it is rebuilt: this string changes when someone plugs a controller
// in, and was being re-allocated 60 times a second to say so. (`pads` above is left
// alone — it is at most a handful of small structs, and `PadInfo` would have to grow a
// `PartialEq` in another crate to be worth the same treatment.)
let chip = pad.unwrap_or("No controller — keyboard works too");
if self.chip.as_deref() != Some(chip) {
self.chip = Some(chip.to_owned());
}
let (full_w, full_h) = (f64::from(viewport.width), f64::from(viewport.height));
let ins = viewport.insets;
@@ -172,6 +178,7 @@ impl Shell {
platform: self.platform,
pads: &self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: &self.device_name,
t,
glyphs: self.glyphs,
@@ -331,6 +338,8 @@ struct LayerEnv<'a> {
platform: crate::platform::Platform,
pads: &'a [PadInfo],
deck: bool,
/// See [`crate::shell::ConsoleOptions::fallback_ui`] — a screen's row set can ask.
fallback_ui: bool,
device_name: &'a str,
t: f64,
glyphs: GlyphStyle,
@@ -350,7 +359,26 @@ impl LayerEnv<'_> {
scale: f64,
) -> Vec<(crate::glyphs::HintKey, Rect)> {
let canvas = self.canvas;
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
// Only RAISE the layer when it carries something. A settled screen is painted at full
// alpha, unscaled and unslid, and an unbounded `save_layer` allocates an offscreen the
// size of the whole SURFACE and composites it back — so the console was paying for one
// full-screen offscreen on every frame it sat still, to apply an alpha of 1. Skia does
// not elide it either: `SkCanvas::saveLayerAlphaf` forwards alpha ≥ 1 straight to
// `saveLayer(bounds, nullptr)`, whose only early-out is an empty clip.
//
// Dropping the layer is pixel-identical rather than merely close: nothing in this crate
// draws with a blend mode other than `SrcOver`, and `SrcOver` is associative, so
// compositing the draws into a transparent layer and then over the backdrop lands on
// exactly the value drawing them straight onto the backdrop does. (It is also why the
// text stays grayscale-AA — no LCD subpixel text to gain or lose an isolation.) Same
// reasoning `screens::home` already bounds its per-tile layer by.
let layered = alpha < 0.999 || (scale - 1.0).abs() > 0.001 || dy.abs() > 0.001;
if layered {
canvas.save_layer_alpha_f(None, alpha.clamp(0.0, 1.0) as f32);
} else {
// Still a save: the transform below is undone by the same `restore`.
canvas.save();
}
canvas.translate((0.0, dy as f32));
let (cx, cy) = ((self.w / 2.0) as f32, (self.h / 2.0) as f32);
canvas.translate((cx, cy));
@@ -365,6 +393,7 @@ impl LayerEnv<'_> {
platform: self.platform,
pads: self.pads,
deck: self.deck,
fallback_ui: self.fallback_ui,
device_name: self.device_name,
t: self.t,
};
+5 -4
View File
@@ -402,10 +402,11 @@ fn a_replace_carries_the_screen_it_replaced() {
assert!(matches!(s.stack.last(), Some(Screen::HostOptions(_))));
finish_motion(&mut s);
// Walk to "Edit…" and take it. The first fixture host is paired and online and cannot
// wake, so its menu is [Send logs, Copy link, Edit…, Forget, Cancel] — Edit is two down.
// Pressed exactly rather than searched, so that reordering the menu fails HERE instead of
// quietly landing this test's Confirm on "Forget".
// Walk to "Edit…" and take it. The first fixture host is paired, saved and online and
// cannot wake, so its menu is [Send logs, Library, Copy link, Edit…, ] — Edit is three
// down. Pressed exactly rather than searched, so that reordering the menu fails HERE
// instead of quietly landing this test's Confirm on something destructive.
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Move(MenuDir::Down));
s.handle_menu(MenuEvent::Confirm);
+183 -48
View File
@@ -7,12 +7,15 @@
use anyhow::{anyhow, Result};
use skia_safe::textlayout::{
FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle, TypefaceFontProvider,
FontCollection, Paragraph, ParagraphBuilder, ParagraphStyle, TextAlign, TextStyle,
TypefaceFontProvider,
};
use skia_safe::{
gradient, Canvas, Color4f, Font, FontMgr, FontStyle, MaskFilter, Paint, PathEffect, Point,
RRect, Rect, TileMode, Typeface,
};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
// --- Paint ----------------------------------------------------------------------------------
@@ -396,9 +399,16 @@ pub(crate) fn panel_highlight(canvas: &Canvas, rect: Rect, corner: f32, k: f32)
),
None,
));
canvas.draw_rrect(RRect::new_rect_xy(inset, corner * k, corner * k), &p);
// Concentric, the same rule the halo states: pulled in by half a unit, so the radius
// comes in by half a unit too or the lit edge crosses the panel's own corner arc.
let r = ((corner - 0.5) * k).max(0.0);
canvas.draw_rrect(RRect::new_rect_xy(inset, r, r), &p);
}
/// How far [`focus_halo`] is grown past the card on every side, in design units. Both the
/// rect AND the corner radius take it — see the draw there.
const HALO_OUTSET: f32 = 4.0;
/// An accent-tinted glow under the focused card — the palette-aware mark that says "this
/// one" from across a room, where a 2 % scale difference says nothing at all. Drawn behind
/// [`drop_shadow`], and only ever for the ONE focused tile, so it costs a single extra
@@ -439,8 +449,13 @@ pub(crate) fn focus_halo(canvas: &Canvas, rect: Rect, corner: f32, k: f32, f: f3
// it overran the coverflow's 58 dp focused-to-neighbour gap, and since the strip paints
// farthest-first the focused card's corona landed on top of its neighbours — which is
// what made every card look like it was glowing.
let spread = rect.with_outset((4.0 * k, 4.0 * k));
canvas.draw_rrect(RRect::new_rect_xy(spread, corner * k, corner * k), &p);
let spread = rect.with_outset((HALO_OUTSET * k, HALO_OUTSET * k));
// Concentric: a shape grown by `d` on every side keeps its corners parallel to the
// original's only if its radius grows by `d` too (the two arcs then share a centre).
// Reusing the card's own radius left the halo squarer than the card it sits under, so
// it read as a misaligned outline at the four corners and a clean glow along the edges.
let r = (corner + HALO_OUTSET) * k;
canvas.draw_rrect(RRect::new_rect_xy(spread, r, r), &p);
}
pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alpha: f32) {
@@ -509,7 +524,7 @@ pub(crate) const EDGE_INSET: f64 = 24.0;
// --- Typography ---------------------------------------------------------------------------
/// Geist weights the console uses (matching the Apple client's `.geist(size, weight)`).
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum W {
Regular,
Medium,
@@ -526,6 +541,111 @@ pub(crate) struct Fonts {
semibold: Typeface,
bold: Typeface,
collection: FontCollection,
/// Shaped paragraphs, keyed by everything that shapes one ([`ParaKey`]).
///
/// `Paragraph::layout` runs the whole shaper — HarfBuzz, line breaking, font fallback —
/// and the shell re-built every paragraph on screen from scratch EVERY frame, which on a
/// TV box is the largest CPU cost in the frame. Position is deliberately not part of the
/// key (`paint` takes it), so one shaped paragraph serves a string wherever it moves to:
/// a scrolling shelf and a screen transition both re-use it rather than re-shaping.
///
/// `RefCell` because every draw path here takes `&self` and the console's shell is
/// single-threaded by construction (one render thread owns it on all three ABIs).
paragraphs: RefCell<HashMap<ParaKey, Cached>>,
/// The frame counter [`Fonts::begin_frame`] bumps — the cache's liveness clock.
frame: Cell<u64>,
}
/// The three paragraph shapes the console draws. A single tag rather than a loose
/// `(TextAlign, Option<usize>)` pair because it is half of a hash key, and because those two
/// were never independent — every call site picks one of these three.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Para {
/// Centred, wrapping freely.
Centered,
/// Left-aligned, wrapping freely.
Leading,
/// Left-aligned, clamped to one ellipsized line.
Heading,
}
impl Para {
/// The paragraph style this shape asks for: alignment, and the line clamp if it has one.
fn style(self) -> (TextAlign, Option<usize>) {
match self {
Para::Centered => (TextAlign::Center, None),
Para::Leading => (TextAlign::Left, None),
Para::Heading => (TextAlign::Left, Some(1)),
}
}
}
/// Everything [`shape`] bakes into a laid-out `Paragraph` — change any of it and the shaped
/// result differs, so all of it is in the key.
///
/// The floats ride as bits: the sizes and widths are all `k`-scaled, so they are never whole
/// numbers, and `f64`/`f32` are not `Hash`. Bit equality is the right test anyway — the same
/// `k` produces the same bits, and a different `k` must re-shape.
#[derive(PartialEq, Eq, Hash)]
struct ParaKey {
text: String,
kind: Para,
weight: W,
size: u64,
max_w: u32,
/// ARGB, as `[a, r, g, b]`.
color: [u8; 4],
}
/// One shaped paragraph and the frame it was last drawn on.
struct Cached {
para: Paragraph,
used: u64,
}
/// How many shaped paragraphs stay resident before the cold ones are dropped. A screen draws
/// well under this; the ceiling exists for the library, where paging a large catalogue walks
/// through thousands of titles and every one of them would otherwise be kept forever.
const PARA_CACHE_MAX: usize = 512;
/// Build and lay out one paragraph — the shaping [`Fonts::draw_paragraph`]'s cache exists to
/// do exactly once per distinct key.
///
/// A free function rather than a method because the cache hands it a `&ParaKey` borrowed out
/// of the map it is inserting into, which rules out holding `&self` across the call.
fn shape(collection: &FontCollection, key: &ParaKey) -> Paragraph {
let (align, clamp) = key.kind.style();
let mut style = ParagraphStyle::new();
style.set_text_align(align);
if let Some(lines) = clamp {
style.set_max_lines(lines);
style.set_ellipsis("\u{2026}");
}
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(f64::from_bits(key.size) as f32);
let [a, r, g, b] = key.color;
ts.set_color(skia_safe::Color::from_argb(a, r, g, b));
ts.set_font_style(match key.weight {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
});
style.set_text_style(&ts);
let mut builder = ParagraphBuilder::new(&style, collection.clone());
builder.add_text(&key.text);
let mut p = builder.build();
p.layout(f32::from_bits(key.max_w));
p
}
/// The Geist faces ride in the binary — the console must look right on a bare gamescope
@@ -562,6 +682,8 @@ pub(crate) fn build_fonts() -> Result<Fonts> {
semibold,
bold,
collection,
paragraphs: RefCell::new(HashMap::new()),
frame: Cell::new(0),
})
}
@@ -629,50 +751,59 @@ impl Fonts {
}
}
/// `clamp` caps the paragraph at that many lines and ellipsizes what doesn't fit; `None`
/// wraps freely. A heading has to clamp — an over-long one used to grow DOWNWARD into the
/// screen's content, which is why both other clients pin theirs to one line.
/// Start a frame — the paragraph cache's clock. Anything not drawn on this frame or the
/// one before it becomes a candidate for eviction, so the live set is exactly "what the
/// last two frames drew". The shell calls this once per `render_in`.
pub(crate) fn begin_frame(&self) {
self.frame.set(self.frame.get().wrapping_add(1));
}
/// Draw a shaped paragraph, building and laying it out only the first time this exact
/// (text, shape, weight, size, width, colour) is asked for — see [`Fonts::paragraphs`].
/// `at` is the paragraph's TOP-LEFT, and is deliberately not part of the key.
#[allow(clippy::too_many_arguments)]
fn paragraph(
fn draw_paragraph(
&self,
canvas: &Canvas,
text: &str,
kind: Para,
w: W,
size: f64,
color: Color4f,
align: TextAlign,
max_w: f64,
clamp: Option<usize>,
) -> skia_safe::textlayout::Paragraph {
let mut style = ParagraphStyle::new();
style.set_text_align(align);
if let Some(lines) = clamp {
style.set_max_lines(lines);
style.set_ellipsis("\u{2026}");
}
let mut ts = TextStyle::new();
ts.set_font_families(&["Geist"]);
ts.set_font_size(size as f32);
ts.set_color(color.to_color());
ts.set_font_style(match w {
W::Regular => FontStyle::normal(),
W::Medium => FontStyle::new(
skia_safe::font_style::Weight::MEDIUM,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::SemiBold => FontStyle::new(
skia_safe::font_style::Weight::SEMI_BOLD,
skia_safe::font_style::Width::NORMAL,
skia_safe::font_style::Slant::Upright,
),
W::Bold => FontStyle::bold(),
at: Point,
) {
let frame = self.frame.get();
// ponytail: the key owns its text, so a HIT still costs one small `String` allocation
// where a borrowed-key lookup would cost none. Deliberate — it is a rounding error
// against the shape it replaces, and the alternatives (hash-only keys, `hashbrown`'s
// raw entry) trade a real collision risk or a dependency for it. Revisit only if a
// profile ever puts this line on the board.
let key = ParaKey {
text: text.to_owned(),
kind,
weight: w,
size: size.to_bits(),
max_w: (max_w as f32).to_bits(),
color: {
// The 8-bit ARGB the paragraph actually bakes, not the `Color4f` it came
// from — two float colours that round to the same pixel share an entry.
let c = color.to_color();
[c.a(), c.r(), c.g(), c.b()]
},
};
let mut cache = self.paragraphs.borrow_mut();
let entry = cache.entry(key).or_insert_with_key(|k| Cached {
para: shape(&self.collection, k),
used: frame,
});
style.set_text_style(&ts);
let mut b = ParagraphBuilder::new(&style, self.collection.clone());
b.add_text(text);
let mut p = b.build();
p.layout(max_w as f32);
p
entry.used = frame;
entry.para.paint(canvas, at);
// Drop what the last two frames did not draw. Every entry still on screen is
// re-stamped above on the frame it appears in, so this only reaps strings that left.
if cache.len() > PARA_CACHE_MAX {
cache.retain(|_, c| c.used + 1 >= frame);
}
}
/// Centered, wrapping paragraph with `y` as its TOP edge (shaping + CJK fallback).
@@ -688,8 +819,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Center, max_w, None);
p.paint(canvas, Point::new((cx - max_w / 2.0) as f32, y as f32));
let at = Point::new((cx - max_w / 2.0) as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Centered, w, size, color, max_w, at);
}
/// [`centered`](Self::centered)'s LEFT-ALIGNED twin: `x` is the text's left edge, `y` its
@@ -707,8 +838,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, None);
p.paint(canvas, Point::new(x as f32, y as f32));
let at = Point::new(x as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Leading, w, size, color, max_w, at);
}
/// A screen's heading: left-aligned at `x`, top edge at `y`, clamped to ONE ellipsized
@@ -731,8 +862,8 @@ impl Fonts {
y: f64,
max_w: f64,
) {
let p = self.paragraph(text, w, size, color, TextAlign::Left, max_w, Some(1));
p.paint(canvas, Point::new(x as f32, y as f32));
let at = Point::new(x as f32, y as f32);
self.draw_paragraph(canvas, text, Para::Heading, w, size, color, max_w, at);
}
/// A single shaped line, middle-ellipsized to `max_w`, drawn at a baseline. For
@@ -758,8 +889,12 @@ impl Fonts {
let ell_w = font.measure_str(ell, None).0;
let mut fitted = String::new();
let mut used = 0.0f32;
// The char goes onto the stack to be measured, not into a fresh `String` per character:
// this runs for every over-long title on screen, every frame, and the allocation was
// the bulk of it. `encode_utf8` writes the same bytes `to_string` would have.
let mut buf = [0u8; 4];
for ch in text.chars() {
let cw = font.measure_str(ch.to_string().as_str(), None).0;
let cw = font.measure_str(&*ch.encode_utf8(&mut buf), None).0;
if used + cw + ell_w > max_w as f32 {
break;
}
@@ -116,11 +116,16 @@ static MANAGED_LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// The display-manager unit we stopped for the takeover (any DM that drove a LIVE gaming session
/// is stopped for the stream — see [`dm_plan`]), so the restore brings the box back via
/// A display-manager unit stopped for a takeover, so the restore brings the box back via
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
/// cannot work on a mask-fragile flavor: without a DM login session there is no seat, so gamescope
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
///
/// ⚠ **Adoption-only since 0.31.0**: the takeover idles the box's autologin session
/// ([`install_idle_dropin`]) and leaves the DM up, so nothing in this process ever writes this any
/// more — only [`restore_takeover_on_startup`], for a takeover stranded by a host old enough to
/// have stopped one. It is therefore NOT the marker of a live takeover; [`takeover_idled`] is.
/// Reading it as that marker is what silently unreachable-d the in-stream switch gate in 0.31.0.
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
@@ -136,12 +141,16 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request.
/// "Switch to Desktop" detector compares against. The ChimeraOS-layout `os-session-select`
/// (Nobara, ChimeraOS) writes `~/.config/steamos-session-select` unconditionally in its USER pass,
/// before any of its display-manager checks, so an advanced mtime after a capture loss is the one
/// durable trace of the user's switch request — the switch itself leaves nothing else behind that
/// this host can see.
///
/// ⚠ Bazzite/SteamOS write NO sentinel: their `os-session-select` is a thin wrapper over
/// `steamosctl` D-Bus calls. The detector is therefore inert there by construction, which is
/// exactly right — those platforms default the mid-stream session watcher ON
/// ([`is_steam_htpc_platform`]) and follow the switch with it instead.
///
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
@@ -358,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
@@ -390,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
@@ -674,21 +722,28 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
if steamos_session_present() {
return create_managed_session_steamos(mode, hdr);
}
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(dm) = dm_takeover {
if session_select_requested() {
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
honor_session_select_switch(dm);
return Err(anyhow!(
"the user switched the box to the desktop session — display manager restored; \
re-detection follows the desktop compositor as it comes up"
));
}
// In-stream "Switch to Desktop": the user's session-select inside the streamed game mode
// advanced the sentinel, so the box is on its way to a desktop session. Without this, the
// capture loss that switch causes just relaunches game mode over the booting desktop — the
// "thrown back in" field report of 2026-07-24, and again on Nobara 2026-08-20.
//
// ⚠ Gated on the IDLED takeover, not on [`STOPPED_DM`]. Until 0.31.0 the takeover stopped the
// display manager, and setting that static was what armed this gate; the idled takeover
// replaced both the stop and the static ([`install_idle_dropin`]) and nothing re-armed the
// gate, so this branch became unreachable on every box. Bazzite did not notice — its
// `os-session-select` is a `steamosctl` D-Bus call that writes no sentinel, and its session
// watcher is on by default ([`is_steam_htpc_platform`]) so the stream follows the switch
// anyway. The ChimeraOS-layout distros are the ones that lost their handling: their
// `os-session-select` DOES write the sentinel, and `ID=nobara` matches no HTPC default.
if takeover_idled() && session_select_requested() {
// `take`, so an adopted DM stop is consumed exactly once — see
// [`honor_session_select_switch`] for why a 0.31.0 takeover has none to consume.
let adopted_dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
honor_session_select_switch(adopted_dm);
return Err(anyhow!(
"the user switched the box to the desktop session — the box's own game mode is handed \
back; re-detection follows the desktop compositor as it comes up"
));
}
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
@@ -1349,19 +1404,34 @@ fn install_idle_dropin() -> Result<()> {
.parent()
.context("the idle drop-in path has no parent directory")?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::write(
&path,
format!(
"[Service]\nExecStart=\nExecStart={} infinity\n",
sleep_binary()
),
)
.with_context(|| format!("write {}", path.display()))?;
std::fs::write(&path, idle_dropin_body(sleep_binary()))
.with_context(|| format!("write {}", path.display()))?;
systemctl_user(&["daemon-reload"]);
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
Ok(())
}
/// The idle drop-in's body (the unit-testable core of [`install_idle_dropin`]).
///
/// The **empty `ExecStart=` comes first and is load-bearing**: `ExecStart` is a list-valued
/// directive, so a drop-in that only adds a line APPENDS to the box's own — which would run the
/// real gamescope session *and* the sleep, i.e. exactly the Steam-fighting session the takeover
/// exists to get out of the way, with no symptom pointing here. The reset is what replaces it.
fn idle_dropin_body(sleep_bin: &str) -> String {
format!("[Service]\nExecStart=\nExecStart={sleep_bin} infinity\n")
}
/// Does THIS host hold the box's game mode idled right now? The successor to "did we stop the
/// display manager" as the marker of a live managed takeover, and so what arms the in-stream
/// switch gate in [`create_managed_session`].
///
/// Reads [`IDLE_DROPIN_ARMED`] — this process's own memory — deliberately, unlike
/// [`remove_idle_dropin`]: a drop-in on disk that we did not write belongs to a dead host, and
/// honoring a "switch" against someone else's takeover would hand back a box we never took.
fn takeover_idled() -> bool {
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner())
}
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
/// the drop-in outliving a host that died is exactly the case that has to be swept.
@@ -2282,10 +2352,36 @@ fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
}
/// The host's mid-stream session watcher calls this on every switch it confirms; see
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
/// [`switch_ends_mask_window`] for which ones end the takeover's hold on the box's own game mode.
///
/// This is the SECOND of the two ways a box can leave our takeover mid-stream — the sentinel
/// detector in [`create_managed_session`] is the other — and both owe the box the same hand-back.
/// The watcher is the one that covers Bazzite/SteamOS, where it is on by default
/// ([`is_steam_htpc_platform`]) and no sentinel is ever written; the detector covers the
/// ChimeraOS-layout distros, which are the reverse. Fixing only one leaves the other's boxes
/// holding an idled game mode.
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
if switch_ends_mask_window(switched_to) {
lift_autologin_mask();
if !switch_ends_mask_window(switched_to) {
return;
}
lift_autologin_mask();
// The idle drop-in is the mask's successor and inherits its whole hazard: it replaces the
// box's game-mode `ExecStart` with a sleep, and a switch to a desktop is exactly where that
// stops being ours to hold. Left on, the user's "Return to Gaming Mode" starts a unit that
// only sleeps — the same barred way back this function's mask lift exists to prevent, and
// measured in that state on Bazzite `.41` 2026-08-20 (`ExecStart=/usr/bin/sleep infinity`
// still on the unit after a completed switch to KDE).
//
// Deliberately NOT a full [`clear_takeover`]: the takeover outlives this window, exactly as
// the mask lift's own note says. The box may come back to game mode, and the disconnect
// restore still owes [`STOPPED_AUTOLOGIN`] a start. All this says is "the box's own game mode
// runs for real again".
if remove_idle_dropin() {
tracing::info!(
switched_to = ?switched_to,
"gamescope: the box left our game session for a desktop — removed the takeover's idle \
drop-in so its own Game Mode runs for real again"
);
}
}
@@ -2754,11 +2850,12 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
/// as a live request and pushing the box to the desktop the user never asked for.
/// the idle drop-in goes in, which is what arms the honor gate — see [`takeover_idled`]) and again
/// at a successful launch: the switch INTO game mode writes the sentinel on its way in, and that
/// write must never read as a request to go back out. Baselining only at launch left the window in
/// between — a takeover whose launch failed, then a client retry inside the restore debounce —
/// reading a months-old sentinel as a live request and pushing the box to the desktop the user
/// never asked for.
fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE
.lock()
@@ -2801,11 +2898,11 @@ fn sentinel_advanced(
///
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
/// re-detection follows the desktop compositor once it's up instead of racing it.
fn honor_session_select_switch(dm: String) {
fn honor_session_select_switch(adopted_dm: Option<String>) {
tracing::info!(
%dm,
"gamescope: in-stream session-select detected — restoring the display manager and \
switching the box to the desktop session"
adopted_dm = ?adopted_dm,
"gamescope: in-stream session-select detected — handing the box's own game mode back and \
following the desktop session the user selected"
);
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
// FIRST and while the unit list still exists — this path discards that list, and it is the only
@@ -2818,7 +2915,43 @@ fn honor_session_select_switch(dm: String) {
clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
if let Err(e) = restore_display_manager(&dm) {
// Give the box its own Game Mode back before anything else can ask for it. The takeover
// replaced that session's `ExecStart` with a sleep ([`install_idle_dropin`]), and a switch is
// the one exit that used to leave it behind: the disconnect restore sweeps it, but a switch is
// not a disconnect. Without this the user's next "Return to Gaming Mode" starts a unit that
// does nothing at all — measured on the Nobara VM 2026-08-20, and on glass it is
// indistinguishable from broken hardware.
if remove_idle_dropin() {
tracing::info!(
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
real again"
);
}
// Only an ADOPTED takeover still owes a display-manager restore. 0.31.0 leaves the DM up for
// exactly this reason, so by the time we get here the OS's own switch has already done the
// whole job — config rewrite and relogin (measured end to end on the Nobara VM). A takeover
// inherited from a host old enough to have STOPPED the DM has not: for it the switch really
// was the silent no-op that every write branch of `os-session-select` becomes without a
// running DM, so that one still has to be replayed.
if let Some(dm) = adopted_dm {
replay_switch_under_restored_dm(&dm);
}
record_session_select_baseline();
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// Restore a display manager an ADOPTED takeover stopped, then replay the user's switch under it —
/// every verb live-validated on the Nobara repro VM:
/// 1. start the DM (its autologin heads back into game mode briefly — the config still names it);
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
/// into the now-selected desktop.
///
/// Reachable only from [`honor_session_select_switch`], and only for a takeover inherited from a
/// pre-0.31.0 host: nothing stops a display manager any more.
fn replay_switch_under_restored_dm(dm: &str) {
if let Err(e) = restore_display_manager(dm) {
tracing::warn!(
%dm,
reason = %e,
@@ -2831,7 +2964,7 @@ fn honor_session_select_switch(dm: String) {
// Budgeted: this is a 10 s loop, and a single unbounded `is-active` against a system
// manager that is itself mid-restart would consume the whole window in one tick.
let active = crate::proc::output_within(
Command::new("systemctl").args(["is-active", &dm]),
Command::new("systemctl").args(["is-active", dm]),
UNIT_STATE_BUDGET,
)
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
@@ -2888,8 +3021,48 @@ fn honor_session_select_switch(dm: String) {
session instead of switching to the desktop"
);
}
record_session_select_baseline();
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// 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
@@ -2919,33 +3092,9 @@ fn honor_session_select_switch(dm: String) {
/// 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
@@ -2979,6 +3128,13 @@ fn stop_autologin_sessions() -> Result<()> {
// switch.
if plan.dm_relogins {
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
// Baseline the switch sentinel HERE, not only at a successful launch: arming the idle
// drop-in is what arms the honor gate in [`create_managed_session`], so from this instant
// an unbaselined sentinel would read as an in-stream "Switch to Desktop" — including the
// write left by the switch that just brought the box INTO game mode. A successful launch
// re-baselines (tighter still). This moved here from the display-manager stop that 0.31.0
// retired; losing it with that stop is what left the gate unarmed.
record_session_select_baseline();
}
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
let mut stopped = Vec::new();
@@ -3340,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,
@@ -3404,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.
@@ -3475,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;
}
}
@@ -3600,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)"
@@ -3632,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
@@ -3668,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);
}
}
}
@@ -5233,13 +5564,14 @@ mod tests {
use super::{
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags,
mode_mismatch, nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
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,
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, 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};
@@ -5434,6 +5766,58 @@ mod tests {
assert!(!sentinel_advanced(Some(Some(t0)), None));
}
/// `ExecStart` is list-valued, so the reset line is the whole mechanism: without it the
/// 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");
assert_eq!(
body, "[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n",
"{body}"
);
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines[1], "ExecStart=", "the reset must come first: {body}");
// The path is resolved per box ([`sleep_binary`]) and must reach the unit verbatim — a
// bare `sleep` would depend on the unit's PATH, and an ExecStart that fails to EXECUTE is
// the failing unit the display manager relogin-loops against.
assert!(idle_dropin_body("/bin/sleep").contains("ExecStart=/bin/sleep infinity"));
}
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
@@ -5661,10 +6045,32 @@ mod tests {
release_autologin_mask(crate::ActiveKind::None);
assert_eq!(is_enabled(), "masked-runtime");
// The idle drop-in is the mask's successor and shares this exact window, so it has to come
// off with it. A takeover that leaves it on has replaced the box's game-mode `ExecStart`
// with a sleep — a "Return to Gaming Mode" that starts and does nothing, which is the same
// barred way back, measured on Bazzite `.41` 2026-08-20.
install_idle_dropin().expect("arm the takeover's idle drop-in");
assert!(idle_dropin_path().exists());
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
// what our own managed session reads as, and `None` is one momentarily down between
// relaunches — lifting on either would void the mask for the whole stream.
release_autologin_mask(crate::ActiveKind::Gaming);
release_autologin_mask(crate::ActiveKind::None);
assert_eq!(is_enabled(), "masked-runtime");
assert!(
idle_dropin_path().exists(),
"the idle drop-in must survive a switch that is not to a desktop"
);
// The user switched the box to its own desktop mid-stream: the window is over, and the way
// back into game mode has to be clear before they ask for it.
release_autologin_mask(crate::ActiveKind::DesktopKde);
assert_ne!(is_enabled(), "masked-runtime");
assert!(
!idle_dropin_path().exists(),
"the idle drop-in outlived the switch — the box's Game Mode is a sleep now"
);
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
// and the disconnect restore still owes these units a `start`.
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
@@ -5673,6 +6079,7 @@ mod tests {
assert_ne!(is_enabled(), "masked-runtime");
unmask_unit(PROBE);
remove_idle_dropin();
STOPPED_AUTOLOGIN.lock().unwrap().clear();
*AUTOLOGIN_MASKED.lock().unwrap() = false;
}
@@ -483,7 +483,12 @@ fn gamescope_patch_level() -> u32 {
cursor composited into the capture stream"
);
} else {
tracing::debug!(
// INFO, not DEBUG: this is the whole reason a box streams SDR, and the branch above
// announces the good news at INFO. A field report ("HDR stopped working after the
// update") cost a deep dive because the handshake's `capture_supports_hdr=false` was
// visible at INFO while the ONE line saying why sat a level below it. Fires once per
// process — the answer is cached in `LEVEL`.
tracing::info!(
bin = %gamescope_bin(),
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
with a host-composited cursor (install punktfunk-gamescope for HDR)"
+1
View File
@@ -215,6 +215,7 @@ include = ["PunktfunkEndReason"]
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_DELIVERY_REPORT" = "PUNKTFUNK_MSG_DELIVERY_REPORT"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
+7 -1
View File
@@ -1,7 +1,9 @@
//! `CtrlRequest` (the embedder's control-stream requests) and `Negotiated` (the handshake result).
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::quic::{ClipControl, ClipOffer, ColorInfo, LossReport, ProbeRequest, RfiRequest};
use crate::quic::{
ClipControl, ClipOffer, ColorInfo, DeliveryReport, LossReport, ProbeRequest, RfiRequest,
};
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
/// speed test. One outbound channel carries both so the worker's `select!` has a single writer
@@ -15,6 +17,10 @@ pub(crate) enum CtrlRequest {
/// forcing a full IDR. See [`RfiRequest`].
Rfi(RfiRequest),
Loss(LossReport),
/// How many data-plane packets have reached us all session — sent straight after every
/// [`CtrlRequest::Loss`], because `loss_ppm` is ambiguous at zero (no loss and no packets look
/// identical) and only this separates them. See [`DeliveryReport`].
Delivery(DeliveryReport),
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
/// [`BitrateController`] when the user's bitrate setting is Automatic.
SetBitrate(u32),
@@ -57,6 +57,21 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// Spacing of a client's keyframe re-asks while it has received **no video at all** — the other
/// reason a client asks on a perfectly fixed cadence, and the OPPOSITE fault to [`FLUSH_COOLDOWN`]'s
/// (nothing arriving, versus more arriving than it can drain).
///
/// **Public, and deliberately a different value, for the same reason [`FLUSH_COOLDOWN`] is public.**
/// While both were 2000 ms the host's recovery-cadence detector could not tell which failure it was
/// looking at, and reported the confident wrong one: a 2026-08-20 field case where not one byte of
/// video ever reached the client was diagnosed for days as a client too slow to keep up. Embedders
/// own the no-video timer (it lives in each decode loop), so this is the value they must use — a
/// local copy is exactly the drift that made the two indistinguishable in the first place.
///
/// The delivery count on [`crate::quic::LossReport`] settles it outright for clients new enough to
/// send one; this keeps the period itself informative for those that are not.
pub const NO_VIDEO_RETRY: Duration = Duration::from_millis(2600);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
+1 -1
View File
@@ -42,7 +42,7 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::frame_channel::{FLUSH_COOLDOWN, NO_VIDEO_RETRY};
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+3 -3
View File
@@ -11,9 +11,9 @@ use crate::abr::BitrateController;
use crate::config::Role;
use crate::packet::FLAG_PROBE;
use crate::quic::{
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome,
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync,
DeliveryReport, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
ResyncAdmit, ResyncGuard, ResyncStep, SetBitrate, Start, Welcome,
};
use crate::session::Session;
use crate::transport::UdpTransport;
@@ -107,6 +107,7 @@ impl ControlTask {
}
CtrlRequest::Rfi(r) => r.encode(),
CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::Delivery(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
+166 -10
View File
@@ -77,6 +77,12 @@ impl DataPump {
// size FEC to the link. Suppressed during a speed test (its FLAG_PROBE filler would skew it).
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now();
// Has the host been told, once, that data-plane packets are reaching us? See the send site:
// the delivery count is reported every window while it is ZERO (the state the host acts on)
// and once more when the first packets land, then never again. A host that predates the
// message logs "unknown control message" for each one, so a healthy session must not stream
// them — one line per session is a fair price on an old host, eighty a minute is not.
let mut delivery_confirmed = false;
let (
mut last_recovered,
mut last_late,
@@ -122,18 +128,31 @@ impl DataPump {
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
// reply) or never answer (timeout clears the state so LossReports resume) — either way
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
// default): the target is deliberately far above any plausible link so the burst measures
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
// timeout delayed first video to 14 s, and a "successful" one still reported
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
// embedder that caps its own speed test wants this capped to match.
// The burst target is DERIVED from `stream_cap_kbps`, not set "far above any plausible
// link". It used to be a flat 2 Gbps on that reasoning — the burst must measure the link
// and not itself but the ABR already discards every bit measured above what the session
// could use: `set_ceiling` clamps to the stream cap set a few lines up, so everything past
// `stream_cap_kbps / 0.7` is thrown away the moment it lands. All that height bought was
// bufferbloat for a number nothing reads, and on links the burst DISTURBS it backfires — a
// constrained Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting
// the 6 s timeout delayed first video to 14 s, and a "successful" one still reported
// send_dropped=20211; the same shape is reported on a Fire TV Stick 4K Max), and a 2-3
// core TV client starves decoding the firehose.
//
// ×2 is the smallest multiplier that still PROVES the cap: the measured ceiling is
// `delivered × 0.7`, so reaching `stream_cap_kbps` needs `delivered ≥ cap × 1.43` and the
// rest is margin. Deriving it this way cannot cap anyone — a session whose mode and codec
// justify a high ceiling asks for a correspondingly high target by itself, and a mode we
// cannot size (`stream_ceiling_kbps` → `u32::MAX`) still gets the old 2 Gbps. It also
// fixes webOS and every other constrained client, not just the box that reported it.
//
// `PUNKTFUNK_ABR_PROBE_KBPS` overrides the target outright (unset/0/garbage → the derived
// one). An embedder that caps its own speed test wants this capped to match.
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(2_000_000);
.unwrap_or_else(|| probe_target_kbps(stream_cap_kbps));
const CAPACITY_PROBE_MS: u32 = 800;
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
@@ -148,6 +167,9 @@ impl DataPump {
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
// `frames_completed` as the burst began, so the probe-end block below can ask "did ANY
// frame survive this burst" rather than only "has one ever arrived" — see there.
let mut frames_at_probe_start: u64 = 0;
// The window this closes is discarded outright: no LossReport, no standing-latency close,
// no ABR feed. Two causes, both of them "this window's signals describe something other
// than the link, and one bogus congestion verdict here ends slow start for good":
@@ -283,6 +305,24 @@ impl DataPump {
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
// …and if the burst swallowed the video with it, re-anchor the decoder. This runs
// on EVERY probe end — a successful one, a timed-out one, an embedder "Test
// connection" — and the frame-count guard is what makes it a no-op the rest of the
// time: a burst the link couldn't hold can take the keyframe down with it, and
// then nothing re-requests one, so the client sits on black until some unrelated
// recovery path happens to fire. That is the reported Fire TV / webOS black
// screen. Compared against the count SNAPSHOTTED at the burst's leading edge
// rather than against 0: at startup the two are the same test, but this one also
// catches a burst that kills an already-running stream (an embedder speed test
// mid-session), which the cumulative counter never could. At most one request per
// probe, and it funnels through the control task's coalescer like the other two
// emitters in this file, so it cannot IDR-storm.
if st.frames_completed == frames_at_probe_start {
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
tracing::warn!(
"no frame survived the capacity probe — requested a keyframe to re-anchor"
);
}
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
@@ -290,6 +330,7 @@ impl DataPump {
if !was_probing && probe_active {
let burst = Duration::from_millis(pump_probe.lock().unwrap().duration_ms as u64);
probe_watchdog = Some(Instant::now() + burst + CAPACITY_PROBE_TIMEOUT);
frames_at_probe_start = st.frames_completed;
}
if !probe_active {
probe_watchdog = None;
@@ -415,6 +456,27 @@ impl DataPump {
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Rides with the loss report — it is what makes `loss_ppm = 0` readable at the
// host, which cannot otherwise tell a flawless link from one delivering
// nothing. The session TOTAL, not this window's, so one message stands on its
// own. Deliberately inside the same arm: a discarded window is discarded
// because the host was rebuilding or a probe distorted it, and staying silent
// there keeps that contract exact. Nothing is lost — the state this reports
// (no packets at all) produces no discards, so its windows always send.
//
// Sent every window while the count is ZERO, then ONCE when the first packets
// land (so the host stops guessing and can name the other failure confidently),
// then never again: a healthy session must not stream a message that older
// hosts log as unknown on every arrival.
// ponytail: only start-of-session death is covered. A path that dies MID-stream
// leaves the count frozen above zero and silent, which the host still reads as
// healthy — detecting that needs a stalled-counter check with its own timing,
// worth adding if a mid-session case is ever reported.
if should_report_delivery(st.packets_received, &mut delivery_confirmed) {
let _ = ctrl_tx.try_send(CtrlRequest::Delivery(DeliveryReport {
packets_received: st.packets_received,
}));
}
}
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
@@ -757,10 +819,104 @@ fn take_pipeline_gap(slot: &AtomicU32) -> Option<u32> {
}
}
/// Does this report window owe the host a [`DeliveryReport`], and record that it has been told?
///
/// Every window while `packets_received` is ZERO — that is the state the host escalates on, and it
/// must keep hearing it — then exactly ONCE more when the first packets land, so the host learns
/// delivery works and can stop hedging its stall diagnosis. Silent after that: a host that predates
/// the message logs every unknown control message, and a healthy hours-long session must not fill
/// its log with them.
fn should_report_delivery(packets_received: u64, confirmed: &mut bool) -> bool {
let owed = packets_received == 0 || !*confirmed;
*confirmed = packets_received > 0;
owed
}
/// The capacity probe's burst target for a session bounded at `stream_cap_kbps`, in kbps — the
/// default `PUNKTFUNK_ABR_PROBE_KBPS` overrides. See the probe's comment in the pump for why it is
/// derived rather than fixed: `BitrateController::set_ceiling` clamps the measurement to the
/// stream cap, so every bit measured above `cap / 0.7` is discarded, and bursting for it only
/// buys bufferbloat. ×2 clears that `1.43×` bar with margin.
///
/// `u32::MAX` in (a mode [`crate::abr::stream_ceiling_kbps`] declines to size) keeps the historic
/// 2 Gbps, which is also the ceiling on the whole derivation: this can only ever lower the target.
fn probe_target_kbps(stream_cap_kbps: u32) -> u32 {
stream_cap_kbps.saturating_mul(2).min(2_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
/// The host must keep hearing "zero" for as long as it is true (that is the black-screen
/// signal), get exactly one confirmation when video starts, and then silence — the noise budget
/// on an older host, which warns per unknown message, is what pays for the first two.
#[test]
fn the_delivery_count_is_reported_while_zero_then_once_more_and_never_again() {
let mut confirmed = false;
// Nothing arriving: reported every window, for as long as it stays true.
for _ in 0..5 {
assert!(
should_report_delivery(0, &mut confirmed),
"a dead data plane must be re-reported every window"
);
}
// First packets land: one confirmation, so the host can name the other failure confidently.
assert!(should_report_delivery(500, &mut confirmed));
// Healthy from here: silent.
for n in [900, 1_200, 90_000] {
assert!(
!should_report_delivery(n, &mut confirmed),
"a healthy session must not stream delivery reports"
);
}
}
/// A session that never receives anything must never look confirmed, no matter how long it runs
/// — the whole point is that the host keeps being told.
#[test]
fn a_session_that_receives_nothing_never_reports_itself_healthy() {
let mut confirmed = false;
for _ in 0..100 {
assert!(should_report_delivery(0, &mut confirmed));
assert!(!confirmed);
}
}
/// The burst has to be big enough to PROVE the stream cap and no bigger. Anything the burst
/// measures above `cap / 0.7` is discarded by `BitrateController::set_ceiling` (pinned by
/// `abr::tests::the_stream_bound_clamps_a_learned_ceiling_only`) and paid for in bufferbloat.
#[test]
fn the_probe_target_proves_the_stream_cap_without_overshooting_it() {
// Real modes, from the smallest a session runs to the largest — including 1440p120, the
// field session that walked to 657 Mbps and taught the ABR the cap in the first place.
for (w, h, hz, codec, depth) in [
(1280, 720, 60, crate::quic::CODEC_HEVC, 8),
(1920, 1080, 60, crate::quic::CODEC_H264, 8),
(2560, 1440, 120, crate::quic::CODEC_HEVC, 8),
(3840, 2160, 120, crate::quic::CODEC_HEVC, 10),
] {
let cap = crate::abr::stream_ceiling_kbps(w, h, hz, codec, depth, 0);
let target = probe_target_kbps(cap);
// Enough: a link that delivers the whole burst measures `delivered × 0.7`, and that
// has to reach the cap or the session can never climb to what its mode allows.
assert!(
target.saturating_mul(7) / 10 >= cap,
"{w}x{h}@{hz}: a {target} kbps burst cannot prove a {cap} kbps cap"
);
// …and no more: a target that overshoots what the clamp keeps is pure bufferbloat.
// (The old flat 2 Gbps overshot 1440p120 by 6×.)
assert!(
target <= cap.saturating_mul(2),
"{w}x{h}@{hz}: {target} kbps chases capacity the clamp discards"
);
}
// A mode `stream_ceiling_kbps` declines to size (`u32::MAX`) keeps the historic 2 Gbps,
// which is also the hard ceiling on the derivation — it can only ever lower the target.
assert_eq!(probe_target_kbps(u32::MAX), 2_000_000);
assert_eq!(probe_target_kbps(1_500_000), 2_000_000);
}
#[test]
fn a_pipeline_gap_is_taken_exactly_once() {
let slot = AtomicU32::new(0);
@@ -935,8 +1091,8 @@ mod tests {
.expect("the window after the gap reports on schedule");
assert!(
matches!(reported, Some(CtrlRequest::Loss(_))),
"the window after the gap must produce a loss report — an idle session's only \
outbound request"
"the window after the gap must produce a loss report — the first of the two requests \
an idle session makes (the delivery count follows it)"
);
assert!(
started.elapsed() >= Duration::from_millis(1_400),
+84
View File
@@ -97,6 +97,33 @@ pub struct LossReport {
pub loss_ppm: u32,
}
/// `client → host`, sent immediately after each [`LossReport`]: data-plane packets this client has
/// received all session, cumulative.
///
/// ⚠ Exists because `loss_ppm` alone is **ambiguous at zero**: a client receiving a flawless stream
/// and a client receiving *nothing at all* both report `loss_ppm = 0` — loss is a ratio over a
/// window whose denominator is the packets that arrived, so no-packets is indistinguishable from
/// no-loss. That ambiguity let a host decay adaptive FEC to its floor while the client sat behind a
/// black screen having received zero bytes, and the host's own stall diagnosis blamed the client for
/// "not sustaining the stream" it had never been sent (field 2026-08-20: a Windows host whose
/// per-session data port was closed inbound, so the client's hole-punch never opened the return
/// path). `0` while the host has sent frames is the one unambiguous statement of "the video data
/// plane is not reaching me" — the control plane carrying this report is, by construction, healthy.
///
/// ⚠ A SEPARATE MESSAGE rather than a field appended to [`LossReport`], and that is load-bearing:
/// `LossReport::decode` length-checks EXACTLY, so a longer report is rejected outright by every host
/// already shipped — a new client would silently lose adaptive FEC against them. Mixed versions are
/// normal here (the field case that motivated this ran a current host against a months-old client),
/// so the compatible shape is a new type byte an older host simply ignores, exactly as it already
/// ignores every other control message it predates.
///
/// Cumulative, not per-window, so a single message is self-contained; `u64` to match the counter it
/// mirrors, with no saturation to reason about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DeliveryReport {
pub packets_received: u64,
}
/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate
/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request
/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with
@@ -270,6 +297,8 @@ pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
/// and [`BitrateChanged`] already feed. Deliberately NOT in the 0x30 clock block — it carries a
/// duration precisely so that no clock domain is involved.
pub const MSG_PIPELINE_GAP: u8 = 0x0A;
/// Type byte of [`DeliveryReport`].
pub const MSG_DELIVERY_REPORT: u8 = 0x0B;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -436,6 +465,26 @@ impl LossReport {
}
}
impl DeliveryReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] packets_received[5..13]
let mut b = Vec::with_capacity(13);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_DELIVERY_REPORT);
b.extend_from_slice(&self.packets_received.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<DeliveryReport> {
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_DELIVERY_REPORT {
return Err(PunktfunkError::InvalidArg("bad DeliveryReport"));
}
Ok(DeliveryReport {
packets_received: u64::from_le_bytes(b[5..13].try_into().unwrap()),
})
}
}
impl SetBitrate {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] bitrate_kbps[5..9]
@@ -1291,6 +1340,41 @@ mod tests {
.is_err());
}
#[test]
fn delivery_report_roundtrip() {
for packets_received in [0u64, 1, 9_999, u32::MAX as u64 + 1, u64::MAX] {
let r = DeliveryReport { packets_received };
assert_eq!(DeliveryReport::decode(&r.encode()).unwrap(), r);
}
assert!(DeliveryReport::decode(&RequestKeyframe.encode()).is_err());
assert!(DeliveryReport::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
}
/// The delivery count MUST NOT ride on [`LossReport`]: that message is length-checked EXACTLY,
/// so lengthening it would make every already-shipped host reject the loss reports its adaptive
/// FEC runs on — a silent regression for a new client against an old host, which is the normal
/// mixed-version case here (the field report that motivated this ran a current host against a
/// months-old client). Its own type byte keeps `LossReport` byte-identical while an older host
/// simply ignores the message it does not know.
#[test]
fn the_delivery_count_does_not_disturb_the_loss_report_wire_form() {
let loss = LossReport { loss_ppm: 42 }.encode();
assert_eq!(loss.len(), 9, "LossReport must stay the 9-byte wire form");
assert_eq!(loss[4], MSG_LOSS_REPORT);
let delivery = DeliveryReport {
packets_received: 0,
}
.encode();
assert_ne!(
delivery[4], MSG_LOSS_REPORT,
"a distinct type byte is what makes an old host ignore it instead of failing"
);
// Neither can be silently mis-parsed as the other.
assert!(LossReport::decode(&delivery).is_err());
assert!(DeliveryReport::decode(&loss).is_err());
}
#[test]
fn window_loss_ppm_estimates_and_caps() {
// No traffic → 0. A clean window (nothing recovered) → 0.
@@ -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 {
+104 -29
View File
@@ -25,7 +25,9 @@
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::mpsc;
use std::time::Duration;
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
}
}
/// Holds the mDNS daemon; dropping it unregisters the service.
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
pub struct Advert {
_daemon: ServiceDaemon,
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
/// of leaving one behind polling for a service nobody advertises.
_stop: mpsc::Sender<()>,
}
/// How often a live advert re-checks the address it is announcing.
const IP_RECHECK: Duration = Duration::from_secs(10);
/// The address to advertise right now — loopback only while the machine still has none.
fn current_ip() -> IpAddr {
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
///
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
/// announced until something registers a newer one. The host process comes up during boot, which
/// on a cold start is before the machine has an address — so the first registration could be
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
/// again.
///
/// 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 default route lands late, so no interface event ever fires.
pub(crate) fn advertise_live(
service: &'static str,
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let registered = current_ip();
daemon
.register(build(registered)?)
.with_context(|| format!("register {service} mDNS service"))?;
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let bg_daemon = daemon.clone();
std::thread::spawn(move || {
let mut announced = registered;
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
while matches!(
stop_rx.recv_timeout(IP_RECHECK),
Err(mpsc::RecvTimeoutError::Timeout)
) {
let now = current_ip();
if now == announced {
continue;
}
match build(now)
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
{
Ok(()) => {
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
announced = now;
}
// Leave the previous record standing and retry next tick rather than going dark.
Err(e) => {
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
}
}
}
});
Ok(Advert {
_daemon: daemon,
_stop: stop_tx,
})
}
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
@@ -95,7 +166,6 @@ pub struct Advert {
#[allow(clippy::too_many_arguments)]
pub fn advertise_native(
hostname: &str,
ip: IpAddr,
port: u16,
fingerprint: &str,
require_pairing: bool,
@@ -103,14 +173,17 @@ pub fn advertise_native(
mgmt_port: Option<u16>,
os_chain: &str,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
// has to be a legal DNS name, hence the separate sanitized label.
let host_name = format!("{}.local.", dns_label(hostname));
let mut props: HashMap<String, String> = HashMap::new();
props.insert("proto".into(), NATIVE_PROTO.into());
props.insert("fp".into(), fingerprint.to_string());
props.insert(
// Owned, because the record is rebuilt whenever the host's address changes — see
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
// so it is computed once here and moved into the builder.
let instance = hostname.to_string();
let mut fixed: HashMap<String, String> = HashMap::new();
fixed.insert("proto".into(), NATIVE_PROTO.into());
fixed.insert("fp".into(), fingerprint.to_string());
fixed.insert(
"pair".into(),
if require_pairing {
"required"
@@ -119,31 +192,14 @@ pub fn advertise_native(
}
.into(),
);
props.insert("id".into(), uniqueid.to_string());
fixed.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
fixed.insert("mgmt".into(), mgmt.to_string());
}
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
if !os_chain.is_empty() {
props.insert("os".into(), os_chain.to_string());
fixed.insert("os".into(), os_chain.to_string());
}
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
// silently fails.
crate::wol::warn_if_not_armed(ip);
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
.register(service)
.context("register native mDNS service")?;
tracing::info!(
service = "_punktfunk._udp",
port,
@@ -151,7 +207,26 @@ pub fn advertise_native(
pair = if require_pairing { "required" } else { "optional" },
"native punktfunk/1 mDNS advertising"
);
Ok(Advert { _daemon: daemon })
advertise_live(NATIVE_SERVICE, move |ip| {
let mut props = fixed.clone();
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
// NIC first. A client persists these while the host is awake so it can send a
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
// the magic packet is inert and the cert fingerprint still gates the actual connection.
// Omitted when none can be read, which is what a host that came up before its network did
// used to report forever.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
// different one now.
crate::wol::warn_if_not_armed(ip);
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")
})
}
#[cfg(test)]
+284 -36
View File
@@ -114,9 +114,18 @@ pub enum LeaseKind {
Child,
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
Matched,
/// Nothing identifies this title's process — no detect signals and no child we own. Both
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
/// guessing.
/// A launcher owns the game and **tells us** when it starts and stops
/// ([`crate::runstate`]) — no process signal of our own.
///
/// The one lease kind whose liveness the host does not determine for itself, and the answer to
/// a title that has nothing to scan for: Playnite launches an emulated or manually-added game
/// through its own tracking and reports the edges, where the host could see only a
/// `playnite://` forwarder exiting. Before this such a title was [`Untracked`](Self::Untracked)
/// — the honest answer at the time, and a dead end.
Reported,
/// Nothing identifies this title's process — no detect signals, no child we own, and no
/// provider reporting on it. Both lifetime behaviors stay inert for it, and the host says so
/// once in the log rather than guessing.
Untracked,
}
@@ -126,6 +135,7 @@ impl LeaseKind {
Self::Nested => "nested",
Self::Child => "child",
Self::Matched => "matched",
Self::Reported => "reported",
Self::Untracked => "untracked",
}
}
@@ -387,6 +397,12 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
LeaseKind::Child
} else if !spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(game.id.as_deref()) {
// Nothing to scan for, but the provider that published this title is reporting liveness for
// it — so it is tracked after all. Asked once, here, rather than every poll: a lease's kind
// is what decides whether it is watched at all, and a title that flipped kind mid-flight
// would make both lifetime behaviors depend on a plugin's uptime.
LeaseKind::Reported
} else {
LeaseKind::Untracked
};
@@ -551,8 +567,32 @@ fn watch(
s.is_some_and(|p| !scanner.alive(&[p]).is_empty())
};
// What this title's provider says about it, when one reports at all ([`crate::runstate`]) —
// `None` on every host with no reporting plugin, which is what keeps all of this inert until
// someone opts in. Re-read each poll rather than captured: the whole value of it is that it
// changes while the lease is alive.
let reported = || shared.game.id.as_deref().and_then(crate::runstate::opinion);
// What a `Child` lease falls back to once its child turns out to be a shim: the store's own
// signals, else the provider's reporting, else nothing. The same ladder [`open`] walks, minus
// the child that has just gone away — and the reason a hint-less Playnite title is tracked at
// all on Windows, where the launch is `explorer.exe "playnite://…"` and therefore ALWAYS a
// hand-off, so every such lease arrives here.
let fallback_kind = || {
if !shared.spec.is_empty() {
LeaseKind::Matched
} else if crate::runstate::speaks_for(shared.game.id.as_deref()) {
LeaseKind::Reported
} else {
LeaseKind::Untracked
}
};
// ---- 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;
@@ -567,8 +607,10 @@ fn watch(
&& !spawned_up(&spawned)
{
spawned = None;
if spawned_at.elapsed() < SHIM_WINDOW {
if shared.spec.is_empty() {
let quick = spawned_at.elapsed() < SHIM_WINDOW;
kind = fallback_kind();
if quick {
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and this \
@@ -582,11 +624,10 @@ fn watch(
}
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by its store \
signals instead"
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game another way"
);
kind = LeaseKind::Matched;
} else if shared.spec.is_empty() {
} else if matches!(kind, LeaseKind::Untracked) {
// It ran long enough to have BEEN the game, and nothing else identifies it.
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -604,31 +645,30 @@ fn watch(
shared.forget_child();
if quick && status.success() {
// A launcher that handed the game off and exited. Fall back to recognizing
// the game by its store's signals; with none, stop tracking entirely rather
// than pretend the shim's exit was the game's.
kind = if shared.spec.is_empty() {
// the game by its store's signals (or its provider's reporting); with
// neither, stop tracking entirely rather than pretend the shim's exit was
// the game's.
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and \
this title has no detect signals stopping game tracking for it"
);
LeaseKind::Untracked
} else {
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by \
its store signals instead"
);
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
}
tracing::debug!(
title = %shared.game.title,
kind = kind.as_str(),
"the launch command handed off and exited — recognizing the game \
another way"
);
} else {
// It ran long enough to have BEEN the game (or failed outright). Either way
// the game is gone; only a success after a real run counts as "played".
if shared.spec.is_empty() {
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
if spawned_at.elapsed() >= SHIM_WINDOW {
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
@@ -642,11 +682,7 @@ fn watch(
Some(Err(e)) => {
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
child = None;
kind = if shared.spec.is_empty() {
LeaseKind::Untracked
} else {
LeaseKind::Matched
};
kind = fallback_kind();
if matches!(kind, LeaseKind::Untracked) {
shared.set_state(GameState::Untracked);
return;
@@ -680,7 +716,39 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
// 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. 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 scan_settled || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -693,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;
@@ -754,6 +824,27 @@ fn watch(
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else if let Some(said) = reported() {
// Nothing of the game is visible to us, but its provider is still reporting on it — and
// that report is decisive in BOTH directions, where `running_hint` below may only ever
// delay an exit.
//
// The difference is what backs each claim. Steam's registry flag is a leftover that
// survives an unclean exit, so believing it indefinitely produces a session that never
// ends; a provider report is an event from the launcher that started the game, restated
// continuously, and it stops counting the moment it goes stale
// ([`crate::runstate::REPORT_TTL`]) — after which this branch simply stops being taken
// and the scan-only path below resumes. So a *live* provider is allowed to hold the
// session open for a game the host cannot see at all, which is the entire point for a
// title with no detect signals, and a dead one costs at most one TTL.
if said.running {
gone_since = None;
vetoed = false;
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
} else {
finish(&shared, &on_exit, "its provider reported the game stopped");
return;
}
} else {
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
// the veto below — letting it run on is exactly what bounds the veto.
@@ -909,7 +1000,7 @@ fn terminate_blocking(shared: &LeaseShared) {
"released the nested session's kept display to end its game"
);
}
LeaseKind::Child | LeaseKind::Matched => {
LeaseKind::Child | LeaseKind::Matched | LeaseKind::Reported => {
#[cfg(target_os = "linux")]
unix_term_ladder(shared);
#[cfg(windows)]
@@ -919,6 +1010,26 @@ fn terminate_blocking(shared: &LeaseShared) {
}
}
/// The process this lease's provider reports for its game, re-resolved and pinned to its start
/// time, or `None`.
///
/// The reason the wire carries a pid at all: for a [`LeaseKind::Reported`] title the matcher finds
/// nothing by construction, so without this "End" would have no target and would silently do
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
/// pid the kernel has since recycled, contributes nothing.
#[cfg(any(target_os = "linux", windows))]
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
let pid = shared
.game
.id
.as_deref()
.and_then(crate::runstate::opinion)
.filter(|l| l.running)?
.pid?;
crate::procscan::resolve(pid)
}
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
///
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
@@ -942,11 +1053,22 @@ fn unix_term_ladder(shared: &LeaseShared) {
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
unsafe { libc::kill(target, sig) == 0 }
};
// Everything the matcher can find, plus the pid the provider reported (see `reported_proc`) —
// which for a `Reported` lease is the only member of this set.
let targets = || {
let mut procs = scanner.find(&shared.spec, shared.launch_stamp);
if let Some(p) = reported_proc(shared) {
if !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
}
procs
};
let signal_matched = |sig: i32| -> usize {
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
// sweep is never hit.
scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.alive(&targets())
.into_iter()
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
@@ -965,9 +1087,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
let still = scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.len();
let still = scanner.alive(&targets()).len();
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
let child_gone = !signal_child(0);
if still == 0 && child_gone {
@@ -1000,11 +1120,19 @@ fn windows_term_ladder(shared: &LeaseShared) {
let live = || {
let mut procs = scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
// Re-verified like everything else, so a dead or recycled pid contributes nothing, and
// de-duplicated: the matcher may well have found this same process by its image.
if let Some(p) = shared.spawned {
// de-duplicated: the matcher may well have found this same process by its image. The
// provider's reported pid joins on the same terms, and for a `Reported` lease it is the
// only thing here (see `reported_proc`).
let mut fold = |p: crate::procscan::ProcRef| {
if !scanner.alive(&[p]).is_empty() && !procs.iter().any(|q| q.pid == p.pid) {
procs.push(p);
}
};
if let Some(p) = shared.spawned {
fold(p);
}
if let Some(p) = reported_proc(shared) {
fold(p);
}
procs
};
@@ -1570,6 +1698,54 @@ mod tests {
assert!(!l.shared().is_trackable());
}
/// A title with nothing to scan for is tracked after all when its provider reports on it.
///
/// This is the Playnite case the static `detect` hints could never reach: an emulated game, a
/// manually added one, a library plugin that records no install directory. The launch is a
/// `playnite://` hand-off, so the host holds nothing; the spec is empty, so the matcher finds
/// nothing; and the honest verdict used to be [`LeaseKind::Untracked`] — no exit detection, and
/// `POST /game/end` with nothing to aim at. Playnite knew the whole time.
#[test]
fn a_reported_title_is_tracked_where_it_used_to_be_untracked() {
// The same request with no provider reporting: unchanged, and the control for what follows.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
assert!(!l.shared().is_trackable());
drop(l);
// A provider that speaks for the title — while reporting it NOT running, which is exactly
// what a report looks like at the moment a game is launched. Trackability follows from the
// provider *reporting*, not from what it currently says; a lease whose kind flipped with
// the answer would make both lifetime behaviours depend on a plugin's timing.
crate::runstate::report(
"playnite-lease-test",
["playnite:lease-test".to_string()].into_iter().collect(),
std::collections::HashMap::new(),
);
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
assert!(
l.shared().is_trackable(),
"so its exit is noticed and `POST /game/end` has a target"
);
drop(l);
crate::runstate::forget("playnite-lease-test");
// …and once the provider is gone, so is the tracking. Pinned because a report that outlived
// its plugin is the one way this could hold a session open forever.
let l = open(
req("playnite:lease-test", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
}
#[test]
fn an_untracked_lease_is_never_terminated() {
let l = open(
@@ -1875,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.
///
@@ -30,7 +30,7 @@
use super::{AppState, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
use anyhow::{anyhow, Context, Result};
use punktfunk_core::input::InputEvent;
use punktfunk_core::input::{GamepadEvent, InputEvent};
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
@@ -229,6 +229,65 @@ fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
false
}
/// The virtual Xbox pad a Moonlight session presents, and the one place this plane decides which
/// backend builds it.
///
/// On Windows there are two, and they are not interchangeable to a game: the XUSB companion
/// registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi
/// enumeration, SDL, RawInput, DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all —
/// only classic `XInputGetState` can. The native plane made the HID pad its default on
/// 2026-08-09 for exactly that reason; this plane kept constructing
/// [`GamepadManager`](crate::inject::gamepad::GamepadManager) directly and so kept handing
/// Moonlight clients a pad most games cannot enumerate. Both planes now read the same knob —
/// `native::gamepad::windows_xbox_hid` (not an intra-doc link: it is `cfg(windows)`, so the link
/// would not resolve on any other target) — so `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both
/// together and neither can drift again.
///
/// Everywhere else the choice does not exist: Linux has one uinput X-Box pad, and the stub
/// backend on other platforms drops events.
enum SessionPads {
/// Linux uinput / the Windows XUSB companion — `crate::inject::gamepad`.
Xusb(GamepadManager),
/// The Windows UMDF HID Xbox pad, what the native plane builds by default.
#[cfg(target_os = "windows")]
Hid(crate::inject::xbox_windows::XboxWindowsManager),
}
impl SessionPads {
/// Build this session's pad manager, honoring the shared Windows backend knob.
fn new() -> SessionPads {
#[cfg(target_os = "windows")]
if crate::native::gamepad::windows_xbox_hid() {
return SessionPads::Hid(crate::inject::xbox_windows::XboxWindowsManager::new());
}
SessionPads::Xusb(GamepadManager::new())
}
/// Apply one decoded controller event (create/destroy by mask, then state).
fn handle(&mut self, ev: &GamepadEvent) {
match self {
SessionPads::Xusb(m) => m.handle(ev),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.handle(ev),
}
}
/// Service the pads' feedback protocol and relay changed rumble levels. Games block inside the
/// kernel/driver handshake until answered, so call this every tick.
///
/// The HID pad's rich-feedback plane is discarded rather than plumbed: an Xbox pad has no
/// lightbar or adaptive triggers to report, and GameStream has no vocabulary for one either —
/// its rumble message (`0x010B`, [`super::gamepad::rumble_plaintext`]) carries the two handle
/// motors and nothing else, which is also why the trigger levels are dropped at the call site.
fn pump_rumble(&mut self, rumble: impl FnMut(u16, u16, u16, u16, u16)) {
match self {
SessionPads::Xusb(m) => m.pump_rumble(rumble),
#[cfg(target_os = "windows")]
SessionPads::Hid(m) => m.pump(rumble, |_| {}),
}
}
}
/// Reconcile the control port to the paired-client list: bound while at least one pairing
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
/// wherever the paired list changes — startup, pairing phase 4, unpair.
@@ -362,7 +421,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived
// from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across
// message types in the host direction.
let mut pads = GamepadManager::new();
let mut pads = SessionPads::new();
// Pen/touch translator (SS_PEN/SS_TOUCH → virtual tablet / wire touch). Sent only
// by clients that saw our SS_FF_PEN_TOUCH_EVENTS feature flag (rtsp.rs).
let mut pointer = super::pen::GsPointer::new();
@@ -480,7 +539,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
hdr_sent = false;
// Unplug the session's virtual pads + tablet (destroying the
// uinput pen releases any held tool/tip kernel-side).
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
// Surface the session's enforcement-drop totals (WP13).
drops.end_of_session();
@@ -583,7 +642,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
detected = None;
decrypt_fails = 0;
hdr_sent = false;
pads = GamepadManager::new();
pads = SessionPads::new();
pointer = super::pen::GsPointer::new();
drops.end_of_session();
}
@@ -689,7 +748,7 @@ fn on_receive(
detected: &mut Option<Scheme>,
decrypt_fails: &mut u64,
inj_tx: &Sender<InputEvent>,
pads: &mut GamepadManager,
pads: &mut SessionPads,
pointer: &mut super::pen::GsPointer,
grants: u32,
drops: &mut GrantDrops,
+18 -21
View File
@@ -3,37 +3,34 @@
use super::Host;
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use mdns_sd::ServiceInfo;
use std::collections::HashMap;
/// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert {
_daemon: ServiceDaemon,
}
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
// keeps the record pointed at the host's current address.
use crate::discovery::Advert;
const SERVICE: &str = "_nvstream._tcp.local.";
pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
let service = ServiceInfo::new(
"_nvstream._tcp.local.",
&host.hostname,
&host_name,
host.local_ip,
host.http_port,
props,
)
.context("build mDNS ServiceInfo")?;
daemon.register(service).context("register mDNS service")?;
let instance = host.hostname.clone();
let port = host.http_port;
tracing::info!(
service = "_nvstream._tcp",
port = host.http_port,
port,
host = %host_name,
"mDNS advertising"
);
Ok(Advert { _daemon: daemon })
// The advertised address is supplied per-registration so the record follows the host onto a
// network that only came up after boot — see [`crate::discovery::advertise_live`].
crate::discovery::advertise_live(SERVICE, move |ip| {
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
.context("build mDNS ServiceInfo")
})
}
+197 -8
View File
@@ -138,7 +138,6 @@ pub struct Host {
pub hostname: String,
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
pub uniqueid: String,
pub local_ip: IpAddr,
pub http_port: u16,
pub https_port: u16,
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
@@ -155,13 +154,25 @@ impl Host {
Ok(Host {
hostname: hostname_string(),
uniqueid: load_or_create_uniqueid()?,
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: os.chain.clone(),
os_name: os.pretty.clone(),
})
}
/// Best-effort primary LAN IP, re-read on every call.
///
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
/// into. Loopback here means "still no LAN address", not a stale one.
pub fn local_ip(&self) -> IpAddr {
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
}
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
@@ -432,7 +443,7 @@ pub fn serve(
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
ip = %state.host.local_ip(),
native_port = native.port,
require_pairing = native.require_pairing,
gamestream,
@@ -656,10 +667,43 @@ fn load_or_create_uniqueid() -> Result<String> {
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
/// local address the OS would route through. No packets are actually sent.
fn primary_local_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
sock.local_addr().ok().map(|a| a.ip())
///
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
/// network dependency), so it falls back to the first non-loopback interface address, which the
/// NIC has as soon as it is configured even if the default route is not installed yet.
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
let routed = UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
sock.connect("8.8.8.8:80")?;
sock.local_addr()
})
.ok()
.map(|a| a.ip())
.filter(|ip| usable_lan_ip(*ip));
routed.or_else(first_lan_ipv4)
}
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
///
/// Split out because this is the branch the boot race actually takes, and the one nothing would
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
/// back to loopback for good.
fn first_lan_ipv4() -> Option<IpAddr> {
if_addrs::get_if_addrs()
.ok()?
.into_iter()
.map(|i| i.ip())
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
}
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
fn usable_lan_ip(ip: IpAddr) -> bool {
!ip.is_loopback() && !ip.is_unspecified()
}
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
@@ -716,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;
@@ -740,6 +884,52 @@ mod host_name_tests {
}
}
#[cfg(test)]
mod local_ip_tests {
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn loopback_and_unspecified_are_never_advertisable() {
// The bug: a host that started before its network did advertised these as its address and
// kept doing so for the life of the process.
for unusable in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
] {
assert!(
!usable_lan_ip(unusable),
"{unusable} must not be advertised"
);
}
for usable in [
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
] {
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
}
}
#[test]
fn probe_reports_no_address_rather_than_loopback() {
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
// advert keep retrying instead of freezing a wrong answer in place.
assert!(primary_local_ip().is_none_or(usable_lan_ip));
}
#[test]
fn interface_fallback_never_offers_loopback() {
// The branch a cold boot takes, before the default route exists. It may legitimately find
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
// do is hand back the loopback that `get_if_addrs` also reports.
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
}
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -748,7 +938,6 @@ mod session_tests {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -250,7 +250,7 @@ async fn h_launch(
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
st.host.local_ip()
);
xml(session_url_xml(&st, "gamesession")).into_response()
}
@@ -405,7 +405,7 @@ fn gamestream_admission(
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
st.host.local_ip()
)
}
@@ -485,13 +485,11 @@ fn error_xml() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn test_state() -> Arc<AppState> {
let host = super::super::Host {
hostname: "t".into(),
uniqueid: "id".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
uniqueid = host.uniqueid,
https_port = host.https_port,
http_port = host.http_port,
local_ip = host.local_ip,
local_ip = host.local_ip(),
)
}
@@ -205,7 +205,6 @@ mod tests {
let host = Host {
hostname: "test".into(),
uniqueid: "uid".into(),
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
http_port: 47989,
https_port: 47984,
os_chain: "linux".into(),
+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>,
+3
View File
@@ -105,6 +105,9 @@ mod plugins;
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
mod procscan;
// The live half of the same binding: what a provider PLUGIN reports about its titles' liveness,
// where `procscan` can only look at the process table.
mod runstate;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
+3 -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")]
@@ -372,6 +373,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::report_provider_running))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
+4
View File
@@ -250,6 +250,10 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Liveness reporting for a provider's OWN titles. No new authority: the host maps the
// report through the catalog, so a plugin can only ever speak about entries it published,
// and the worst a defective one can do to someone else's session is nothing at all.
(&Method::PUT, "/api/v1/library/provider/{}/running"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
+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
+6 -3
View File
@@ -23,13 +23,16 @@ pub(crate) struct Health {
abi_version: u32,
}
/// Host identity and advertised capabilities (static for the life of the process).
/// Host identity and advertised capabilities (static for the life of the process, except
/// `local_ip`).
#[derive(Serialize, ToSchema)]
pub(crate) struct HostInfo {
hostname: String,
/// Stable per-host id (persisted across restarts), matched on pairing.
uniqueid: String,
/// Best-effort primary LAN IP.
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
/// host that moves networks reports the new one. Poll it rather than caching it.
local_ip: String,
/// `punktfunk-host` crate version.
version: String,
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
Json(HostInfo {
hostname: h.hostname.clone(),
uniqueid: h.uniqueid.clone(),
local_ip: h.local_ip.to_string(),
local_ip: h.local_ip().to_string(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
app_version: APP_VERSION.into(),
+118
View File
@@ -607,12 +607,130 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
if removed > 0 {
tracing::info!(provider, removed, "library provider entries removed");
}
// Its entries are gone, so its opinions about them are meaningless — and a lease must
// never be held open by a provider that no longer exists.
crate::runstate::forget(&provider);
Json(ProviderRemoved { removed }).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// One running title in a provider's liveness report.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RunningTitle {
/// The provider's own stable id for the title — the same key its reconcile payload uses.
pub external_id: String,
/// The process id the provider started for it, when it knows one. Optional, and never trusted
/// as a bare number: the host re-resolves it and pins it to its start time before it is ever
/// signalled, so a stale or recycled pid simply contributes nothing.
#[serde(default)]
pub pid: Option<u32>,
}
/// Request body for `reportProviderRunning`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ProviderRunningInput {
/// Every title of this provider's that is running **right now**. The full set, not a delta:
/// anything absent from it is reported as stopped.
#[serde(default)]
pub running: Vec<RunningTitle>,
}
/// The result of a liveness report.
#[derive(Serialize, ToSchema)]
pub(crate) struct ProviderRunningAccepted {
/// How many reported titles matched an entry this provider currently publishes.
matched: usize,
/// How many were ignored because no such entry exists (a report that raced a reconcile).
unknown: usize,
/// Seconds this report stays authoritative without being restated — re-report inside it while
/// anything is running.
ttl_s: u64,
}
/// Report which of a provider's titles are running
///
/// The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to
/// recognize* a title's process, this one says *it is running now* (design §9,
/// [`crate::runstate`]). For a provider that starts games itself and knows when they stop —
/// Playnite tracks every launch and fires an event on both edges — this is a fact the host would
/// otherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated
/// game, a manually added one) could not derive at all.
///
/// Declarative and idempotent, like the reconcile: the body is the provider's **complete** running
/// set, so a missed event, a plugin restart or an install mid-game all self-correct on the next
/// report rather than drifting.
///
/// The report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a
/// live provider to keep a streaming session open for a game the host cannot see: a plugin that
/// dies with a game running stops counting shortly after, and the host falls back to process
/// scanning exactly as it does without one. Re-report on every change **and** on a timer well
/// inside the window.
///
/// Titles the provider does not currently publish are ignored (counted in `unknown`), not an error:
/// a report may legitimately race its own reconcile.
#[utoipa::path(
put,
path = "/library/provider/{provider}/running",
tag = "library",
operation_id = "reportProviderRunning",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
request_body = ProviderRunningInput,
responses(
(status = OK, description = "The report was accepted", body = ProviderRunningAccepted),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn report_provider_running(
Path(provider): Path<String>,
ApiJson(input): ApiJson<ProviderRunningInput>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
// Resolve the provider's own keys to the ids the rest of the host uses. A plugin knows its
// titles by `external_id`; a lease knows them by the library id the catalog assigned
// (`playnite:<guid>`), and only the catalog can map between the two — which is also what makes
// this authorization-safe, since a provider can only ever speak about entries it published.
let mine: Vec<(String, String)> = crate::library::load_custom()
.into_iter()
.filter(|e| e.provider.as_deref() == Some(provider.as_str()))
.filter_map(|e| {
let external = e.external_id.clone()?;
Some((external, crate::library::library_id_for(&e)))
})
.collect();
let owned: std::collections::HashSet<String> = mine.iter().map(|(_, id)| id.clone()).collect();
let mut running = std::collections::HashMap::new();
let mut unknown = 0usize;
for t in &input.running {
match mine.iter().find(|(external, _)| *external == t.external_id) {
Some((_, id)) => {
running.insert(id.clone(), t.pid);
}
None => unknown += 1,
}
}
let matched = running.len();
tracing::debug!(
provider,
owned = owned.len(),
matched,
unknown,
"provider liveness report"
);
crate::runstate::report(&provider, owned, running);
Json(ProviderRunningAccepted {
matched,
unknown,
ttl_s: crate::runstate::REPORT_TTL.as_secs(),
})
.into_response()
}
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
+247 -22
View File
@@ -47,7 +47,6 @@ use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use tower::ServiceExt;
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux/arch/steamos".into(),
@@ -821,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.
@@ -830,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);
@@ -1003,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() {
@@ -1380,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),
(
@@ -1440,6 +1604,16 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// Liveness for a provider's own titles: the plugin lane's, like the reconcile beside it,
// and for the same reason — the host maps the report through the catalog, so a provider can
// only ever speak about entries it published. Never the cert lane: a streaming client has
// no titles of its own to report on.
(
"PUT",
"/api/v1/library/provider/{provider}/running",
true,
false,
),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
@@ -2935,3 +3109,54 @@ async fn provider_reconcile_validation() {
let (s, _) = send(&app, del).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
/// Liveness reporting: the provider id is validated like every other provider write, and a title
/// the provider does not publish is *counted*, not refused.
///
/// That tolerance is the point. A report races its own reconcile by construction — a game can start
/// before the entry that describes it has landed — and 400-ing the whole report over one unknown id
/// would throw away the liveness of every other running title, which is precisely the failure the
/// launcher-tile 400 taught us to avoid (`sanitize_launcher_entries`). The developer's real catalog
/// is not touched here, so every id in this test is `unknown` by construction — which is exactly
/// the case being pinned.
#[tokio::test]
async fn provider_running_report_validation() {
let app = test_app(test_state(), None);
let put = |provider: &str, body: serde_json::Value| {
axum::http::Request::put(format!("/api/v1/library/provider/{provider}/running"))
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
let (s, json) = send(&app, put("manual", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(json["error"].as_str().unwrap().contains("reserved"));
let (s, _) = send(&app, put("Bad%2FName", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// An unreported provider is a legitimate report of "nothing is running".
let (s, json) = send(&app, put("playnite", serde_json::json!({"running": []}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 0);
assert!(json["ttl_s"].as_u64().unwrap() > 0);
// An id this provider does not publish is ignored, not an error.
let (s, json) = send(
&app,
put(
"playnite",
serde_json::json!({"running": [{"external_id": "no-such-title", "pid": 4242}]}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(json["matched"], 0);
assert_eq!(json["unknown"], 1);
// A report leaves no opinion behind about a title nobody published, so nothing this test did
// can hold a real lease open.
assert!(!crate::runstate::speaks_for(Some("playnite:no-such-title")));
crate::runstate::forget("playnite");
}
+136 -9
View File
@@ -48,8 +48,10 @@ mod compositor;
use compositor::resolve_compositor;
/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here.
mod gamepad;
/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. Crate-visible because the choice of
/// Windows Xbox backend (`windows_xbox_hid`) is not the native plane's alone — the GameStream plane
/// presents the same virtual pad and has to make the same choice, from one definition.
pub(crate) mod gamepad;
use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision};
/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here.
@@ -154,9 +156,33 @@ pub struct Punktfunk1Options {
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
///
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
fn bind_data_socket(
data_port: Option<u16>,
local_ip: Option<std::net::IpAddr>,
) -> std::io::Result<(std::net::UdpSocket, bool)> {
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
// socket that will `connect` to a v4 peer — the families have to match.
let local_ip = local_ip.map(|ip| match ip {
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
v4 => v4,
});
let wildcard = |ip: Option<std::net::IpAddr>| {
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
};
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
@@ -166,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
Ok(sock) => Ok((sock, false)),
// The control plane arrived on this address moments ago, so a failure here means it just
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
// routing table can route to — degraded, not dead — so take it and say why.
Err(e) if local_ip.is_some() => {
tracing::warn!(
local_ip = ?local_ip,
error = %e,
"could not bind the data plane to the address the control connection arrived on \
falling back to the wildcard. On a multi-homed host video may now egress from \
a different interface than the client dialed, which it silently drops."
);
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
Err(e) => Err(e),
}
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
@@ -363,7 +405,6 @@ pub(crate) async fn serve(
match crate::gamestream::Host::detect() {
Ok(h) => crate::discovery::advertise_native(
&h.hostname,
h.local_ip,
opts.port,
&fingerprint_hex(&fingerprint),
opts.require_pairing,
@@ -1402,6 +1443,12 @@ async fn serve_session(
// evidence (a refusal without the score left a 23-minute floor-pinned field session with no
// trace of why).
let cadence_behind_score = Arc::new(AtomicU32::new(0));
// Delivery truth, control task → data plane: the packet count the client reports having
// received all session (`u32::MAX` until a client new enough to answer sends one). The data
// plane needs it to tell a clean link from a dead one — `loss_ppm = 0` means both — before it
// blames the client for a stream that never reached it.
let client_packets_received = Arc::new(AtomicU32::new(u32::MAX));
let client_packets_received_ctl = client_packets_received.clone();
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
let (probe_result_tx, probe_result_rx) = tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
// Mode-switch outcome, data plane → control task (same pattern as `probe_result_tx`): the accept
@@ -1533,6 +1580,7 @@ async fn serve_session(
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
cadence_behind_score.clone(),
client_packets_received_ctl,
fec_target_ctl,
phase_ctl_control,
reconfig_tx,
@@ -2048,6 +2096,10 @@ async fn serve_session(
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
let resize_ms_dp = resize_ms.clone();
// The address the control connection arrived on, for the data plane's source-address check
// below — the one comparison that distinguishes "the client is filtering our video" from
// "the video never left". Captured here because the send loop runs on a blocking thread.
let control_local_ip = conn.local_ip();
let result: Result<()> = async {
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
@@ -2082,15 +2134,64 @@ async fn serve_session(
}
};
bringup_dp.mark("punch_done");
// Post-`connect`, `local_addr` reports the source address the kernel will actually
// stamp on every video datagram — the number that has to match the host IP the client
// dialed, because its data socket is connected and its kernel drops anything else
// before userspace. Logged unconditionally: a black-screen report is unanswerable
// without it (this session's showed only the port).
let local = transport.local_addr().ok();
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
local = ?local,
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A video source address that isn't the one the control plane arrived on means the
// client will discard every datagram we send, however healthy this end looks.
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
let c = match c {
std::net::IpAddr::V6(v6) => {
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
}
v4 => v4,
};
if !l.is_unspecified() && l != c {
tracing::warn!(
video_source_ip = %l,
control_local_ip = %c,
"the video data plane egresses from a DIFFERENT host address than the one \
this client connected to its data socket is connected to the address it \
dialed, so its kernel drops every video datagram before userspace: black \
screen, zero reported loss, healthy control plane. Usual cause is two \
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
a VPN/overlay adapter claiming the route)"
);
}
}
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
// CLAIMED, unverified, and if anything on the path needed the flow opened client-first
// it silently goes nowhere: black picture, healthy control plane, no error anywhere.
// On Windows the usual cause is a firewall rule that opens fixed ports only, while
// this port is ephemeral and different every session (fixed by the program-scoped rule
// `service install` now adds — an install predating it still has the old rules).
// `direct` skips the punch by operator choice, so it is not a failure there.
if !direct && !punched {
tracing::warn!(
%client_udp,
udp_port,
"no hole-punch reached this host's data port — inbound UDP to it looks \
BLOCKED, so video is being sent to the address the client reported without \
any confirmed return path. If the picture stays black while the session is \
otherwise healthy, this line is the reason: allow inbound UDP for the host \
executable (any port), or pin --data-port and open that one"
);
}
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
match source {
@@ -2125,6 +2226,7 @@ async fn serve_session(
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
bitrate_auto,
bit_depth,
chroma,
@@ -2485,7 +2587,7 @@ mod tests {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
@@ -2502,13 +2604,14 @@ mod tests {
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
let (fallback, direct2) =
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
@@ -2517,6 +2620,30 @@ mod tests {
);
}
/// The multi-homed black screen: video must egress from the address the client's control
/// connection arrived on, because the client's data socket is connected to the host address it
/// dialed and its kernel drops every datagram from any other source — silently, before
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
/// choose a different interface whenever the host has two paths to the client.
#[test]
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let (sock, direct) =
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
assert!(!direct);
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// No reported local address (platform can't say) keeps the old wildcard behaviour.
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
assert!(sock.local_addr().unwrap().ip().is_unspecified());
}
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
@@ -30,6 +30,10 @@ pub(super) async fn run(
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
cadence_behind_score: Arc<AtomicU32>,
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
// the packets the client says it has received all session (`u32::MAX` = a client too old to
// send one, the pre-seeded value).
client_packets_received: Arc<AtomicU32>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
@@ -162,6 +166,16 @@ pub(super) async fn run(
if rfi_tx.send((req.first_frame, req.last_frame)).is_err() {
break; // data plane gone
}
} else if let Ok(rep) = punktfunk_core::quic::DeliveryReport::decode(&msg) {
// What the client has actually RECEIVED — published unconditionally, because it
// is what lets the data plane read `loss_ppm = 0` correctly and must survive
// both the `adaptive_fec` opt-out and a pinned FEC percentage (a host with
// PUNKTFUNK_FEC_PCT set is exactly as blind to a dead data plane otherwise).
// Saturated into the u32 bridge; the value only ever matters near zero.
client_packets_received.store(
rep.packets_received.min(u32::MAX as u64 - 1) as u32,
Ordering::Relaxed,
);
} else if let Ok(rep) = LossReport::decode(&msg) {
// Adaptive FEC: size recovery to the loss the client is seeing. The data-plane
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
+6 -1
View File
@@ -363,8 +363,13 @@ fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
///
/// The two backends are mutually exclusive per pad by construction (one match arm or the other) —
/// presenting both would hand a game two controllers for one pair of hands.
///
/// Read by BOTH input planes. The native plane branches on it in `Pads::handle`; the GameStream
/// plane in `gamestream::control::SessionPads`. It was `pub(super)` while only the native plane
/// consulted it, and that is exactly how Moonlight sessions spent two releases on the XUSB pad
/// after this default flipped — the knob was unreachable from the module that needed it.
#[cfg(target_os = "windows")]
pub(super) fn windows_xbox_hid() -> bool {
pub(crate) fn windows_xbox_hid() -> bool {
match std::env::var("PUNKTFUNK_XBOX_BACKEND") {
Ok(v) if v.trim().eq_ignore_ascii_case("xusb") => false,
// Anything else — unset, empty, "hid", or a typo — takes the default. A misspelled opt-out
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
// video from the host IP it dialed (see `bind_data_socket`).
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
let udp_port = data_sock.local_addr()?.port();
// The session's video geometry (see the `shard_payload` field below). Resolved before the

Some files were not shown because too many files have changed in this diff Show More