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 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 2f071a9a93 Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:08:15 +00:00
enricobuehler 62d35bc4b6 Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:07:54 +00:00
enricobuehler 5d06ef26ac Merge pull request 'fix(feedback): the pad stops keeping a game's trigger effect after the stream ends' (#44) from worktree-haptics-m9-richfb into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:07:35 +00:00
enricobuehler fcf4076eb7 Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:07:18 +02:00
enricobuehler 53eb592c43 Merge pull request 'fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn' (#43) from worktree-haptics-m8-proto into main
deb / build-publish-client-arm64 (push) Successful in 1m17s
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 5s
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 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
android / android (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m58s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / web (push) Successful in 2m52s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 3m8s
ci / docs-site (push) Canceled after 2m56s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 2m48s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 13s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:03:33 +00:00
enricobuehler 956d8dd8ef Merge pull request 'fix(host/windows): two virtual pads stop tearing each other's reports' (#39) from worktree-haptics-m7-windows into main
deb / build-publish-host (push) Canceled after 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 4s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 10s
ci / rust-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
ci / web (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 14s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 14s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m58s
2026-08-04 21:03:13 +00:00
enricobuehler b2e716ad5f Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
android / android (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:02:36 +00:00
enricobuehler ec288d64d3 Merge pull request 'fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking' (#35) from worktree-haptics-m5-android into main
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
android / android (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
2026-08-04 21:02:15 +00:00
enricobuehler 68353a5d57 Merge pull request 'fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying' (#32) from worktree-haptics-m4-apple into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
2026-08-04 21:01:51 +00:00
enricobuehler ffd5a33598 Merge pull request 'fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals' (#30) from worktree-haptics-m3-rumble-engine into main
windows-host / package (push) Canceled after 1m3s
windows-host / canary-manifest (push) Canceled after 0s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 56s
deb / build-publish-client-arm64 (push) Canceled after 49s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Canceled after 56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 49s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
android / android (push) Canceled after 7s
release / apple (push) Canceled after 5m31s
2026-08-04 21:01:32 +00:00
enricobuehler 4af8b02be1 Merge pull request 'fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back' (#52) from worktree-conflict-detect-and-isolate-recovery into main
deb / build-publish (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
android / android (push) Canceled after 25s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
apple / swift (push) Canceled after 32s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 46s
ci / web (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 48s
ci / docs-site (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
Reviewed-on: #52
2026-08-04 21:00:56 +00:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler b31495bea5 fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.

1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
   Both uninstallers leave their config/log directory in Program Files behind, and
   `detect.rs` counted a bare directory, or a service registered at ANY start type
   (including `disabled`), as a live conflict. The installer's own probe was
   narrowed to "service start type <= 2" after exactly this cried wolf on a
   `winget install`, and the tray dropped its always-on warning for the same reason
   in 3e782852 — the runtime probe never got the same treatment, so the one surface
   the user actually looks at kept shouting. `Evidence::is_active` now draws the
   line (running, or set to start on its own) and only active detections reach the
   startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant
   findings still print in the full report, under a heading that says they need no
   action — that report is where "why does it think I have Apollo?" gets answered.

2. The console's conflicts card hardcoded "Another game-streaming server is
   **running** on this machine" regardless of what was found, so a dormant leftover
   was announced as a running server. It now says "active", and each entry names
   the observation — `Sunshine (running)`, `Apollo (starts automatically)`.

3. "The exclusive screen never times out going back to re-enabling the display."
   `isolate_displays_ccd` deactivates the operator's panels and hands the
   pre-isolate topology to the caller, which restores it at teardown — but that
   snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
   topology to the CCD database. So a host that crashed, was killed, or was stopped
   mid-session left the desk dark with nothing in the product to undo it. There was
   one startup recovery leg already, but only for the EXPERIMENTAL
   `pnp_disable_monitors` axis, which is off by default — the default Exclusive path
   had none. `isolate_journal` now marks what an isolate is about to switch off
   (before the apply, so dying mid-apply is covered), clears the mark on restore,
   and force-EXTENDs at host startup if a mark survived. EXTEND rather than
   replaying the saved blob: the blob pins the virtual display's target id, which
   dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
   the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
   correct across a reboot, where saved ids would be stale.
2026-08-04 22:34:33 +02:00
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler ee0b179618 Merge pull request 'docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits' (#49) from worktree-appstore-copy into main
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
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 6s
ci / web (push) Successful in 2m49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
release / apple (push) Successful in 9m23s
apple / swift (push) Successful in 1m34s
ci / rust (push) Successful in 7m27s
docker / deploy-docs (push) Failing after 6m11s
apple / screenshots (push) Successful in 6m13s
Reviewed-on: #49
2026-08-04 20:03:34 +00:00
enricobuehler d7e22c3db2 Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48
2026-08-04 20:02:37 +00:00
enricobuehler c1231fa2e6 Merge pull request 'feat(clients/input): system buttons route around local overlays' (#47) from worktree-system-buttons-routing into main
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Successful in 8m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
release / apple (push) Canceled after 0s
decky / build-publish (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m11s
android / android (push) Successful in 5m19s
deb / build-publish (push) Successful in 5m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m32s
deb / build-publish-host (push) Successful in 5m51s
apple / swift (push) Canceled after 9s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m57s
ci / rust (push) Canceled after 12s
flatpak / build-publish (push) Successful in 6m23s
ci / rust-arm64 (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
Reviewed-on: #47
2026-08-04 20:02:22 +00:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehler 83a12c7413 Merge pull request 'Decky, slimmed: a thin Gaming-Mode wrapper around the client' (#46) from worktree-decky-slim-rework into main
ci / web (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m25s
android / android (push) Successful in 7m27s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
decky / build-publish (push) Successful in 19s
apple / screenshots (push) Successful in 6m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / deploy-docs (push) Successful in 34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
flatpak / build-publish (push) Canceled after 10m10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Successful in 7m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
arch / build-publish (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 4m33s
apple / swift (push) Successful in 1m18s
deb / build-publish (push) Successful in 5m28s
ci / rust-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
Reviewed-on: #46
2026-08-04 19:41:32 +00:00
enricobuehler 0d407a866d fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.

**The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json`
always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as
`punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which
copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE
advert's address. Nothing on a Deck ever writes a moved address back — `discover` and
`hosts list` are both reads, and only the desktop shells' hosts pages update one.

So after any DHCP move the row read "online" at the new address and every press dialled the old
one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the
full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted
`--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`.

This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix
restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the
matching record instead of filing a second one (the fingerprint is the identity — this is the
same rule that makes the verb idempotent), and the panel re-points a host it can see has moved
before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and
`launch abc-123` then emits `--connect 10.0.0.99:9777`.

**"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes
`client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed
was told its network was empty, under a button that launches the client that isn't there. It now
says which of the two it is.

**The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the
doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that
ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch
the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the
only caller is a short-lived CLI process, but the function invites in-process use, where it would
leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top
of the loop. Verified: ten back-to-back browses settle back to the baseline thread count.

**A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust
sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran
`--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who
pins its identity — there is no operator decision, so nothing was approved and the same box read
"paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead,
which pins and streams without claiming an approval, and the "approve this Deck" toast is no
longer shown to someone who has nobody to ask.

Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
2026-08-04 21:26:09 +02:00
enricobuehler 0890cf3244 docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.

Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
2026-08-04 21:25:55 +02:00
enricobuehler bf2d8505cf docs(decky): the gamepad-UI shortcut comment still named PF_HOST
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
2026-08-04 21:08:52 +02:00
enricobuehler 414380fc9e fix(cli): discover reads the host store without writing to it
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.

That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.

`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.

Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
2026-08-04 21:07:37 +02:00
enricobuehler 6267dcdcd3 fix(decky): let a CLI payload's own key never override this layer's ok
`{"ok": True, **data}` let a future payload carrying its own `ok` report failure through the
field the shell layer owns. Spread first, set `ok` last.
2026-08-04 21:04:16 +02:00
enricobuehler 8042a2fd52 fix(decky): a saved host's pin is what it PINNED, never what it's advertising
`mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by
address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that
address and rendered as ready to stream. The launch then refused for want of a pin, from a row
that had just shown "Stream" and "trusted".

Under the old rule the mistake was mostly hidden, because `needsPair` asked a different
question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so
the same conflation would now decide the whole thing.

The two are different facts and are now separate fields. `fp` is what the RECORD pins — the
thing the session binary requires. `advertisedFp` is what the host is offering right now, which
is what request access would pin, and moving one to the other is a trust decision the user
makes in the sheet rather than something the merge does behind them.

The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens
to be advertising can now be let in with request access, and one that isn't still gets the PIN
path with the reason.
2026-08-04 21:01:59 +02:00
enricobuehler 7e40098bc6 test(decky): tear the CLI fixture dir down before building it
The "a native install with no sibling CLI resolves to None" check created
/tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is
ABSENT held only on the first run on a given machine and failed on every rerun. Caught by
running the suite twice.
2026-08-04 21:00:15 +02:00
enricobuehler ac5299d4ce docs: the Deck plugin is a launcher now, not a second client
The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the
docs described all four in detail. Sweeps clients/decky/README.md and the docs site.

steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator
approves the Deck, which is the one genuinely new thing a user gets — and says plainly where
the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap
from the same panel. A removal that reads as a regression is worth a sentence, not a silence.
The troubleshooting table drops the rows for surfaces that no longer exist and gains the two
questions the new path will actually raise ("request access isn't offered", "the stream just
sits there").

client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console
home offers them. Its intro now names the console home's real sections (Stream, Video,
Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the
deleted sidebar.

Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are
fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what
found them:

  • "Render scale — offered everywhere except the console home's list". RowId::RenderScale has
    been in the console's ROWS since 2026-07-31.
  • wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does —
    RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of
    the setting, which is the true half that sentence was built on.
  • The console home's Library button was documented as gated on the "Show game library"
    toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle
    row itself; home.rs offers Library on any paired, saved host.

Also updated: support-matrix (Decky's Profiles and Game library go /⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
2026-08-04 20:58:11 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 77ddd05b13 fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults.

An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. An embedder draining the queue could be handed an index it would
use to subscript its own per-pad array. The host never emits one, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.

The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.

HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint
first, address second — so a host that moved DHCP lease still matches its record, and a
different box that inherited the old address does not inherit its pairing. The CLI annotates
`saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online
first, then most recently used, then by name: the host you streamed last night is the first
thing under your thumb, and a host that is off right now never is.

`needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless
connect, so a row without one can offer nothing but a button that fails. The old rule also
consulted the advertised policy for unsaved hosts, which made the same box read differently
before and after being saved.

PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their
own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a
pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own
BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and
showing it twice would suggest the two do different things. This plugin creates, edits and
deletes no profile and no card — pin creation belongs where profiles are edited.

TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the
GTK dialog's order and wording. Request access is not a second ceremony — it saves the host
with the fingerprint it ADVERTISED, then launches; the host parks that connect until its
operator approves this Deck, admits it, and the stream starts by itself.

No fingerprint, no request access. A host typed in by address advertises none, so the sheet
offers the PIN path only and says why, rather than showing a button that could only fail. The
sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a
185 s wait and an impostor answering for the host.

The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from
panel state — everything it acts on later is read through a ref. Reading a captured value is
precisely what made pinning a second game compute from a stale base and clobber the first.

LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS /
PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now
`punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever
rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link
grammar enforces.

Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the
session, so no process survives to see the stream come up and record the approval. Safe for
gamescope because focus follows reaper's descendant tree, not a single process, and
flatpak-run/bwrap already sit in that tree on every other path.

Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch
the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era.
`punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which
is strictly better and deletes a backend method, a frontend call and a shell branch.

The console-home branch of the wrapper is untouched on purpose: the shell binary already execs
the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there.

Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset
key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
2026-08-04 20:41:30 +02:00
enricobuehler 2fd303e22f refactor(decky): delete the second client
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00
enricobuehler a9a514dea0 fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.

Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.

A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.

One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
2026-08-04 20:37:25 +02:00
enricobuehler f84c5b8114 feat(cli): launch --request-access — let the host's operator admit this device
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.

  punktfunk launch <host-ref> --request-access

Two behaviours, both small:

* `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything
  shorter gives up while the approval prompt is still on the operator's screen.
* `run_plan` records the host as paired on SessionEvent::Ready. That event IS the
  approval arriving, and it records the pin the session actually connected WITH rather
  than re-reading the store — the handshake completed against that identity, which is
  what makes the record true. Every other launch still records nothing: a plain connect
  proves reachability, not a new trust decision.

Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI
BECOMES the session, so no process survives to observe Ready — a quiet downgrade would
leave hosts reading "trusted" forever with nobody able to explain why.
2026-08-04 20:28:34 +02:00
enricobuehler aec02b9d26 fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it
`punktfunk hosts add <addr> --fp <hex>` against an address already in the store printed
"is already saved" and exited 0 — having done nothing at all. The --fp was silently
discarded, so a host saved by address stayed pinless and every later connect refused
for want of a fingerprint, with no line anywhere saying why.

Three outcomes now, and the difference between them is a trust decision:

  • no fingerprint on the record, one offered  → fill it in, print `updated <addr>:<port>`
  • the same fingerprint offered again        → no-op, exit 0 (a panel may retry a step
                                                whose state is already correct without
                                                having to invent an error to show)
  • a DIFFERENT fingerprint                    → refuse, exit 3

The refusal is the important one. A changed identity is a decision for a person at a
surface that can show them both — the rule `upsert_trusted` exists to enforce — and
quietly overwriting a pin here would be a back door through the pinning the rest of the
client is built on.

A record still named after its own address takes an offered --name; a label the user
chose is theirs and an advert's name must not overwrite it.
2026-08-04 20:28:11 +02:00
enricobuehler 48bb1769b4 feat(cli): punktfunk discover — browse the LAN, annotated against what you've saved
The CLI could do everything with a host except FIND one, so every headless consumer
grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python,
which drifts from the host's advert every time a key is added and makes the plugin
depend on Avahi being the resolver.

`discovery::discover_for(timeout)` is the bounded collector beside the streaming
`browse()` the UI uses — same service type, same TXT keys, folded to one row per host.
A refreshed advert wins (it carries the newer address), a removal drops the row, and
dropping the receiver on the way out stops the worker so a one-shot call can't leak a
browse per invocation.

The verb annotates each hit against the saved-hosts store rather than handing back two
lists to join: `saved`/`paired` are answered by fingerprint first and address second —
the same rule every other surface uses. That is what stops a host that moved DHCP lease
from reading as new, and stops a different box that inherited the old address from
reading as paired.

  punktfunk discover [--json] [--timeout SECS]

Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
2026-08-04 20:26:59 +02: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 6e001e54b4 fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
android / android (pull_request) Successful in 9m9s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 11m18s
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
Four encoder faults, plus a note on a fifth that turned out not to be one.

A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.

Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.

An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.

Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.

The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
2026-08-04 20:08:56 +02:00
enricobuehler 31b5f90b12 fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 25m42s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
Three faults on the Windows pad path, two of them races that only bite when a
game drives a pad hard enough for two callbacks to overlap.

pf-gamepad's output ring could hand the host a torn report. Publishing is a
read-modify-write — read the cursor, write the slot it names, advance it — and
the framework dispatches output callbacks in parallel, so two could be inside
it at once: both read the same head, both wrote the SAME slot, and both stored
head+1, so the cursor moved once for two reports and the host read a single
entry with two reports mixed into it. An atomic fetch_add does not fix this. It
hands each writer its own slot but advances the cursor before the bytes exist,
so the host is then invited to read a slot still being filled. Serializing the
publish is what makes the cursor bump mean "the slot below is complete". The
ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat
that STOP with no idle watchdog behind it.

Both drivers also promised the host an ordering they never established. The
host loads out_seq and rumble_seq with Acquire and says so in its own comments
— "Acquire pairs with the driver's publish-then-bump store order" — but the
drivers bumped both with plain writes, and an Acquire load pairs with a Release
store and nothing else. On a weakly-ordered core the host could see a fresh seq
against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring:
two SET_STATE calls could both read one value and both write back value+1, so
the host saw one bump for two writes and skipped a level. A skipped stop is the
one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the
game went quiet, which is what bounds the damage.

Diagnosing an unattached driver stalled the session. The pad service thread —
the one feeding input and rumble — waited up to two seconds for a pnputil
enumeration, per unattached pad, at exactly the moment a session was already
going wrong. The diagnosis now runs on its own thread. Off the hot path the
wait no longer has to be a compromise, so it is generous enough to report what
it actually found instead of giving up with "still enumerating" — which, given
pnputil routinely takes longer than the old budget, is what it usually did.
2026-08-04 19:20:01 +02:00
enricobuehler 8abdd74a62 fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
2026-08-04 19:10:45 +02:00
enricobuehler 66a28d5abb ci(android): run the kit's unit tests
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / docs-site (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 3m17s
ci / rust (pull_request) Failing after 13m58s
They were running nowhere. This workflow only assembled, and the screenshot
workflow runs the :app module's tests, so nothing enforced :kit's — the pure
parsers, migrations and feedback policies could go red without anyone
noticing. A couple of seconds against a module the build already produces.
2026-08-04 18:14:13 +02:00
enricobuehler e2faecfd42 fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking
Four faults in the Android feedback path, all of them silent.

Rumble stopped for the rest of the session if one vibrator call threw. The
poll thread called cancel() unguarded while every call around it was already
wrapped, so an unchecked throw — DeadSystemRuntimeException, or the
RuntimeException a dying service wraps a RemoteException in — unwound the
thread. `running` stayed true, so nothing noticed it was gone and nothing
restarted it. Guarding the two bare cancels is not enough on its own: the
binder calls that bind a vibrator can throw just the same, so the loop itself
now survives a failed render, and the same guard covers the hidout thread.

A rumble stop that was never written was treated as one that landed. The
DualSense capture disarmed its backstop timer *before* the write, on a queue
that discarded failed submits without saying so, so a dropped stop left the
motors running with nothing scheduled to try again — and a USB pad holds its
last level until told zero. Writes now report whether they were accepted, the
backstop is disarmed only once the stop is actually on its way, and the
backstop re-arms rather than giving up if its own write is refused.

A full write queue dropped lightbar colours, player-LED masks and trigger
effects. Its overflow rule was "drop the oldest", which is right for rumble —
re-sent continuously, so a lost frame returns milliseconds later — and wrong
for everything else, which the host sends once on change and never repeats.
Eviction is now driven by an explicit key from the caller rather than by
inspecting the bytes: rumble supersedes the pending rumble in place, and a
one-shot is discarded only if the queue holds nothing but one-shots. The key
cannot be recovered from the report itself, which is why this is not keyed by
report id — every DualSense output report carries the *same* id and differs
only in its valid_flag bytes, so an id-keyed rule would let a rumble
supersede a lightbar, which is this bug again by another route.

An unplug leaked the USB connection and the detach receiver. The link only
signalled the drop; neither capture released anything, so the interfaces
stayed claimed (the pad could not return to Android's own input stack) and a
re-plug overwrote the field holding the receiver, stranding one live for the
rest of the process. The captures now release the transport, stop() is safe
to call from the callback it arrives on — the reader thread must not join
itself — and a close is reported exactly once however many detectors see it.
A reader that could not queue a single request now reports itself down too,
instead of leaving the owner waiting on a capture that never streams.
2026-08-04 18:14:07 +02:00
enricobuehler 76832a5b86 fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
2026-08-04 08:20:12 +02:00
enricobuehler ec4bf75a6e fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals
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 2m54s
ci / rust-arm64 (pull_request) Successful in 4m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 9m39s
Three faults in the shared rumble policy engine, all answered by one change of
shape: the free-running jitter phase becomes `last_emit` — the exact value last
handed to an embedder — and every emit routes through one helper. That single
field answers all three live questions: would re-sending this be a no-op device
write, is this stop redundant, and would the nudge invent a stop.

The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an
SDL-class layer discards a write identical to the last one. But the nudge lived
only in the keepalive branch, so every host renewal re-emitted the raw level,
collided with the last jittered write, was discarded, AND re-anchored the
keepalive timer. The gap between distinct device writes stretched to 80 ms at
the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half
times the cadence the quirk exists to guarantee. Nudging on any repeat closes
it: 40 ms throughout.

Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for
"stop now" — and handed it out with a non-zero backstop, under a live lease.
It is the only such level: high must already be zero, and low ^ 1 == 0 implies
low == 1. The nudge now steps the LSB up instead, so the phase still alternates
and no stop is ever invented.

A zero for a pad the engine already believes silent is now dropped. Under the
legacy hatch the host re-sends zeros for every latched pad every 500 ms for the
rest of the session, which cost Android an unconditional log line and a binder
cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because
a stop that was LOST leaves the pad buzzing, and that is exactly the guard's
pass condition.

The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is
sender-side only, so a modified or third-party host could stamp a long TTL and
wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness
policy of its own — and a Deck slot buzzing for all of it.

Every new test was proven to fail with its own fix reverted, including the two
that guard against over-reach: a default-quirks pad must still get the level
verbatim, or an off-by-one amplitude would land in Apple's identical-target
comparison and Android's one-shots.

One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0
cannot take the legacy backstop, because the expiry check preempts the relay
branch. No fix; pinned with a test so that ordering stays load-bearing.

Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings
= 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips`
fails on this Mac with a linker error, identically on an unmodified tree.)

From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1).
2026-08-04 07:40:19 +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
272 changed files with 24126 additions and 5302 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"
+8
View File
@@ -160,6 +160,14 @@ jobs:
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
# built module) and it is the only automated cover those behaviours have.
- name: kit unit tests
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
+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")
}
}
}
}
}
}
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
private class GpRow(
internal class GpRow(
val id: String,
val header: String?,
val label: String,
@@ -78,6 +78,15 @@ private class GpRow(
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
// A disabled row is INERT, not just dim — the step is refused instead of writing a
// setting that has nothing to act on (see `liveRow`).
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
// its detail explains itself) but never flips it.
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
})
}
}
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
@@ -478,11 +494,27 @@ private fun buildSettingsRows(
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
@@ -501,8 +533,18 @@ private fun buildSettingsRows(
"sc2", null, "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture,
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
)
}
@@ -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,
)
}
}
@@ -44,6 +44,8 @@ data class SettingsOverlay(
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -78,6 +80,8 @@ data class SettingsOverlay(
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -115,6 +119,8 @@ data class SettingsOverlay(
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -142,6 +148,8 @@ data class SettingsOverlay(
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -166,6 +174,8 @@ data class SettingsOverlay(
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -198,6 +208,8 @@ data class SettingsOverlay(
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -214,6 +226,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -237,6 +250,8 @@ data class SettingsOverlay(
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -45,6 +45,20 @@ data class Settings(
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -156,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
@@ -228,6 +262,8 @@ class SettingsStore(context: Context) {
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -255,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
@@ -275,6 +313,8 @@ class SettingsStore(context: Context) {
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -290,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()
@@ -305,6 +347,8 @@ class SettingsStore(context: Context) {
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -335,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. */
@@ -539,6 +585,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android — the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
@@ -621,3 +676,17 @@ val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
)
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
options = SYSTEM_BUTTON_OPTIONS,
selected = s.systemButtons,
field = "system_buttons",
enabled = s.gamepadForwarding,
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
"Automatic sends them to the host whenever this device delivers them.",
) { v -> update(s.copy(systemButtons = v)) }
SettingDropdown(
label = "Hold Select for guide",
options = GUIDE_GESTURE_OPTIONS,
selected = s.guideGesture,
field = "guide_gesture",
enabled = s.gamepadForwarding,
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
"slightly delayed. For devices that intercept the real guide button.",
) { v -> update(s.copy(guideGesture = v)) }
DeviceScopeOnly {
ClickableRow(
title = "Connected controllers",
@@ -877,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")
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
@@ -506,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 {
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}
@@ -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,18 +133,28 @@ 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
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
// Nothing can retry after this point, so a failure is worth saying out loud: it is
// the difference between a quiet pad and one that buzzes until it is unplugged.
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
// triggers the game was holding, which outlive the link just as stubbornly.
resetRichFeedback(m)
}
disarmBackstop()
usb.stop()
@@ -131,20 +169,119 @@ 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
releaseSlot()
// Release the transport too: the link only *signals* the drop, so without this an unplug
// left its connection open, its interfaces claimed and its detach receiver registered.
usb.stop()
if (wasActive) onActiveChanged?.invoke(false)
}
@@ -216,17 +353,24 @@ class DsCapture(
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
val stop = low == 0 && high == 0
if (!stop) armBackstop(backstopMs)
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
}
if (stop) {
// Disarm only once the stop is actually on its way. Dropping the net *before* the
// 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()
}
}
@@ -252,6 +396,9 @@ class DsCapture(
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
// collapsing a backlog of them down to the last.
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
@@ -261,8 +408,38 @@ class DsCapture(
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
OutReportQueue.KEY_RUMBLE,
)
/**
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
*
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
* firmware they outlive the stream, the app, and being unplugged. Ending a session while a
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
* nothing to release it but another game that happens to set one.
*
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
* queue would never drain. Writes are best-effort the pad may already be gone.
*/
private fun resetRichFeedback(m: DsDevice.Model) {
if (m == DsDevice.Model.DUALSHOCK4) {
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
// blacking the lightbar is a single composed report.
ds4Rgb = 0
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
return
}
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
for (which in 0..1) {
usb.writeControl(
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
)
}
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
}
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
@@ -284,7 +461,12 @@ class DsCapture(
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
val m = model ?: return@Runnable
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
// than give up: this is the last thing between a stalled poll thread and a pad that
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
// clears `model` and disarms.
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
@@ -297,5 +479,9 @@ class DsCapture(
private companion object {
const val TAG = "DsCapture"
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
* and the host has already moved on, so nothing else is coming to silence them. */
const val STOP_RETRY_MS = 100L
}
}
@@ -276,11 +276,26 @@ 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()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
it[3] = wireAmplitudeToByte(high).toByte()
it[4] = wireAmplitudeToByte(low).toByte()
}
/**
@@ -324,17 +339,11 @@ object DsDevice {
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[4] = wireAmplitudeToByte(high).toByte()
it[5] = wireAmplitudeToByte(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -88,6 +88,9 @@ class GamepadFeedback(
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
const val LOG_EVERY = 128L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
@@ -125,37 +128,51 @@ class GamepadFeedback(
fun start() {
running = true
rumbleThread = Thread({
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
// rumble policy engine — it owns every lease/staleness/close decision (uniform
// across all clients; the old 60 s legacy-host exposure is gone) and emits
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
// the backstop (the hardware net under a stalled poll thread).
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
// against the Rust packer.
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
// Rendering is binder calls into the vibrator service, and every one of them can
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
// the ordinary RuntimeException a dying service wraps its RemoteException in.
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
// the session. Losing a single command is recoverable; losing the loop is not.
runCatching {
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
}
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
var failures = 0L
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
dispatchHidout(buf, n)
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
// calls, and an unchecked throw here would silently end the rich-feedback plane.
runCatching { dispatchHidout(buf, n) }
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
}
}, "pf-hidout").apply { isDaemon = true; start() }
}
/**
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
* command, which at a rumble plane's rate would bury the log.
*/
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
if (seen == 0L || seen % LOG_EVERY == 0L) {
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
}
return seen + 1
}
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() {
running = false
@@ -264,12 +281,12 @@ class GamepadFeedback(
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
runCatching { m.cancel() } // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
@@ -294,7 +311,7 @@ class GamepadFeedback(
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
runCatching { lv.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
@@ -314,8 +331,8 @@ class GamepadFeedback(
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
@@ -329,12 +346,6 @@ class GamepadFeedback(
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
@@ -50,12 +50,35 @@ class GamepadRouter(
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
* with this device.
*/
private val systemForward: Boolean = true,
/**
* The hold-Select guide gesture (`Settings.guideGesture` resolved auto = off on
* Android): holding Select ALONE [GUIDE_HOLD_MS] sends the HOST's guide button, down
* until release so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
* while other buttons are down passes through untouched, so the exit/mic chords keep
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
*/
private val guideGesture: Boolean = false,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
// timer / a delivered tap's owed release (both on the main handler), and whether
// the held Select was transformed into a synthetic guide.
var pendingGuide: Runnable? = null
var pendingTapUp: Runnable? = null
var selectAsGuide = false
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
@@ -139,7 +162,24 @@ class GamepadRouter(
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
// on release), a combo member (the next button flushes it as a real
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
// it either way, so the exit/mic chords read as if the gesture didn't
// exist (Select+Y still fires the mic toggle: the flush sends Select's
// down before Y's).
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
slot.held = slot.held or bit
armGuide(slot)
return
}
flushPendingSelect(slot)
}
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
@@ -155,7 +195,8 @@ class GamepadRouter(
onMicChord?.invoke()
}
} else {
if (send && forwarding) {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -167,6 +208,61 @@ class GamepadRouter(
}
}
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
private fun armGuide(slot: Slot) {
val r = Runnable {
slot.pendingGuide = null
slot.selectAsGuide = true
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
}
}
slot.pendingGuide = r
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
}
/**
* A second button joined while Select was pending it was a real Select after all; its
* deferred down goes out before the caller sends the new button's, preserving chronology.
*/
private fun flushPendingSelect(slot: Slot) {
val r = slot.pendingGuide ?: return
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
}
}
/**
* Select released with gesture state outstanding true when the gesture owned the
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
* can fold into nothing in the host's per-pad input fold).
*/
private fun consumeSelectRelease(slot: Slot): Boolean {
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
return true
}
val r = slot.pendingGuide ?: return false
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
val up = Runnable {
slot.pendingTapUp = null
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
slot.pendingTapUp = up
mainHandler.postDelayed(up, TAP_PRESS_MS)
}
return true
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
@@ -362,6 +458,24 @@ class GamepadRouter(
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
// release goes out NOW (its down is already on the wire and the handle may not
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
slot.pendingGuide = null
slot.pendingTapUp?.let {
mainHandler.removeCallbacks(it)
slot.pendingTapUp = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
}
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
}
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
@@ -403,5 +517,14 @@ class GamepadRouter(
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
const val GUIDE_HOLD_MS = 350L
/**
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
* this much, so the pair can't coalesce into no press at all.
*/
const val TAP_PRESS_MS = 50L
}
}
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
@@ -81,17 +81,57 @@ class HidUsbLink(
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
* what gets discarded when it fills, and why that is not simply "the oldest". */
private val outQueue = OutReportQueue()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
* it. Reset by [start]. */
private val down = AtomicBoolean(false)
/** 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.
@@ -114,6 +154,7 @@ class HidUsbLink(
connection = conn
device = dev
claims = claimed
down.set(false)
running = true
Log.i(
config.tag,
@@ -134,10 +175,7 @@ class HidUsbLink(
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
linkDown()
}
}
}
@@ -221,6 +259,9 @@ class HidUsbLink(
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
// `start` already returned true, so without this the owner would sit waiting on a
// capture that never streams and never reports itself dead.
linkDown()
return
}
val scratch = ByteArray(64)
@@ -295,10 +336,23 @@ class HidUsbLink(
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
linkDown()
}
/**
* Report the link down, exactly once, from whichever detector noticed first the detach
* broadcast (main thread) or the reader thread on its way out.
*
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
* the interfaces claimed (the pad could not return to Android's own input stack) and the
* receiver still registered and a re-plug overwrote the field holding it, leaking a receiver
* that stayed live for the process's lifetime.
*/
private fun linkDown() {
running = false
if (down.compareAndSet(false, true)) onClosed()
}
private fun finishReader(claims: List<Claim>) {
@@ -314,28 +368,35 @@ class HidUsbLink(
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
* this one [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
*
* Returns whether the report reached the device or is queued for it. A caller that is writing
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
* for one that landed.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
if (data.isEmpty()) return false
return when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
outQueue.offer(data, coalesce)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
else -> false
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
private fun setReport(type: Int, data: ByteArray): Boolean {
val conn = connection ?: return false
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
return sendReport(conn, ifId, type, data)
}
/**
@@ -344,9 +405,8 @@ class HidUsbLink(
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
fun writeControl(data: ByteArray): Boolean =
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
@@ -358,27 +418,48 @@ class HidUsbLink(
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
private fun sendReport(
conn: UsbDeviceConnection,
ifaceId: Int,
type: Int,
data: ByteArray,
): Boolean {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
// controlTransfer returns the byte count, or a negative value on failure — a failed write
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
val n = runCatching {
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}.getOrDefault(-1)
return n >= 0
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
/**
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
*
* Safe to call from the `onClosed` handler itself that is how an unplug now gets cleaned up,
* and it arrives on the reader thread, which must not try to join itself.
*/
fun stop() {
running = false
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
down.set(true)
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
if (reader !== Thread.currentThread()) {
runCatching { reader?.join(1000) }
// Only forget the thread once it is actually gone: clearing it while it still runs
// would let a later stop() skip the join and free the connection under it.
reader = null
}
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
@@ -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
@@ -0,0 +1,89 @@
package io.unom.punktfunk.kit
/**
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
* policy knows which reports may be thrown away and which may not.
*
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
* be bounded a stalled or unplugged device would otherwise grow it without limit and the
* question is what to discard when it fills.
*
* The old policy was "newest wins": drop from the head until there is room. That is right for
* rumble, which is *level-styled* the host re-sends it continuously, so a dropped frame is
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
* time that value happens to change, which may be never.
*
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
* That distinction cannot be recovered from the report itself: every DualSense output report
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
* would happily let a rumble supersede a lightbar the very bug this replaces, relocated.
*
* Two rules:
* - A report offered with a coalescing key **replaces** the pending report with that key, in
* place. A burst of rumble collapses to its latest value and never displaces anything else.
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
* report goes first. A one-shot is discarded only if the queue is full of nothing but
* one-shots which needs [cap] distinct one-shots outstanding, far beyond what a real pad
* produces.
*
* Thread-safe: offered by the feedback threads, drained by the reader thread.
*/
internal class OutReportQueue(private val cap: Int = CAP) {
private class Entry(val key: Int, val data: ByteArray)
private val items = ArrayDeque<Entry>()
/**
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
* constant identifying a level-styled stream whose newer values supersede older ones.
*
* Returns false only if the report had to be dropped outright the caller can then treat the
* write as failed rather than assuming it is on its way.
*/
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
if (key != NO_COALESCE) {
val at = items.indexOfFirst { it.key == key }
if (at >= 0) {
// Supersede in place: keeping the queue position stops a fast rumble stream from
// repeatedly jumping the one-shots queued ahead of it.
items[at] = Entry(key, data)
return true
}
}
if (items.size >= cap) {
val victim = items.indexOfFirst { it.key != NO_COALESCE }
if (victim >= 0) {
items.removeAt(victim)
} else if (key != NO_COALESCE) {
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
// rather than a one-shot that will never come again.
return false
} else {
items.removeFirst()
}
}
items.addLast(Entry(key, data))
return true
}
/** The next report to submit, or null when nothing is pending. */
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
fun clear() = synchronized(items) { items.clear() }
val size: Int get() = synchronized(items) { items.size }
companion object {
/** This report is a one-shot: never superseded, evicted only as a last resort. */
const val NO_COALESCE = 0
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
const val KEY_RUMBLE = 1
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
const val CAP = 32
}
}
@@ -0,0 +1,47 @@
package io.unom.punktfunk.kit
/**
* The two conversions every rumble path in this module needs, in one place.
*
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
* with no test on either side of the JNI boundary. Neither is complicated which is exactly why a
* silent divergence between copies would have been hard to notice.
*/
/**
* Wire amplitude (`0..0xFFFF`) an 8-bit motor/vibrator level.
*
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
* imperceptibly light, but it moves.
*/
internal fun wireAmplitudeToByte(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
/**
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
*
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 the Rust side has a
* compile-time assertion tying the two together, so this can't silently start truncating.
*
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
*/
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
if (ev < 0L) return null // timeout / closed
return RumbleCmd(
pad = ((ev ushr 49) and 0xFL).toInt(),
low = ((ev ushr 16) and 0xFFFF).toInt(),
high = (ev and 0xFFFF).toInt(),
backstopMs = (ev ushr 32) and 0xFFFF,
)
}
@@ -273,10 +273,20 @@ class Sc2Capture(
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
// Both transports share this callback, so read which one was live BEFORE clearing it —
// releasing the other would tear down a link that never dropped.
val dropped = activeLink
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
// round-trip a link many times in one session.
when (dropped) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
}
onActiveChanged?.invoke(false)
}
@@ -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()
@@ -0,0 +1,102 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
*/
class OutReportQueueTest {
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
private fun drain(q: OutReportQueue): List<Int> =
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
@Test
fun `rumble supersedes the pending rumble instead of queueing another`() {
val q = OutReportQueue()
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
assertArrayEquals(report(3), q.poll())
assertNull(q.poll())
}
@Test
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10)) // a one-shot queued behind it
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
// behind an endlessly-renewed entry.
assertEquals(listOf(2, 10), drain(q))
}
@Test
fun `a full queue sacrifices rumble, never a one-shot`() {
val q = OutReportQueue(cap = 4)
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.offer(report(11))
q.offer(report(12))
assertEquals(4, q.size)
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
// ordering; what matters is that the one-shots all survive.
assertTrue(q.offer(report(13)))
assertEquals(listOf(10, 11, 12, 13), drain(q))
}
@Test
fun `the one-shot the host never repeats survives a rumble storm`() {
val q = OutReportQueue(cap = 4)
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
// old newest-wins eviction the colour was dropped from the head and never came back,
// leaving the pad lit wrong until the value next happened to change.
q.offer(report(200)) // lightbar
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
val out = drain(q)
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
}
@Test
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertFalse(
"with nothing coalescable to sacrifice, the replaceable report yields",
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
)
assertEquals(listOf(10, 11), drain(q))
}
@Test
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertTrue(q.offer(report(12)))
assertEquals(listOf(11, 12), drain(q))
}
@Test
fun `clear empties the queue`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.clear()
assertEquals(0, q.size)
assertNull(q.poll())
}
}
@@ -0,0 +1,79 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
* sides, which is the only thing that catches one of them drifting.
*/
class RumbleWireTest {
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
((pad and 0xF).toLong() shl 49) or
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
(low.toLong() shl 16) or
high.toLong()
@Test
fun `every field round-trips at its extremes`() {
val cases = listOf(
listOf(0, 0, 0, 0),
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
listOf(1, 0x1234, 0x5678, 500),
listOf(7, 0, 0xFFFF, 2000),
)
for ((pad, low, high, backstop) in cases) {
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
assertEquals("pad", pad, cmd.pad)
assertEquals("low", low, cmd.low)
assertEquals("high", high, cmd.high)
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
}
}
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
@Test
fun `all sixteen pad indices are distinct`() {
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
assertEquals((0 until 16).toList(), seen)
}
@Test
fun `the negative sentinel is not a command`() {
assertNull(unpackRumbleEvent(-1L))
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
}
@Test
fun `a stop is distinguishable from a hold`() {
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
assertEquals(0, stop.low)
assertEquals(0, stop.high)
assertNotEquals(stop, hold)
}
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
@Test
fun `amplitude takes the high byte`() {
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
assertEquals(0x80, wireAmplitudeToByte(0x8000))
assertEquals(0x12, wireAmplitudeToByte(0x1234))
}
@Test
fun `zero stays silent but a weak nonzero never does`() {
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
for (v in listOf(1, 0x0042, 0x00FF)) {
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
}
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
}
}
+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
+99 -6
View File
@@ -18,6 +18,29 @@ use std::time::Duration;
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
const PAD_BITS: u32 = 4;
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
/// rather than failing the build.
const _: () = assert!(
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
);
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
///
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
/// without a live session handle — the shift arithmetic is the part worth pinning.
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(low) << 16)
| jlong::from(high)
}
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
@@ -54,12 +77,15 @@ 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) {
Ok(cmd) => {
(jlong::from(cmd.pad & 0xF) << 49)
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(cmd.low) << 16)
| jlong::from(cmd.high)
}
// 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
}
})
@@ -156,7 +182,74 @@ 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
})
}
#[cfg(test)]
mod pack_rumble_tests {
use super::*;
use punktfunk_core::input::MAX_PADS;
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
/// broken, and nothing else in the build would say so.
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
let pad = ((ev >> 49) & 0xF) as u16;
let backstop = ((ev >> 32) & 0xFFFF) as u32;
let low = ((ev >> 16) & 0xFFFF) as u16;
let high = (ev & 0xFFFF) as u16;
(pad, low, high, backstop)
}
#[test]
fn round_trips_every_field_at_its_extremes() {
for &(pad, low, high, backstop) in &[
(0u16, 0u16, 0u16, 0u32),
(15, 0xFFFF, 0xFFFF, 0xFFFF),
(1, 0x1234, 0x5678, 500),
(7, 0, 0xFFFF, 2000),
] {
let ev = pack_rumble(pad, low, high, backstop);
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
}
}
#[test]
fn every_representable_pad_survives_the_four_bit_field() {
for pad in 0..MAX_PADS as u16 {
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
}
}
#[test]
fn a_packed_command_is_never_negative() {
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
// "no command" and the rumble would simply vanish.
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
assert!(pack_rumble(0, 0, 0, 0) >= 0);
}
#[test]
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
let ev = pack_rumble(3, 0, 0, u32::MAX);
let (pad, _, _, backstop) = unpack(ev);
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
assert_eq!(backstop, 0xFFFF);
}
#[test]
fn a_stop_is_distinguishable_from_a_hold() {
let stop = pack_rumble(2, 0, 0, 0);
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
assert_ne!(stop, hold);
assert_eq!(unpack(stop).1, 0);
assert_eq!(unpack(stop).2, 0);
}
}
+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
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -164,6 +166,11 @@ struct GamepadSettingsView: View {
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
@@ -216,7 +223,8 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -237,9 +245,13 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
// still reads as a row you can sit on (which you can: its detail is the point).
.opacity(row.enabled ? 1 : 0.45)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
@@ -274,6 +286,13 @@ struct GamepadSettingsView: View {
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
/// currently off. It stays in the list and stays FOCUSABLE its `detail` is how the
/// user learns which switch to flip first, and a row that vanished mid-list would
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -284,12 +303,14 @@ struct GamepadSettingsView: View {
/// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool {
lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
return row.adjust(delta)
}
private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
row.activate()
}
private var rows: [Row] {
@@ -389,16 +410,36 @@ struct GamepadSettingsView: View {
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
// The four rows below only mean something while something is being forwarded, so
// they follow the switch above the same relationship the touch settings draw with
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
// `Row.enabled` existed, so it alone left them live and steppable.
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
options: controllers, current: gamepads.preferredID,
enabled: gamepadForwarding
) { gamepads.preferredID = $0 },
choiceRow(
id: "padType", icon: "dpad", label: "Controller type",
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
options: SettingsOptions.padTypes, current: gamepadType,
enabled: gamepadForwarding
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", icon: "house.circle", label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons,
enabled: gamepadForwarding
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture,
enabled: gamepadForwarding
) { guideGesture = $0 },
choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
@@ -569,13 +610,15 @@ struct GamepadSettingsView: View {
private func choiceRow<T: Equatable>(
id: String, header: String? = nil, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
write: @escaping (T) -> Void
) -> Row {
let index = options.firstIndex { $0.tag == current }
return Row(
id: id, header: header, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
guard let index else {
@@ -596,12 +639,13 @@ struct GamepadSettingsView: View {
private func toggleRow(
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, header: header, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
let target = delta > 0
@@ -34,6 +34,22 @@ enum SettingsOptions {
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
@@ -126,6 +126,14 @@ enum SettingsFields {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var systemButtons: SettingsField<String> {
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
overlay: \.systemButtons, effective: \.systemButtons)
}
static var guideGesture: SettingsField<String> {
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
overlay: \.guideGesture, effective: \.guideGesture)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -681,6 +681,29 @@ extension SettingsView {
}
.disabled(!effective.gamepadForwarding)
}
described("Where the guide (Xbox/PS) and share presses go while streaming. "
+ "Automatic sends them to the host whenever this device delivers them "
+ "— the hold-Select gesture below reaches the host regardless.",
field: "system_buttons") {
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
Text("Automatic").tag("auto")
Text("Send to host").tag("forward")
Text("This device").tag("local")
}
.disabled(!effective.gamepadForwarding)
}
described("Hold Select on its own to press the host's guide button — keep "
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
+ "goes through, slightly delayed. Automatic arms it wherever the real "
+ "button can't reach the host (this device reserves it).",
field: "guide_gesture") {
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
Text("Automatic").tag("auto")
Text("On").tag("on")
Text("Off").tag("off")
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
@@ -12,7 +12,7 @@ import GameController
public final class ControllerTester: ObservableObject {
// `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private let renderer = RumbleRenderer()
private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
@@ -21,8 +21,12 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
/// A caller that owns a particular pad passes the location id it wants (see
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
/// lowest location id wins an arbitrary but *stable* choice, where `Set.first` was neither.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
@@ -43,9 +47,57 @@ final class DualSenseHID {
deinit { close() }
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
/// The IOKit location id of the device this instance opened the handle a caller correlates
/// with its `GCController`. `nil` until a successful `open`.
private(set) var locationID: UInt32?
/// A device's location id, or `nil` if IOKit does not report one.
static func locationID(of dev: IOHIDDevice) -> UInt32? {
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
}
/// Every connected DualSense/Edge, by location id what a caller pairs against its controllers.
static func attachedLocationIDs() -> [UInt32] {
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let matches = productIDs.map { pid in
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
return []
}
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
return devices.compactMap(locationID(of:)).sorted()
}
/// Which attached device to drive, as an index into `ids` the whole selection rule, pure so
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
///
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
/// merely arbitrary it can differ between two calls in one process. With two DualSenses that
/// made each renderer's paddevice binding a coin flip: both could land on the same device
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
/// place never displaces one it can.
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
}
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
let ordered = Array(devices)
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
return nil
}
return ordered[i]
}
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
@@ -55,13 +107,21 @@ final class DualSenseHID {
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
let dev = Self.pick(devices, preferring: preferred)
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
locationID = Self.locationID(of: dev)
if let preferred, locationID != preferred {
// Not fatal one pad still gets rumble but with two pads attached it means this
// renderer is driving the wrong one, and it is invisible without the log line.
log.error(
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
)
}
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
@@ -70,8 +130,16 @@ final class DualSenseHID {
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
///
/// Returns whether the write reached the device. The caller needs this: it used to be logged
/// and swallowed, so a failed write still counted as a successful render. That matters most
/// for a **stop**, which has nothing behind it the renderer stamps its write clock even on
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
/// stop left the motors running with nothing scheduled to try again.
@discardableResult
func rumble(low: UInt8, high: UInt8) -> Bool {
guard let dev = device else { return false }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
@@ -81,7 +149,9 @@ final class DualSenseHID {
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
return false
}
return true
}
func close() {
@@ -67,6 +67,17 @@ public final class GamepadCapture {
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
// Hold-Selectguide gesture state (pf-client-core's `SelectGesture`, adapted to
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
// until it resolves into a tap (delivered on release) or past `guideHold` a
// synthetic guide, down until release.
var selectPending = false
var selectAsGuide = false
/// A delivered tap's release is owed (`tapTimer` scheduled) its down went out
/// outside `buttons`, so `flush` must know to lift it.
var tapReleaseOwed = false
var gestureTimer: Timer?
var tapTimer: Timer?
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
self.controller = controller
self.pad = pad
@@ -87,10 +98,29 @@ public final class GamepadCapture {
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
/// capture) has no Apple equivalent worth wiring macOS has Q/D, touch has the HUD.
private static let escapeChord: UInt32 =
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
/// `escapeChordElements` below the two must not drift.
static let escapeChord: UInt32 =
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// `escapeChord`'s four elements by GameController alias the ONLY system gestures claimed
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
static let escapeChordElements = [
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
]
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long the HOST's guide goes
/// down (until release, so a long hold is the host's long-press a Gaming-Mode
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
/// and tvOS never delivers it at all.
private static let guideHold: TimeInterval = 0.35
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
/// its release this far behind back-to-back transitions can fold into nothing in
/// the host's per-pad input fold.
private static let tapPress: TimeInterval = 0.05
private var chordTimer: Timer?
/// Fired ON MAIN once the escape chord has been held `disconnectHold` the session owner
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
@@ -115,10 +145,23 @@ public final class GamepadCapture {
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
/// default true on Apple where the OS shows its own overlay for them, that's the OS's
/// business; local mode exists for profile parity with the Gaming-Mode clients).
public let systemForward: Bool
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` auto = on
/// everywhere but macOS). See `guideHold`.
public let guideGesture: Bool
public init(
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
systemForward: Bool = true, guideGesture: Bool = false
) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
self.systemForward = systemForward
self.guideGesture = guideGesture
}
public func start() {
@@ -202,11 +245,28 @@ public final class GamepadCapture {
// gesture attached the press is the system's, not the game's. During capture the remote
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
for element in c.physicalInputProfile.elements.values {
//
// With forwarding OFF none of that applies no press reaches the host, so taking the
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
// is still read off this slot, and on tvOS it is the only controller way out of a
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
// its gesture attached on tvOS and the press is the system's the chord would never
// complete and the session would have no controller exit at all.)
let claimed = forwarding
? Array(c.physicalInputProfile.elements.values)
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
for element in claimed {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// BTN_MODE on the virtual xpad the Steam-overlay button). On iOS 26 the OS opens its
// Game Overlay for this press regardless of the gesture claim below (the app is
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
// `GCControllerHomeButtonSettingsManager` and surface a one-time
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
// the stream the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
@@ -235,7 +295,11 @@ public final class GamepadCapture {
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
}
}
if let motion = c.motion {
// Motion is wire-only `forwardMotion` has nothing to do with forwarding off, and no
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
// it back down), so with nothing to forward we simply never turn it on.
if forwarding, let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
@@ -289,7 +353,14 @@ public final class GamepadCapture {
// as "changed" otherwise the first stick/button move after a guide press would emit a
// spurious guide-UP while the button is still physically held (and drop the bit from
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
var raw = Self.buttonMask(g)
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
// masked here, guide is gated at its own handler.
if !systemForward { raw &= ~GamepadWire.misc1 }
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
// until it resolves (tap on release / synthetic guide past the threshold).
if guideGesture { raw = gestureFiltered(slot, raw) }
let newButtons = raw | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -312,10 +383,106 @@ public final class GamepadCapture {
updateEscapeChord()
}
/// The hold-Selectguide state machine over one sync's raw mask (pf-client-core's
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
/// button joining makes it real (unsuppressed the diff sends its down); released
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
/// guide, lifted here when Select physically releases.
///
/// One deliberate divergence from the Rust worker: while transformed into a guide the
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
/// an in-flight guide-hold release Select and press the chord plainly instead (the
/// chord's four-at-once press never lingers in pending long enough to be affected).
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
let back = GamepadWire.back
let backDown = raw & back != 0
let othersDown = raw & ~back != 0
if slot.selectAsGuide {
if backDown { return raw & ~back }
slot.selectAsGuide = false
sendGuide(slot, down: false, raw: false)
return raw
}
if slot.selectPending {
if !backDown {
endPending(slot)
deliverTap(slot)
return raw
}
if othersDown {
// A combo after all Select unsuppresses and the diff sends its down.
endPending(slot)
return raw
}
return raw & ~back
}
if backDown, !othersDown, slot.buttons & back == 0 {
// Newly pressed, alone: hold it back. An owed tap release goes out first so
// the host never sees two downs in a row.
if slot.tapReleaseOwed { finishTap(slot) }
slot.selectPending = true
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.gestureHoldFired(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.gestureTimer?.invalidate()
slot.gestureTimer = timer
return raw & ~back
}
return raw
}
/// The hold threshold passed with Select still pending it IS the guide now, down
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
private func gestureHoldFired(_ slot: Slot) {
guard slot.selectPending else { return }
slot.selectPending = false
slot.gestureTimer = nil
slot.selectAsGuide = true
sendGuide(slot, down: true, raw: false)
}
private func endPending(_ slot: Slot) {
slot.selectPending = false
slot.gestureTimer?.invalidate()
slot.gestureTimer = nil
}
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
/// outlive the slot.
private func deliverTap(_ slot: Slot) {
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
slot.tapReleaseOwed = true
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.finishTap(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.tapTimer?.invalidate()
slot.tapTimer = timer
}
private func finishTap(_ slot: Slot) {
guard slot.tapReleaseOwed else { return }
slot.tapReleaseOwed = false
slot.tapTimer?.invalidate()
slot.tapTimer = nil
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
/// released by `flush` on focus loss / close just like the others.
private func sendGuide(_ slot: Slot, down: Bool) {
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
/// physical Home handler's calls, which the system-buttons policy can keep local; the
/// gesture's synthetic transitions pass `raw: false` and always go out.
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
if raw, !systemForward { return }
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
@@ -449,6 +616,12 @@ public final class GamepadCapture {
/// (no GC calls) safe against an already-removed device. Does NOT close the slot or send
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
// goes out, and a transformed guide's bit folded into `buttons` by `sendGuide`
// is lifted by the loop below like any held button.
endPending(slot)
slot.selectAsGuide = false
if slot.tapReleaseOwed { finishTap(slot) }
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
#if os(iOS)
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
deviceRumble = RumbleRenderer(actuator: .device)
} else {
deviceRumble = nil
}
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
// per-motor `CHHapticEngine.stop()` an XPC round trip to gamecontrollerd, which the
// renderer's own notes record as able to hang plus `DualSenseHID.close()`, whose
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
// change, and the main thread is what drives the presenter's CADisplayLink, so
// blocking here hitches the picture mid-stream. The renderer is already detached from
// routing above, so nothing observes it after this point.
if let renderer { Task.detached { renderer.stop() } }
}
for (pad, controller) in want {
if let slot = slots[pad] {
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
replay(slot)
} else {
slots[pad] = Slot(controller: controller)
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(controller)
withRouting { rumbleByPad[pad] = renderer }
}
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
// Put the lightbar out too. This class is what turned it on (see the `Led` and
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
// set stays lit in firmware after the stream ends back at the launcher, or for a pad
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
// indicator IS the lightbar; a DualSense is not.
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
@@ -43,8 +43,14 @@ enum RumbleTuning {
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Wire amplitude DualSense HID motor byte. A nonzero command never collapses to silence:
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
/// nothing at all on this path. Floored at 1 imperceptibly light, but moving. (Android's
/// `toAmplitude` has always done this; this was the odd one out.)
static func hidByte(_ wire: UInt16) -> UInt8 {
let b = UInt8(wire >> 8)
return wire != 0 && b == 0 ? 1 : b
}
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
@@ -81,10 +87,11 @@ enum RumbleTuning {
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
/// `sessionStaleSeconds`; both are gone. Every liveness decision lease expiry, legacy-host
/// staleness, session close now belongs to punktfunk-core's shared policy engine
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
/// is told and never decides on its own when a level should end.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
@@ -93,17 +100,6 @@ enum RumbleTuning {
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
/// commands verbatim the engine (punktfunk-core `client/rumble.rs`) owns every lease,
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
/// the call sites' intent.
struct Policy {
static let session = Policy()
static let manual = Policy()
}
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) the opt-in
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
private let actuator: Actuator
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif
init(policy: Policy = .session, actuator: Actuator = .controller) {
self.policy = policy
init(actuator: Actuator = .controller) {
self.actuator = actuator
}
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
// which handle died lose `high` and `render` falls to the combined branch (selected
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
// which at least renders both motors somewhere.
if low == nil || high == nil {
log.warning("rumble: only one split-handle engine came up — falling back to combined")
teardown() // disarms handlers, stops the survivor's players + engine, nils both
}
} else {
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
}
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
// Ask for the device this renderer's controller actually is, so two attached DualSenses
// do not both get driven through whichever one an unordered Set happened to yield first.
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
dualSenseHID = hid
return true
#else
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
#endif
}
#if os(macOS)
/// Correlate a `GCController` with an IOKit location id.
///
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
/// missing. With one pad attached this is the same device it always was.
static func hidLocationID(for c: GCController) -> UInt32? {
let ids = DualSenseHID.attachedLocationIDs()
guard ids.count > 1 else { return ids.first }
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
return ids.first
}
return ids[rank]
}
#endif
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
if hid.rumble(low: levels.0, high: levels.1) {
lastHidWrite = (levels, .now())
} else {
// The write did not reach the device. Do NOT stamp the clock that would claim a
// render that never happened, and for a stop there is nothing behind it: the
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
// target is (0, 0), so the motors would keep running with nothing scheduled.
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
// rebuilds it. Health is reported so the state is visible rather than silent.
log.error("rumble: HID write failed — dropping the handle, falling back")
closeHID()
reportHealth("Lost the direct connection to this DualSense; using the system path.")
return false
}
}
return true
#else
@@ -38,6 +38,17 @@ public enum DefaultsKey {
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
/// `"auto"` | `"forward"` | `"local"` the cross-client `system_buttons` key. Auto
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
/// send would gain nothing.
public static let systemButtons = "punktfunk.systemButtons"
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` the cross-client
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
/// holding Select is the controller route to the host's guide there.
public static let guideGesture = "punktfunk.guideGesture"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
public var systemButtons = "auto"
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
public var guideGesture = "auto"
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
return "normal"
}
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
/// share/QAM misc) presses? Auto = forward on every Apple platform where the OS shows
/// its own overlay for the press that is the OS's business, and suppressing our send
/// would only break users who handed the button to the app (iOS 27's Home-button
/// setting; macOS with the gestures claimed).
public var systemButtonsForward: Bool {
switch systemButtons {
case "local": return false
default: return true
}
}
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
/// route to the host's guide and, held on, to a Gaming-Mode host's QAM. On macOS the
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
public var guideGestureEnabled: Bool {
switch guideGesture {
case "on": return true
case "off": return false
default:
#if os(macOS)
return false
#else
return true
#endif
}
}
/// The one resolution seam: this overlay on top of these settings. Pure no store reads, no
/// clock so it is testable field by field. A `.some` that happens to equal the base is a
/// legitimate PIN: it keeps its value when the global later moves.
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.systemButtons { s.systemButtons = v }
if let v = overlay.guideGesture { s.guideGesture = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
public var systemButtons: String?
public var guideGesture: String?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case systemButtons = "system_buttons"
case guideGesture = "guide_gesture"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
systemButtons = str(.systemButtons)
guideGesture = str(.guideGesture)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -277,6 +285,8 @@ public enum OverlayField {
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "system_buttons": overlay.systemButtons = nil
case "guide_gesture": overlay.guideGesture = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -313,6 +323,8 @@ public enum OverlayField {
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "system_buttons": return o.systemButtons != nil
case "guide_gesture": return o.guideGesture != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
XCTAssertEqual(crc, 0xCBF4_3926)
}
// MARK: - Device selection (B14)
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
/// `Set.first` from an unordered set, so the paddevice binding was a coin flip that could
/// point both renderers at the same pad.
func testPreferredIndexHonoursAnExplicitLocation() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
}
/// No preference (or one the pad no longer has): fall back to the LOWEST id arbitrary, but
/// stable across calls, which `Set.first` was not.
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
// open it degrades to the same stable fallback.
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
}
/// A device IOKit reports no location for must never displace one it can place.
func testPreferredIndexSortsUnplaceableDevicesLast() {
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
}
}
#endif
@@ -0,0 +1,52 @@
import GameController
import XCTest
@testable import PunktfunkKit
/// The escape chord's mask and its GameController alias list have to describe the same four
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
/// is on, but only of `escapeChordElements` while it is off so if the alias list ever stops
/// covering the mask, the missing button's press stays the system's and the chord never completes.
///
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
/// Hence a test on the invariant rather than trusting the comment beside it.
@MainActor
final class GamepadEscapeChordTests: XCTestCase {
/// The intended aliasbit pairing, spelled out independently of the implementation.
private let pairing: [(alias: String, bit: UInt32)] = [
(GCInputLeftShoulder, GamepadWire.leftShoulder),
(GCInputRightShoulder, GamepadWire.rightShoulder),
(GCInputButtonMenu, GamepadWire.start),
(GCInputButtonOptions, GamepadWire.back),
]
func testChordMaskIsExactlyTheFourPairedButtons() {
XCTAssertEqual(
pairing.reduce(UInt32(0)) { $0 | $1.bit },
GamepadCapture.escapeChord,
"the chord mask and the alias pairing describe different buttons")
}
func testEveryChordBitHasAnElementToClaim() {
// One alias per bit a mask that grew a fifth button without a matching alias would
// leave that button's gesture with the OS while forwarding is off.
XCTAssertEqual(
GamepadCapture.escapeChordElements.count,
GamepadCapture.escapeChord.nonzeroBitCount,
"alias list and chord mask differ in size")
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
}
/// The claim list is a strict subset of what a forwarding slot takes it is a NARROWING of
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
/// is the behaviour this deliberately avoids).
func testClaimListIsNonEmptyAndAllDistinct() {
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
XCTAssertEqual(
Set(GamepadCapture.escapeChordElements).count,
GamepadCapture.escapeChordElements.count,
"a repeated alias would mean a chord bit has no element")
}
}
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
renderer.apply(low: 0x8000, high: 0x8000)
Thread.sleep(forTimeInterval: 0.1)
+51
View File
@@ -0,0 +1,51 @@
# App Store copy
Source of truth for what goes into App Store Connect. Every character-limited field in here has
been counted with `check-limits.py`; run it after any edit.
```sh
python3 clients/apple/store/check-limits.py
```
| File | Covers |
|------|--------|
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
German is primary throughout and uses the same informal "du" voice as the website
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
few lines diverge where the German idiom does not carry.
## Three things that contradicted the original brief
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
display, or encode backend. The macOS copy is client-only and says so explicitly.
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
the app. See `privacy-app-addendum.md` for the text to append.
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
template is 3919 and fits.
## Claims used, and where they come from
Everything asserted in the copy was checked against the source rather than the marketing site:
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
symbols in `Sources/`, `URLSession` used only against the paired host
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
- Feature ship dates — `git tag --contains` on the relevant commits
## Not done here
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
alone because it is a code change, not copy.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Check every App Store copy block in this directory against its field limit.
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
so `len()` on a `str` is the right measure do not switch this to a byte count.
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
"""
from __future__ import annotations
import pathlib
import re
import sys
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
def blocks(text: str):
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
heading = None
buf: list[str] | None = None
for line in text.split("\n"):
if line.startswith("#") and buf is None:
heading = line.lstrip("#").strip()
if line.strip() == "```":
if buf is None:
buf = []
else:
yield heading or "", "\n".join(buf)
buf = None
continue
if buf is not None:
buf.append(line)
def kind_of(heading: str, body: str) -> str:
low = heading.lower()
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
return "KW"
if "template" in low:
return "NOTES"
return "DESC" if len(body) > 400 else "PROMO"
def main() -> int:
here = pathlib.Path(__file__).parent
failures = 0
stale = 0
for path in sorted(here.glob("*.md")):
found = list(blocks(path.read_text(encoding="utf-8")))
if not found:
continue
print(f"\n=== {path.name} ===")
for heading, body in found:
kind = kind_of(heading, body)
limit = LIMITS[kind]
n = len(body)
over = n > limit
failures += over
# Headings carry the count in parentheses; flag any that drifted from the real length.
claimed = re.search(r"\((\d+)\)\s*$", heading)
drift = ""
if claimed and int(claimed.group(1)) != n:
drift = f" [heading claims {claimed.group(1)}]"
stale += 1
status = "OVER" if over else "ok"
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
if failures:
print(f"\n{failures} block(s) OVER the limit")
elif stale:
print(f"\nAll within limits, but {stale} heading count(s) are stale")
else:
print("\nAll blocks within limits, all heading counts accurate")
return 1 if failures or stale else 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
# iOS / iPadOS — App Store metadata
Existing, unchanged:
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
build or a review, so it is the right place for "what landed most recently".
---
## Promotional Text (DE) — max 170 characters
### Primary (160)
```
Neu: Profile pro Host Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
```
### Alternate A — evergreen hook, no "new" claim (156)
```
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
```
### Alternate B — leads on the DualSense (161)
```
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
```
### Alternate C — leads on latency (153)
```
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
```
---
## Promotional Text (EN) — max 170 characters
### Primary (152)
```
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
```
### Alternate A — evergreen hook (159)
```
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
```
### Alternate B — leads on the DualSense (160)
```
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
```
---
## Notes on the claims
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
drop the word once 0.25 ships something newer.
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
*mention* but should not be called "neu".
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
(`b297542c`), which is too niche to headline.
+159
View File
@@ -0,0 +1,159 @@
# macOS — App Store metadata
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
> None of the copy below claims a Mac can host, and it should not until that ships.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (164)
```
Neu: Profile pro Host ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
```
### Alternate (156)
```
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
```
## Promotional Text (EN) — max 170 characters
### Primary (161)
```
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
```
### Alternate (156)
```
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk streamt deinen Gaming-PC auf den Mac in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
Für jede Verbindung legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
FÜR DEN MAC GEMACHT
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
• Maus und Tastatur gehen vollständig durch Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
• Mikrofon-Uplink mit Echounterdrückung dein Mac wird zum Headset am PC
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
DEINE BIBLIOTHEK, DEIN NETZWERK
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
MESSEN STATT GLAUBEN
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
YOUR MAC GETS A DISPLAY OF ITS OWN
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
BUILT FOR THE MAC
• In a window or full screen, on any attached display
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
YOUR LIBRARY, YOUR NETWORK
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
MEASURED, NOT PROMISED
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
absent — repeating them would waste characters.
### DE (97)
```
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
```
### EN (95)
```
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
```
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
Moonlight and mention Steam only as a factual statement about your own library.
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
+146
View File
@@ -0,0 +1,146 @@
# Privacy — what to link from App Store Connect
## The situation
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
self-hosted fonts. It does not mention the apps at all.
That is a problem for App Store Connect in two directions:
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
the link finds a page about a website.
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
Analytics" and has every reason to question the App Privacy answers.
**Recommendation:** keep the existing page and append an app-specific section to it (the text
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
works, but one URL is less to keep in sync.
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
code change.
## Confirming the "collects no data" framing
Checked against the source rather than taken on trust, and it holds:
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
**from the paired host**, over a TLS session that pins the host's own certificate. The only
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
the source on `git.unom.io`, and the Discord invite.
- **No account system.** Identity is a client keypair in the device keychain
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
entitlement.
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
stating in the policy text anyway, because they are true and pre-empt questions:
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
is better than staying silent about a microphone permission.
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
Apple's processing, not yours, but naming it avoids looking like an omission.
---
## Text to append — Deutsch
> ## Die Punktfunk-Apps
>
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
> Android im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
>
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
> übermittelt.
>
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
> selbst betreiben in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
> Controller-Eingaben sowie sofern Sie ihn einschalten Ihr Mikrofon werden ausschließlich
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
>
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
> wenn Sie die App entfernen.
>
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und nur
> wenn Sie die Mikrofonübertragung nutzen das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
> aufgezeichnet und nicht an uns gesendet.
>
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
>
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
> Rechner betreiben. Welche Daten dabei anfallen etwa lokale Protokolldateien , bleibt
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
> git.unom.io/unom/punktfunk einsehbar.
---
## Text to append — English
> ## The Punktfunk apps
>
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
> Android — as distinct from the sections above, which concern this website.
>
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
> data is transmitted to us or to any third party.
>
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
> streaming, and we have no access to the contents of a session at any point.
>
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
> your device and is removed when you delete the app.
>
> **Permissions.** The app requests only the permissions it needs to work: access to the local
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
> virtual microphone; it is not recorded and is not sent to us.
>
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
> and crash statistics). We have no influence over this, and the respective provider's privacy
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
> not allow any individual to be identified.
>
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
> data it produces — local log files, for instance — remains entirely under your control, and none
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
---
## Also update
- Bump **Stand: / Effective date:** on the page when you add this.
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
+132
View File
@@ -0,0 +1,132 @@
# App Review notes
## The core problem, stated plainly
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
on their network sees an empty "On this network" list.
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
attach **one** of:
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
can add a host by IP or hostname, so mDNS discovery is not required for this path.
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
> section with the video URL and say explicitly that no host can be provided.
---
## Notes template — paste into App Store Connect
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
longer than the values that replace it). If you add to it, re-check the count — an over-long note
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
live.
```
WHAT THIS APP IS
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
and this app has no purchases.
This app is the client half only: it renders video and audio from the user's own machine and
sends input back. There is no content library and no server of ours in a session.
IMPORTANT: THIS APP NEEDS A HOST
With no reachable host, the app can only show its host list, the pairing screen and settings --
inherent to what it is, not an incomplete build. We have provided a live host for review.
CONNECTING TO OUR DEMO HOST
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
Name it anything, then confirm.
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
4. The host's game library appears as a grid. Select any title to stream; video and audio start
within a few seconds.
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
"Speed test" suggests a bitrate for the link.
The host stays reachable throughout review. If you cannot reach it, please contact
[[FILL IN: contact email]] and we will restore it promptly.
WHY THE APP ASKS FOR WHAT IT ASKS FOR
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
entire purpose.
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
there as a virtual microphone for voice chat. Never recorded, never sent to us.
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
silence to stay alive, nor use the mode outside an audible session.
REGARDING BUILD 0.4.2 (3384)
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
no temporary exception.
ACCOUNTS, PURCHASES, DATA
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
```
---
## Before you submit — checklist
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
review window, and that pairing is left open (it is on-demand in the web console).
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
successful pairing looks like a broken app.
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
tvOS rejection.
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
## Separately worth checking: the privacy manifest
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
missing a declaration typically get an automated **ITMS-91053** notice on upload.
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
submission. Confirm the current reason codes against Apple's documentation rather than taking the
code above on trust; the list has changed since it was introduced.
+145
View File
@@ -0,0 +1,145 @@
# tvOS — App Store metadata
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
and which the copy therefore avoids claiming:
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
headset" line does not transfer.
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
macOS only — tvOS uses the native focus engine instead.
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
best Apple TV feature, because it is what removes the trip to the other room.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (161)
```
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
```
### Alternate A — leads on the picture (157)
```
Dein Gaming-PC am großen Bildschirm in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
```
### Alternate B — leads on the DualSense (160)
```
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
```
## Promotional Text (EN) — max 170 characters
### Primary (160)
```
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
```
### Alternate A — leads on the picture (148)
```
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auch headless auf einem Rechner, an dem gar kein Monitor hängt.
VOM SOFA AUS, VON ANFANG BIS ENDE
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
Für den Apple TV legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
DEINE BIBLIOTHEK AUF DEM FERNSEHER
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
FROM THE COUCH, START TO FINISH
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
THE PICTURE YOUR TV CAN ACTUALLY SHOW
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
YOUR LIBRARY ON THE BIG SCREEN
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
### DE (93)
```
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
```
### EN (91)
```
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
```
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
+399 -12
View File
@@ -41,16 +41,23 @@ mod cli {
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
/// give up while the approval prompt is still on their screen.
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
const USAGE: &str = "\
punktfunk the Punktfunk client, headless
punktfunk discover [--json] [--timeout SECS]
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
punktfunk wake <host-ref> [--wait]
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"discover" => {
"\
punktfunk discover [--json] [--timeout SECS] browse the LAN for hosts
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
device already has a record for it, matched by fingerprint first and address
second the same rule every other surface joins the two lists by.
--timeout SECS how long to browse (default 3, capped at 30) a bounded
call, so a panel can wait for it
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
\"os\",\"saved\",\"paired\"}]}
Nothing answering is an answer, not a failure: an empty list exits 0. A host
mDNS never sees (Tailscale, another subnet) will not appear here save it by
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
}
"pair" => {
"\
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
for a host already saved is a no-op, and giving a known fingerprint a new
address MOVES that host's record there rather than filing a second one
(which is how a host that changed DHCP lease stays reachable by its id).
A different fingerprint for an address already saved is refused, exit 3
a changed identity is a decision for a person.
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
Start a stream waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
--exec become the session process instead of supervising it the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
--request-access
ask the host's operator to let this device in instead of
typing a PIN. The host PARKS the connect until somebody
approves it in its console or web UI (up to ~185 s), then
admits it and the stream starts by itself; the host is
recorded as paired once that happens, so later streams are
silent. Needs the host's fingerprint pinned already
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
combined with --exec under --exec there is no process
left to record the approval.
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
fn flag_takes_value(flag: &str) -> bool {
matches!(
flag,
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
)
}
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
return OK;
}
match verb.as_str() {
"discover" => discover(&rest),
"pair" => pair(&rest),
"hosts" => hosts(&rest),
"wake" => wake(&rest),
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
}
}
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
/// `--timeout 3000` would hang that panel with no way to cancel it.
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
const DISCOVER_MAX_SECS: f64 = 30.0;
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
/// annotated against the saved-hosts store.
///
/// The annotation is the point: a caller wants "can I stream this", which is a question
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
/// the same host. So the match rule lives here, once, and is the same one every other
/// surface uses — fingerprint first (survives a DHCP move), address second.
fn discover(args: &[String]) -> u8 {
let secs = value(args, "--timeout")
.and_then(|v| v.parse::<f64>().ok())
.filter(|s| *s > 0.0)
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
.map(|(d, saved)| {
serde_json::json!({
"name": d.name,
"addr": d.addr,
"port": d.port,
"fp": d.fp_hex,
"pair": d.pair,
"id": d.advertised_id(),
// 0 = not advertised, which is what a consumer's own "no mgmt port"
// already means — an older host simply omits the TXT.
"mgmt": d.mgmt_port.unwrap_or(0),
"os": d.os,
"saved": saved.is_some(),
"paired": saved.is_some_and(|h| h.paired),
})
})
.collect();
println!("{}", serde_json::json!({ "hosts": hosts }));
} else {
for (d, saved) in &rows {
println!(
"{}\t{}:{}\t{}\t{}",
d.name,
d.addr,
d.port,
if saved.is_some() { "saved" } else { "new" },
if saved.is_some_and(|h| h.paired) {
"paired"
} else {
"unpaired"
},
);
}
}
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
// asking "did the browse run", and it did.
OK
}
/// The saved record an advert belongs to, if any: fingerprint first, address second.
///
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
/// still matches its record, and a *different* host that inherited the old address does not
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
/// the box in front of you is paired.
fn match_saved<'a>(
known: &'a KnownHosts,
advert: &pf_client_core::discovery::DiscoveredHost,
) -> Option<&'a KnownHost> {
known
.hosts
.iter()
.find(|h| {
!h.fp_hex.is_empty()
&& !advert.fp_hex.is_empty()
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
})
.or_else(|| {
known
.hosts
.iter()
.find(|h| h.addr == advert.addr && h.port == advert.port)
})
}
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
return UNRESOLVED;
};
let (addr, port) = split_host_port(&target);
let fp = value(args, "--fp").unwrap_or_default();
let name = value(args, "--name");
let mut known = KnownHosts::load();
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
eprintln!("{addr}:{port} is already saved");
return OK;
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == addr && h.port == port)
{
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
AddOutcome::Unchanged => {
eprintln!("{addr}:{port} is already saved");
OK
}
AddOutcome::Conflict => {
eprintln!(
"{addr}:{port} is already saved with a different fingerprint — \
forget it first if you really mean to replace it \
(punktfunk hosts forget {addr}:{port})"
);
TRUST_REJECTED
}
AddOutcome::Pinned => match known.save() {
Ok(()) => {
println!("updated {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
},
};
}
// No record at this address — but a record carrying this exact FINGERPRINT is
// this same host at a new one. Re-point it rather than filing a second record:
// the fingerprint is the identity, and a host that changed DHCP lease is the
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
// moved host accumulates one record per address it has ever held, and the one a
// stable id resolves to keeps the address it can no longer be reached at.
if let Some(i) = known
.hosts
.iter()
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
{
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
known.hosts[i].addr = addr.clone();
known.hosts[i].port = port;
return match known.save() {
Ok(()) => {
println!("moved {was} to {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
};
}
known.hosts.push(KnownHost {
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
name: name.unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: value(args, "--fp").unwrap_or_default(),
fp_hex: fp,
..Default::default()
});
match known.save() {
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
}
}
/// What `hosts add` did to a record that was ALREADY saved for this address.
#[derive(Debug, PartialEq, Eq)]
enum AddOutcome {
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
/// invent an error to show for a state that is already correct.
Unchanged,
/// The record had no fingerprint and now has this one.
Pinned,
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
Conflict,
}
/// `hosts add --fp` against an address that is already saved. The difference between these
/// three is a trust decision, not bookkeeping.
///
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
/// the floor and the launch that follows refuses for want of a pin — which is what this did
/// before, silently and with exit 0.
///
/// A *different* fingerprint is refused because a changed identity is a decision for a
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
/// of the client is built on.
fn merge_saved_host(
known: &mut KnownHosts,
i: usize,
fp: &str,
name: Option<&str>,
) -> AddOutcome {
let existing = known.hosts[i].fp_hex.clone();
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
return AddOutcome::Unchanged;
}
if !existing.is_empty() {
return AddOutcome::Conflict;
}
known.hosts[i].fp_hex = fp.to_string();
// Only a record still named after its own address is renamed: a label the user chose is
// theirs, and an advert's name must not quietly overwrite it.
if let Some(label) = name {
if known.hosts[i].name == known.hosts[i].addr {
known.hosts[i].name = label.to_string();
}
}
AddOutcome::Pinned
}
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
/// second, 90 s budget).
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
return UNRESOLVED;
};
let exec = has(args, "--exec");
let request_access = has(args, "--request-access");
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
// session, so nothing survives to see `Ready` and record the approval. A launch that
// quietly dropped the persistence would leave hosts reading "trusted" forever with
// nobody able to say why.
if request_access && exec {
eprintln!(
"--request-access can't be combined with --exec: under --exec there is no \
process left to record the host's approval"
);
return UNRESOLVED;
}
let (known, i) = match resolve(&reference) {
Ok(v) => v,
Err(code) => return code,
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
if has(args, "--fullscreen") {
plan.settings.fullscreen_on_stream = true;
}
run_plan(plan, has(args, "--exec"))
if request_access {
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
}
run_plan(plan, exec, request_access)
}
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
&trust::Settings::load(),
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
// A URL may never pair or trust on its own — that is a decision for a person, at a
// surface that can show them the fingerprint.
Ok(PlanOutcome::ConfirmUnknown(u)) => {
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
}
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
///
/// `persist_paired` records the host as *paired* when the child reports ready. Only
/// `launch --request-access` passes true: there, the host parked the connect until an
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
/// which is correct: a plain connect proves reachability, not a new trust decision.
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
if plan.host.fp_hex.is_none() {
eprintln!(
"{} has no pinned fingerprint — punktfunk pair {}",
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
let mut failure: Option<(String, bool)> = None;
while let Ok(ev) = rx.recv() {
match ev {
SessionEvent::Ready => eprintln!("streaming"),
SessionEvent::Ready => {
eprintln!("streaming");
// The pin we connected WITH, not one re-derived from the store: the record
// is what we are about to rewrite, and the session proved the host holds
// exactly this identity by completing a pinned handshake against it.
if persist_paired {
if let Some(fp_hex) = &plan.host.fp_hex {
trust::persist_host(
&plan.host.name,
&plan.host.addr,
plan.host.port,
fp_hex,
true,
);
trust::forget_placeholder(&plan.host.addr, plan.host.port);
}
}
}
SessionEvent::Error {
msg,
trust_rejected,
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
#[test]
fn every_usage_verb_has_help() {
for verb in [
"discover",
"pair",
"hosts",
"wake",
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
assert!(verb_help("bogus").is_none());
}
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: 9777,
fp_hex: fp.into(),
..Default::default()
}
}
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
/// no way to tell why.
#[test]
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
let mut known = KnownHosts {
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
AddOutcome::Pinned
);
assert_eq!(known.hosts[0].fp_hex, "abc123");
assert_eq!(
known.hosts[0].name, "living-room",
"a record still named after its address takes the offered label"
);
}
/// A label the user chose is theirs — an advert's name must not overwrite it.
#[test]
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
let mut known = KnownHosts {
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
};
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
assert_eq!(known.hosts[0].name, "Basement rig");
}
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
/// already carries is a state that is already correct, not an error to render.
#[test]
fn re_adding_the_same_fingerprint_changes_nothing() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", None),
AddOutcome::Unchanged,
"fingerprints compare case-insensitively"
);
// And a bare `hosts add` with no --fp at all leaves the pin alone.
assert_eq!(
merge_saved_host(&mut known, 0, "", None),
AddOutcome::Unchanged
);
assert_eq!(known.hosts[0].fp_hex, "ABC123");
}
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
#[test]
fn a_different_fingerprint_is_refused_not_overwritten() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "deadbeef", None),
AddOutcome::Conflict
);
assert_eq!(
known.hosts[0].fp_hex, "abc123",
"the pin must survive intact"
);
}
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
/// the record a stable id resolves to keeps an address the host has left, so a launch
/// dials into the void while the panel shows the live one.
#[test]
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
let by_addr = known
.hosts
.iter()
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
assert!(
by_addr.is_none(),
"the new address is not yet on any record"
);
let by_fp = known
.hosts
.iter()
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
known.hosts[0].addr = "192.168.1.50".into();
assert_eq!(known.hosts.len(), 1, "one host, one record");
}
#[test]
fn value_reads_the_argument_after_its_flag() {
let a = argv(&["--game", "steam:570", "--exec"]);
+18
View File
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
let out = punktfunk(&["help", "frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
}
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
///
/// The Decky panel detects a too-old client by exactly the signature the test above pins
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
/// enough, `discover` is a verb with help rather than an unknown word.
#[test]
fn the_request_access_surfaces_document_themselves() {
let out = punktfunk(&["help", "discover"]);
assert!(out.status.success(), "discover has its own help topic");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--timeout"), "discover documents --timeout");
assert!(stdout.contains("--json"), "discover documents --json");
let out = punktfunk(&["launch", "--help"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
}
+86 -56
View File
@@ -2,49 +2,61 @@
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
the panel looks and feels native to Gaming Mode.
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
## What it does
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair**for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
**"Open library on screen"** to launch the client's controller-driven, console-style library
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
scrolling. The categories and their order are the console settings screen's — Stream (resolution
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
library / fullscreen). The device pickers are populated
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
there is more than one adapter.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
online even though it never advertises. Rows sort online-first, then most recently used.
2. **Trust**an unpaired host opens a small sheet with two ways in:
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
console or web UI and the stream starts by itself. See [Request access](#request-access).
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
and streams with that settings profile applied. Cards are the **shared** pinning model every
other client speaks, stored on the host's record — so one you make in the desktop client shows
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
everything the panel no longer does now lives.
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
for a wedged stream.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode.
### Request access
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
the device, then admits the same connection; the stream starts on its own, and the record flips
to **paired** so every later stream is silent.
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
past a missing fingerprint.
## Install on the Deck
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
`avahi-browse`, which ships on SteamOS/Bazzite.
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md))
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
A native install (sysext, distro package, nix profile, your own build) works too.
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
the panel drives everything through it. An older client says so in the panel, with the update
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
client's own mDNS does it.)
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode**
**Install Plugin from URL**, paste:
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
directly). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
store first, which may be slow or blackholed before the actual download proceeds.
### Updating the client
The plugin also reports — and where it can, installs — updates for the **client** it launches.
What is possible depends on how that client was installed, and the About tab names the install
kind so the answer is never a mystery:
What is possible depends on how that client was installed:
| Install | Update |
| --- | --- |
@@ -88,6 +98,8 @@ pnpm install
pnpm build # rollup → dist/index.js
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
```
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
## Architecture
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
the client's data files and re-implements none of its rules.
| File | Role |
| --- | --- |
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
### Why the launch goes through Steam
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
visible, stateless library entry that opens console home.
## Limitations / next steps
- No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
it remotely.
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
simply sees host rows, and nothing is broken.
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
migration can read it.
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
plugin can't arm it remotely. Request access needs no arming — just an approval.
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
screen the same "waiting for approval" copy the console shell already has would pay off for every
shell.
## Related
+56 -53
View File
@@ -1,33 +1,32 @@
#!/usr/bin/env bash
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
#
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
# from the (root) Decky backend produces an unfocused, invisible window.
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
# (root) Decky backend produces an unfocused, invisible window.
#
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host (and every pinned game):
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
# firing Wake-on-LAN so the connect survives the host's resume)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# every host:
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
# PF_PROFILE settings-profile id for a pinned card (optional)
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
# pairing with a PIN. The connect PARKS until somebody approves it.
# PF_BROWSE non-empty = open the client's console home instead of streaming
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
# resolved a non-flatpak install — then the client is exec'd directly and
# resolved a non-flatpak install — then the client is run directly and
# PF_APPID/PF_FLATPAK are unused)
#
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
# session, PF_BROWSE to the client's hosts page.
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
# difference is the prefix in front of it.
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
# client's absolute path here when that is what it resolved.
#
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
# reclaims focus automatically (no manual refocus needed).
run_client() {
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
# flatpak, reachable with `--command=`, and one bindir natively.
run_cli() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
# PATH at all — which is the environment a Steam launch option can leave us in.
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
fi
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
}
run_session() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
exec "$PF_CLIENT_BIN" "$@"
fi
@@ -58,40 +66,35 @@ run_client() {
# What we are about to run, for the log line each branch prints.
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
# binary already execs the session for `--browse`, so there is nothing to repoint here.
if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_client --browse --fullscreen
fi
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
run_client --browse "$PF_HOST" --fullscreen
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_session --browse --fullscreen
fi
# Streaming modes need a host (browse above is the only host-less path).
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
if [ -z "${PF_REF:-}" ]; then
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
set -- --fullscreen
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
if [ -n "${PF_PROFILE:-}" ]; then
set -- --profile "$PF_PROFILE" "$@"
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
# process survives to see the stream come up and record the host as paired; the CLI refuses the
# combination outright rather than downgrading silently. This is safe for gamescope because
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
# already sit between reaper and the client on every other path.
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
run_cli launch "$PF_REF" --request-access "$@"
fi
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
run_client --connect "$PF_HOST" "$@"
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
run_cli launch "$PF_REF" --exec "$@"
+265 -723
View File
File diff suppressed because it is too large Load Diff
+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)"
+126 -127
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
the REAL Display strings in clients/linux/src/library.rs if those are reworded, the
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
python3 clients/decky/scripts/test-backend.py
Needs Python >= 3.10 for `X | None` annotations macOS ships 3.9, so run it explicitly:
python3.13 clients/decky/scripts/test-backend.py
"""
import sys
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
failures += 1
# ---- _parse_library_tsv -----------------------------------------------------------------
tsv = (
"steam:570\tsteam\tDota 2\n"
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
"2 game(s)\n" # the count trailer has no tabs — self-skips
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
#
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
# an app id that drifts left silently turns our flags into the client's. This is the shape the
# deleted _session_argv used and the one thing about it that is easy to get wrong.
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
main._flatpak = lambda: "/usr/bin/flatpak"
check(
"cli argv: flatpak form, app id last",
main._cli_argv()
== [
"/usr/bin/flatpak",
"run",
"--arch=x86_64",
"--command=punktfunk",
"io.unom.Punktfunk",
],
)
games = main._parse_library_tsv(tsv)
check("tsv: two games parsed", len(games) == 2)
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
check(
"err: not-paired",
main._classify_library_error(
"library: The host didn't recognize this device. Pair with the host first — the "
"library is authorized by this device's certificate (no token needed)."
)
== "not-paired",
)
check(
"err: pin-mismatch",
main._classify_library_error(
"library: The host's certificate doesn't match the pinned fingerprint. "
"Re-pair with a PIN to re-establish trust."
)
== "pin-mismatch",
)
check(
"err: unreachable",
main._classify_library_error(
"library: Couldn't reach the host's management API: connection refused. Check the "
"host is updated and reachable."
)
== "unreachable",
)
check(
"err: http",
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
)
check(
"err: outdated client (GTK init noise)",
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
== "client-outdated",
)
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
avahi = (
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
)
hosts = main._parse_avahi_browse(avahi)
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
lr = next(h for h in hosts if h["name"] == "living-room")
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
check("avahi: id parsed", lr["id"] == "abc123")
bare = next(h for h in hosts if h["name"] == "bare-host")
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
check("avahi: id absent -> empty", bare["id"] == "")
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
import asyncio # noqa: E402
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
# caller must see as "unavailable" rather than as an empty result.
#
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
import shutil # noqa: E402
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
plugin = main.Plugin()
pin = {
"game_id": "steam:570",
"title": "Dota 2",
"store": "steam",
"host_fp": "AABBCC",
"host_id": "abc123",
"host_name": "living-room",
"host": "192.168.1.42",
"port": 9777,
"mgmt": 47990,
"added_at": 1780000000,
}
dupe = dict(pin, title="Dota 2 again")
junk = {"title": "no game id"}
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
check("pins: write ok", res.get("ok") is True)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: dedup + junk dropped", len(got) == 1)
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
cfg = main._client_config_dir()
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "client-known-hosts.json").write_text(
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
'"fp_hex": "aabbcc", "paired": true}]}'
)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
tmp = Path("/tmp/pf-test-native/bin")
tmp.mkdir(parents=True, exist_ok=True)
(tmp / "punktfunk-client").write_text("")
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
(tmp / "punktfunk").write_text("")
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
sinks, sources = main._parse_audio_endpoints(
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
#
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
# wrong makes an out-of-date client look like a broken plugin.
check(
"err: unknown verb => client-outdated",
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
== "client-outdated",
)
check("audio: sinks parsed", [d["name"] for d in sinks] == [
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
])
check("audio: sources parsed", len(sources) == 1)
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
check(
"err: exit 5 without that phrase is NOT outdated",
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
)
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
# short line is malformed, and an unknown kind belongs to neither list. A blank description
# falls back to the name so no entry renders unlabelled.
sinks, sources = main._parse_audio_endpoints(
"sink\t\tNo node name\n"
"sink\tonly-two-columns\n"
"monitor\tsome.monitor\tNot a sink or source\n"
"source\tbare.node\t\n"
"\n"
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
import asyncio # noqa: E402
def _fake_cli(rc: int, out: str, err: str = ""):
async def run(_args, timeout=20.0):
return rc, out, err
return run
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
main._run_cli = _fake_cli(0, "not json at all")
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
check("json: ...and says so specifically", got["error"] == "client-error")
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
check("field: commit", main._field_from(info, "Commit") == "abc123def")
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
check("field: absent", main._field_from(info, "Nope") == "")
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
#
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
# touching them.
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
existing = (
'"controller_config"\n'
"{\n"
'\t"halflife2"\n'
"\t{\n"
'\t\t"template"\t\t"other.vdf"\n'
"\t}\n"
"}\n"
)
check("audio: junk lines dropped", sinks == [])
check("audio: blank description falls back to the node name", sources == [
{"name": "bare.node", "description": "bare.node"}
])
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
check("vdf: ours is inserted", '"punktfunk"' in added)
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
# session gated only by a localStorage marker, so idempotence is the whole contract).
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
# a case-sensitive match would append a duplicate the game never reads.
mixed = existing.replace('"halflife2"', '"Punktfunk"')
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
print()
if failures:
+100 -217
View File
@@ -1,95 +1,94 @@
// Bridge to the Python backend (main.py) + shared types.
//
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
// used to model the client's stores itself and drifted from them with every field the client
// added.
import { callable } from "@decky/api";
export interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional" — the HOST's policy
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
}
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
// launch handle (PF_LAUNCH → the session Hello).
export interface GameEntry {
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
export interface Profile {
id: string;
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
title: string;
name: string;
}
export interface LibraryResult {
ok: boolean;
games?: GameEntry[];
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
// "http" | "client-outdated" | "client-error"
error?: string;
detail?: string; // the client's own one-line reason, for the generic error copy
}
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
// address as the launch fallback when the host isn't currently advertising.
export interface PinnedGame {
game_id: string;
title: string;
store: string;
host_fp: string;
host_id: string;
host_name: string;
host: string;
port: number;
mgmt: number;
added_at: number; // unix seconds
paired?: boolean; // annotated by get_pins from the client's known-hosts store
}
export interface PairResult {
ok: boolean;
fp?: string;
error?: string;
}
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
// too old for `--list-hosts`, which then also can't probe).
export interface SavedHost {
/**
* A host answering on mDNS right now (`punktfunk discover --json`).
*
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store fingerprint first,
* address second. The plugin does not join the two lists itself; that rule living in one place
* is what stops this surface disagreeing with the desktop client about the same box.
*/
export interface DiscoveredHost {
name: string;
addr: string;
port: number;
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
pair: string; // the HOST's policy: "required" | "optional"
id: string; // the host's advertised stable id; "" when not advertised
mgmt: number; // management-API port; 0 = not advertised
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
saved: boolean;
paired: boolean;
}
/**
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) the same
* `client-known-hosts.json` the desktop client owns.
*
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
* shown offline merely because it never advertises; `null` means the probe was skipped.
*
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
* back already resolved against the profile catalog, so this plugin never opens it.
*/
export interface SavedHost {
id: string | null; // the record's stable id — the reference a launch should use
name: string;
addr: string;
port: number;
fp_hex: string; // "" for a placeholder saved by address with no pin yet
paired: boolean;
mac: string[];
// OS-identity chain learned by the desktop client; optional because the installed
// flatpak client may predate the field.
os?: string;
os: string;
last_used: number | null;
clipboard_sync: boolean;
profile: Profile | null;
pinned_profiles: Profile[];
online: boolean | null;
}
export interface HostsResult {
ok: boolean;
hosts: SavedHost[];
probed: boolean;
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
}
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
export interface MutationResult {
/**
* Every backend call answers in this shape. `error` is a stable code, never prose:
*
* - `client-unavailable` no client is installed, or the call never ran
* - `client-outdated` the installed client predates the verb (exit 5 + `unknown command`)
* - `unreachable` the host did not answer
* - `refused` trust rejected: a wrong PIN, or a fingerprint that already differs
* - `needs-pairing` the CLI refused because it needs a person
* - `unresolved` nothing matched what was named
* - `client-error` anything else; `detail` carries the CLI's own last line
*/
export interface CliResult {
ok: boolean;
error?: string;
detail?: string;
}
export interface DiscoverResult extends CliResult {
hosts?: DiscoveredHost[];
}
export interface HostsResult extends CliResult {
hosts?: SavedHost[];
}
export interface PairResult extends CliResult {
fp?: string;
}
export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
@@ -101,99 +100,6 @@ export interface RunnerInfo {
client_bin?: string;
}
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
// and the console's settings screen own, so a value changed in any of them shows in the others.
//
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
// cannot be answered from a plugin backend or aren't settings at all:
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
// the client process has; there is no CLI that enumerates pads.
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
// preference anyone sets.
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
// spreads, and set_settings merges onto what's on disk.
//
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
// that key existed simply lacks it. Read those through the same fallback the client uses —
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
// while the stream runs with it on.
export interface StreamSettings {
// ---- Stream mode ----
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Stream mode follows the session window instead of width/height, renegotiating on resize.
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
match_window?: boolean;
// ---- Video ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
enable_444?: boolean; // default off — ask for full chroma
adapter?: string; // decode/present GPU by marketing name; "" = automatic
// ---- Presentation ----
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
present_priority?: string;
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 13
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
// ---- Audio ----
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
speaker_device?: string; // PipeWire node.name for playback; "" = system default
mic_enabled: boolean;
mic_device?: string; // PipeWire node.name for capture; "" = system default
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
// ---- Controllers ----
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
gamepad_forwarding?: boolean;
// ---- Touchscreen, mouse & keyboard ----
touch_mode?: string; // "trackpad" | "pointer" | "touch"
mouse_mode?: string; // "capture" | "desktop"
invert_scroll?: boolean;
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
inhibit_shortcuts: boolean;
// ---- Interface & behaviour ----
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
fullscreen_on_stream?: boolean;
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
}
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
// human name to show.
export interface AudioDevice {
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
description: string; // human label ("Steam Deck Speakers")
}
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
// pickers stay on their stored value rather than pretending the device is gone.
export interface DeviceLists {
ok: boolean;
adapters: string[]; // Vulkan physical devices, discrete first
sinks: AudioDevice[]; // playback endpoints
sources: AudioDevice[]; // capture endpoints
}
export interface UpdateInfo {
current: string; // installed PLUGIN version (package.json)
latest: string; // newest plugin version in our registry for this channel
@@ -229,21 +135,30 @@ export interface ShortcutArt {
icon_path: string;
}
export const discover = callable<[], Host[]>("discover");
// ---- The four CLI shells --------------------------------------------------------------
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
export const discover = callable<[], DiscoverResult>("discover");
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
export const hosts = callable<[], HostsResult>("hosts");
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
export const pair = callable<
[host: string, port: number, pin: string, name: string],
[addr: string, port: number, pin: string, name: string],
PairResult
>("pair");
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
export const library = callable<
[host: string, mgmt_port: number, fp: string],
LibraryResult
>("library");
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
"set_pins",
);
/**
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
* The launch that follows pins the same fingerprint, which is the only thing standing between a
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
*/
export const trustHost = callable<
[addr: string, port: number, fp: string, name: string],
CliResult
>("trust_host");
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
@@ -254,48 +169,16 @@ export const applyControllerConfig = callable<
[name: string],
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
export const listDevices = callable<[], DeviceLists>("list_devices");
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
"wake",
// Whether the streaming client's control socket exists (a stream/console client is up) —
// gates the QAM panel's host-button section.
export const streamRunning = callable<[], { running: boolean }>("stream_running");
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
// presses stay on the Deck by default (the client's Controllers settings), so this — and
// holding Select — is how the host's own menus are reached.
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
"host_action",
);
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
// pair next; a later pair replaces it with the fingerprinted entry.
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
"add_host",
);
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
// current addr[:port]; empty fields are left untouched.
export const editHost = callable<
[selector: string, name: string, addr: string, port: number],
MutationResult
>("edit_host");
// Remove a saved host by fingerprint or addr[:port] (idempotent).
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
export const probeHost = callable<
[target: string],
{ ok: boolean; online?: boolean; error?: string }
>("probe_host");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
+217 -348
View File
@@ -1,18 +1,14 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
// Shared state hooks + user actions for the QAM panel.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
checkUpdate,
discover,
GameEntry,
getPins,
Host,
listHosts,
PinnedGame,
resetConfig,
DiscoveredHost,
hosts as listHosts,
Profile,
SavedHost,
setPins as setPinsBackend,
updateClient,
UpdateInfo,
} from "./backend";
@@ -37,19 +33,210 @@ declare global {
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
/**
* How far this device has got with a host. The three states are what the row says under the
* name, and which of them a host is in decides whether pressing it streams or opens the trust
* sheet.
*
* - `paired` the host approved this device (a PIN ceremony, or request access).
* - `trusted` its fingerprint is pinned but nobody has approved us yet. Streams work if
* the host's policy is `optional`; under `required` the connect parks.
* - `needs-access` no pinned fingerprint. Not streamable until the trust sheet runs.
*/
export type TrustState = "paired" | "trusted" | "needs-access";
/**
* One host as the panel shows it the union of the saved store and the live mDNS browse.
*
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
* appended as extra rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
/**
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
* makes a host unstreamable the session binary refuses a pinless connect.
*
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
* user makes in the sheet.
*/
fp: string;
/** What the host is advertising right now, if anything — what request access would pin. */
advertisedFp: string;
/**
* The host is answering at an address its record does not carry it changed DHCP lease.
*
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
* the RECORD holds. So the row would show the live address and dial the dead one. The record
* has to be re-pointed before such a host can stream; `startStream` does it.
*/
moved: boolean;
paired: boolean;
online: boolean;
saved: boolean;
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
pairPolicy: string;
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
os: string;
/**
* What a launch should NAME this host by: the record's stable id, which survives renames and
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
* the trust sheet is about to save, or a client too old to have minted ids).
*/
ref: string;
/** The host's default profile binding — applied silently by a plain connect, not a card. */
profile: Profile | null;
/** The cards to render nested under this host; already resolved against the catalog. */
pinnedProfiles: Profile[];
lastUsed: number | null;
}
export function trustState(v: HostView): TrustState {
if (v.paired) return "paired";
return v.fp ? "trusted" : "needs-access";
}
/**
* Must this host go through the trust sheet before it can stream?
*
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
* without one can offer nothing but a button that fails; with one, the connect is verified and
* the host either admits it or parks it for an operator. The old rule also consulted the
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
* came from the same box could read differently before and after being saved.
*/
export function needsPair(v: HostView): boolean {
return v.fp === "";
}
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.addr && s.port === a.port)
);
}
/**
* 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.
*
* Fingerprint first, address second a host that moved DHCP lease still matches its record,
* and a different box that inherited the old address does not inherit its pairing. The CLI's
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
*/
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// 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: hostLabel(s, advert),
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
advertisedFp: advert?.fp ?? "",
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
os: advert?.os || s.os || "",
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
profile: s.profile,
pinnedProfiles: s.pinned_profiles ?? [],
lastUsed: s.last_used,
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved row, with a live pip
}
views.push({
name: a.name,
addr: a.addr,
port: a.port,
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
fp: "",
advertisedFp: a.fp,
moved: false, // no record, so nothing to be stale
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
os: a.os,
ref: `${a.addr}:${a.port}`,
profile: null,
pinnedProfiles: [],
lastUsed: null,
});
}
return views.sort(sortRows);
}
/**
* Online first, then most recently used, then by name. The host you streamed last night should
* be the first thing under your thumb; a host that is off right now should never be.
*/
function sortRows(a: HostView, b: HostView): number {
if (a.online !== b.online) return a.online ? -1 : 1;
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
return a.name.localeCompare(b.name);
}
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
// views mounting them independently; the panel is the only view now, and merging them means
// the "scanning" state covers the whole row set rather than half of it flickering in first.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [views, setViews] = useState<HostView[]>([]);
const [scanning, setScanning] = useState(false);
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
// "client-outdated" — the installed client predates `punktfunk discover`
// "client-unavailable" — there is no client installed at all
const [problem, setProblem] = useState<string | null>(null);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
// running them in sequence would cost the sum of two waits for no benefit.
const [d, s] = await Promise.all([discover(), listHosts()]);
// Both calls run the same binary, so they fail the same way; take whichever answered.
setProblem(
d.error === "client-unavailable" || s.error === "client-unavailable"
? "client-unavailable"
: d.error === "client-outdated" || s.error === "client-outdated"
? "client-outdated"
: null,
);
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
} finally {
setScanning(false);
}
@@ -59,157 +246,7 @@ export function useHosts() {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
// ----------------------------------------------------------------------------------------
export function useSavedHosts() {
const [saved, setSaved] = useState<SavedHost[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const r = await listHosts(true);
setSaved(r.hosts ?? []);
} catch {
/* backend unavailable — keep the current view */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { saved, loading, refresh };
}
/**
* One host as the UI shows it the union of the saved store and the live mDNS scan. A saved
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
* are appended as unsaved rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
fp: string; // "" for a saved-but-unpaired placeholder
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
online: boolean;
saved: boolean; // present in the known-hosts store
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
id: string; // advertised stable host id ("" when not advertising)
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
}
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.host && s.port === a.port)
);
}
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
addr: advert?.host ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex || advert?.fp || "",
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
mgmt: advert?.mgmt ?? 0,
id: advert?.id ?? "",
os: advert?.os || s.os || "",
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved card (with a live pip)
}
views.push({
name: a.name,
addr: a.host,
port: a.port,
fp: a.fp,
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
mgmt: a.mgmt,
id: a.id,
os: a.os,
});
}
return views;
}
/**
* True when this host must be paired before it can stream. A saved host is streamable once it
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
*/
export function needsPair(v: HostView): boolean {
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
}
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
export function toHost(v: HostView): Host {
return {
name: v.name,
host: v.addr,
port: v.port,
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
fp: v.fp,
proto: "",
paired: v.paired,
id: v.id,
mgmt: v.mgmt,
os: v.os,
};
}
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
const fp = pin.host_fp.toLowerCase();
return views.some(
(v) =>
v.online &&
((!!fp && v.fp.toLowerCase() === fp) ||
(!!pin.host_id && v.id === pin.host_id) ||
(v.addr === pin.host && v.port === pin.port)),
);
}
/**
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
*/
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
try {
const r = await resetConfig();
for (const fn of refreshers) void fn();
toaster.toast({
title: "Punktfunk",
body: r.ok
? "Reset — saved hosts, settings, and pins cleared."
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
});
} catch {
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
}
return { views, scanning, problem, refresh };
}
// ----------------------------------------------------------------------------------------
@@ -260,36 +297,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
);
}
/**
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
*/
export function clientInstallLabel(kind: string): string {
switch (kind) {
case "flatpak":
return "Flatpak (per-user)";
case "apt":
return "System package (apt)";
case "dnf":
return "System package (dnf)";
case "rpm-ostree":
return "Layered package (rpm-ostree)";
case "pacman":
return "System package (pacman)";
case "sysext":
return "System extension (sysext)";
case "nix":
return "Nix profile";
case "steamos-source":
return "On-device build";
case "source":
return "Built from source";
default:
return kind;
}
}
/** True when the only pending update is one this Deck can't apply itself. */
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
@@ -399,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,
@@ -427,167 +437,26 @@ export async function applyUpdate(
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
// ----------------------------------------------------------------------------------------
/**
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
* runs the supervised launch that waits for the host's operator to approve this Deck.
*
* The host is named by REFERENCE (`v.ref`), never by value no resolution, bitrate or codec
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
*/
export async function startStream(
h: Host,
v: HostView,
opts: LaunchOpts = {},
label?: string,
): Promise<void> {
try {
await launchStream(h.host, h.port, opts);
await launchStream(v.ref, opts);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${v.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
export async function startBrowse(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
// client's config (survives plugin reinstalls).
// ----------------------------------------------------------------------------------------
export interface PinsApi {
pins: PinnedGame[];
addPin: (h: Host, g: GameEntry) => void;
removePin: (hostFp: string, gameId: string) => void;
isPinned: (hostFp: string, gameId: string) => boolean;
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
updatePinHost: (pin: PinnedGame, h: Host) => void;
refresh: () => Promise<void>;
}
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
// game in the same session would compute from the stale `[]` and clobber the first (silent
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
// callbacks keep a stable identity (deps free of `pins`).
const pinsRef = useRef<PinnedGame[]>([]);
pinsRef.current = pins;
const refresh = useCallback(async () => {
try {
setPins((await getPins()).pins);
} catch {
/* backend unavailable — keep the current view */
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
[refresh],
);
const addPin = useCallback(
(h: Host, g: GameEntry) => {
const pin: PinnedGame = {
game_id: g.id,
title: g.title,
store: g.store,
host_fp: h.fp,
host_id: h.id,
host_name: h.name,
host: h.host,
port: h.port,
mgmt: h.mgmt,
added_at: Math.floor(Date.now() / 1000),
paired: h.paired,
};
save([
...pinsRef.current.filter(
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
),
pin,
]);
},
[save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[save],
);
const isPinned = useCallback(
(hostFp: string, gameId: string) =>
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
[pins],
);
const updatePinHost = useCallback(
(pin: PinnedGame, h: Host) => {
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
return;
}
save(
pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
}
/**
* The host a pin should launch against right now: match the live mDNS scan by cert
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
* id, else fall back to the stored address (host offline or scan flaky still launch).
*/
export function resolvePinHost(
pin: PinnedGame,
live: Host[],
): { host: Host; online: boolean } {
const fp = pin.host_fp.toLowerCase();
const match =
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
undefined;
if (match) {
return { host: match, online: true };
}
return {
host: {
name: pin.host_name || pin.host,
host: pin.host,
port: pin.port,
pair: pin.paired ? "optional" : "required",
fp: pin.host_fp,
proto: "",
paired: !!pin.paired,
id: pin.host_id,
mgmt: pin.mgmt,
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
},
online: false,
};
}
-164
View File
@@ -1,164 +0,0 @@
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
// for the numeric PIN).
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
import { toaster } from "@decky/api";
import { ChangeEvent, FC, useState } from "react";
import { addHost, editHost, MutationResult } from "./backend";
import { HostView } from "./hooks";
import { actionButton } from "./ui";
/** Stable copy for a failed host-store mutation. */
export function mutationError(r: MutationResult): string {
switch (r.error) {
case "client-unavailable":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for host management — update it from the About tab.";
default:
return r.detail || "Couldn't save the host.";
}
}
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
function targetFrom(addr: string, port: string): string {
const a = addr.trim();
if (a.includes(":")) {
return a;
}
const p = port.trim() || "9777";
return `${a}:${p}`;
}
const field: React.CSSProperties = { marginBottom: "0.8em" };
const HostForm: FC<{
title: string;
submitLabel: string;
initial: { addr: string; port: string; name: string };
addrDisabled?: boolean;
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
onDone: () => void;
closeModal?: () => void;
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
const [addr, setAddr] = useState(initial.addr);
const [port, setPort] = useState(initial.port);
const [name, setName] = useState(initial.name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!addr.trim()) {
setError("Enter an address.");
return;
}
setBusy(true);
setError(null);
try {
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
if (r.ok) {
onDone();
closeModal?.();
} else {
setError(mutationError(r));
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
<div style={field}>
<TextField
label="Address"
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
value={addr}
disabled={addrDisabled || busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Port"
value={port}
mustBeNumeric
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Name (optional)"
value={name}
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
onDone,
closeModal,
}) => (
<HostForm
title="Add host"
submitLabel="Add"
initial={{ addr: "", port: "9777", name: "" }}
onSubmit={async (addr, port, name) => {
const r = await addHost(targetFrom(addr, port), name, "");
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
* changes), else by its current address. */
export const EditHostModal: FC<{
host: HostView;
onDone: () => void;
closeModal?: () => void;
}> = ({ host, onDone, closeModal }) => {
const selector = host.fp || `${host.addr}:${host.port}`;
return (
<HostForm
title={`Edit ${host.name}`}
submitLabel="Save"
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
onSubmit={async (addr, port, name) => {
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
};
+205 -117
View File
@@ -1,5 +1,10 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
// route, the settings screen, the host editor and the games picker are gone, because the
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
// a QAM panel re-implementing them never quite was).
//
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
import {
ButtonItem,
Field,
@@ -10,37 +15,35 @@ import {
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { FC } from "react";
import { definePlugin, toaster } from "@decky/api";
import { FC, useEffect, useState } from "react";
import {
FaDownload,
FaGamepad,
FaLock,
FaLockOpen,
FaPlay,
FaPlus,
FaStopCircle,
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { hostAction, killStream, streamRunning } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
HostView,
needsPair,
pinIsOnline,
startStream,
toHost,
trustState,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
import { OsMark } from "./os-icon";
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
import { TrustSheet } from "./trust";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
@@ -54,22 +57,106 @@ async function recreatePunktfunkShortcut(): Promise<void> {
});
}
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// and pinned games.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
async function forceStop(): Promise<void> {
stopStream();
try {
await killStream();
} catch {
/* best-effort — the TerminateApp above is usually enough */
}
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
}
const hosts = mergeHosts(saved, discovered);
const busy = scanning || loadingSaved;
const refresh = () => {
void refreshDiscovered();
void refreshSaved();
};
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
// is the panel route to the host's menus; holding Select is the controller route.
async function pressHost(action: "guide" | "qam"): Promise<void> {
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
if (r.ok) {
Navigation.CloseSideMenus();
} else {
toaster.toast({
title: "Punktfunk",
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
});
}
}
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
function hostDescription(v: HostView): string {
const trust = {
paired: "paired",
trusted: "trusted",
"needs-access": "needs access",
}[trustState(v)];
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
}
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
const gated = needsPair(host);
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
return (
<>
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() =>
gated
? showModal(
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
)
: stream()
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{gated ? <FaLock /> : <OsMark os={host.os} />}
{host.name}
</span>
}
description={hostDescription(host)}
>
{gated ? "Connect…" : "Stream"}
</ButtonItem>
</PanelSectionRow>
{/* Pinned cards, nested under their host rather than in a section of their own: a card
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
tile reads as a duplicate host" problem the desktop shells still have. The host's
own BOUND profile is deliberately not a card it applies silently on the plain row
above, and showing it twice would suggest they do different things. */}
{!gated &&
host.pinnedProfiles.map((p) => (
<PanelSectionRow key={`${host.ref}:${p.id}`}>
<ButtonItem
layout="below"
onClick={() => void startStream(host, { profileId: p.id }, `${p.name}`)}
label={`${p.name}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
))}
</>
);
};
const QamPanel: FC = () => {
const { views, scanning, problem, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
// The host-buttons section shows only while the streaming client is up (checked per
// panel open — the QAM panel mounts fresh each time).
const [streaming, setStreaming] = useState(false);
useEffect(() => {
let live = true;
void streamRunning()
.then((r) => live && setStreaming(r.running))
.catch(() => {});
return () => {
live = false;
};
}, []);
return (
<>
@@ -110,15 +197,62 @@ const QamPanel: FC = () => {
</PanelSection>
))}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{/* A client that is missing or too old explains itself rather than rendering an empty
list "no hosts on your LAN" would blame the network for the plugin's problem, and
for the outdated case the button that fixes it is in this same panel. */}
{problem && (
<PanelSectionRow>
<Field
focusable={false}
label={
problem === "client-unavailable"
? "Punktfunk isnt installed"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt on this Deck yet. Install it in Desktop Mode."
: "This client is too old to find hosts on your network. Saved hosts still work."
}
/>
</PanelSectionRow>
)}
{views.length === 0 && scanning && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{views.length === 0 && !scanning && !problem && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts yet"
description="Open Punktfunk to find and pair one."
/>
</PanelSectionRow>
)}
{views.map((v) => (
<HostRow key={v.ref} host={v} refresh={refresh} />
))}
</PanelSection>
<PanelSection title="Punktfunk">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Host details, stream settings, and help"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
description="Settings, adding a host by address, and browsing a host's games all live here."
onClick={() => void launchGamepadUi()}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open Punktfunk
@@ -126,85 +260,31 @@ const QamPanel: FC = () => {
</PanelSectionRow>
</PanelSection>
{/* Pinned games the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page host row games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Pinned Games">
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
{streaming && (
<PanelSection title="Host menus">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Press the Steam/guide button on the host"
onClick={() => void pressHost("guide")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Steam menu on host
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Open the host's Quick Access Menu"
onClick={() => void pressHost("qam")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Quick access on host
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
{busy ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{busy ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{hosts.length === 0 && busy && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !busy && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts found"
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
/>
</PanelSectionRow>
)}
{hosts.map((v) => {
const pair = needsPair(v);
const h = toHost(v);
return (
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
<ButtonItem
layout="below"
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pair ? <FaLock /> : <FaLockOpen />}
{v.name}
</span>
}
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
pair ? " · pairing required" : v.paired ? " · paired" : ""
}`}
>
{pair ? "Pair & Stream" : "Stream"}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
@@ -236,21 +316,32 @@ const QamPanel: FC = () => {
Recreate library shortcut
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Ends a stream that stopped responding."
onClick={() => void forceStop()}
>
<FaStopCircle style={{ marginRight: "0.5em" }} />
Force-stop
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
};
export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
// home) exists and is repointed to the current plugin dir — also installs the native-touch
// 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>,
@@ -260,8 +351,5 @@ export default definePlugin(() => {
</PluginErrorBoundary>
),
icon: <FaTv />,
onDismount() {
routerHook.removeRoute(ROUTE);
},
};
});
-230
View File
@@ -1,230 +0,0 @@
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
// host's library through the backend (headless flatpak --library — a cold client start
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`).
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
switch (store) {
case "steam":
return "Steam";
case "custom":
return "Custom";
case "heroic":
return "Heroic";
case "lutris":
return "Lutris";
case "epic":
return "Epic";
case "gog":
return "GOG";
case "xbox":
return "Xbox";
default:
return "Game";
}
}
/**
* Stream a pinned game: resolve the host from the live scan (fp id stored address),
* opportunistically refresh a drifted stored address, and route through pairing first if
* this device is no longer paired with the host.
*/
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
const { host, online } = resolvePinHost(pin, live);
if (online) {
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
}
if (!pin.paired) {
showModal(
<PairModal
host={host}
onPaired={() => {
void pins.refresh(); // pick up the now-paired annotation
void startStream(host, { launchId: pin.game_id }, pin.title);
}}
/>,
);
return;
}
void startStream(host, { launchId: pin.game_id }, pin.title);
}
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
case "not-paired":
return "This Deck isn't paired with the host — pair first, then browse its library.";
case "pin-mismatch":
return "The host's identity changed — re-pair to re-establish trust.";
case "unreachable":
return "Couldn't reach the host's management API. Is the host online and up to date?";
case "timeout":
return "Timed out talking to the host — try again.";
case "flatpak-not-found":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for library browsing — update it from the About tab.";
default:
return res.detail || "Couldn't fetch the library.";
}
}
// ----------------------------------------------------------------------------------------
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
// ----------------------------------------------------------------------------------------
export const GamePickerModal: FC<{
host: Host;
pins: PinsApi;
clientUpdatePending?: boolean;
closeModal?: () => void;
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
// The modal is a detached `showModal` portal that never re-renders from the page's pin
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
// never flip within a session. Track this host's pinned ids locally, seeded once from the
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
);
const togglePin = (g: GameEntry) => {
const wasPinned = pinnedIds.has(g.id);
setPinnedIds((prev) => {
const next = new Set(prev);
if (wasPinned) next.delete(g.id);
else next.add(g.id);
return next;
});
if (wasPinned) pins.removePin(host.fp, g.id);
else pins.addPin(host, g);
};
useEffect(() => {
let stale = false;
setResult(null);
library(host.host, host.mgmt, host.fp)
.then((res) => {
if (!stale) setResult(res);
})
.catch((e) => {
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
});
return () => {
stale = true;
};
}, [host.host, host.mgmt, host.fp, attempt]);
const games = (result?.ok && result.games) || [];
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} Games
</div>
<Field
label="Open library on screen"
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
{clientUpdatePending && (
<Field
focusable={false}
description="A client update is available — direct game launch and on-screen browsing need the latest client."
/>
)}
{result === null && (
<Field
focusable={false}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
<Spinner style={{ height: "1em" }} />
Fetching the library
</span>
}
description="This starts the client headlessly — a cold start can take a few seconds."
/>
)}
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<RowActions>
{result.error === "not-paired" && (
<DialogButton
style={actionButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</RowActions>
</Field>
)}
{result?.ok && sorted.length === 0 && (
<Field
focusable={false}
label="No games found"
description="Install Steam titles or add custom entries in the host's web console."
/>
)}
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
key={g.id}
label={g.title}
description={
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field>
);
})}
</div>
)}
</ModalRoot>
);
};
-596
View File
@@ -1,596 +0,0 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
ConfirmModal,
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPen,
FaPlay,
FaPlus,
FaSyncAlt,
FaThLarge,
FaTrashAlt,
} from "react-icons/fa";
import { UpdateInfo, forgetHost, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import { OsMark } from "./os-icon";
import {
DOCS_URL,
HostView,
PinsApi,
applyUpdate,
checkForUpdatesNow,
clientInstallLabel,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
pinIsOnline,
resetAll,
startStream,
toHost,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// The one-line status under a host name: address, live presence, and trust state.
function hostSubtitle(v: HostView): string {
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
if (needsPair(v)) {
parts.push("pairing required");
} else if (v.paired) {
parts.push("paired");
} else if (v.saved) {
parts.push("trusted");
}
return parts.join(" · ");
}
/** Confirm + forget a saved host, then refresh the list. */
function confirmForget(v: HostView, refresh: () => void): void {
const selector = v.fp || `${v.addr}:${v.port}`;
showModal(
<ConfirmModal
strTitle={`Forget ${v.name}?`}
strDescription="You'll need to pair or trust it again to reconnect."
strOKButtonText="Forget"
bDestructiveWarning
onOK={async () => {
const r = await forgetHost(selector);
toaster.toast({
title: "Punktfunk",
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
});
refresh();
}}
/>,
);
}
// ----------------------------------------------------------------------------------------
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{
host: HostView;
onChanged: () => void;
closeModal?: () => void;
}> = ({ host, onChanged, closeModal }) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.addr}:{host.port}
</Field>
<Field focusable={false} label="Presence">
{host.online ? "Online" : "Offline"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
{host.saved && (
<Field label="Manage" childrenContainerWidth="max">
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
showModal(<EditHostModal host={host} onDone={onChanged} />);
}}
>
<FaPen style={{ marginRight: "0.4em" }} />
Edit
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
confirmForget(host, onChanged);
}}
>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Forget
</DialogButton>
</RowActions>
</Field>
)}
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{
host: HostView;
onChanged: () => void;
onGames: () => void;
}> = ({ host, onChanged, onGames }) => {
const pair = needsPair(host);
const h = toHost(host);
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
<OsMark os={host.os} />
{pair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={hostSubtitle(host)}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
>
<FaInfoCircle />
</DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{pair && (
<DialogButton
style={actionButton}
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
>
Pair
</DialogButton>
)}
<DialogButton
style={actionButton}
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</RowActions>
</Field>
);
};
const HostsTab: FC<{
hosts: HostView[];
scanning: boolean;
refresh: () => void;
pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}>
<Field
label="Hosts"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Add
</DialogButton>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts yet"
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow
key={h.fp || `${h.addr}:${h.port}`}
host={h}
onChanged={refresh}
onGames={() =>
showModal(
<GamePickerModal
host={toHost(h)}
pins={pins}
clientUpdatePending={clientUpdatePending}
/>,
)
}
/>
))}
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
{pins.pins.length > 0 && (
<>
<Field
focusable={false}
label="Pinned games"
description="One-tap streams — they also live in the quick-access menu"
bottomSeparator="standard"
/>
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<Field
key={`${pin.host_fp}:${pin.game_id}`}
label={pin.title}
description={`${storeLabel(pin.store)} · ${pin.host_name}${
online ? "" : " · offline?"
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</RowActions>
</Field>
);
})}
</>
)}
</div>
);
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
const SettingsTab: FC = () => (
<div style={settingsPane}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
// and the destructive "reset everything" action.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
showModal(
<ConfirmModal
strTitle="Reset Punktfunk?"
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
strOKButtonText="Reset"
bDestructiveWarning
onOK={() => void resetAll(refreshers)}
/>,
);
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
onReset: () => void;
}> = ({ update, checking, check, onReset }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field>
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
install kind decides everything below it. */}
{!!update?.client_install && (
<Field
label="Client"
description={`${clientInstallLabel(update.client_install)}${
update.client_current ? ` · ${update.client_current}` : ""
}`}
/>
)}
{hasUpdate(update) && (
<Field
label={
update!.update_available
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: `Client update — ${update!.client_latest || "available"}`
}
description={
// Only promise a one-tap install when there is one. On a notify-only install the
// row becomes the command itself, which is the whole answer for that box.
clientUpdateIsManualOnly(update) && !update!.update_available
? update!.client_opt_in || update!.client_command
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
}
childrenContainerWidth="max"
>
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
)}
</Field>
)}
{!!update?.client_error && (
<Field
label="Client update check"
description={
update.client_error === "client-outdated"
? "This client predates update checks — update it once by hand and the check starts working."
: "Couldnt check the client for updates."
}
/>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</RowActions>
</Field>
<Field
label="Reset Punktfunk"
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={onReset}>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Reset
</DialogButton>
</RowActions>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts");
const hosts = mergeHosts(saved, discovered);
// A host action (pair/add/edit/forget) can change either store, so refresh both.
const refreshHosts = () => {
void refreshDiscovered();
void refreshSaved();
};
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
</Focusable>
{/* Two things fight each other on an L1/R1 tab switch:
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
tabs={[
{
id: "hosts",
title: "Hosts",
content: (
<HostsTab
hosts={hosts}
scanning={scanning || loadingSaved}
refresh={refreshHosts}
pins={pins}
clientUpdatePending={!!update?.client_update_available}
/>
),
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: (
<AboutTab
update={update}
checking={checking}
check={check}
onReset={() => confirmReset([refreshHosts, pins.refresh])}
/>
),
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+26 -4
View File
@@ -3,10 +3,32 @@
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
import { pair } from "./backend";
import { HostView } from "./hooks";
/**
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
* would send them the wrong way entirely.
*/
function pairErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return "Wrong PIN, or the host isnt showing one. Arm pairing again and retry.";
case "unreachable":
return `Couldnt reach ${name}.`;
case "client-outdated":
return "Update the Punktfunk client to pair from here.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return "Pairing failed.";
}
}
export const PairModal: FC<{
host: Host;
host: HostView;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
@@ -21,13 +43,13 @@ export const PairModal: FC<{
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
const res = await pair(host.addr, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setError(pairErrorBody(res.error, host.name));
setPin("");
}
} catch (e) {
-657
View File
@@ -1,657 +0,0 @@
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
// launch (main.py set_settings, merged onto what's on disk). This is the same
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
// changed in any of the three shows in the other two.
//
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
//
// The categories, their order, and the wording of the rows are the console's settings screen
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
// trusting either. It shows them as one steppable list because it has no pointer and no room for
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
//
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
// the one above it is a moving target for a thumbstick.
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
// one-GPU Deck). A dead control is worse than an absent one.
// • Anything that behaves differently *here* than it does on a desktop says so in its own
// description, rather than being silently dropped from the screen.
//
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
// `MouseMode` enums, which serialize lowercase.
import {
DialogButton,
Dropdown,
Field,
SidebarNavigation,
SliderField,
Spinner,
ToggleField,
} from "@decky/ui";
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
import {
FaDesktop,
FaGamepad,
FaHandPointer,
FaSlidersH,
FaTv,
FaVideo,
FaVolumeUp,
} from "react-icons/fa";
import {
AudioDevice,
DeviceLists,
getSettings,
listDevices,
refreshDevices,
setSettings,
StreamSettings,
} from "./backend";
import { actionButton, RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
// ----------------------------------------------------------------------------------------
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
// ----------------------------------------------------------------------------------------
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
// Match window is offered even though this plugin's launches are always fullscreen (where it
// degenerates to the display's native mode) — leaving it out would make the row lie about a
// store the desktop client can set it in.
const MATCH_WINDOW = "match";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
[3840, 2160, "3840 × 2160"],
];
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const COMPOSITORS: [string, string][] = [
["auto", "Automatic"],
["kwin", "KDE Plasma (KWin)"],
["wlroots", "Sway (wlroots)"],
["mutter", "GNOME (Mutter)"],
["gamescope", "gamescope"],
];
const CODECS: [string, string][] = [
["auto", "Automatic"],
["hevc", "HEVC (H.265)"],
["h264", "H.264 (AVC)"],
["av1", "AV1"],
// Opt-in wired-LAN low-latency codec (100400 Mbit/s class, 8-bit SDR). Only ever selected
// when the host advertises it too; anything else falls back to HEVC.
["pyrowave", "PyroWave (wired LAN)"],
];
const DECODERS: [string, string][] = [
["auto", "Automatic"],
["vulkan", "Vulkan Video"],
["vaapi", "VAAPI"],
["software", "Software"],
];
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
// one profile reads the same on every device.
const PRESENT_PRIORITIES: [string, string][] = [
["latency", "Lowest latency"],
["smooth", "Smoothness"],
];
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [number, string][] = [
[0, "Automatic"],
[1, "1 frame"],
[2, "2 frames"],
[3, "3 frames"],
];
const AUDIO_CHANNELS: [number, string][] = [
[2, "Stereo"],
[6, "5.1 surround"],
[8, "7.1 surround"],
];
const GAMEPADS: [string, string][] = [
["auto", "Automatic"],
["xbox360", "Xbox 360"],
["xboxone", "Xbox One"],
["dualsense", "DualSense"],
["dualshock4", "DualShock 4"],
["steamdeck", "Steam Deck"],
];
const TOUCH_MODES: [string, string][] = [
["trackpad", "Trackpad"],
["pointer", "Direct pointer"],
["touch", "Touch passthrough"],
];
const MOUSE_MODES: [string, string][] = [
["capture", "Capture (games)"],
["desktop", "Desktop (absolute)"],
];
const STATS_TIERS: [string, string][] = [
["off", "Off"],
["compact", "Compact"],
["normal", "Normal"],
["detailed", "Detailed"],
];
// ----------------------------------------------------------------------------------------
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
// twelve of them below stay one line each and can't drift apart.
// ----------------------------------------------------------------------------------------
const SelectRow = <T extends string | number>({
label,
description,
options,
value,
onChange,
formatUnknown,
disabled,
indent,
}: {
label: string;
description?: ReactNode;
options: [T, string][];
value: T;
onChange: (v: T) => void;
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
formatUnknown?: (v: T) => string;
disabled?: boolean;
indent?: boolean;
}): ReactElement => {
// A Dropdown can only display a value that is one of its options, and this store has four other
// writers — the desktop client, the console, a settings profile, a newer client with presets
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
// different value than the stream will actually use), carry the stored one as its own entry.
const shown: [T, string][] = options.some(([v]) => v === value)
? options
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
return (
<Field
label={label}
description={description}
disabled={disabled}
indentLevel={indent ? 1 : undefined}
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
disabled={disabled}
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
selectedOption={value}
onChange={(o) => onChange(o.data as T)}
/>
</div>
</RowActions>
</Field>
);
};
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
// a real preference that simply isn't plugged in right now, and dropping it would silently
// re-point the next stream at the default without ever showing the user why.
const DeviceRow: FC<{
label: string;
description: string;
devices: AudioDevice[] | null;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
indent?: boolean;
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
const options: [string, string][] = [["", "System default"]];
for (const d of devices ?? []) options.push([d.name, d.description]);
if (value && !options.some(([name]) => name === value)) {
options.push([value, `${value} (not connected)`]);
}
return (
<SelectRow
label={label}
description={devices === null ? "Reading this device's audio endpoints…" : description}
options={options}
value={value}
onChange={onChange}
disabled={disabled || devices === null}
indent={indent}
/>
);
};
// ----------------------------------------------------------------------------------------
// The pages. One settings object, seven views on it — every page takes the same context rather
// than fetching or holding state of its own, so a change on one page is visible on the others
// the moment you switch.
// ----------------------------------------------------------------------------------------
interface PageCtx {
s: StreamSettings;
patch: (p: Partial<StreamSettings>) => void;
devices: DeviceLists | null;
reading: boolean;
readDevices: (again: boolean) => void;
}
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
// inset the tabs use).
const pageBody: CSSProperties = { paddingBottom: "80px" };
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
const renderScale = s.render_scale ?? 1;
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
return (
<div style={pageBody}>
<SelectRow
label="Resolution"
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
options={[
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
[MATCH_WINDOW, "Match window"] as [string, string],
]}
value={resolution}
// A size set from a desktop profile that isn't one of these presets, spelled the way the
// presets are rather than left as the raw "1600x900" key.
formatUnknown={(v) => v.replace("x", " × ")}
onChange={(v) => {
if (v === MATCH_WINDOW) {
// The tri-state the console stores: the flag on, the explicit size cleared.
patch({ match_window: true, width: 0, height: 0 });
return;
}
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
}}
/>
<SelectRow
label="Refresh rate"
description="Native follows the display the stream is on."
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
value={s.refresh_hz}
formatUnknown={(v) => `${v} Hz`}
onChange={(v) => patch({ refresh_hz: v })}
/>
<SelectRow
label="Render scale"
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
value={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
)}
onChange={(v) => patch({ render_scale: v })}
/>
<SliderField
label="Bitrate"
description="0 = the host's own default (20 Mbit/s)."
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<SelectRow
label="Host compositor"
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
options={COMPOSITORS}
value={s.compositor}
onChange={(v) => patch({ compositor: v })}
/>
</div>
);
};
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
// picker with a single option is a control that can't do anything.
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
return (
<div style={pageBody}>
<SelectRow
label="Video codec"
description="A preference — the host falls back when its GPU can't encode this one."
options={CODECS}
value={s.codec ?? "auto"}
onChange={(v) => patch({ codec: v })}
/>
<SelectRow
label="Video decoder"
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
options={DECODERS}
value={s.decoder ?? "auto"}
onChange={(v) => patch({ decoder: v })}
/>
{showGpuRow && (
<SelectRow
label="Decode GPU"
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
options={[
["", "Automatic"],
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
]}
value={s.adapter ?? ""}
onChange={(v) => patch({ adapter: v })}
/>
)}
<ToggleField
label="10-bit HDR"
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
checked={s.hdr_enabled ?? true}
onChange={(v) => patch({ hdr_enabled: v })}
/>
<ToggleField
label="Full chroma (4:4:4)"
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
checked={s.enable_444 ?? false}
onChange={(v) => patch({ enable_444: v })}
/>
</div>
);
};
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
const smooth = (s.present_priority ?? "latency") === "smooth";
return (
<div style={pageBody}>
<SelectRow
label="Prioritize"
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
options={PRESENT_PRIORITIES}
value={s.present_priority ?? "latency"}
onChange={(v) => patch({ present_priority: v })}
/>
<SelectRow
label="Smoothness buffer"
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
options={SMOOTH_BUFFERS}
value={s.smooth_buffer ?? 0}
formatUnknown={(v) => `${v} frames`}
onChange={(v) => patch({ smooth_buffer: v })}
disabled={!smooth}
indent
/>
<ToggleField
label="V-Sync"
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
checked={s.vsync ?? true}
onChange={(v) => patch({ vsync: v })}
/>
<ToggleField
label="Follow variable refresh"
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
checked={s.allow_vrr ?? true}
onChange={(v) => patch({ allow_vrr: v })}
/>
</div>
);
};
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
const micOn = s.mic_enabled;
// What the pickers get: null while the enumeration is in flight (they show a loading state),
// [] when it answered but couldn't read the endpoints (System default plus whatever is
// stored), and the real list otherwise.
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
reading || !devices ? null : devices.ok ? (list ?? []) : [];
return (
<div style={pageBody}>
<SelectRow
label="Audio channels"
description="The speaker layout requested from the host, which clamps it to what it can capture."
options={AUDIO_CHANNELS}
value={s.audio_channels ?? 2}
formatUnknown={(v) => `${v} channels`}
onChange={(v) => patch({ audio_channels: v })}
/>
<DeviceRow
label="Output device"
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
devices={endpoints(devices?.sinks)}
value={s.speaker_device ?? ""}
onChange={(v) => patch({ speaker_device: v })}
/>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
checked={micOn}
onChange={(v) => patch({ mic_enabled: v })}
/>
<DeviceRow
label="Microphone device"
description="Which input the mic uplink captures from."
devices={endpoints(devices?.sources)}
value={s.mic_device ?? ""}
onChange={(v) => patch({ mic_device: v })}
disabled={!micOn}
indent
/>
<ToggleField
label="Echo cancellation"
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
checked={s.echo_cancel ?? true}
onChange={(v) => patch({ echo_cancel: v })}
disabled={!micOn}
indentLevel={1}
/>
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
answer when the enumeration failed outright (a client too old to ship the session
binary). Rendered unconditionally, including while it is reading: a row that comes and
goes under a thumbstick is a moving target, so only its wording changes. */}
<Field
label={
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
}
description={
reading
? "Reading this device's audio endpoints and GPUs…"
: devices && !devices.ok
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
: "Plugged something in just now? Read the audio endpoints and GPUs again."
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
</DialogButton>
</RowActions>
</Field>
</div>
);
};
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
const forwarding = s.gamepad_forwarding ?? true;
return (
<div style={pageBody}>
<ToggleField
label="Forward controllers"
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
checked={forwarding}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
<SelectRow
label="Controller type"
description="The virtual pad the host creates. Automatic matches the controller you're holding."
options={GAMEPADS}
value={s.gamepad}
onChange={(v) => patch({ gamepad: v })}
disabled={!forwarding}
indent
/>
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
indentLevel={1}
/>
)}
</div>
);
};
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
<div style={pageBody}>
<SelectRow
label="Touch mode"
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
options={TOUCH_MODES}
value={s.touch_mode ?? "trackpad"}
onChange={(v) => patch({ touch_mode: v })}
/>
<SelectRow
label="Mouse mode"
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
options={MOUSE_MODES}
value={s.mouse_mode ?? "capture"}
onChange={(v) => patch({ mouse_mode: v })}
/>
<ToggleField
label="Invert scroll direction"
description="Reverses the wheel and trackpad scroll direction sent to the host."
checked={s.invert_scroll ?? false}
onChange={(v) => patch({ invert_scroll: v })}
/>
<ToggleField
label="Capture system shortcuts"
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
checked={s.inhibit_shortcuts}
onChange={(v) => patch({ inhibit_shortcuts: v })}
/>
</div>
);
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
// which itself defaults to true.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
return (
<div style={pageBody}>
<SelectRow
label="Statistics overlay"
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
options={STATS_TIERS}
value={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tiers still honours an Off chosen here.
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
/>
<ToggleField
label="Wake hosts automatically"
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
checked={s.auto_wake ?? true}
onChange={(v) => patch({ auto_wake: v })}
/>
<ToggleField
label="Show game library in the client"
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
checked={s.library_enabled ?? false}
onChange={(v) => patch({ library_enabled: v })}
/>
<ToggleField
label="Start streams fullscreen"
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
checked={s.fullscreen_on_stream ?? true}
onChange={(v) => patch({ fullscreen_on_stream: v })}
/>
</div>
);
};
// ----------------------------------------------------------------------------------------
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
// null until the enumeration answers — the pickers show a loading state rather than briefly
// claiming this device has no endpoints.
const [devices, setDevices] = useState<DeviceLists | null>(null);
const [reading, setReading] = useState(true);
const readDevices = (again: boolean) => {
setReading(true);
void (again ? refreshDevices() : listDevices())
.then(setDevices)
.finally(() => setReading(false));
};
useEffect(() => {
void getSettings().then(setS);
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
// takes seconds, and the rest of the screen must not wait for it.
readDevices(false);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
return (
<SidebarNavigation
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
// reporting would have this nav push entries of its own onto the router and fight the
// page for the back gesture; the pages are addressed by `identifier` instead.
disableRouteReporting
pages={[
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
{
title: "Presentation",
identifier: "presentation",
icon: <FaTv />,
content: <PresentationPage {...ctx} />,
},
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
{
title: "Controllers",
identifier: "controllers",
icon: <FaGamepad />,
content: <ControllersPage {...ctx} />,
},
{
title: "Touch & mouse",
identifier: "pointer",
icon: <FaHandPointer />,
content: <PointerPage {...ctx} />,
},
{
title: "Interface",
identifier: "interface",
icon: <FaSlidersH />,
content: <InterfacePage {...ctx} />,
},
]}
/>
);
};
+76 -62
View File
@@ -8,16 +8,16 @@
//
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
// see applyControllerConfig):
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
// launch, so one shortcut serves every host. Hidden — an implementation detail.
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
// This is the library-visible "Punktfunk" app the user opens directly.
//
// Both get the shipped artwork and the native-touch controller config.
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -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;
}
@@ -257,11 +266,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
const startDir = info.runner.replace(/\/[^/]*$/, "");
void ensureControllerConfig();
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
// default stands and this shortcut is exactly what it always was.
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
// what it always was.
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
@@ -319,77 +329,81 @@ export async function launchGamepadUi(): Promise<void> {
}
}
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
export interface LaunchOpts {
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
launchId?: string;
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
browse?: boolean;
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
mgmt?: number;
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
profileId?: string;
/**
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED see the
* wrapper for why `--exec` is dropped on this path alone.
*/
requestAccess?: boolean;
}
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
const UNSAFE_TOKEN = /["'\\$`\s]/;
export function isSafeLaunchId(id: string): boolean {
return (
id.length > 0 &&
id.length <= 128 &&
UNSAFE_LAUNCH_ID.exec(id) === null &&
UNSAFE_TOKEN.exec(id) === null &&
/^[\x21-\x7e]+$/.test(id)
);
}
/**
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* library title, or into a host's gamepad library). Encodes the target into the STREAM
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
* then RunGame.
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
* is just where the user installed the client so the browse shortcut degrades to its flatpak
* default rather than refusing to exist.
*/
export async function launchStream(
host: string,
port: number,
opts: LaunchOpts = {},
): Promise<void> {
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false }));
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`];
function safeClientBin(bin: string | undefined): bin is string {
return !!bin && isSafeLaunchId(bin);
}
/**
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
* target into the STREAM shortcut's launch options one hidden shortcut serves every host
* then RunGame.
*
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
*/
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
if (!isSafeLaunchId(ref)) {
throw new Error(`unsupported host reference: ${ref}`);
}
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
throw new Error(`unsupported profile id: ${opts.profileId}`);
}
const { appId, runner, clientBin } = await ensureStreamShortcut();
const env = [`PF_REF=${ref}`];
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
// existing Deck install produces byte-identical launch options to before.
if (clientBin) {
// The one launch-option value that comes from the backend rather than a store id, and so
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
if (!isSafeLaunchId(clientBin)) {
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
}
env.push(`PF_CLIENT_BIN=${clientBin}`);
}
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
// already-awake host the connect still lands in under a second, so this costs nothing.
if (woke.ok) {
env.push("PF_CONNECT_TIMEOUT=75");
if (opts.profileId) {
env.push(`PF_PROFILE=${opts.profileId}`);
}
if (opts.browse) {
env.push("PF_BROWSE=1");
if (opts.mgmt) {
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
}
} else if (opts.launchId) {
if (!isSafeLaunchId(opts.launchId)) {
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
throw new Error(`unsupported launch id: ${opts.launchId}`);
}
env.push(`PF_LAUNCH=${opts.launchId}`);
if (opts.requestAccess) {
env.push("PF_REQUEST_ACCESS=1");
}
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_* from the environment. The wake was
// awaited above, so the magic packet is out before the connect attempt.
// script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
}
+164
View File
@@ -0,0 +1,164 @@
// The trust sheet — the step between "I can see a host" and "I can stream it".
//
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
//
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
// then launch. The host parks that connect until its operator approves this Deck in the
// console or web UI, admits it, and the stream starts by itself. It is not a second
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
// is why it costs no ceremony surface here at all.
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
//
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
// told why, rather than being shown a button that could only fail. Under no circumstances does
// this sheet trust-on-first-use its way past a missing fingerprint.
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useRef, useState } from "react";
import { trustHost } from "./backend";
import { HostView } from "./hooks";
import { PairModal } from "./pair";
/** User-facing copy for a `trustHost` failure code. */
function trustErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
case "client-outdated":
return "Update the Punktfunk client to use request access.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return `Couldnt save ${name}.`;
}
}
export const TrustSheet: FC<{
host: HostView;
closeModal?: () => void;
/** Stream this host, having just been let in. */
onStream: (opts: { requestAccess?: boolean }) => void;
/** Re-read the host list — the record changed underneath the panel. */
onChanged: () => void;
}> = ({ host, closeModal, onStream, onChanged }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
// from panel state. Anything it needs to act on later must be read through a ref, not out of
// a captured value — reading a captured array is exactly what made pinning a second game
// compute from a stale base and clobber the first.
const props = useRef({ host, onStream, onChanged });
props.current = { host, onStream, onChanged };
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
// a host that already has one streams without ever opening this sheet.
const hasIdentity = host.advertisedFp !== "";
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
// operator decision to wait for, and asking for one would be a wait that never ends and a
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
const needsApproval = host.pairPolicy !== "optional";
const canRequestAccess = hasIdentity && needsApproval;
const canTrustDirectly = hasIdentity && !needsApproval;
/**
* Pin the advertised identity, then stream.
*
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
*/
const letIn = async (approval: boolean) => {
setBusy(true);
setError(null);
const { host: h, onStream: stream, onChanged: changed } = props.current;
try {
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
// Idempotent, so a retry after a declined approval is free.
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
if (!r.ok) {
setError(trustErrorBody(r.error, h.name));
setBusy(false);
return;
}
changed();
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
// it starts. That toast is a patch over that, and the real fix belongs in the session.
if (approval) {
toaster.toast({
title: "Punktfunk",
body: `Approve this Deck in ${h.name}s console — the stream starts by itself`,
duration: 10_000,
});
}
stream({ requestAccess: approval });
closeModal?.();
} catch (e) {
setError(String(e));
setBusy(false);
}
};
const usePin = () => {
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
// the gamepad focus model expects.
const { host: h, onStream: stream, onChanged: changed } = props.current;
closeModal?.();
showModal(
<PairModal
host={h}
onPaired={() => {
changed();
stream({});
}}
/>,
);
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Connect to {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
{!hasIdentity
? "No advertised identity for this host — pair with a PIN instead."
: canTrustDirectly
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
: `${host.name} needs to let this device in before it can stream.`}
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
{canRequestAccess && (
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
</DialogButton>
)}
{canTrustDirectly && (
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
</DialogButton>
)}
<DialogButton disabled={busy} onClick={usePin}>
Use a PIN instead
</DialogButton>
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
</Focusable>
{canRequestAccess && (
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
Request access asks {host.name}s operator to approve this Deck in its console or web
UI. No PIN to type the stream starts as soon as they do.
</div>
)}
</ModalRoot>
);
};
-46
View File
@@ -1,46 +0,0 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+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"),
+75 -1
View File
@@ -157,6 +157,20 @@ mod index {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn system_buttons(s: &Settings) -> u32 {
SYSTEM_BUTTONS
.iter()
.position(|&v| v == s.system_buttons)
.unwrap_or(0) as u32
}
pub fn guide_gesture(s: &Settings) -> u32 {
GUIDE_GESTURES
.iter()
.position(|&v| v == s.guide_gesture)
.unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("system_buttons") {
o.system_buttons = Some(values.system_buttons.clone());
}
if touched.has("guide_gesture") {
o.guide_gesture = Some(values.guide_gesture.clone());
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
"dualshock4",
"steamdeck",
];
/// System-button routing values (persisted under the cross-client `system_buttons` key):
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
/// only where the raw guide press can't reach the host (Gaming Mode here).
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
// needs either off Automatic — they exist here because profiles are authored on the
// desktop and applied everywhere, Gaming Mode included.
let sysbtn_row = ChoiceRow::new(
&dialog,
inline,
"Steam / guide button",
"Automatic sends it to the host, except where this device reacts to it too",
SYSTEM_BUTTON_LABELS,
);
let gesture_row = ChoiceRow::new(
&dialog,
inline,
"Hold Select for guide",
"Hold Select alone for the host's guide button — a tap still goes through",
GUIDE_GESTURE_LABELS,
);
// The pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
sb.set_sensitive(seed.gamepad_forwarding);
gg.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
sb.set_sensitive(r.is_active());
gg.set_sensitive(r.is_active());
});
}
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
sysbtn_row.set_selected(index::system_buttons(s));
gesture_row.set_selected(index::guide_gesture(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
// set_selected never fires the changed hook, so seed the dynamic caption directly.
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
choice!(
sysbtn_row,
"system_buttons",
o.system_buttons.is_some(),
index::system_buttons
);
choice!(
gesture_row,
"guide_gesture",
o.guide_gesture.is_some(),
index::guide_gesture
);
toggle!(
pad_forward_row,
"gamepad_forwarding",
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
controllers_group.add(forward_row.widget());
}
controllers_group.add(pad_row.widget());
controllers_group.add(sysbtn_row.widget());
controllers_group.add(gesture_row.widget());
controllers.add(&controllers_group);
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.system_buttons = SYSTEM_BUTTONS
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
.to_string();
s.guide_gesture = GUIDE_GESTURES
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
.to_string();
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.mouse_mode =
+106 -3
View File
@@ -18,6 +18,78 @@
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
/// The session control socket: a line-per-connection unix socket other same-user
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
/// `ok\n` / `err\n` back.
///
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
/// reach a flatpak-run session.
#[cfg(all(unix, any(target_os = "linux", windows)))]
mod ctl_socket {
use pf_client_core::gamepad::GamepadService;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
fn path() -> Option<PathBuf> {
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
if let Ok(id) = std::env::var("FLATPAK_ID") {
p.push("app");
p.push(id);
}
Some(p.join("punktfunk-session-ctl.sock"))
}
/// Bind + serve on a background thread, once per process (later calls no-op). Any
/// failure just logs at debug — the socket is a convenience surface, never worth
/// failing a stream over.
pub(crate) fn spawn(gamepad: GamepadService) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(move || {
let Some(path) = path() else { return };
// A previous session's socket file refuses the bind — it's ours to replace.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
return;
}
};
let spawned = std::thread::Builder::new()
.name("pf-session-ctl".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut line = String::new();
if BufReader::new(&s).read_line(&mut line).is_err() {
continue;
}
let ok = match line.trim() {
"guide" => {
gamepad.tap_guide();
true
}
"qam" => {
gamepad.tap_qam();
true
}
_ => false,
};
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
}
});
if let Err(e) = spawned {
tracing::debug!(error = %e, "session ctl thread failed to start");
}
});
}
}
#[cfg(any(target_os = "linux", windows))]
mod session_main {
use pf_client_core::gamepad::GamepadService;
@@ -44,14 +116,20 @@ mod session_main {
std::env::args().any(|a| a == flag)
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
pub(crate) fn gaming_mode() -> bool {
std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
/// gated with `mod browse`, its one caller.)
#[cfg(feature = "ui")]
pub(crate) fn fullscreen_mode() -> bool {
arg_flag("--fullscreen")
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
arg_flag("--fullscreen") || gaming_mode()
}
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
@@ -194,6 +272,26 @@ mod session_main {
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
// local Steam UI reacts to the same physical buttons there no matter what, so
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
// unconditionally for the same browse-mode-reuse reason as the line above.
let game_mode = gaming_mode();
gamepad.set_system_buttons(
settings.system_buttons_forward(game_mode),
settings.guide_gesture_enabled(game_mode),
);
// The control socket (guide/QAM injection — the Decky panel's host buttons).
// 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
@@ -297,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).
+98 -2
View File
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
("steamdeck", "Steam Deck"),
];
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
/// Automatic forwards on desktop and stays local under Gaming Mode.
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
@@ -479,6 +490,8 @@ struct OverrideFlags {
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
system_buttons: bool,
guide_gesture: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
@@ -512,6 +525,8 @@ impl OverrideFlags {
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
system_buttons: o.system_buttons.is_some(),
guide_gesture: o.guide_gesture.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
@@ -966,6 +981,13 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default();
s.save();
})
// Dimmed with the master switch above it, like echo cancellation under the mic
// (see that row) — this and the three below have nothing to act on while no
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
// screen, so they follow the toggle live. Brings this client in line with how GTK
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
// console UI (dim + refuse the step) have always drawn the same relationship.
.enabled(s.gamepad_forwarding)
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
@@ -976,7 +998,32 @@ pub(crate) fn settings_page(
});
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
})
.enabled(s.gamepad_forwarding);
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
let sysbtn_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
sysbtn_names,
sysbtn_i,
|s, i| {
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
let gesture_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
gesture_names,
gesture_i,
|s, i| {
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
@@ -1407,6 +1454,30 @@ pub(crate) fn settings_page(
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"system_buttons",
"Steam / guide button",
over.system_buttons,
sysbtn_combo,
"Where the guide (Xbox/PS) and quick-access presses go while \
streaming. Automatic sends them to the host \u{2014} except on \
devices whose own overlay reacts to the same press (Gaming Mode), \
where they stay local and the gesture below reaches the host.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"guide_gesture",
"Hold Select for guide",
over.guide_gesture,
gesture_combo,
"Hold Select on its own to press the host's guide button \u{2014} keep \
holding for a Gaming-Mode host's quick-access menu. A Select tap \
still goes through, slightly delayed. Automatic arms it only where \
the real button can't reach the host.",
)),
]
.into_iter()
.flatten()
@@ -1840,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,
+186 -3
View File
@@ -4,6 +4,8 @@
//! cards and flip a saved host's online pip when its advert disappears.
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
pub os: String,
}
impl DiscoveredHost {
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
pub fn advertised_id(&self) -> &str {
if self.key == self.fullname {
""
} else {
&self.key
}
}
}
/// One discovery update for the UI's advert map.
pub enum DiscoveryEvent {
/// A host advert appeared or refreshed (new address, pairing flipped, …).
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
Removed { fullname: String },
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
return;
}
};
while let Ok(event) = receiver.recv() {
// Polled rather than blocked on: the worker has to notice that its consumer went
// away even when NOTHING is arriving, which is the normal state of a LAN with no
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
loop {
// Checked at the TOP so it also covers the arms below that `continue` without
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
// address. Those are the paths that would otherwise keep this thread alive with
// nobody to send to.
if tx.is_closed() {
break;
}
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
Ok(event) => event,
Err(_) if receiver.is_disconnected() => break,
Err(_) => continue,
};
let update = match event {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
.expect("spawn mdns thread");
rx
}
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
/// fold — which is where dedupe and removal actually live — is testable without a network.
type Adverts = BTreeMap<String, DiscoveredHost>;
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
match event {
DiscoveryEvent::Resolved(host) => {
adverts.insert(host.key.clone(), host);
}
DiscoveryEvent::Removed { fullname } => {
adverts.retain(|_, h| h.fullname != fullname);
}
}
}
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
///
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
let rx = browse();
let deadline = Instant::now() + timeout;
let mut adverts = Adverts::new();
while Instant::now() < deadline {
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
// receive has no timeout, and the whole point of this call is that it is bounded.
std::thread::sleep(Duration::from_millis(50).min(timeout));
}
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
drop(rx);
sorted(adverts)
}
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
hosts.sort_by_key(|h| {
(
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
h.addr.clone(),
h.port,
)
});
hosts
}
#[cfg(test)]
mod tests {
use super::*;
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
DiscoveredHost {
key: key.into(),
fullname: fullname.into(),
name: fullname.split('.').next().unwrap_or("?").into(),
addr: addr.into(),
port: 9777,
fp_hex: "aa".into(),
pair: "required".into(),
mgmt_port: Some(47990),
mac: vec![],
os: String::new(),
}
}
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
/// a host that moved to a new address stops being listed at the stale one.
#[test]
fn refreshed_advert_supersedes_the_earlier_one() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
);
let out = sorted(adverts);
assert_eq!(out.len(), 1, "same key must not render twice");
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
}
/// A host that goes away during the browse window is not in the answer.
#[test]
fn removal_drops_the_advert_it_names() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
);
fold(
&mut adverts,
DiscoveryEvent::Removed {
fullname: "desk._punktfunk._udp.local.".into(),
},
);
let out = sorted(adverts);
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, "id-2");
}
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
/// fullname as an id, which would send a caller launching against a nonexistent reference.
#[test]
fn advertised_id_is_empty_without_the_txt() {
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
assert_eq!(named.advertised_id(), "id-1");
let anonymous = host(
"desk._punktfunk._udp.local.",
"desk._punktfunk._udp.local.",
"10.0.0.1",
);
assert_eq!(anonymous.advertised_id(), "");
}
/// Addresses sort the way a person reads them, not the way strings compare.
#[test]
fn addresses_sort_numerically() {
let mut adverts = Adverts::new();
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
.into_iter()
.enumerate()
{
fold(
&mut adverts,
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
);
}
let out = sorted(adverts);
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
}
}
File diff suppressed because it is too large Load Diff
+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
+22
View File
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_buttons: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guide_gesture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
@@ -159,6 +163,12 @@ impl SettingsOverlay {
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = &self.system_buttons {
s.system_buttons = v.clone();
}
if let Some(v) = &self.guide_gesture {
s.guide_gesture = v.clone();
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -252,6 +262,12 @@ impl SettingsOverlay {
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.system_buttons != before.system_buttons {
self.system_buttons = Some(after.system_buttons.clone());
}
if after.guide_gesture != before.guide_gesture {
self.guide_gesture = Some(after.guide_gesture.clone());
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
@@ -302,6 +318,8 @@ impl SettingsOverlay {
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"system_buttons" => self.system_buttons = None,
"guide_gesture" => self.guide_gesture = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
@@ -506,6 +524,8 @@ mod tests {
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
system_buttons: Some("local".into()),
guide_gesture: Some("on".into()),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
@@ -532,6 +552,8 @@ mod tests {
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert_eq!(out.system_buttons, "local");
assert_eq!(out.guide_gesture, "on");
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
+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
}
+310 -14
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())
}
}
@@ -232,17 +341,29 @@ impl KnownHosts {
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
/// is keyed by the id yet (design §4.5).
pub fn load() -> KnownHosts {
let mut k: KnownHosts = Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut k = Self::read();
if k.mint_missing_ids() {
let _ = k.save();
}
k
}
/// The store exactly as it is on disk — no mint, and so no write.
///
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
/// two processes started together against a pre-mint store will each mint a *different* id
/// for the same record and race to save it — after which whichever one already handed its
/// ids to a caller has handed out references that no longer resolve. A read that stays a
/// read cannot take part in that.
pub fn read() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Give every record still missing one a stable id; returns true if anything changed
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
/// once is left byte-identical.
@@ -867,6 +988,25 @@ pub struct Settings {
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` —
/// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host,
/// the pre-setting behaviour), or `"local"` (they stay with this device; the host's
/// are reached via the hold-Select gesture instead). Auto resolves per platform in
/// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode,
/// where the local Steam UI always reacts to the same physical press — forwarding
/// there opens BOTH overlays, the local one on top of the stream.
#[serde(default = "default_auto")]
pub system_buttons: String,
/// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST
/// the guide button (down for as long as it's held, so a long hold is the host's
/// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`,
/// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw
/// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own
/// auto in the Apple client). While armed, a Select TAP is delivered on release —
/// costing it up to the hold threshold in latency — and a Select held as part of a
/// combo (any other button already down) passes through untouched.
#[serde(default = "default_auto")]
pub guide_gesture: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
@@ -993,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
@@ -1020,6 +1175,10 @@ fn default_codec() -> String {
"auto".into()
}
fn default_auto() -> String {
"auto".into()
}
fn default_touch_mode() -> String {
"trackpad".into()
}
@@ -1036,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).
@@ -1069,6 +1232,29 @@ impl Settings {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// Whether raw system-button presses (guide + QAM) are forwarded to the host.
/// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope),
/// where the local Steam UI reacts to the same physical buttons no matter what we
/// do — auto keeps them local there and forwards everywhere else.
pub fn system_buttons_forward(&self, game_mode: bool) -> bool {
match self.system_buttons.as_str() {
"forward" => true,
"local" => false,
_ => !game_mode,
}
}
/// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]).
/// Auto = on only under Gaming Mode, where it is the sole controller route to the
/// host's guide once raw presses stay local.
pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool {
match self.guide_gesture.as_str() {
"on" => true,
"off" => false,
_ => game_mode,
}
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -1095,6 +1281,8 @@ impl Default for Settings {
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
system_buttons: "auto".into(),
guide_gesture: "auto".into(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
mouse_mode: "capture".into(),
@@ -1119,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,
@@ -1859,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()
@@ -1872,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 {
+50 -2
View File
@@ -41,6 +41,8 @@ enum RowId {
PadForward,
Pad,
PadType,
SystemButtons,
GuideGesture,
Touch,
Mouse,
InvertScroll,
@@ -57,7 +59,7 @@ enum RowId {
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
const ROWS: [RowId; 27] = [
const ROWS: [RowId; 29] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::SystemButtons,
RowId::GuideGesture,
RowId::Touch,
RowId::Mouse,
RowId::InvertScroll,
@@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [
("dualshock4", "DualShock 4"),
("steamdeck", "Steam Deck"),
];
/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the
/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the
/// local Steam UI reacts to the same press and both overlays would open at once.
const SYSTEM_BUTTONS: [(&str, &str); 3] = [
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture — the shared `guide_gesture` key.
const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")];
pub(crate) struct SettingsScreen {
list: MenuList,
@@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
@@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Controller type",
label_for(&PAD_TYPES, &s.gamepad).into(),
),
RowId::SystemButtons => (
None,
"Steam / guide button",
label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(),
),
RowId::GuideGesture => (
None,
"Hold Select for guide",
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
),
RowId::Touch => (
Some("Touchscreen"),
"Touch mode",
@@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str {
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::SystemButtons => {
"Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \
sends them to the host except in Gaming Mode, where Steam on this device \
reacts to the same press and both overlays would open at once."
}
RowId::GuideGesture => {
"Hold Select on its own to press the host's guide button — keep holding for \
the host's quick-access menu. Automatic arms it only where the real button \
can't reach the host. A Select tap still goes through, slightly delayed."
}
RowId::Touch => {
"How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
@@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::SystemButtons => {
if !s.gamepad_forwarding {
return false;
}
step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap)
}
RowId::GuideGesture => {
if !s.gamepad_forwarding {
return false;
}
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)

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