Compare commits

..
Author SHA1 Message Date
enricobuehler 147bc82130 chore(release): bump workspace version to 0.27.0
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m56s
android / android (pull_request) Successful in 8m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 8m39s
ci / rust (pull_request) Successful in 18m11s
nix / flake (pull_request) Successful in 17m53s
87 commits since v0.26.0. The number is forced rather than chosen: the C ABI moves
17 -> 18, and `scripts/ci/pf-version.sh` already reports PF_BASE=0.27.0.

Fills in what `docs/releases/v0.27.0.md` and the `## v0.27.0 — in development`
CHANGELOG section deferred to the bump -- the lead-in, the TL;DR, "Before you update",
the version table and the commit count -- and merges in the work that landed without
notes: the Windows Xbox HID pad, the Apple console-UI batch, and the v0.26.0 field
repairs. Everything already accumulated on main is kept as written.

Version table, all re-derived at HEAD rather than read off the diff:
  * wire 2 (unchanged), C ABI 17 -> 18 (ADDED symbol, not widened)
  * crate dirs 26 -> 27 (`punktfunk-encode-worker`), 39 members
  * gamescope `+pfhdr4` -> `+pfhdr5` (6 patches -> 7, the PipeWire UAF), pkgrel resets to 1
  * driver proto 6, gamepad channel 3, plugin index 1, SDK 0.1.4, plugin-kit 0.4.0 -- all unchanged
  * `api/openapi.json` stays 0.25.0: it moves only on an API EDIT, and the last one
    (`6cffe29b`) predates the v0.26.0 tag. It is not a mechanical per-release bump.

⚠ `crates/pf-driver-proto` is NO LONGER byte-identical to the previous release -- it was
through both v0.25.0 and v0.26.0, and the last two CHANGELOGs told packagers so. Three
additive `device_type`s. Called out explicitly so a packager who has been skipping it stops.

The Play whatsnew names the one real Android change (the OLED palette is Eclipse now;
display name only, the stored `oled` id is unchanged so saved choices carry over) and is
otherwise explicit that the release is host-side.

`docs-site/.../running-as-a-service.md` claimed "0.26.0-2 revoked it everywhere". No such
version exists -- the Arch registry goes 0.26.0-1 straight to 0.27.0 canaries -- and the
corrected 0.26.0 packages shipped under the SAME version string, which is exactly why a
machine that installed an early one was never offered the fix. Reworded to say that, since
it is the reason this release matters to those users.

Gates, all green on this commit:
  * `cargo fmt --all --check` clean
  * `cargo metadata --locked` resolves
  * `Cargo.lock` diff versions-only -- 72/72 changed lines, 36 crates
  * Play gate's own python3 arithmetic: 400/500 CHARACTERS, not byte-identical to any other
    release (`wc -c` misleads -- `•` is 3 bytes)
  * notes voice check: 1 hit, reviewed and kept -- `getcap /usr/bin/punktfunk-host` is a
    command the reader runs, the same class as the `usermod` line v0.26.0's notes carried
  * `docs-site` builds

NOT DONE: no tag yet.
2026-08-10 20:51:45 +02:00
enricobuehler 30c47eb691 Merge pull request 'NixOS could not detect a graphical session at all — and the module was never checked by CI' (#158) from worktree-nixos-wrapped-comm-detection into main
apple / swift (push) Successful in 1m43s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m47s
deb / build-publish-client-arm64 (push) Successful in 1m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
apple / screenshots (push) Successful in 3m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
ci / bun-nix (push) Successful in 35s
deb / build-publish (push) Successful in 8m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m47s
arch / build-publish (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 13s
android / android (push) Successful in 6m57s
ci / rust (push) Successful in 6m38s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 14m42s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m12s
Reviewed-on: #158
2026-08-10 18:37:32 +00:00
enricobuehler bd26949e0a Merge pull request 'The library's hide button was invisible AND clickable — a corner nobody could see dropped games' (#157) from worktree-library-hide-affordance into main
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 41s
ci / docs-site (push) Canceled after 42s
ci / web (push) Canceled after 42s
ci / rust-arm64 (push) Canceled after 41s
ci / bun-nix (push) Canceled after 40s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
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 4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 4s
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
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 1m10s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #157
2026-08-10 18:37:03 +00:00
enricobuehler 61dfc3dadc Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s
2026-08-10 20:26:23 +02:00
enricobuehler 1befa8a2c4 docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared
There are three places Nix is documented — the public docs-site, packaging/nix/
README.md, and packaging/README.md — plus the changelog. All had drifted.

STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit
ships **disabled** — enable it once you have" something to run. That is true only
of Arch and source installs: the deb postinst and RPM %post both
`systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext
bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried
the same claim about its own image. Both corrected, per channel, with the reason
the default flipped — the library scanners are plugins, so a host without the
runner can come up with an empty library — and the `mask`-not-`disable` opt-out
the sysext's own comment documents.

docs-site:
  * install.md NixOS — `desktopSession` in the example and explained, the runner
    no longer needs enabling, and the host/console line says what autoStart does.
  * running-as-a-service.md — "Restart the host with your desktop" documented the
    drop-in for packaged installs only; NixOS gets its one-liner beside it.
  * bazzite.md — the runner is started for you, not "isn't started".

packaging/nix/README.md:
  * option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and
    the `punktfunk` group next to `input` (both are required — the udev rule
    chgrp's the vhci nodes and fails outright if the group was never created).
  * "what the module configures" gains the security.wrappers entry, and a note on
    why the capability sits on the encode worker and never on the host: a wrapper
    raises it into the ambient set, which lands it in the permitted set and fails
    KWin's /proc/<pid>/exe readlink identically to a file capability.
  * the appliance snippet no longer tells you to put pkgs.gamescope on PATH —
    gamescopeHdr does that with the patched build, and desktopSession is called
    out as the thing to leave off there.
  * a caveat recording that `nix flake check` does not check the module, and the
    two rules for editing module-check.nix (assertions stay pure Nix; assert
    list-valued unit fields on the lists, not the rendered text).

packaging/README.md: the flake ships five packages, not "host + client".

CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the
module changes including the scripting default flip as an explicit behaviour
change, and the flake-check gap — plus the documentation bullets above.
2026-08-10 20:24:21 +02:00
enricobuehler f8cde0adaf feat(nix): actually check the NixOS module in CI, and close the sweep's open issues
THE CI GAP. `nix flake check` does not check `nixosModules`. It forces the value
and asserts it is a lambda taking an open attribute set — nothing more; nix's own
source carries `// FIXME: if we have a 'nixpkgs' input, use it to check the
module.` Measured: a flake whose module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.

Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).

17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.

Also closed from the sweep:

  * services.punktfunk.host.desktopSession (new, default false) — binds the host
    to graphical-session.target, the declarative form of the
    punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME
    restart leaves the host holding a Wayland socket and portal D-Bus connection
    that died with the old compositor: it still listens, still answers, and every
    session it then serves fails at capture. Off by default because an appliance
    may never reach that target and would be left permanently stopped.

  * scripting.autoStart now defaults ON, matching the deb postinst and RPM %post,
    which both `systemctl --global enable` the runner. It was opt-in here on the
    reasoning that the runner is inert until you add automation — which stopped
    being true when the game-library scanners became plugins. A NixOS host came up
    with an empty library and no obvious reason why. The module and README carried
    the superseded rationale verbatim; both updated.

  * A warning when the host is enabled and xdg.portal is not. A warning rather
    than `xdg.portal.enable = mkDefault true`, because enabling the portal service
    with no `extraPortals` backend is its own broken state and only the operator
    knows which backend their compositor needs.

  * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
    critical path of every host build (`gamescopeHdr` defaults true) yet nothing
    compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
    of ours — is what breaks it, and the first to find out would be an operator
    whose system rebuild fails.

All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
2026-08-10 19:57:10 +02:00
enricobuehler e93947969f fix(console): the hide button was invisible AND clickable — a corner nobody could see dropped games
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 4m39s
Hiding a library title shipped in 0.26.0 and was, in practice, unusable: the operator
reported there was simply no button, then found it by CLICKING the empty top-right corner
of a poster.

Both halves of that are the same defect. The card's control row was `opacity-0` until
`group-hover`, and `opacity-0` paints nothing while still HIT-TESTING. So the corner of
every tile in the grid was a live hide button with no visual presence at all: a stray
click there removed that title from every play surface — the client grid, the native
clients, the GameStream app list — with nothing on screen having suggested a control was
under the cursor. What read as "the button finally appeared" was the hide taking effect,
since `hidden` is the one state that drops the `opacity-0`. The feature announced itself
by firing.

And the reveal rested on hover ALONE. `:hover` never fires on a touch screen, so on a
tablet the hide control was unreachable by construction and discoverable only by the blind
click above. The original commit spotted this hazard for UN-hide — it kept those controls
always-visible so nobody could be stranded in the hidden state — but left the hide side
hover-gated, which is the same trap one step earlier.

So opacity and `pointer-events` now move together, always: whatever cannot be seen cannot
be clicked. `pointer-coarse:` shows the row outright wherever the device has no hover to
give, rather than making touch a second-class path. Keyboard reach is unchanged —
`pointer-events: none` does not block focus, so tabbing in still trips `focus-within`,
which now restores interactivity along with opacity.

The eye icon also gains a `title`. On a scanned entry it is the ONLY control on the card,
with no edit/delete beside it to read as a toolbar, and an unlabelled eye-with-slash is
not a promise that a game is about to leave the library.

Verified in the built CSS rather than by eye, because a variant that does not compile
fails exactly like the bug being fixed: `@media(pointer:coarse)` emits both
`pointer-coarse:opacity-100` and `pointer-coarse:pointer-events-auto`, and it lands at the
END of the sheet — media queries add no specificity, so this tie against the base
`.pointer-events-none` / `.opacity-0` is won on source order, not by accident. The
`group-hover:` and `focus-within:` forms compile to `:is(:where(.group):hover *)` and
`:focus-within`, carrying a pseudo-class each, so they win on specificity outright.

Console: tsc clean, production build clean, biome clean on the touched file, i18n 633
messages across en+de.
2026-08-10 19:55:13 +02:00
enricobuehler 159bbdbfc2 fix(nix): port three NixOS-module divergences from the shipped systemd units
A sweep of the Nix packaging against the units the deb/rpm actually install
found three decisions that were made, documented and deliberate everywhere
else, and simply not carried into packaging/nix/nixos-module.nix.

punktfunk-web — StartLimitIntervalSec=0.
  The unit's EnvironmentFile for the mgmt token is mandatory ON PURPOSE, so the
  console genuinely fails until the host's first `serve` writes it. systemd's
  default rate limit (5 starts / 10 s) against RestartSec=2 then gives up
  permanently after ~10 s — which on an appliance is exactly the window before
  the host is ready, so a console enabled before the host's first run stayed
  dead until someone restarted it by hand. scripts/punktfunk-web.service has
  carried the override since that defect was found; the Nix module omitted it
  while its own comment went on promising "Restart retries until the host has
  created it".

punktfunk-web — Restart=always, not on-failure.
  A console that exits 0 has still stopped serving, and on-failure leaves it
  down. Matches the shipped unit and web-run.cmd on Windows, both of which
  relaunch bun on ANY exit. An explicit `systemctl --user stop` is unaffected.

punktfunk-scripting — the sandbox was missing entirely.
  The shipped unit confines the runner with NoNewPrivileges, ProtectSystem=
  strict, ReadWritePaths=%h /tmp and an AF_UNIX/AF_INET/AF_INET6 address-family
  restriction, plus PrivateTmp=no (a field report: a private /tmp hides
  /tmp/vhclient and /tmp/.X11-unix, so a plugin launches its vendor binary and
  then cannot reach the daemon behind it). The NixOS unit had none of it — so
  the one unit here that executes arbitrary operator TypeScript by design ran
  strictly LESS confined on NixOS than on every other channel.

Verified by evaluating the module against the pinned nixpkgs and rendering the
units: assertions clean, cap_sys_nice=ep on the encode-worker wrapper, firewall
47984/47989/47990/47992/47993/48010, and each unit carrying exactly the
directives above. That evaluation is NOT something CI does — measured: `nix
flake check` passes a nixosModule containing a nonexistent option, a nonexistent
pkgs attribute and a nonexistent lib function, printing "checking NixOS module
... all checks passed!" while never evaluating it against nixpkgs. nix.yml's
header claims that leg covers the module. It does not; tracked separately.
2026-08-10 19:27:14 +02:00
enricobuehler 002702bcec fix(pf-vdisplay): NixOS sessions were undetectable — comm is the WRAPPER's name
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].

nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.

Nothing downstream could recover from that one string comparison:

  - detect_active_session returned ActiveKind::None on a *running* KDE desktop;
  - wayland_display is only resolved for a detected kind, so the connect log
    reported wayland="-" even though WAYLAND_DISPLAY was correct;
  - pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
    working KWin sitting in available() was never chosen — every connect died
    "no usable compositor";
  - and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
    consults the same probe, turning the miss into a hard error instead.

No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.

Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.

Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.

Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
2026-08-10 18:38:14 +02:00
enricobuehler 88c1e94d94 Merge pull request 'A takeover's mask no longer bars the box's own way back into Game Mode' (#156) from worktree-worktree-gamescope-unmask-midstream into main
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 3m29s
ci / web (push) Successful in 2m41s
ci / bun-nix (push) Successful in 44s
ci / docs-site (push) Successful in 1m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
apple / screenshots (push) Successful in 3m4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
android / android (push) Successful in 7m9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 45s
ci / rust (push) Successful in 9m31s
arch / build-publish (push) Successful in 10m50s
deb / build-publish (push) Successful in 7m41s
deb / build-publish-host (push) Successful in 8m52s
windows-host / package (push) Successful in 14m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
Reviewed-on: #156
2026-08-10 16:23:44 +00:00
enricobuehler 657e82cd29 fix(gamescope): a takeover's mask no longer bars the box's own way back into Game Mode
ci / bun-nix (pull_request) Successful in 17s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m47s
android / android (pull_request) Successful in 4m24s
ci / rust (pull_request) Successful in 6m24s
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.

`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is

    systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).

So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.

Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.

The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.

Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
2026-08-10 17:16:29 +02:00
enricobuehler e283f17ab4 Merge pull request 'Apple: a Shortcut or a home-screen widget jumps straight into a host's game library' (#155) from worktree-apple-shortcuts-library-widget into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
ci / bun-nix (push) Successful in 41s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 1m33s
docker / builders-arm64cross (push) Successful in 6s
ci / web (push) Successful in 1m38s
apple / swift (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m21s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 2m57s
ci / rust (push) Successful in 6m14s
release / apple (push) Successful in 3m48s
Reviewed-on: #155
2026-08-10 14:56:19 +00:00
enricobuehler 1fc184516a feat(apple): the browse route is real — a Shortcut or a widget jumps straight into a host's library
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m36s
The reserved punktfunk://browse/<host-ref> route now routes on Apple: it
drives the same libraryTarget every internal surface writes, so the link
lands in whichever presentation the current mode owns — the gamepad
console's in-place library screen, the touch cover, the macOS sheet, or
tvOS's cover. Connect's posture minus the connect: a pin conflict
refuses, a live session is never preempted, an unsaved host gets a
notice (the library rides the paired mTLS identity, so there is nothing
to browse before the host is saved). browse ignores launch=/profile= —
nothing streams until a title is picked, and that connect resolves its
own profile.

On top of the route, the two new front doors:

- OpenLibraryIntent ("Open Game Library") beside Connect/Wake/End in
  Shortcuts/Siri/Spotlight, host-parameterized like the others and
  round-tripping through the URL — one router, no second path.
- A configurable library widget (kind "PunktfunkLibrary",
  AppIntentConfiguration over HostEntity — the configuration the
  HostEntity doc comment anticipated): pick a host, tap into its
  library. Unconfigured it follows the most recent host; a configured
  host that was removed shows the empty state rather than silently
  following a different host. Same .never timeline + HostStore push as
  the hosts widget, now reloading both kinds.

DeepLink.browse(host:) is the one emitter both doors share, covered by
a round-trip test beside connect's; the parse side was already in the
grammar and the vector file. Docs updated (clients, game-library,
profiles-and-links).
2026-08-10 14:11:01 +02:00
enricobuehler 7a8f63e906 Merge pull request 'The console UI answers the iPad field test — and the screen that would never open again was a UIKit read mid-render' (#154) from worktree-apple-gamepad-relanded into main
apple / swift (push) Successful in 1m37s
release / apple (push) Successful in 4m9s
ci / rust-arm64 (push) Successful in 4m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 2m6s
apple / screenshots (push) Successful in 3m2s
android / android (push) Successful in 11m1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 3m48s
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 8s
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 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m48s
ci / rust (push) Successful in 14m25s
docker / deploy-docs (push) Successful in 31s
arch / build-publish (push) Successful in 15m35s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
deb / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
Reviewed-on: #154
2026-08-10 10:51:03 +00:00
enricobuehler 5ca0dfdcd2 fix(apple): stream audio coexists with Music instead of fighting it
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 2m28s
ci / bun-nix (pull_request) Successful in 32s
android / android (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 2m24s
ci / rust (pull_request) Successful in 8m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m29s
Field report, with a competitor cited doing it right: connecting to a host
PAUSED the user's Apple Music, and resuming Music mid-stream silenced the
stream's audio for good. Both are one defect — the audio session was
EXCLUSIVE. Activating it evicted whoever was playing (the pause at
connect), and Music's resume evicted us right back; iOS stops the engines
on that interruption and restarts nothing, so the stream stayed silent
until reconnect.

`.mixWithOthers` on every category path (playAndRecord, playback, tvOS) is
the heart of it: a mixable session interrupts nobody at activation and is
not interrupted by another app's mixable playback — game audio rides over
the playlist, the way a console does it. The trade is that a mixable
session is nobody's Now Playing app, so the lock screen keeps showing the
music. For a game stream that is the correct reading.

The other half is `installInterruptionObserver`: interruptions still exist
for a mixable session — a phone call, Siri, an app claiming a NON-mixable
session of its own — and ending one restarts nothing by itself. On
`.ended` the observer re-runs the full activation (an interruption can drop
the category, and the earpiece steer is per-route) and revives the engines
through the same conservative `reviveStoppedEngines` the route-change and
media-reset observers already use — unconditionally, not only on the
`.shouldResume` hint: a live stream is the one case where the user's intent
to keep hearing it is not in doubt, and the revive already declines when
playback never went down.

The teardown's `.notifyOthersOnDeactivation` stays as a courtesy for the
edge where an old exclusive install interrupted something; a mixable
session has nobody to notify.

macOS build (session paths are #if'd out there), tvOS typecheck, 284 tests.
2026-08-10 12:47:05 +02:00
enricobuehler a14b000c9b feat(apple): the tray gradient blur is back — as a real blur this time
The form screens' pinned title and legend lost their gradient blur when
GamepadTrayScrim went (2590238b): the scrim was `.ultraThinMaterial`, and a
material by definition lifts and tints whatever it blurs — it read grey over
the aurora, and washed with the palette's ground it read coloured. There is
no public way to blur a backdrop without that tint, so the layer was deleted
rather than re-tuned, and the rows have scrolled to a raw edge since.

Glur's GlurView blurs the backdrop through a gradient with NO material stage
on top — the rows soften as they slide under the pinned trays and nothing
carries a colour. Three things about the adoption are deliberate:

  - It is the library's GlurBackdrop product, which uses a private API
    (a variable-blur CAFilter on UIKit/AppKit). The public `.glur()`
    modifier is a Metal shader on a view's OWN content and silently no-ops
    over platform-backed views like ScrollView, so it cannot reach a
    backdrop at all. GlurBackdrop ships as a separate module precisely so
    adopters can weigh the App Store exposure; it disables hit testing
    itself, so the band never eats a touch.

  - The dependency is pinned by REVISION: GlurBackdrop exists only on main —
    no release carries it (the newest tag, `1.1`, predates it and is not
    three-component semver anyway, so `from:` resolution stops at 1.0.4,
    which doesn't have the product). Wired into BOTH manifests — the
    package for `swift build`, and the xcodeproj (remote package reference
    + per-target product deps) for the app targets, which compile these
    sources themselves and know nothing of Package.swift's dependencies.

  - The band takes NO content-side overhang, unlike the scrim it replaces —
    and dropping the scrim's hard-won -44/-72 runway is the point, not an
    oversight. That runway existed because a material carries body at every
    alpha and had to dissolve OUTSIDE the tray; carried over here it blurred
    fully-visible rows at rest (first on-glass verdict). Glur's ramp reaches
    literal zero, so the band ends exactly at the tray's layout edge: open
    content untouched, rows soften only where they actually slide under.
    The 80 pt outer bleed and the pinned shape
    (`transaction { $0.animation = nil }`) are kept — full-bleed by LAYOUT
    rather than `.ignoresSafeArea()`, so a push never shows the band
    growing into place.

Mounted exactly where the scrim was: settings, add-host and pair trays
(top + bottom) and the library title (top). Glur is attributed on the
Acknowledgements screen alongside the bundled font — it is a Swift package,
so the Rust-closure notices generator rightly knows nothing about it.

macOS build, tvOS typecheck; band placement and the blur itself verified on
the iPad on glass.
2026-08-10 12:46:45 +02:00
enricobuehler 35b5ee6a36 Merge pull request 'punktfunk-encode-worker: GPU priority via a capability-carrying worker, with WP3 on-glass complete' (#153) from worktree-worktree-encode-worker into main
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (web) (push) Failing after 20s
audit / bun-audit (sdk) (push) Successful in 20s
audit / pnpm-audit (push) Successful in 9s
audit / docs-site-audit (push) Successful in 20s
audit / cargo-audit (push) Successful in 1m9s
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m21s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 17s
android / android (push) Canceled after 5m0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 5m1s
ci / rust (push) Canceled after 4m17s
ci / rust-arm64 (push) Canceled after 4m8s
deb / build-publish (push) Canceled after 54s
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 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
flatpak / build-publish (push) Canceled after 3s
release / apple (push) Canceled after 3m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m10s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
decky / build-publish (push) Successful in 26s
audit / license-gate (push) Successful in 6m39s
windows-host / package (push) Successful in 13m21s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 15m53s
windows-host / canary-manifest (push) Successful in 25s
Reviewed-on: #153
2026-08-10 10:45:23 +00:00
enricobuehler 548eb4fa14 Merge pull request 'gamescope aborted on every connect — a PipeWire use-after-free wearing an "unreachable" assert' (#152) from worktree-gamescope-pipewire-buffer-abort into main
arch / build-publish (push) Canceled after 27s
ci / rust (push) Canceled after 36s
ci / rust-arm64 (push) Canceled after 36s
ci / web (push) Canceled after 36s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (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
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
Reviewed-on: #152
2026-08-10 10:45:05 +00:00
enricobuehler 84faeb1bf1 docs(pf-encode): Linux Main10 is live — the 'inert until Phase 5.1' comment outlived the code
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m36s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 1m15s
ci / web (pull_request) Successful in 1m52s
ci / docs-site (pull_request) Successful in 1m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m26s
ci / rust (pull_request) Failing after 4m39s
ci / rust-arm64 (pull_request) Successful in 5m35s
android / android (pull_request) Successful in 5m44s
nix / flake (pull_request) Failing after 18m37s
The bit_depth field said '8 on Linux until Phase 5.1 lands a P010 capture path'.
The code outran it: the gamescope HDR capture patches offer 10-bit BT.2020/PQ,
nvenc_fmt maps X2Rgb10/X2Bgr10 to ARGB10/ABGR10, and is_ten_bit_input flips
bit_depth and hdr from the negotiated input. Verified on home-nobara-1:
'resolved session plan ... bit_depth: 10, hdr: true' on the direct backend.

A 10-bit frame deliberately takes neither the NV12 nor the YUV444 convert (both
compute CSCs write 8-bit planes) and rides packed RGB to the encoder, which does
its own BT.2020 CSC — pf-capture/src/linux/pipewire.rs owns that gate. So Main10
needed no P010 path to arrive, and P010 is now a perf follow-up (skip NVENC's
internal CSC, as NV12 does for SDR), not the thing that makes 10-bit work.
2026-08-10 12:39:46 +02:00
enricobuehler ad63994cb9 fix(pf-encode): the 10-bit probe was the last ffmpeg NVENC open on a direct-SDK host
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m3s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m59s
android / android (pull_request) Canceled after 3m11s
ci / rust (pull_request) Canceled after 3m13s
ci / rust-arm64 (pull_request) Canceled after 3m13s
nix / flake (pull_request) Canceled after 2m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 1m9s
`can_encode_10bit`'s Linux NVIDIA arm answered "can this GPU encode 10-bit?" by
opening an ffmpeg `hevc_nvenc` encoder. On a host that then streams over the
direct SDK, that is the LOG-3 field bug: one ffmpeg NVENC open in a direct-SDK
process wedges every later open process-wide with `NV_ENC_ERR_INVALID_VERSION`
until the host restarts.

`can_encode_444` was moved off the ffmpeg probe for exactly this reason on
2026-07-27. The 10-bit one was deliberately left behind, on the reading that
"Linux HDR genuinely rides the libav P010 path". `open_video` contradicts that:

    if cuda && nvenc_direct_enabled() {          // no 10-bit exclusion
        … NvencCudaEncoder::open(…, bit_depth, …)

A CUDA capture goes to the direct backend at whatever depth was resolved, and
`is_ten_bit_input` already accepts the packed 10-bit RGB (`X2Bgr10`) that a
gamescope HDR capture negotiates. So on a default NVIDIA host the probe was
loading ffmpeg's NVENC client for a session that never uses it.

Observed on home-nobara-1 2026-08-10, gamescope + RTX 5070 Ti, client HDR on:

    resolved session plan … bit_depth: 10, hdr: true
    pipewire format negotiated … xBGR_210LE mapped=Some(X2Bgr10) modifier=0 hdr=true
    encoder submit failed — encoder rebuilt in place … NV_ENC_ERR_INVALID_VERSION
    encoder did not recover after repeated in-place rebuilds — ending the video session

and with `PUNKTFUNK_NVENC_DIRECT=0` (nothing mixes, libav serves everything) the
same HDR session streams clean: 0 errors, bit_depth=10, hdr: true.

The 10-bit cap now rides `nvenc_cuda::probe_support()`'s existing throwaway
session — the same place the 4:4:4 cap already rides, queried per listed GUID
with `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, which is what the Windows NVENC arm has
always done (`enc/windows/nvenc.rs`). Unanswered fails CLOSED: an 8-bit session
beats a wedged one. A host that will really serve over libav
(`PUNKTFUNK_NVENC_DIRECT=0`, or a build without `--features nvenc`) keeps the
ffmpeg probe, where it validates the actual path and ffmpeg's client is loaded
anyway.

⚠ NOT YET VALIDATED ON GLASS. Gates are green — clippy `-D warnings` with
`--features nvenc,vulkan-encode,pyrowave` on linux/amd64, 67 pf-encode tests,
fmt — but the end-to-end HDR run is still owed. This branch is 42 commits behind
main and its build cannot complete a punktfunk/1 handshake on home-nobara-1 at
all (it stalls between "audio channels resolved" and "encode bit depth" and
times out at 10 s, on EVERY attempt). That stall is NOT this change: a control
build with only the routing reverted stalls identically, and the released
0.27.0 RPM on the same box handshakes fine and reaches `bit_depth=10`. Rebase
onto main before re-testing.
2026-08-10 12:30:24 +02:00
enricobuehler bac63059a9 feat(apple): drive the console UI from a hardware keyboard
Asked for by a field user: "make the iPadOS client compatible with keyboard to
select games with keyboard arrows, enter to launch". An iPad on a Magic
Keyboard and a couch Mac are the same situation the console layout was built
for — a screen driven from a distance with a fixed set of directional inputs —
and the cursor/confirm/back model already exists here for the pad. A keyboard
is a third input onto it, not a new navigation scheme: arrows move, Return and
Space activate, Esc backs out, everywhere the controller already worked
(carousel, menu lists, prompts) plus the plain poster grid.

`active` mirrors each caller's existing controller gate rather than being a
second, parallel notion of "who has input". Without that, a launcher sitting
under an open screen would keep eating key presses and navigate behind it —
the same defect the pad gate exists to prevent. Esc returns `.ignored` when a
screen has no back action, so it still reaches the `.cancelAction` shortcut
that closes a macOS sheet.

The plain grid needed real arithmetic rather than a flat index. It renders up
to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by
the wrong amount at the boundary whenever the first section's last row is
partial — up from the titles' first row lands mid-launcher-row instead of
above. `LibraryGridNav` moves within a section and hands off at its edges
preserving the column, clamping into partial rows. It lives in PunktfunkKit
because it is edge-case arithmetic and that is the target tests can reach; 12
cases cover the partial row, the hand-off, a stale cursor, an empty grid and a
zero column count.

The column count comes from the grid's MEASURED width run through
`.adaptive`'s own fitting rule, so up/down move exactly one visual row instead
of a guess that drifts with window size. Measured via a background
GeometryReader — a sibling inside a ScrollView would claim the whole viewport.

The grid cursor starts nil and only appears on the first arrow press, so a
touch user is never shown a selection they didn't ask for.

tvOS is excluded throughout: its focus engine already routes hardware arrows,
and these screens hand it navigation authority deliberately.

17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings
verified rendering and navigating in the iPad Pro 13" simulator.
2026-08-10 09:59:28 +02:00
enricobuehler 3daead7d71 fix(apple): the sub-screens' legends sit where the launcher's does
Carries the display-corner inset (previous commit) through the three screens
that pin the same legend pill — settings, add-host, pair — so they don't drift
from the launcher the moment the user opens one. Each of those trays is
`maxWidth: .infinity, alignment: .leading`, so the pill is left-aligned there
too and the iPad's short centre indicator is never in its way; the keyboard
tray sits ABOVE the legend in the stack, so pulling the tray down doesn't put
keycaps over the strip.

And the settings tab strip now takes its padding from the row metrics instead
of two fixed numbers. It was the other half of the iPad sizing complaint: the
tier work grew the fields under it while the tab pills stayed at phone scale,
which read as a tab bar that belonged to a different screen.

macOS + tvOS typecheck.

Re-landed on the probe spelling: the corner inset is read from
`\.displayBottomInset` (DisplayBottomInsetProbe via ContentView), never asked
of UIKit during body — the original spelling froze the update graph of any
view that called it on device (see the legend-pinning commit).
2026-08-10 09:59:25 +02:00
enricobuehler 18d0009c35 feat(apple): the trust prompt takes controller input
Trust-on-first-use had no pad wiring at all, and it appears at the worst
possible moment for that: mid-connect, with capture already disabled so
ContentView can blur the stream and stop forwarding. A pad-only user pressed A
to connect, got this card, and found the controller in their hands doing
nothing — the only way past was to reach for the screen. A trusts, B cancels,
X runs the PIN ceremony instead.

The legend only appears with a pad attached; naming controller buttons to a
trackpad user would be describing hardware they don't have. `GamepadManager` is
observed rather than read once so the bar arrives if a pad wakes up while the
card is already open.

Safe to poll unconditionally while the card is up: capture is off for its whole
lifetime and the home screens are unmounted behind the session view, so nothing
else is reading the pad. The A press that STARTED the connect is typically
still held when this mounts — `GamepadMenuInput` adopts the held state without
firing on start, so it doesn't auto-trust.

tvOS is untouched: the focus engine already drives these buttons.

macOS + tvOS typecheck.
2026-08-10 09:58:26 +02:00
enricobuehler 8ef5350431 fix(apple): pin the launcher legend to the display's corner
On an iPad the controls pill sat two to three times further from the bottom of
the screen than from the left, so it read as floating rather than tucked into
the corner. `safeAreaInset` places its content INSIDE the safe area, so the
pill's bottom margin was stacking on top of the device's own ~24 pt
home-indicator inset while the leading margin had nothing to stack on.

Subtracting that inset is the fix; GETTING the inset is where this commit's
history is. Three spellings failed before this one, each silently:

  - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports no
    inset for an edge it has been told to ignore, so it can only answer 0;
  - `.ignoresSafeArea(.container, edges: .bottom)` on the inset CONTENT,
    which does not move content the inset mechanism itself placed;
  - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`)
    DURING body — which answers the right number and then KILLS the asking
    view. On a physical iPad (never the simulator) that walk re-enters UIKit
    layout mid-render and SwiftUI silently severs the view's update graph:
    from then on every `@State` write lands in storage without `body` ever
    running again. That is exactly how Settings and Add Host stopped opening
    while their triggers kept firing and `showSettings` kept reading true —
    no AttributeGraph warning, nothing in the log. Found by bisecting device
    builds; a build that opens the screen programmatically renders green with
    input-driven navigation completely dead, so only a real press counts.

So the inset is measured where it is knowable: DisplayBottomInsetProbe, a
UIView that reads its WINDOW's inset from UIKit's own callbacks
(didMoveToWindow / safeAreaInsetsDidChange / layoutSubviews), hops out of the
current update, and publishes through `\.displayBottomInset` from ContentView.
The legend reads the environment — proven safe on glass — and
`gamepadLegendBottomPadding` is pure arithmetic.

Tablets only. The padding goes negative to pull the pill back down through the
indicator strip, which is safe there because the pill is left-aligned and an
iPad's indicator is a short bar in the middle. A phone's indicator is taller
and its legend runs most of the width, so the same move would cross it —
phones keep the plain margin, and the tier comes from the size classes, so an
iPad in a narrow Stage Manager window is treated as the in-hand case it is.

macOS + tvOS typecheck; margins and Settings/Add Host opening from a real
controller press verified on the iPad itself.
2026-08-10 09:58:20 +02:00
enricobuehler 522ac7bd49 feat(apple): the connect path's modals are pad-navigable
`.alert` and `.confirmationDialog` are UIKit/AppKit surfaces: a game controller
cannot move through their buttons or press one. On iOS/macOS that made every
prompt in the connect path a dead end for a pad-only user, and they are not
incidental prompts — "Pairing required" is the FIRST thing an unpaired host
shows (so pairing was unreachable before it even got to a PIN), "Connection
failed" strands the console UI behind a modal only a finger can dismiss, and
"Waiting for approval" owns the only Cancel for a connect that may never
complete. GamepadPromptView renders those states as a console card with a
focus list of actions; the system alerts stand down while it is up. tvOS keeps
them — the focus engine drives them natively there, which is exactly why this
gap was invisible from that platform.

Two things are deliberate rather than incidental:

The gate is "not STREAMING", not `model.connection == nil`. A connection
object exists well before a stream does — through the pair-required and
approval handshakes, precisely when these fire — so gating on the connection
would hand those cases back to the system dialog. Streaming is the one state
that must keep the alert: there the pad belongs to GamepadCapture.

And the overlay hangs off `driven`, not `home`, for the same reason: `home`
renders only while the connection is nil, so a prompt mounted there would be
skipped in the very case it was written for.

The launcher stands down from the controller poll while a prompt is up
(`promptActive`) — without it the host carousel keeps scrolling underneath the
modal and one A press reaches both.

macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the prompts wired in.
2026-08-10 09:56:25 +02:00
enricobuehler 8ab4918923 fix(apple): the host strip stopped flashing as it settles
Reported from the iPad: the cards arrive, come to rest, and then flash. They
did — and it was not the motion. Every card sat at the WRONG fill for as long
as the entrance animation ran, then snapped to its real one in a single frame
once SwiftUI tore the animation down, roughly a third of a second AFTER the
last card had stopped moving.

`rotation3DEffect` was the cause. A real 3D transform renders its subtree
through an offscreen layer, and these cards carry translucent glass, which
resolves differently in there — so the strip showed the aurora through itself
for the duration and went opaque the instant the layer was dropped.

Measured on an iPad Pro 13", sampling the centred tile: it held #4a3d87 across
twelve frames in which nothing moved, then stepped to #423970 — 23/255 of blue
in one frame, on every card at once, while the backdrop beside them stayed
exactly #713fa0 throughout. Stretching the timeline from 1.02 s to 2.82 s moved
the step from 0.70 s to 2.50 s after launch: the same 0.32 s before the end in
both runs, which is what proves it is the animation ENDING and not any state
the tiles were waiting on. Deleting the rotation deleted the step outright.
`compositingGroup()` above and below the transforms each changed nothing, so
this is the 3D layer specifically, not compositing in general.

The turn is now projected instead of rendered: cos(angle) as a horizontal
squeeze IS the orthographic projection of a Y-axis rotation, hinged on the edge
the card fans from — which also puts the code behind the doc comment that
already described the fan that way (the sign of a rotation carried it before;
cos is even, so the anchor has to). Affine, so there is no offscreen pass and
no layer to drop. What's lost is the perspective trapezoid, which at card size
was never what sold the motion; the fan-out, the stagger and the overshoot are
untouched.

Also drops the temporary PUNKTFUNK_DIAG_OPEN hook that gated each step of this
branch in the simulator.

284 tests pass; macOS + tvOS typecheck; entrance re-recorded on the iPad Pro
simulator — the tile now settles monotonically (#4153cf → #383c77 → #39396e →
#393a6e) and holds, with the fan-out still reading as a turn.
2026-08-10 09:36:02 +02:00
enricobuehler 09bac99090 feat(apple): pairing you can actually do with a controller
PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives
those natively, but on iOS/macOS a controller cannot reach a text field, type
into it, or press the button underneath — so for anyone in the console UI,
pairing (the ONE thing between a fresh install and a first stream) ended at
"now touch the screen".

GamepadPairView is the same ceremony in the gamepad UI's own vocabulary: the
vertical focus list the settings and add-host screens use, A on a field to
open GamepadKeyboard in a bottom tray, B to peel one layer. It mirrors
GamepadAddHostView field for field, because it is the same interaction and
someone who has added a host should recognise it immediately.

The ceremony itself moved to a shared `PairCeremony` used by both
presentations, so they can never disagree about what a wrong PIN means, what
a host rejection says, or when a late result must be discarded.

On iOS it is a shell layer like settings and add-host, and it LEADS the
shell's screen order: it blocks a connect the user already asked for and can
be raised from on top of the library (launching a title on an unpaired host),
so it has to win; backing out reveals whatever it interrupted. macOS has no
shell, so its sheet switches content by mode instead. tvOS is untouched.

macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the pair screen wired into the shell.
2026-08-10 08:12:50 +02:00
enricobuehler 5db3b3c4fd feat(apple): the controls legend is clickable
Every hint cell that names an action is a real button on iOS/iPadOS/macOS.
The legend already lists every action a screen has, in one fixed place, so a
user without a pad in their hands — an iPad on a stand, a Mac driven by
trackpad, anyone running `gamepadUIMode == "always"` — was reading a complete
menu they could not press.

tvOS keeps them inert deliberately. There is no pointer there, so a tappable
cell would have to be FOCUSABLE, and that puts six new stops in the path of a
focus engine whose flow on these screens is load-bearing and hard-won — while
every action in the legend already has a native route (select, Menu,
Play/Pause, the focusable tab pills).

Cells that name an INPUT rather than an action stay labels: "↔ Adjust" is the
stick itself, and "A Type" over the on-screen keyboard has no tap equivalent
because a touch user types by tapping the keycap.

Two details that are load-bearing rather than tidy: the decorative hairline
gets `allowsHitTesting(false)` (it sits on top of the cells), and the press
style's `contentShape` sits below its `scaleEffect` so shrinking the artwork
cannot move the target out from under a resting finger and lose the touch-up.

macOS + tvOS typecheck; 272 tests pass; console UI verified opening Settings
in the iPad simulator.
2026-08-10 08:05:14 +02:00
enricobuehler a23c028492 fix(host): the console reported the resolution the client asked for, not the one it got
`/api/v1/local/summary` (and the console card behind it) read the live-stats mode
slot, which bring-up seeded from the NEGOTIATED mode:

    let live_mode = Arc::new(AtomicU64::new(pack_mode(
        mode.width, mode.height, interval_hz(interval))));

The refresh was already corrected there — the comment says so, because KWin caps
a virtual output's rate — but the SIZE was still the request. Only a mid-stream
resize ever fixed it: the rebuild path below publishes `delivered_mode(frame..)`,
and bring-up never did.

Attach is what makes this matter rather than being pedantry. On a box with a
physical display the gamescope backend logs

    gamescope: box drives a physical display — attaching at its own mode (no
    re-mode) client_w=5120 client_h=1440

and streams the panel's size. Measured on home-nobara-1 with a 1080p HDMI panel
attached: the capture negotiated 1920x1080 and NVENC opened 1920x1080@240, while
the summary reported 5120x1440 — the console confidently naming a resolution
nobody was watching, which is exactly the shape of the stale attach-path report
noted on .41 in July ("reusing w=5120 h=1440" while the session was really 1080p).

Seeding the slot from `delivered_mode(frame.width, frame.height, interval)` uses
the same helper the rebuild path already trusts, and changes only the two fields
that were wrong — its refresh term IS `interval_hz(interval)`, so that half is
bit-for-bit what it was.

This publishes the STATS slot only. It deliberately does not send the client a
corrective `Reconfigured`: that remains owed exactly where it already was, under
`adopted_at_bringup`, because an ordinary connect's mode came from the Welcome
rather than from an accept the client has already acted on.

Verified on home-nobara-1, attach session against a 1080p panel:
  summary session:   {"width":1920,"height":1080,"fps":240}
  actually captured: pipewire format negotiated width=1920 height=1080
Before the change the same session reported 5120x1440.
2026-08-10 07:55:18 +02:00
enricobuehler 5b3ea6e8db fix(pf-encode): one NVENC open failure could kill every session on the box
`punktfunk-host` died twice on home-nobara-1 with the same stack:

    __strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
      <- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2
      <- NvencEncoder::open <- NvencEncoder::reset <- virtual_stream

once as an outright SIGSEGV mid-session, and once as a thread wedged in that
stack so the service never answered SIGTERM and systemd escalated to SIGABRT
("State 'stop-sigterm' timed out. Aborting."). Both times a client's session was
rebuilding its encoder. The blast radius is the whole host process — every other
client's session goes with it.

The fault is in libav, not here. `ff_cuda_check` logs the failing CUDA call as
`"%s failed -> %s: %s"` using an `err_name`/`err_string` pair the error lookup
does not always fill, and glibc then walks whatever was on the stack. We cannot
patch the distro's FFmpeg, so the fix denies it the chance to format: the guard
already used by the 4:4:4 probe drops the level to AV_LOG_FATAL across the open,
and `av_log_default_callback` returns on the level check before `format_line` —
these messages are AV_LOG_ERROR. The failure is not swallowed; it still comes
back as `Err(e)` and is reported with our own context, which now says the libav
text was deliberately silenced so nobody hunts for a message that will not come.

Scoped to the `open_with` call ALONE. The ENOSYS arm immediately below recurses
into `Self::open`, and `QuietLibavLog` holds a non-reentrant global mutex —
wrapping the whole `match` would have deadlocked the intra-refresh retry.

Verified on home-nobara-1 (fc44, libavcodec 62). With CUDA made unavailable so
the open fails inside the CUDA layer, the old binary prints

    [hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable
    device is detected

— that line IS `ff_cuda_check` formatting the two `%s` — and the fixed binary
does not; both exit 1 with our error instead. A successful open is unaffected on
both the direct-SDK and the libav paths (90/90 frames, identical output size).

What this does NOT claim: the uninitialized-pointer condition itself was not
reproduced on demand — it depends on the CUDA error lookup failing to fill the
strings, and in the forced case above it filled them fine. What is demonstrated
is that the formatting call which faulted is no longer reached during the open.
2026-08-10 07:46:47 +02:00
enricobuehler ffb1ecfebe feat(apple): size the console UI for the iPad
GamepadFormMetrics had exactly two tiers, tvOS and everything-else, so a 13"
iPad Pro drew its settings rows, section tabs and legend at iPhone scale in
the middle of a very large display — the field verdict was that the sizing
"does not adapt to larger screens". There is a third tier now, between the
in-hand and 10-foot sets.

Chosen from the SIZE CLASSES, not the device idiom: an iPad in a narrow Stage
Manager or Split View window keeps the in-hand numbers, because what is being
read is the window, not the panel behind it. Phone sizing is untouched.

Published from ContentView — the app root — rather than only from
`gamepadPaletteInk`. A screen that applies that modifier itself sits ABOVE its
own copy of the environment, so its `@Environment` resolves against its parent
and would read the bare phone default no matter what the modifier published.
The screenshot harness publishes it too, for the same reason: it mounts these
screens with no ContentView above them, so an iPad capture used to render the
whole gamepad UI at iPhone scale.

macOS + tvOS typecheck; the console UI verified in the iPad simulator opening
Settings at the tablet tier.
2026-08-10 07:44:36 +02:00
enricobuehler 08e462fee9 fix(apple): Graphite's selected settings tab was white on light grey
The tab strip's selected pill is FILLED with the palette accent, and
`ink.onAccent` — chosen by that accent's own luminance, and documented for
"a filled pill's label" — has existed for exactly this since the palettes
landed. The strip was using the plain foreground instead, which is white on
every dark palette and therefore looked fine on all but one: Graphite's
accent is a light grey (luma ≈ 0.80), so its selected tab read white on
white.
2026-08-10 07:41:43 +02:00
enricobuehler 2590238b8f fix(apple): the console sub-screens' backdrop, glass and option band
Four things the field reported on glass, all in the console's visual layer.

The tray scrim is GONE, not re-tuned. It laid `.ultraThinMaterial` across the
top and bottom of every form screen, and a material by definition lifts and
tints whatever it blurs — so it read grey, and washing it with the palette's
ground only made it read coloured. There is no public way to blur a backdrop
without that tint, so the layer had to go. The darkening it also provided was
never its to provide: the backdrop's own legibility scrim
(0.38/0.06/0.08/0.40 — the same gradient pf-console-ui bakes into its shader)
has always been one layer down, which is why the console has no tray band.

Pale palettes rendered every row, pill and card as a flat white slab, and
lowering the tint did nothing, because the opacity came from the glass BODY:
`.regular` is a bright, high-body material and a pale palette's `ink.glass` is
literal white. Pale palettes take `Glass.clear` now, with a light wash to keep
dark ink legible.

A focused settings row changed colour in a hard jump, a beat late, while its
scale animated smoothly beside it — the focus tint rode `Glass.tint`, and a
Glass value is opaque to SwiftUI's animation system. It is a plain fill
between the glass and the label now, so the existing animation covers it.

The host cards appeared to be swapped for different ones as their entrance
landed: `CardEntrance` swings each tile in on a rotation3DEffect, and Liquid
Glass samples the backdrop through its own layer, which it cannot do under a
3D transform. Those tiles take the material path (`forceMaterial`).

And the option band never turned like a cylinder because the band MASKED
itself: a mask rasterises what it covers, flattening `rotation3DEffect`'s
perspective, so the projection was computed and discarded every frame. The
soft edge is folded into each option's own opacity instead. Its ±1 neighbours
stay hidden at rest — showing them reproduced the documented overlap defect
("2752 × 2064" with "280 ×" through it), which is why they were dropped.

Also fixes pale palettes washing out: the calm mix added a plusLighter wash of
a near-white ground on top of a field already mixed toward it, saturating the
form screens to white.

macOS + tvOS typecheck; console UI verified opening Settings in the simulator,
with no tray band.
2026-08-10 07:33:11 +02:00
enricobuehler beb639f067 fix(ui): the OLED palette is called Eclipse now
Every other background reads as a place or a material — Violet, Nebula,
Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and
"OLED" was a spec sheet sitting among them, naming the panel it suits rather
than what it looks like. It looks like black with a thin violet corona at one
corner, which is an eclipse, and that puts it beside Nebula and Abyss.

The ID stays "oled" in all three clients: it is the stored ui_palette value
AND the cross-client key, so renaming it would orphan every saved choice and
silently desync Apple, Android and the console UI. Only the label moved. No
test or doc pinned the old string.

Palette tests pass; macOS builds.
2026-08-10 07:30:30 +02:00
enricobuehler 90450ff1f6 feat(apple): the legends remember which pad you own
`buttonGlyph` asked the LIVE controller for its `sfSymbolsName` and fell back
to generic letter glyphs, so the moment a DualSense slept, disconnected or
ran flat, every ✕/◯ hint turned into A/B under its owner's hands. Under
`gamepadUIMode == "always"` — the console UI up with no pad by design — they
were never right at all.

GamepadManager now remembers the family of the last controller that was
actually attached (`DefaultsKey.lastGamepadKind`, written on selection and
deliberately never cleared on disconnect), and the legends resolve live →
remembered → generic. Not a user-facing setting: a glyph-style picker is one
more row asking a question the app can answer itself, and the remembered pad
is right essentially always.

GamepadGlyphs maps by POSITION, not by name, because that is what
`GCExtendedGamepad`'s buttonA/B/X/Y are. So ✕ is role `.a` (the bottom
button), and a Switch pad gets Nintendo's transposed labels rather than Xbox
letters printed on the wrong physical buttons.

The test asserts every symbol name RESOLVES on the running OS. That is the
load-bearing one: `Image(systemName:)` draws an unknown name as nothing at
all — no crash, no log, no red build — so a typo would silently blank a
legend on device and no amount of building would catch it.

macOS + tvOS typecheck; the 5 glyph tests pass; the console UI verified
opening its Settings screen in the iPad simulator.
2026-08-10 07:29:33 +02:00
enricobuehler 890b67a863 fix(validation): v5 called jitter a leak — a leak is a trend, not a spread
v5's verdict was `max - min` over the sampled fd counts with a default tolerance
of 0. An encode worker's fd count legitimately moves by one when a dmabuf fd is
in flight at the sampling instant, so the spread was permanently 1 and the leg
failed on a perfectly healthy box — reported, like every red leg here, as "a
shipping blocker, not a flake".

Measured on home-nobara-1 (KDE, RTX 5070 Ti), 33 samples over 480 s:

    54 54 54 54 54 54 54 54 55 55 54 54 54 55 54 54 55 54 54 54 55 54 …54

It oscillates and ENDS on 54, exactly where it started. Nothing accumulates.

The replacement is median-of-thirds: median(last third) - median(first third).
That is strictly MORE sensitive to what R2 is actually about — a steady leak
moves the trend just as much as it moves the spread, while bounded jitter moves
only the spread — so this is not the tolerance being widened to get a green.
The spread is still printed, now labelled as jitter when the trend is flat. The
warm-up window already covers the one-off first-sight-of-each-buffer cost, so a
plateau inside it is by design not a leak; a step that never comes back still
trends and still fails.

The self-test grows the cases that force this to be a real assertion: the
measured oscillation must trend to zero, a synthetic leak must still trend up, a
flat series must be flat, and a step that never returns must be caught. Writing
them is what caught my own arithmetic — the first draft asserted a leak trend of
12 where the reader correctly says 10.

Also records what the v5 log now makes obvious: `--minutes` does NOT set the wall
clock. `spike` is frame-count bounded (`seconds * fps`), and a KWin virtual
output being driven hard delivers ~197 fps against a `--fps 60` budget, so a
"10 minute" run ended after 182 s. Ask for more minutes than you want.
2026-08-10 03:04:05 +02:00
enricobuehler 9cdbfabd4d fix(validation): v4.e demanded a rung the spike vehicle cannot reach
v4.e killed the worker mid-session and then required "the encode worker died
mid-session" in the spike's log. That line, and the respawn that follows it, are
emitted by `RemotePyroWave::reset` — and the only caller of `Encoder::reset` is
the real session's `reset_stalled_encoder` loop in native/stream.rs. `spike` is
a dev tool with no recovery loop at all: it does

    encoder.submit(&frame).context("encoder submit")?

and exits. So a worker killed under the spike can never reach reset, the line
can never appear, and the leg reported

    FAILED — a red leg here is a shipping blocker, not a flake.

for a ladder rung the product implements correctly. A false negative in the one
place that must not have one: this kit exists to refuse false PASSes, and a
false FAIL spends exactly the same credibility.

Verified on glass first, so the rung is not being excused on a reading of the
source. home-nobara-1 (KDE, RTX 5070 Ti), real client session, worker pid 44249
killed with -9: `video_streaming` stayed true across the kill, and the host
logged

    pyrowave: respawned the encode worker after a mid-session death
      worker=/usr/bin/punktfunk-encode-worker priority=Granted(Realtime)
    encoder submit failed — encoder rebuilt in place, forcing an IDR
      error=... Broken pipe (os error 32) reset=1 max=5

v4.e now asserts the half the spike can actually observe — the death surfaces as
an ATTRIBUTABLE worker-IPC error naming the worker, after real encode windows,
and the host process does not die with it. A hang, an unexplained failure, or a
dead host still fails. The respawn half is printed as the human follow-up, in
the same idiom v1 already uses for its on-glass half, and written into `recipe`
with the two commands that close it.
2026-08-10 02:38:55 +02:00
enricobuehler fdef4c90ce fix(gamescope): stop the PipeWire use-after-free that aborted a session on every connect
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / rust (pull_request) Successful in 7m25s
A managed gamescope session on Nobara 44 (VM 123) died on essentially every
client connect. The visible symptom was a black screen; underneath,
`punktfunk-gamescope` was SIGABRT crash-looping — 11 coredumps in three minutes
— until `gamescope-session-plus` ran out of retries and came up on the *stock*
`/usr/bin/gamescope` at its default 1920x1080, which looks like a working game
mode and carries none of our capture patches.

    punktfunk-gamescope: ../src/pipewire.cpp:88: void destroy_buffer(
      pipewire_buffer*): Assertion `false' failed.
    #4  __assert_fail
    #5  destroy_buffer(pipewire_buffer*).cold

The abort is a use-after-free wearing an `assert(false); // unreachable`.
`pw_buffer->user_data` is associated with its `pipewire_buffer` in exactly one
place, at the bottom of `stream_handle_add_buffer` — after all four `goto error`
paths, whose label is a bare `delete buffer`. And `stream_handle_remove_buffer`
clears `buffer->buffer`, the only route back to the `pw_buffer`, while a still-
`copying` buffer is deleted later on the steamcompmgr thread with no way to
reach the slot. PipeWire recycles `pw_buffer` slots across renegotiations, so
the next remove reads `buffer->type` out of freed memory, falls off the end of
the switch and aborts.

The host sets the session to the client's mode on connect, and that mode change
is what renegotiates the stream — which is why "every connect" was the trigger.

Patch 0007 fixes the association rather than the symptom: set `user_data` at
allocation so it is valid on every path out of `add_buffer` and clear it on the
error path; clear it in `remove_buffer`, the last point both halves are known;
null-check the two consumers. The `default:` arm then logs instead of aborting.
Offered upstream — nothing about it is punktfunk-specific.

Two traps this cost time on, both now written down in the README:

  * It is NOT HDR-specific. The abort was first seen right after a 10-bit
    stream negotiated, so `PUNKTFUNK_GAMESCOPE_HDR=0` looked like a workaround.
    The failing argv carries no `--hdr-enabled` at all.
  * `gamescope-session-plus` hides it by falling back to stock gamescope, so a
    session existing proves nothing — read the banner.

`.pfhdrN` moves to 5 even though no capability moved: every deployed pfhdr4
binary crash-loops, so an operator needs to be able to tell them apart. All
`>=` thresholds in the host's probe are unaffected.

Also documents `libstdc++-static` as a build dependency — it is punktfunk's
requirement (the script links the C++ runtime statically on purpose), so no
`dnf builddep` will ever pull it, and without it meson fails with a message
naming neither the flag nor the package.

Verified on VM 123 with the patched binary installed: 5 rapid connect/
disconnect cycles plus 3 further sessions, zero new gamescope coredumps (43
before, 43 after), Steam game mode streaming real content at 5120x1440, and
`/tmp/chimeraos-short-session-tracker` never created — the short-session latch
that used to strand the box in plasma was downstream of this crash.
2026-08-10 02:16:22 +02:00
enricobuehler 8508f8f3c3 Merge pull request 'The gamescope bind is armed on every gamescope box, takes a user namespace that breaks Xwayland, and leaves a drop-in that bricks the next boot' (#151) from worktree-bind-userns into main
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m31s
ci / rust-arm64 (push) Failing after 1m49s
apple / screenshots (push) Canceled after 43s
ci / bun-nix (push) Successful in 42s
android / android (push) Canceled after 2m38s
ci / rust (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 1m27s
deb / build-publish-host (push) Canceled after 9s
deb / build-publish (push) Canceled after 8s
deb / build-publish-client-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 10m41s
windows-host / package (push) Successful in 12m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m38s
Reviewed-on: #151
2026-08-09 22:41:40 +00:00
enricobuehler 6695300b67 fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box
was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that
hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace,
and only this uid is mapped in it. Measured on the box:

    on disk / in a unit without the bind :  drwxrwxrwt 2 0 0          /tmp/.X11-unix
    in a unit WITH the bind              :  drwxrwxrwt 2 65534 65534  /tmp/.X11-unix
    uid_map inside                       :  1000 1000 1

wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:

    wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us
    wlserver: [xwayland/sockets.c:217] No display available in the first 33
    -> SIGSEGV in run_pipewire

Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and
steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is
"thrown onto KDE and I can't get back" — two removes from the cause.

Two further bugs found while fixing it, each worse than the one reported:

  * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`,
    so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite,
    SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path
    ones the mechanism exists for. Now the host READS the session script and arms only where it
    never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is
    bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm.

  * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so
    it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in
    tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a
    missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now
    lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the
    legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host
    start — which is the upgrade path for every box already running canary g13179011. Without it,
    updating the host would not un-brick them.

  * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper
    execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over.
    Fork bomb. Refused ahead of even the operator's force.

Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11
bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees
"us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are
pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away.

And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field
reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same
args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a
rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock
gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a
session launched with the bind armed produces no node in its window, latching one-way per process.

The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side
X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`,
and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader
is never constructed.
2026-08-10 00:39:18 +02:00
enricobuehler 73d435b967 Merge pull request 'The driver clippy gate has been red on main since the Xbox pad landed' (#150) from worktree-drivers-clippy into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m25s
ci / bun-nix (push) Successful in 28s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 9m34s
windows-drivers / probe-and-proto (push) Successful in 21s
windows-drivers / driver-build (push) Successful in 1m39s
windows-host / package (push) Successful in 12m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
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 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
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 / builders-arm64cross (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 58s
docker / deploy-docs (push) Successful in 42s
Reviewed-on: #150
2026-08-09 21:55:42 +00:00
enricobuehler c7df7b45af fix(drivers/pf-gamepad): the three Xbox identities as a range — the driver clippy gate is red on main
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 37s
ci / rust-arm64 (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 11m37s
windows-drivers / probe-and-proto (pull_request) Successful in 23s
windows-drivers / driver-build (pull_request) Successful in 1m43s
`cargo clippy --all-targets -- -D warnings` over the shipped drivers (the step that
enforces the unsafe-audit gates) fails on main since #149 landed: clippy 1.96's
`manual_range_patterns` fires on all five `4 | 5 | 6` device-type arms, and `-D warnings`
turns each into an error, so `pf-gamepad` fails to compile as both lib and lib-test and
the whole step never reaches the other five crates.

Device types 4/5/6 are the Xbox Wireless / One S / Elite Series 2 identities added by
#149 — contiguous by construction, so `4..=6` is the same set. Purely a lint fix: no arm
gains or loses a device type, and the comments that already record *why* the three share
one report shape, one descriptor and one vendor string are untouched.
2026-08-09 23:46:43 +02:00
enricobuehler 5d7091bf87 Merge pull request 'The Windows Xbox pad: make games actually see it' (#149) from worktree-xbox-pad-wgi-visibility into main
apple / swift (push) Successful in 1m35s
windows-drivers / probe-and-proto (push) Successful in 26s
windows-drivers / driver-build (push) Failing after 1m40s
android / android (push) Successful in 6m12s
ci / rust-arm64 (push) Successful in 5m5s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 5m31s
arch / build-publish (push) Successful in 8m28s
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
release / apple (push) Successful in 10m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m4s
docker / deploy-docs (push) Skipped
ci / rust (push) Successful in 12m22s
deb / build-publish-client-arm64 (push) Successful in 4m27s
deb / build-publish-host (push) Successful in 7m16s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish (push) Successful in 9m58s
apple / screenshots (push) Successful in 6m11s
windows-host / package (push) Canceled after 12m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 9m46s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m31s
nix / flake (push) Failing after 21m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m26s
Reviewed-on: #149
2026-08-09 21:36:29 +00:00
enricobuehler e19f11bb0d feat(abi/apple): carry the trigger motors to non-Rust clients — ABI 18, next_rumble_cmd2
windows-drivers / probe-and-proto (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 2m57s
ci / bun-nix (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m36s
android / android (pull_request) Successful in 4m0s
ci / rust-arm64 (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Failing after 9m15s
nix / flake (pull_request) Successful in 15m3s
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.

    PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
        PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
        uint16_t *left_trigger, uint16_t *right_trigger,
        uint32_t *backstop_ms, uint32_t timeout_ms);

⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.

⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.

The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.

Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.

🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.

VERIFIED
  * `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
    clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
  * The generated header is regenerated and idempotent on re-run (CI diffs it).
  * SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
    in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
    `PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
    resolves both, and the `DualSenseHID` references are untouched by this change.)
  * `cargo build -p punktfunk-host` clean on Windows.

NOT VERIFIED
  * End to end — see above; there is no producer.
  * Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
    no code, but the positive case is untested.
  * `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
    only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
    it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.

ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
2026-08-09 23:30:02 +02:00
enricobuehler 7f1f7ba87c fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with

    error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
    (install/repair: punktfunk-host.exe driver install --gamepad)

and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.

WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.

The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.

  * `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
    which separates what the OS collapsed — object-manager lookup precedes the access check, so
    absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
    mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
  * `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
    prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
  * `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
    the fix that matters: every probe an operator runs next will still find a device on that index.
  * `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
    can outlive its session.

DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.

`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.

VERIFIED
  * ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
    clean. Both agents' Windows code was compile-UNVERIFIED before this run.
  * macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
    context chain the Windows code builds — the assumption that could not otherwise be checked.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
    than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
    lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
    the devtest from an elevated console, and check the new sentence appears.
2026-08-09 23:29:30 +02:00
enricobuehler d39843a858 Merge pull request 'The Game Mode takeover blamed polkit for a group it never named, and prescribed two remedies that cannot work' (#148) from worktree-dm-helper-preflight into main
apple / swift (push) Successful in 1m50s
ci / web (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 3m7s
ci / docs-site (push) Successful in 1m14s
ci / bun-nix (push) Successful in 30s
android / android (push) Successful in 6m53s
arch / build-publish (push) Successful in 8m35s
apple / screenshots (push) Successful in 6m15s
deb / build-publish (push) Successful in 3m43s
deb / build-publish-client-arm64 (push) Successful in 3m34s
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 43s
windows-host / package (push) Successful in 12m7s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 11m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 41s
windows-host / canary-manifest (push) Successful in 25s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 9m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m29s
Reviewed-on: #148
2026-08-09 21:17:36 +00:00
enricobuehler fb309e0262 fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m9s
ci / rust-arm64 (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 19m29s
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:

    the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
    package, or install the display-manager polkit rule from the docs)

Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:

    pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
      Grant it with: sudo usermod -aG punktfunk nobara-user   (then re-login)

That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.

Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.

The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.

A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.

Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
2026-08-09 23:15:34 +02:00
enricobuehler 94c2f62490 test(tools): a GameInput probe — and it cannot see our promoted Xbox pad
`win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has
no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports
whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives
`SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is
positional, so a wrong slot calls a different method with the wrong signature.

WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never
exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four
(`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API
that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit
assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1
(the triggers) are inferred from field order and nothing else.

TWO THINGS MEASURED ON .173, 2026-08-09:

1.  GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably
   returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`:
   the API looks like a query and is really a cache someone else fills. A bounded poll fixes it.
   ⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing:
   `GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the
   default already admits background input. Do not "fix" this with `SetFocusPolicy`.

2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and
   sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads —
   and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment.

⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that
nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput
structurally cannot; GameInput can but does not see it.

⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides
the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use,
a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap
and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same
way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a
property of GameInput in a non-interactive session and not our defect — the same shape as the WGI
`ts=0` row, which a real Elite reproduced.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS).
  * Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and
    `SetRumbleState` is accepted.
  * The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than
    failing to link or crashing.

NOT VERIFIED
  * That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing
    observable was checked on that device, and it never reached ours.
  * `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the
    struct has variable-size members whose layout would have to be mirrored exactly; nothing here
    needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad
    has trigger motors" — worth adding if this line of enquiry continues.
2026-08-09 22:55:17 +02:00
enricobuehler 2b1843ed1c fix(drivers/pf-gamepad): the right stick is Z/Rz — as declared, it was dead
Found on glass, first real streaming session: everything worked except the right stick, and Steam
correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`.
`xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and
does not treat `Rx`/`Ry` as one, so those two axes reached nothing.

Two usage bytes. Left and right were declared identically here — same collection, same globals,
same size and count — so the usages were the entire difference, which is what makes the diagnosis
airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its
right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came
from.

⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the
host-side packing are untouched. This is a pure relabelling.

🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement
because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes
at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true,
and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing
is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six
axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they
are not crosstalking onto each other's bytes.

MEASURED ON .173, same run shape before and after, devtest sweeping all six axes:
  before:  LX [-11264..24576]  LY [-32768..31744]  RX [0..0]        RY [-1..-1]       LT [0..248]  RT [7..255]
  after:   LX  [-8192..26624]  LY [-32768..31744]  RX [-32768..31744] RY [-24576..10240] LT [0..248]  RT [7..255]

VERIFIED
  * `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout
    tests still pass because nothing moved.
  * Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert
    is undisturbed.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * Not yet re-tested in a real streaming session — that is the next on-glass run.
  * ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the
    session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap
    mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently
    measures the stale pad instead. Restarting the service releases it. Worth its own look.
2026-08-09 22:19:25 +02:00
enricobuehler 4f9071b980 feat(pads/windows): three Xbox identities — Wireless, One S and Elite Series 2
Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a
client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion,
which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and
two identities join it:

  devtype 4  045E:0B13  pf_xboxwireless  Xbox Wireless Controller
  devtype 5  045E:02FD  pf_xboxones      Xbox Wireless Controller (One S)
  devtype 6  045E:0B22  pf_xboxelite     Xbox Elite Wireless Controller Series 2

`GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test
previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the
sentinel moved to 12. The C ABI mirror and the generated header moved with it.

 ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the
descriptor is the report shape, not the identity. §3 of the handoff records that our single
hand-written descriptor already cost three separate bugs, and inventing two more would multiply
that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device
Manager description only.

⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That
was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list
containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on
it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than
from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read
live by classic XInput. Had this gone the other way the two new identities would have been strictly
worse than the one they joined.

The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only,
so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its
`Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at
runtime, mirroring `degrade_if_no_uhid`.

VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest
is `cfg(windows)`):
  * `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`,
    `hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`,
    all now sweeping the whole identity set and asserting the section split in both directions.
  * `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean.
  * Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the
    descriptor shared three ways.
  * ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own
    devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each
    registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`).
  * macOS: `cargo fmt --all --check` clean in both workspaces.

NOT VERIFIED / NOT DONE
  * **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once
    `xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle
    fields and the HID consumers that do may be locked out, so the buttons would likely reach
    nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see
    whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual
    pad with native back-button slots and nothing should be advertised otherwise.
  * **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no
    Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited
    client setting. All five clients ship the same curated six options by deliberate parity, so
    adding one is a cross-client UX change, not part of this.
  * Nothing here has run in a real streaming session; every measurement came from the devtest.
2026-08-09 21:41:53 +02:00
enricobuehler 1317901122 Merge pull request 'Uninstalling the Windows host left every audio device it minted behind forever — and the installer script documented that as a decision' (#145) from worktree-win-audio-uninstall-cleanup into main
android / android (push) Failing after 1m32s
ci / rust-arm64 (push) Successful in 1m53s
apple / swift (push) Successful in 1m34s
ci / bun-nix (push) Successful in 22s
ci / web (push) Successful in 1m48s
ci / docs-site (push) Successful in 1m44s
deb / build-publish-client-arm64 (push) Successful in 1m44s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 7m24s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 9m40s
deb / build-publish-host (push) Successful in 7m28s
windows-host / package (push) Successful in 13m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
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 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 8s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
docker / deploy-docs (push) Failing after 6m14s
Reviewed-on: #145
2026-08-09 19:30:31 +00:00
enricobuehler 620f017d9a fix(validation): tell the CAPTURE it is a PyroWave session, or NVIDIA hands it a CUDA buffer
`--codec pyrowave` selects the ENCODER. The capture pipeline picks its consumer from
`ZeroCopyPolicy::pyrowave_session`, which on the spike path is fed only by the global
`PUNKTFUNK_ENCODER=pyrowave` lab lever (punktfunk-host/src/capture.rs). Without it, .21 resolved

    capture pipeline resolved: cuda-import -> nvenc   capture_arm="cuda-import" consumer="nvenc"
    zero-copy: dmabuf imported to CUDA (no CPU copy)  nv12=true

and the wavelet encoder refused the payload on its first submit: "unsupported FramePayload (need
Dmabuf or Cpu RGB)". That is not a worker bug — the arm that failed was the pure in-process one.

It reproduces only where the A/B actually lives. An AMD box has no CUDA arm to pick, so .25 resolved
straight to dmabuf-passthrough and the kit looked correct there. With the lever set, .21 resolves
`dmabuf-passthrough -> pyrowave` and both arms encode 2700/2700 frames.

V3b then passes on .21 (RTX 5070 Ti, GRID 2 at ~100% GPU, 5120x1440 — the portal captures the real
monitor, --width/--height being synthetic-only):

    in-process, refused       p50 2.85 ms   p99 8.39 ms   (10 windows)
    capped worker, granted    p50 2.65 ms   p99 4.10 ms   (11 windows)
    p99 delta -4.29 ms

The worker reports `priority=Granted(Realtime)` with `ext=VK_KHR_global_priority` on the FIRST
attempt and logs no fallback line; the refused arm logs "every global queue priority class was
refused". So the capability still buys the lever from a SEPARATE process, with the IPC hop in the
loop — 8.39 -> 4.10 ms is a 51% p99 cut, against PW1's in-host 6.4 -> 4.4 at 1080p. Different
resolution and a harder load, so treat the class as confirmed and the absolute numbers as not
comparable to PW1's.
2026-08-09 21:23:57 +02:00
enricobuehler 854b14a52e fix(validation): a leg that never ran must say so, not blame the arm
The V3b run on .21 died with `open portal capturer: timed out waiting for the ScreenCast portal` —
a GNOME consent dialog nobody answered — and the kit reported "arm A is not the in-process arm".
That is false: the arm was constructed correctly (`PUNKTFUNK_ENCODE_WORKER=off` is right there in
the captured env header), it simply never reached encoder-open, so the line the assert looks for
could not exist. A red that points at the wrong thing costs the same debugging time as a green that
hides a real one.

`spike_failure_reason` now runs BEFORE any arm-identity assert in v2, v3a and v3b, and names the
actual cause: the portal timeout gets its own message saying the dialog appears on the HOST's own
screen and cannot be answered from inside a stream — which is precisely the situation that produced
this failure, since the operator was watching the box through a game session at the time.

Falls back to the first ERROR line, then to "no PUNKTFUNK_PERF window at all", so a spike that dies
some other way still reports that rather than a misattribution.
2026-08-09 21:15:46 +02:00
enricobuehler bd5735b803 feat(pads/windows): make the HID Xbox pad the default, and carry the trigger motors on the wire
Three changes that only make sense together: the HID backend becomes the default now that it is a
superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the
INF-shape tests learn about the Xbox identity's own install section.

WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch.
The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not
reach classic XInput, so defaulting to it would trade a known-working path for an unproven one.
That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is
promoted like real hardware and keeps classic XInput while gaining everything XUSB never had
(Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all.
The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing
update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value
takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with
no HID collection.

WP-D — the `0xCA` rumble datagram gains a v3 form:
  v1  7 B: [0xCA][u16 pad][u16 low][u16 high]
  v2 10 B: … [u8 seq][u16 ttl_ms]
  v3 14 B: … [u16 lt][u16 rt]
v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is
structural instead of a convention two encoders have to keep agreeing on, and every reader gates
with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the
pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop
burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new
`RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean
zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume.
Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries
them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other
producer sends `lt = rt = 0`.

⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured;
bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so
inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose
expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two
motors.

The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,`
and so stopped seeing the Xbox hardware ids the moment that identity moved to its own
`pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and
tolerant of further per-identity sections. Added
`only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH
directions: the Xbox line must not install the shared section, and no other line may install the
Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense
to Microsoft's Xbox translator.

VERIFIED
  * ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104,
    including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean.
  * macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble
    suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing.
  * The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found`
    and reproduces with these changes stashed.

NOT VERIFIED
  * No trigger rumble has ever been observed end to end — nothing can drive it yet (see the
    conjecture note above), and no client renders it.
  * The default flip has NOT been exercised in a real streaming session; every measurement so far
    came from the devtest harness. That is the on-glass run.
  * Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first
    (`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could
    render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its
    packed jlong is full) and has no trigger actuators anyway.
2026-08-09 21:10:08 +02:00
enricobuehler 7a9fa4501c Merge pull request 'Nobara could never use the patched gamescope — and the RPM it was told to install was unsigned' (#144) from worktree-gamescope-pin-bump-nobara into main
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Failing after 2m37s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 2m27s
deb / build-publish-client-arm64 (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish (push) Successful in 3m37s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / screenshots (push) Successful in 5m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / deploy-docs (push) Skipped
arch / build-publish (push) Successful in 9m55s
deb / build-publish-host (push) Successful in 6m53s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 11m23s
flatpak / build-publish (push) Successful in 9m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
windows-host / package (push) Successful in 18m53s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
nix / flake (push) Failing after 17m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #144
2026-08-09 18:53:59 +00:00
enricobuehler d87a8df28d fix(windows): uninstall removes the audio devices the host mints
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 7m37s
The field report: uninstalling punktfunk left "Punktfunk Speakers",
"Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints
sitting in Windows' Sound settings forever.

They have no installer payload behind them, which is why nothing in the
uninstall touched them. The host mints them at RUNTIME as extra devnodes
on Valve's streaming-audio drivers, and both providers deliberately
re-resolve their devnode across restarts instead of re-minting it — so
they persist by design. Persistent across restarts must not mean
permanent: the .iss even documented leaving them behind as a decision.

New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry,
after the two driver legs and well after `service uninstall`, since a
live host re-mints on its next wiring pass):

* restores the default playback device first, if a host that died
  mid-stream left it parked on our loopback sink — otherwise Windows
  re-picks by its own ranking rather than giving the operator back the
  device they had;
* removes every MEDIA-class devnode carrying one of our three durable
  owner markers (pad slot, minted role, probe), phantoms included;
* deletes each endpoint's MMDevices record, resolved through the
  devnode link BEFORE the devnode goes.

Marker-matched, never name-matched: our instances are name-identical to
Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the
era when we bundled one carry no marker and stay untouched. A ROOT\
enumeration guard means a marker-shaped value on a real sound card can
never cost the user their hardware.

The registry half is best-effort: those keys are SYSTEM-owned and the
uninstaller runs elevated but as a user, so on a stock box the record
survives as an inert NOTPRESENT entry that Sound settings only shows
behind "Show Disconnected Devices". The device itself is gone either
way, and seizing ownership of SYSTEM registry keys from an uninstaller
is a worse thing to ship than that scrap.
2026-08-09 20:53:22 +02:00
enricobuehler 46390739d8 fix(pf-vdisplay): the box's OWN session unit needs the gamescope bind too
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m40s
nix / flake (pull_request) Failing after 19m15s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m28s
ci / rust (pull_request) Successful in 6m26s
`launch_session` spawns a transient unit and can hand `systemd-run` the
`BindReadOnlyPaths` directly, but a box that owns an autologin
`gamescope-session-plus@<client>.service` is RESTARTED IN PLACE instead — no `systemd-run`,
so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit
fixed only half the problem. Found on the box: after a reboot the host took the
`ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one.

Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the
box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the
wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or
systemd runs the old unit. Best-effort: a failure to write it must not block a restart that
would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`.

⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and
cursor flags — under the user's ORDINARY game mode, which is exactly what
`packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The
bind is ours only for as long as we are driving the session.

`ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already
had it in scope (`self.hdr`, and `create_managed_session`'s parameter).

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
2026-08-09 20:28:00 +02:00
enricobuehler 77f0a25d18 feat(drivers/pf-gamepad): ship the xinputhid bus filter, so Windows finally promotes our Xbox pad
The field report that started this work was an Xbox controller that no game could see on a Windows
host for two weeks. Root cause was that our Xbox pad reaches no Windows input API a modern title
uses. This is the fix, and it is two registry values.

Windows promotes Xbox pads with `xinputhid`, whose INF is an explicit hardware-id ALLOW-LIST — its
own comment says "we can not use a Compatability ID for the loading of this driver, and so rely on
individual hardware IDs". A software-enumerated devnode can never match those ids, so we write what
the matching install sections would have written. `045E:0B13`, the PID this identity already
claimed, is on that allow-list twice, so the identity choice turned out to be exactly right.

🛑 THE PAIRING IS THE WHOLE FINDING, AND THE TWO VALUES GO IN DIFFERENT KEYS. `UpperFilters` is a
`.HW` AddReg (hardware key); `DevicePropertyFlags` is a DDInstall AddReg (software key). A live A/B
on .173: removing `DevicePropertyFlags` alone reverts EVERYTHING — no `IG_00`, no XUSB interface, no
XInput, no WGI entry — while `UpperFilters` alone is completely inert. `1` = `BusDevice`, which
Microsoft glosses as "a focused bus filter driver for the IG_ problem". It is not a description of
the device, it is the switch. An earlier session installed the filter WITHOUT it, measured a device
that produced nothing, and recorded "never ship it". The filter was never broken; it had never been
switched on. That conclusion is now retracted.

⚠️ The Xbox line gets its OWN DDInstall section, `pfGamepadXbox`. All five identities previously
shared `pfGamepad`, so an AddReg there would have handed a DualSense, DualShock 4, Edge and Steam
Deck to Microsoft's Xbox translator. The regression check below exists for exactly that.

MEASURED ON .173 (Win11 26200), INF-SHIPPED — no hand-written registry values:
  * `UpperFilters=xinputhid` lands on the hardware key and `DevicePropertyFlags=1` on the software
    key, applied by the INF at install.
  * The HID child gains the `IG_00` token: `HID\PUNKTFUNK&IG_00\...`.
  * An XUSB interface appears: `\\?\hid#punktfunk&ig_00#...#{ec87f1e3-...}`.
  * classic XInput reads it live — packets ADVANCING, `buttons=0x1000` (the devtest's A), and the
    stick sweeping. XInput had NEVER seen this backend before.
  * `XInputSetState` rumble round-trips: `rumble from game: pad=0 low=65535 high=32767`.
  * REGRESSION CHECK PASSED: with the DualSense identity up, its devnode has an EMPTY
    `UpperFilters` and no `DevicePropertyFlags`. The PlayStation pads are untouched.

WGI `Gamepad` lists the pad but reads `ts=0`. That is NOT ours: a real Xbox Elite Series 2, promoted
by Microsoft's own driver on the same box, reads `ts=0` in WGI at the very moment classic XInput is
reading live data from it (`buttons=0x1000 LY=-32768`). Our pad is behaviourally indistinguishable
from real hardware here; the row is a property of the non-interactive session.

NOT VERIFIED
  * On-glass in a console session. Everything above ran over ssh, which is what makes the WGI row
    unreadable; the real-Elite control is what settles it, not a clean WGI reading.
  * GameInput — no binding in the `windows` crate, still unmeasured for this backend.
  * `PUNKTFUNK_XBOX_BACKEND` still defaults to XUSB. This changes what the HID backend CAN do; it
    does not change which backend is chosen. That is WP-E and it is a separate decision.
  * Trigger-actuator enable bits, still conjecture — `XINPUT_VIBRATION` has two members and cannot
    exercise them.
2026-08-09 20:18:49 +02:00
enricobuehler 3500e95660 fix(pf-vdisplay): make Nobara's session run the patched gamescope, and stop the WSI layer killing every client
Two independent reasons a Nobara box could never stream from a gamescope session,
both found on glass (VM 123, Nobara 44, RTX 5070 Ti).

**1. The session ran a stock gamescope, so the host refused it.**

Nobara's `gamescope-session-plus` builds its command as

    GAMESCOPECMD="/usr/bin/gamescope \

and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env
var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session
ran stock gamescope, the capability probe rejected it, and every session died with
"pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim".
`~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles
that in one line, and is the first thing to read on any such report.

Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount
namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope
ships under its own name precisely so it sits BESIDE the distro package, and the bind is
scoped to the session — nothing outside it sees the redirect and nothing is written to
`/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`.

**2. With the patched gamescope finally running, every Vulkan client died — black screen.**

The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its
`gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's
`swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no
other symptom, which is what makes it expensive to find.

Measured with `vkcube` under each build, layer on:

    ours 3.16.25-17  ON  -> 1 rejected client
    ours 3.16.25-17  OFF -> 0
    OLD pin 3.16.25-4 ON -> 1 rejected client
    stock 3.16.23.2  ON  -> 0

 The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and
our pin — same interface version, same `uuuuuus` signature — so this is the distro patching
gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a
floor, and an unreadable version on either side leaves the layer alone rather than degrading
a box that works (Bazzite/SteamOS, where it has always been fine).

⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is
pre-existing, not a regression from 5fb8dce4.

Verified against the UNPATCHED distro script, reproducing exactly what this code emits:
the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`,
with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts.

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
Non-vacuity re-verified per the xcheck note — a planted type error in the new function
produced 3 errors, and removing it went back to Finished.

Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in
`destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect.
2026-08-09 20:15:52 +02:00
enricobuehler f9fe496dbc feat(drivers/pf-gamepad): declare the rumble output report, and the Xbox pad gets rumble at all
`XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if
the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the
out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The
entire host-side rumble plane was already built, wired and tested, and was simply never fed. The
HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble.

This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized
to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already
specify. It is declared AFTER the final Input item and re-states every global it uses, so the
16-byte input layout `xbox_proto`'s tests pin is untouched.

⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A
reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction
carries no output collection for that pad — so there was nothing to copy. The comment says so and
asks for a Linux hidraw capture to replace it.

Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`.
Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY:
hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates
truncated or not at all with nothing naming the cause. It now cannot build out of step. This
caught nothing today because I updated both by hand, but it is exactly the trap this descriptor
has already sprung twice in other forms.

MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config:
  * `XInputSetState(0xFFFF, 0x8000)` produced, on the host side,
      `rumble from game: pad=0 low=65535 high=32767`
      `rumble from game: pad=0 low=0 high=0`
    i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring ->
    parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered.
  * The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is
    right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers.
  * This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports
    to a synthesized 045E:0B13 — which was blocking the whole trigger plane.
  * classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and
    `LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only
    LS-X and A.

VERIFIED
  * `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended.
  * `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report
    0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the
    wire, feature 0x85 unchanged, `structure: OK`.
  * Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five
    descriptor/wReportLength pairs agree.
  * fmt clean on both tools; .173 fully reverted afterwards.

NOT VERIFIED
  * The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two
    members, so XInput can never drive them and this run could not exercise them. Still open, as
    trigger-rumble-plane.md WP0 says.
  * That this equals the real pad's output collection, byte for byte. Needs Linux hidraw.
  * Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the
    promotion config ships. The rumble descriptor is inert until something drives it.
2026-08-09 20:07:34 +02:00
enricobuehler 13438b1287 test(tools): ask Windows which input APIs can see the pad, and find what promotes it
The Xbox-pad-on-Windows programme is a five-row matrix — classic XInput, WGI `Gamepad`, WGI
`RawGameController`, GameInput, and the HID/DirectInput/Steam family — and nothing in this tree
measured any of it. Every reading in the handoff came from ad-hoc off-tree tools, which is why
several could not be reproduced later and why one was a false positive. `win-input-matrix` makes
the matrix a command you can run twice and diff.

Two traps are baked into it because both have already cost this programme a wrong conclusion.
`--watch` samples repeatedly and reports LIVE vs MUTE per device, because an API listing a pad that
never reports is the exact failure mode here — worse than not listing it, since a title that binds
the first gamepad latches a dead one. And the doc comment insists on a baseline with the virtual pad
STOPPED: a real Xbox pad owns XInput slot 0, which is how `rc=0 LX=-885` was once read as success
with our pad already killed.

 `wake_wgi()` is not optional and is commented as such. `Gamepad::Gamepads()` and
`RawGameController::RawGameControllers()` return a cache filled by WGI's device-watcher, which a
GUI app has already started and a console app has not. Without subscribing to the Added events
first, BOTH collections come back empty with real controllers attached — measured here: a DualSense
sitting in the HID interface class, `RawGameControllers` count=0. A probe missing this reports "WGI
cannot see the pad" when WGI could not see anything.

WHAT IT FOUND (full record in measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md): with
`UpperFilters=xinputhid` on the pad's PARENT devnode AND `DevicePropertyFlags=1` in that parent's
SOFTWARE key, the HID Xbox pad is promoted for the first time — the child gains the `IG_00` token,
an XUSB interface appears, classic XInput admits it, and WGI `Gamepad` lists it. All four had never
happened on this backend. A one-value A/B proves `DevicePropertyFlags` is the decisive half:
removing it alone reverts all four. That retro-explains the earlier "the filter installs fine and
produces nothing" result — the filter was loading without ever being put in bus-filter mode, which
is what `BusDevice = 0x1` means in Microsoft's own comment in `xinputhid.inf`.

Not a workspace member, for the same reason as `hid-descriptor-dump`: it is a Windows-only
bring-your-own-hardware tool with no business on a CI leg.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS; the target is installed).
  * Builds and runs on .173 (Win11 26200).
  * Self-checked against known-good hardware before any conclusion was drawn from it: baseline
    reads the USB DualSense as LIVE in both WGI collections and the resting 8BitDo as MUTE.
  * The A/B was run in both directions on the same box in one session.
  * `cargo metadata` on the root workspace resolves and does NOT list this crate.
  * .173 fully reverted: registry values removed, devnodes removed, oem100.inf deleted, both certs
    delstored, 6 pre-existing pf_gamepad packages and the production service untouched.

NOT VERIFIED
  * GameInput — no binding in the `windows` crate, needs hand-written COM vtables. Not covered;
    the doc comment says so.
  * That the promotion survives a reboot or a devnode re-create from a shipped INF `AddReg` rather
    than a hand-written registry value. Nothing is shipped: `pf_gamepad.inx` is UNCHANGED and still
    contains no AddReg of any kind.
  * WHY the promoted pad still translates no data. Enumeration is fixed; translation is not. The
    evidence points at the report descriptor, which is gated on the §3.3 decision.
2026-08-09 19:24:28 +02:00
enricobuehler ae35e8b4d7 test(tools): capture the real Xbox descriptor, because ours was invented and disagrees with it
`XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than
captured off hardware, and its own provenance warning has now come true three times. The fix for
that class of bug is not another careful reading — it is a tool that goes and asks the device.

`tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it
into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already
ship through the same decoder (`--rust-source <file> --symbol <NAME>`) so the two are diffable
line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed
descriptor cannot give you.

Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux,
which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a
bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it.

The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER:
the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an
UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the
hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply
stock mappings keyed on it, that ordering difference is exactly how every control silently lands
on the wrong action. The driver comment now records the diff and the two blockers that stop the
capture from simply being pasted in.

VERIFIED
  * `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS).
  * The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK).
  * TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it
    reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at
    bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read`
    returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing.
  * `cargo metadata` on the root workspace still resolves and does NOT list this crate.
  * The Elite capture is reproducible: `--vid 045E --pid 0B22`.

NOT VERIFIED
  * That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's
    literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in
    structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte
    descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is
    the legacy DirectInput view. A byte-exact answer needs Linux hidraw.
  * Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense
    streamed fine on the same code path — untouched pad, or exclusive claim by the XInput
    translator. Unresolved.
  * Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no
    behaviour changes. The only edit to the driver is its provenance comment.
2026-08-09 19:02:13 +02:00
enricobuehler f34acf1d73 fix(drivers/pf-gamepad): the Xbox descriptor never declared the channel-proof report, so the pad served neutral forever
`XBOX_RDESC` declared only Input report 1. The sealed pad channel delivers its DATA section
over a vendor Feature report `0x85` (`ProofTransport::HidFeatureReport`), and the proof
handler's own comment records the assumption that made this invisible — "0x85 is already
declared as a Feature report in all three captured descriptors". True of the captured
PlayStation blobs; false of this hand-constructed one.

So hidclass rejected the host's `HidD_GetFeature` before the driver ever saw it, the host
refused to hand over the section, and the pad answered every read with its neutral report.
The HID Xbox pad had never delivered a single input report since it was written.

Declaring `0x85` with a 63-byte payload (1 id + 63 = 64 = FeatureReportByteLength) fixes it.
Verified on glass on .173: `gamepad driver attached to the shared section proto=3 late=false`,
and WGI's RawGameController path then reads the pad live — advancing timestamps, the devtest's
left-stick sweep, buttons toggling. Before the fix: 12 consecutive samples, one frozen
timestamp, every axis at dead centre.

This is the descriptor-provenance warning in this file coming true. It is still CONSTRUCTED
rather than captured, and that remains the open risk — `xinputhid` appears to validate the
descriptor and refuses ours, and a real Elite is a multi-collection device where ours has one.

Codec layout tests still 11/11; fmt clean. Only device_type 4 is affected, which nothing
shipping uses yet.
2026-08-09 18:16:24 +02:00
enricobuehler bc9201d136 fix(packaging/gamescope): bump the pin past upstream's capture-format probe, and sign the RPM
Three things, one delivery path — a Fedora/Nobara box getting the patched gamescope.

**The pin moves 8c676c39 -> 5fb8dce4** (3.16.25-1 -> 3.16.25-11). The commit that matters
is ff6b924, `rendervulkan: fall back to XBGR2101010 when XRGB2101010 is unsupported`: it
probes `linearTilingFeatures` for STORAGE+SAMPLED and captures as XBGR2101010 where
A2R10G10B10 linear storage is unavailable — which is every NVIDIA. That covers the paths
that are upstream's rather than ours: the RGB intermediate `paint_pipewire()` acquires when
the stream is YCbCr, and AVIF screenshots. #143 fixed our own node host-side; this is the
other half, and its commit message asked for exactly this bump.

All six patches rebased. Only 0006 conflicted: upstream's f8be7ee added
`vulkan_has_drm_modifiers_for_features()` immediately above the `g_device` declaration our
patch turns into a reference — both kept. 0003 and 0005 come out byte-identical; 0006 also
picks up the `--zero-commit --no-signature` form 0001-0005 already used.

**Patch 0001 now offers `xBGR_210LE` BEFORE `xRGB_210LE`**, mirroring the host-side
`HDR_FORMAT_ORDER` rationale on the producer end. A consumer takes the first pod it can use,
and we were handing third-party consumers (OBS and friends) the one format NVIDIA fills
byte-reversed under a correct-looking label. Deliberately NOT done by calling upstream's
`vulkan_get_rgb10_capture_format()`, which is what pw_pods.rs proposes: that symbol landed
after 3.16.25, so it would break `packaging/nix/gamescope.nix` — which applies these patches
to whatever gamescope nixpkgs pins — with an opaque C++ error instead of a patch conflict.
The reorder gets the same outcome on any base. Note added there so the next reader does not
"fix" it.

**And the RPM was never signed.** `Sign RPMs` runs right after `Build RPM`; the gamescope
RPM is built ~90 steps later, behind its own ~10-minute cache, so it missed the signing pass
entirely — every punktfunk-gamescope RPM ever published went out unsigned. The repo file we
tell users to install carries `gpgcheck=1`, so `dnf install punktfunk-gamescope` failed with
"The package is not signed" on every Fedora and Nobara box. The package was in the channel
the whole time and could not be installed from it, which is worse than absent: the notes and
the docs-site both say it is there. `sign-rpms.sh` now takes explicit paths (defaulting to
`dist/*.rpm` as before) and a second pass signs this one before publish, fail-closed on a tag
like the first.

Verified on Nobara 44 (VM 123, RTX 5070 Ti passthrough), canary 0.27.0-0.ci12611.g516a2954:

* Builds clean in the fc44 CI image; banner `3.16.25-17-ga87390d+pfhdr4` (11 upstream + our
  6), so the marker the host probes still reads 4 — no capability moved, hence pkgrel 3 and
  `.pfhdrN` staying put.
* `pw-cli enum-params` on the live node: BGRx, NV12, **xBGR_210LE (81), xRGB_210LE (80)** —
  8-bit consumers still negotiate bit-for-bit, 10-bit now leads with the safe one.
* All four patched flags present, `--pipewire-composite-external-overlay` included.
* Patch 0006 confirmed working by comparison, which is the only way to see it: the new build
  exits 0 where both the pre-0006 `+pfhdr2` build and the stock 3.16.23.2 abort with 134.
* Signing fix proven with a throwaway key: `Signature: (none)` -> `digests signatures OK`.
* Host health on the canary: synthetic spike 300/300 encoded, loopback 300 recovered, 0
  mismatches.

One unexplained one-off: the very first headless run after install segfaulted at exit
(SIGSEGV, after "Primary child shut down!"). Not reproduced in 11 subsequent runs across
every flag combination, so it is recorded rather than diagnosed — the binary is stripped and
there is no symbolised core.
2026-08-09 18:00:28 +02:00
enricobuehler 003ce8bea7 Merge pull request 'Every NVIDIA gamescope HDR stream had red and blue swapped — and a sysext step added in a release was unreachable forever' (#143) from worktree-hdr-rb-swap-nvidia into main
arch / build-publish (push) Failing after 3s
ci / rust (push) Failing after 2s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish (push) Failing after 3s
deb / build-publish-host (push) Failing after 0s
deb / build-publish-client-arm64 (push) Failing after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
docker / deploy-docs (push) Failing after 1m41s
android / android (push) Successful in 5m38s
apple / screenshots (push) Successful in 5m53s
windows-host / package (push) Successful in 16m59s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
Reviewed-on: #143
2026-08-09 15:34:02 +00:00
enricobuehler 235b8e55d4 Merge pull request 'chore(web): console onto @unom/ui 0.9.2' (#142) from worktree-console-unom-092 into main
audit / license-gate (push) Failing after 2s
audit / cargo-audit (push) Failing after 2s
audit / docs-site-audit (push) Successful in 22s
audit / bun-audit (web) (push) Failing after 22s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (plugin-kit) (push) Successful in 29s
audit / pnpm-audit (push) Successful in 20s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 2s
ci / rust (push) Failing after 2s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
arch / build-publish (push) Canceled after 1m29s
ci / web (push) Canceled after 1m12s
ci / docs-site (push) Canceled after 1m9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
deb / build-publish (push) Canceled after 1m14s
deb / build-publish-host (push) Canceled after 1m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1m3s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 25s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Canceled after 1m1s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
nix / flake (push) Failing after 4m16s
Reviewed-on: #142
2026-08-09 15:32:35 +00:00
enricobuehler d2a2bcc25d feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack
The two things this driver's README has always listed as the missing WGI/GameInput work,
both user-mode, neither needing a bus driver:

`IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic
timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the
synchronous path serves. Declining it was enough for classic xinput1_4, which just falls
back to sync GET_STATE polling — that is why the pad has always worked there. It is not
enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not
a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet
spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to
signal on.

The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put
that filter on the HID child of the *other* backend, which was simply the wrong devnode:
XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this
driver registers.

Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode
starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput
still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async
queue is no regression to the path that already worked.

NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for
this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so
its absence says nothing either way. That needs a real WinRT/GameInput enumeration test.
2026-08-09 17:29:10 +02:00
enricobuehler 0b252403cd fix(web): fix the card inset at the root, not at the call sites
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m35s
ci / web (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 9m12s
nix / flake (pull_request) Failing after 19m50s
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.

Two faults, both in components/ui/card.tsx:

1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
   resolves conflicts only within a variant, so any call-site override won at the base
   and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
   Displays card before this change: padding-top 24px at 500px, 0px at 1440px.

2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
   above me and supplies the top inset". Delete the header, which is exactly what tabbing
   a page does since the tab label replaces the card title, and the top inset silently
   vanishes at ≥640px.

Fix:

- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
  @unom/ui's own Card uses, so nested cards finally agree on their inset. A single
  variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
  the author. A headerless CardContent keeps its inset with nothing to remember.

Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.

`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.

Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.

Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
2026-08-09 17:23:09 +02:00
enricobuehler 0ab17ee81d fix(packaging): a post_merge step added in a release was unreachable forever
ci / bun-nix (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m52s
ci / rust (pull_request) Failing after 8m7s
A sysext upgrade is driven by the script from the OLD image -- /usr/bin/punktfunk-sysext
is replaced by the very `systemd-sysext refresh` that runs mid-upgrade -- so a
post_merge step ADDED in the new release is executed by nobody. The old script
does not have it, and the new script never gets a turn: from then on `update`
matches the "already on $cur" branch and returns before post_merge. The step is
permanently unreachable on exactly the installs that need it, and nothing says so.

Field-proven on the Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09). The
casualty was the `punktfunk` group, which post_merge learned to create in 0.26.0
(62a6fa9f): 0.25.0's script ran the upgrade, so the group was never created, and
every `punktfunk-sysext update` since has said "nothing to do". `pf-dm-helper`
gates on membership in that group, so it refused every caller -- pkexec authorised
it and the helper then declined itself -- and every managed gamescope takeover fell
back to "stopping the display manager needs privilege", leaving sddm's autologin
Relogin loop churning logind sessions for the whole stream.

Re-run post_merge when already current. Everything in it is idempotent (guarded
getent/groupadd, `install` of /etc mirrors, udevadm reload/trigger, sysctl,
modprobe), so convergence is the honest behaviour and "nothing to do" was a lie
about host state. Add an explicit `reapply` verb too, so the steps a sysext image
cannot carry can be re-applied without reinstalling the image.

Also print the membership hint. Creating the group is necessary but NOT sufficient
and the difference is invisible until a stream fails: joining stays opt-in by
design (writing vhci `attach` materialises an arbitrary emulated USB device), so
post_merge now names the exact usermod when SUDO_USER is not a member. Matched with
`grep -qx` so `punktfunk-update` does not read as `punktfunk`.

bash -n clean; shellcheck clean apart from the pre-existing SC1091 on
`. /etc/os-release`, which fires on the unmodified file too.
2026-08-09 17:08:24 +02:00
enricobuehler 4e04c2bbf8 feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid
Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real
HID device instead of the XUSB companion, and adds the knob that selects between them.

Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read
today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can
never have, but whether Windows promotes it into an Xbox-profile device that XInput and
WGI Gamepad accept is still the open question. Flipping the default before that is
settled would trade a known-working path for an unproven one. The two backends are
mutually exclusive per pad by construction — one match arm or the other — because
presenting both hands a game two controllers for one pair of hands.

Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean,
`cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean.
2026-08-09 17:03:25 +02:00
enricobuehler 31aef4b09f feat(web): tab the Virtual displays page
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 3m59s
ci / bun-nix (pull_request) Successful in 4m39s
ci / web (pull_request) Successful in 5m1s
ci / rust (pull_request) Failing after 13m18s
nix / flake (pull_request) Failing after 23m11s
Same pill strip the plugin UIs use, via @unom/ui's Tabs: Configuration | Live displays.

The page was two stacked cards, and the configuration card ALONE is taller than the
viewport — the existing comment on the unsaved badge says as much, because that height
is how pending edits went unnoticed. The live-display list sat below all of it, so in
practice it was off screen.

Two details that are not cosmetic:

- The dirty marker moved from the card header onto the Configuration TRIGGER. Behind a
  tab the old badge would vanish entirely while Live was open — a strictly worse version
  of the problem it was added to solve. On the trigger it survives both tabs, and the
  Custom block keeps its own inline badge for when the tab IS open.
- The strip is extracted as a presentational `DisplayTabs` rather than inlined in
  `DisplaySection`. The container calls `useBlocker`, which needs a router, so it cannot
  render in Storybook — and this page's story exists specifically to pin the MOTION
  NESTING of the preset grid (a card sets no delayChildren, so tiles nested one level
  deeper stop staggering). Inserting tabs changes that ancestor chain, so the story has
  to render the real one or it passes for the wrong reason.

Adds Pages/Displays → "Unsaved on other tab", which switches to Live with a dirty draft:
if the marker ever goes silent there, the warning is gone exactly when it matters.

Verified: tsc clean, biome clean, `bun test server/` 9/9, vite build + i18n check clean,
Storybook builds, 32/32 screenshots.
2026-08-09 16:58:46 +02:00
enricobuehler 97928516a0 fix(pf-capture): every NVIDIA HDR stream had red and blue swapped
gamescope's capture textures are mappable, hence linear-tiled, and NVIDIA does
not implement linear-tiled STORAGE for A2R10G10B10_UNORM_PACK32. Upstream says
it plainly in rendervulkan.cpp: "imageStore lands in XBGR order there, swapping
R/B". So the composite writes XBGR bytes into a buffer still LABELLED
XRGB2101010, and our patch's spa_format_to_drm() derives that label from the
negotiated SPA format alone, never asking the hardware what it can actually
write.

The host then believed the label, correctly at every step:
xRGB_210LE -> PixelFormat::X2Rgb10 -> NV_ENC_BUFFER_FORMAT_ARGB10. DRM
XRGB2101010 really is "B in the low 10 bits" and NVENC ARGB10 really is "B in
the lowest 10 bits"; the Windows twin (R10G10B10A2 -> ABGR10) is correct by the
same rule. Every mapping audits clean because the label was right and only the
CONTENT was wrong -- which is why this survived a full trace of both ends.

Fix the preference host-side: offer xBGR_210LE FIRST. The first compatible
consumer pod wins, so that is what a gamescope session lands on, and an
XBGR2101010 texture is one NVIDIA writes in its own order -- label and content
agree. It costs nothing elsewhere: A2B10G10R10_UNORM_PACK32 is the universally
supported packed-10 format, it is what upstream's own fallback picks, and
X2Bgr10 has a first-class encoder path (NVENC ABGR10, VAAPI X2BGR10LE).
xRGB_210LE stays as the second pod so a producer offering only it can still
negotiate HDR instead of dropping to the SDR downgrade.

Doing it here rather than in the patch set is deliberate: the real fix is for
spa_format_to_drm() to offer only what vulkan_get_rgb10_capture_format()
reports, but that function landed after 3.16.25 and the pin is
3.16.25-7-g60561e2+pfhdr4 (0 "2101010" strings in the shipped binary), so the
deployed gamescope cannot self-correct. This ships in the host binary with no
gamescope rebuild.

Field-confirmed on the RTX 5070 Ti Bazzite host with 0.26.0, and confirmed
host-side rather than client-side by reproducing the identical swap from two
unrelated clients (16" MacBook Pro and Mac Studio). SDR was never affected --
it takes no packed-10 path.

Gate (pf-lxcheck2, linux/amd64): fmt clean, clippy --all-targets -D warnings
clean, cargo test -p pf-capture 60 passed / 0 failed incl. the new
hdr_offers_xbgr_before_xrgb order pin.
2026-08-09 16:58:15 +02:00
enricobuehler d498ff4a60 test(drivers): give the Xbox identity a root-enumerated id, and verify the whole thing on Windows
`root\pf_xboxwireless` alongside the plain id, mirroring the DualSense model line — the
INF already documents that variant as the one devgen/devcon tests bind, and without it
the Xbox identity could only be exercised through a running host.

Verified end to end on .173 (Windows 11 26200, WDK 10.0.26100.0):
- build-gamepad-drivers.ps1 builds + signs + catalogs the driver, exit 0
- infverif /v /w on the generated pf_gamepad.inf: "INF is VALID"
- pnputil stages the package; devgen creates the devnode; it starts clean:
  Status OK, Class HIDClass, "Punktfunk Virtual Xbox Wireless Controller"
- it enumerates a HID child, Status OK, carrying HID_DEVICE_SYSTEM_GAME and
  HID_DEVICE_UP:0001_U:0005 — Windows parsed the constructed report descriptor and
  classified the pad as a Game Pad (usage page 0x01, usage 0x05), which is precisely
  what pf-xusb could never do

Test devnode, phantom child, driver package and both certs were removed afterwards.

Two build gotchas worth knowing, both already handled inside build-gamepad-drivers.ps1
and both of which cost a cycle here: CARGO_TARGET_DIR pointing outside the workspace
breaks wdk-sys (wdk-build walks up from OUT_DIR looking for a Cargo.lock and finds
none), and the WDK version must be pinned via Version_Number=10.0.26100.0 or bindgen
picks SDK 10.0.28000.0, which ships no km/crt headers.

Still open: the SwDeviceCreate USB identity (HID\VID_045E&PID_0B13) cannot be checked
through a devgen node, which has no USB hardware ids — that needs the host path. So the
WGI-promotion question is still unanswered, and host routing is still unwritten.
2026-08-09 16:54:00 +02:00
enricobuehler bbc0513f0c fix(validation): the kit could not read a real log — tracing wraps field names in ANSI
Found by running it. The first V3a run on .25 encoded 2700 frames in BOTH arms, at 59.6 fps, with 22
perf windows each — and the kit reported "fewer than 3 usable perf windows", because `tracing`'s fmt
layer wraps field NAMES in SGR escapes. The bytes on disk are `p99_us\e[0m\e[2m=\e[0m4601`, so
`s/.*p99_us=\([0-9][0-9]*\).*/\1/p` never matched. The message text is plain, which is why the
window COUNT was right and only the numbers vanished — and why the fixtures never caught it: they
were hand-written, and cleaner than reality.

Anything matching a field breaks the same way, so this was not only V3a: v2's `priority=Realtime`,
the demotion `reason=`, and v4's rungs all read fields. Every log read now goes through one
`log_cat` that strips SGR, and the spike is launched with NO_COLOR=1 so fresh logs are plain at the
source too — a human grepping a red leg by hand is defeated by those escapes exactly as the parser
was.

The self-test gains the same four perf windows a second time, ANSI-wrapped, asserting an identical
result: same numbers, same expectation, so a failure there can only mean the stripping broke. That
fixture caught its own first draft, which built the line in one printf with 27 placeholders against
23 arguments and emitted empty escapes — hence the field-at-a-time helper.

With this, V3a self-reports on .25 (sway headless, real dmabuf capture, AMD 780M/RADV, 2700 frames
per arm, both arms at default GPU priority):

    in-process        p50 2.08 ms   p99 4.18 ms   (21 windows)
    uncapped worker   p50 2.07 ms   p99 3.52 ms   (21 windows)
    p99 delta -0.66 ms  ->  PASS

R1's pre-registered abandonment gate does not fire: the process boundary is not merely under the
+1.0 ms ceiling, it is measurably FASTER at the tail, while p50 is unchanged (2.08 vs 2.07). An
earlier hand-extraction of the same logs gave -0.43 ms, so the direction reproduces across runs.
Caveat for whoever reads this later: idle iGPU in a KVM guest, RADV, no GPU-bound load. This bounds
the IPC hop; it says nothing about V3b, which still needs .21 under GRID 2.
2026-08-09 16:33:29 +02:00
enricobuehler d13d253c2f chore(web): @unom/ui 0.8.16 → 0.9.2
ci / rust-arm64 (pull_request) Failing after 31s
ci / docs-site (pull_request) Successful in 3m1s
ci / bun-nix (pull_request) Successful in 3m27s
ci / web (pull_request) Successful in 4m29s
ci / rust (pull_request) Failing after 13m16s
nix / flake (pull_request) Failing after 19m47s
Brings the console onto the current design system. 0.9.x adds the Badge, Spinner,
Skeleton, Switch, Table, EmptyState and CodeBlock primitives, and 0.9.2 carries the
form fixes found while overhauling the rom-manager plugin UI:

- Select's border and focus ring resolved to `--main`, which is the FOREGROUND here
  (`--main: var(--foreground)` in web/src/styles.css), so the trigger wore a near-white
  border and a 3px near-white focus ring. Its chevron and placeholder were painted
  `--secondary`, a SURFACE colour, and all but vanished. Now on `--input`/`--ring`, the
  same tokens InputText already used.
- InputNumber declares a color-scheme, so the browser-drawn spinner arrows stop being
  near-black on a near-black field.

Both defects were live in this console too — the console palette is what exposes them.

Verified: codegen + vite build clean, `tsc --noEmit` clean, `bun test server/` 9/9,
Storybook builds, 31/31 screenshots. A probe over all 61 stories reports ZERO page
errors, and the two stories containing a Select now render it at h-input-height with
`border: rgb(42, 33, 72)` (the input token) and a muted-foreground chevron.

Note: the console's components/ui/ wrapper layer is unchanged and still required —
@unom/ui's DialogContent remains a surface with no Portal or placement, which is
exactly what web/src/components/ui/dialog.tsx supplies.
2026-08-09 16:27:08 +02:00
enricobuehler 2de604ecab test(validation): the on-glass kit for the encode worker, including the 0.26.0-1 regression test
WP3 of design/gpu-priority-capability-worker-implementation-plan.md. Five legs, the first of which is
the test that would have caught the field incident: in a KDE session with the worker installed and
capped, `getcap` on the host must be EMPTY, its CapPrm all zeroes, `readlink /proc/<pid>/exe` must
resolve, and `punktfunk-host probe-compositor` must exit 0 — which on KWin succeeds only when the
privileged zkde_screencast_unstable_v1 global was actually advertised to this client.

Read-only by default; the one mutating rung (kill -9) is behind --allow-mutate and kills only a
worker that is a child of the spike the script itself started. It NEVER calls setcap: the uncapped
arms use a plain copy of the worker, which does not carry security.capability, verified uncapped
before use. So no leg needs root and none restores state. A skip is never a pass — exit 2 means
incomplete, distinct from 1 (failure).

V3 is split, which the plan did not do. Its stated form compares against PW1's in-process-capped
baselines, and those exist only on .21 under GRID 2:

  * V3a is the pre-registered abandonment gate and needs no capability at all — in-process versus an
    UNCAPPED worker, both at default priority, so the only difference is the process boundary. Fails
    if the worker's p99 exceeds inline by more than --gate-ms (1.0). This runs on any box with a GPU.
  * V3b is the lever itself, capped worker versus the refused in-process arm, and says plainly that
    an idle GPU makes it meaningless.

The false PASS this kit exists to refuse: a CPU-backed frame makes the proxy pin itself in-process
for the session, so a synthetic source would quietly turn the "worker" arm into a second in-process
arm and pass the gate for the wrong reason. The worker arm is only accepted with a dmabuf-passthrough
capture, a capability-carrying-worker line, and no fallback line anywhere in the log.

Also asserts the host and worker are different inodes — a hardlink shares the file capability, which
is the same incident by another route.
2026-08-09 13:07:31 +02:00
enricobuehler f26d21125d docs: the capability moves to the encode worker, and the host must never be given one
Rewrites the "GPU scheduling priority" section around the split: punktfunk-encode-worker carries
cap_sys_nice=ep, punktfunk-host carries nothing on any channel, ever. The KWin identification
mechanism is spelled out in plain words and the failure line is quoted verbatim
("KWin does not expose zkde_screencast_unstable_v1 to this client") so someone searching for their
symptom lands on the explanation.

The warning names all three ways an operator would reach for the capability — hand setcap, a systemd
AmbientCapabilities= line, a NixOS security.wrappers entry — because all three put it in the same
permitted set and all three cost KDE desktop streaming. That is the failure mode that made this
worth documenting: it looks exactly like a missing .desktop and survives reinstalling both ends.

configuration.md gains PUNKTFUNK_ENCODE_WORKER (path, or `off` to force the in-process encoder) and
re-describes PYROWAVE_QUEUE_PRIORITY as an intent forwarded to whichever process does the encode.
kde.md gains one line on the troubleshooting bullet someone actually lands on: getcap on the host
must print nothing.

The published 0.26.0 notes are deliberately untouched — they are the record of what shipped. The
flipped phrasing lives in v0.27.0's notes instead; v0.26.0.md:37 ("a system privilege that turns out
to stop KDE recognising the host at all") is the line that goes stale when this ships.
2026-08-09 12:51:01 +02:00
enricobuehler 4f8cce6751 feat(packaging): grant CAP_SYS_NICE to the encode worker on all six channels, and assert the host never gets it
767e67ca's per-channel mechanics were correct; they were aimed at the wrong binary. Each one is
restored here pointed at punktfunk-encode-worker, and every host-side removal from #136 stays
verbatim. All grants remain best-effort — an uncapped worker still encodes, at default priority, so
a failed setcap must never fail an install.

  * Arch: setcap in post_install AND post_upgrade (a replaced binary is a new inode).
  * RPM: %caps(cap_sys_nice=ep) in %files, never a %post setcap — %caps applies, restores and
    verifies, and covers Fedora as well as Bazzite via rpm-ostree layering.
  * Bazzite + Arch sysext: setcap on the staging tree before mksquashfs, which does record
    security.capability. The assertion is amended, not removed: host EMPTY is still a hard fail, and
    the worker must carry exactly cap_sys_nice=ep — missing is fine, anything else is not.
  * deb: setcap in postinst.
  * NixOS: security.wrappers for the WORKER plus PUNKTFUNK_ENCODE_WORKER in the unit. A file
    capability cannot live on a store path, and an ambient grant is right here precisely because
    nothing ever identifies the worker. The host's ExecStart stays on the store path.
  * Steam Deck: setcap the worker; the .desktop the script writes stays valid this time.

Four things the plan's channel table missed:

  * packaging/arch/build-sysext.sh had no capability handling at all, and a sysext can never run a
    pacman scriptlet — the SteamOS image would have shipped the lever permanently inert.
  * scripts/steamdeck/update.sh had none either. It rebuilds both binaries, so a new inode drops the
    grant, and it is the documented steady-state path: the lever would have died on the first update.
    It also never healed a Deck already capped by 0.26.0-1.
  * A capped worker is AT_SECURE, and glibc drops $ORIGIN-expanded RPATH entries for secure binaries
    unless they normalise into a trusted system dir. Copying the host's rpath under BUNDLE_FFMPEG=1
    would have left the capped worker unable to find libavcodec on exactly the channel that bundles
    it. Absolute DT_RPATH instead.
  * Nix crane scopes by -p, so the worker would not have been built at all, and it needs its own
    addDriverRunpath.

scripts/ci/assert-cap-matrix.sh mechanizes the lesson from 0.26.0-1 — verify the PACKAGE, never the
board. It unpacks the built Arch package, the deb, the rpm and the mounted sysext raw and asserts one
matrix: the host carries NOTHING (hard fail), the worker exactly cap_sys_nice=ep. The sysext reader
first proves it can round-trip a capability through mksquashfs/unsquashfs at all, so an unreadable
artifact fails rather than issuing a blind PASS, and --self-test red-teams the assertions themselves.

Red-teaming the leg found a real bug: setcap originally ran BEFORE the assertion, so "the worker
arrived carrying something unexpected" was unreachable and a stray %caps would have been silently
overwritten. Both sysext scripts now assert, then grant, then assert again.
2026-08-09 12:50:47 +02:00
enricobuehler 4a4118e3ce feat(pf-encode): encode PyroWave in a capability-carrying worker, so the host never holds a capability
PyroWave encodes on the same GPU shader cores the game saturates, and an elevated
VK_KHR_global_priority queue is the compute-preemption lever for it — measured on .21 (RTX 5070 Ti,
GRID 2 loop): encode p99 6.4 -> 4.4 ms. Every driver refuses every priority class without
CAP_SYS_NICE, on NVIDIA and on RADV alike, so the lever is decoration on a packaged host.

0.26.0-1 granted that capability to punktfunk-host and killed desktop streaming on every KDE box:
KWin identifies a client by resolving /proc/<pid>/exe and matching an installed .desktop's Exec=,
the kernel refuses that readlink to a reader whose effective set is not a superset of the target's
PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities. #136 revoked it everywhere.

The capability therefore cannot live in the process that fronts KWin. It lives in a new, deliberately
small binary — punktfunk-encode-worker — which owns the priority-elevated Vulkan device and talks to
nothing but the socket its parent spawned it on: no Wayland, no D-Bus, no network, no plugins. It is
a SEPARATE FILE and must stay one; a hardlink or a hidden host subcommand shares the inode, hence the
capability, and silently re-creates the incident. That rule is written where someone would break it,
in the worker crate's own Cargo.toml.

`open_inner` is reused verbatim in the worker — the same REALTIME->HIGH->none ladder, the same
refusal-never-fails-open invariant, the same PUNKTFUNK_PERF split — so the A/B stays comparable with
PW1. The only in-process change is a flag for whether THIS process prints the INERT warn, plus an
out-parameter reporting the class that was granted.

Three things the design did not anticipate:

  * An AU cannot ride in the message body. MAX_MSG is 64 KiB and bodies are serde_json, which
    renders a Vec<u8> as one decimal per byte: a 1080p60 AU is ~333 KB of JSON and 4K ~3.3 MB, and
    the minimum per-frame budget is already 64 KiB. So the AU crosses on a memfd the worker creates
    once and pwrites each frame; the fd crosses once, in Ready. A test pins the arithmetic so nobody
    "simplifies" the memfd away. Cursor bitmaps take the same route, only when their serial changes.

  * set_wire_chunking has to cross the wire even though poll_chunk does not. Chunking changes the AU
    BYTES, not merely how they are handed out — it feeds rate_budget()'s deflation and build_au's
    windowed framing — so a proxy-local copy would have the host cutting dense AUs at boundaries that
    are not window boundaries. Forwarded and mirrored. poll_chunk itself needs no protocol: the
    identical AuChunker runs host-side on the whole AU the worker returns.

  * CPU-backed frames really do reach this encoder (force_cpu_for_nvenc_444, and the raw-dmabuf
    degrade latch), and a 1080p BGRA frame is ~8 MB. The first non-dmabuf frame pins the session
    in-process with one warn rather than putting 480 MB/s on a socket.

Every rung falls back to the in-process encoder exactly as today with one warn and never a dead
session: PUNKTFUNK_ENCODE_WORKER=off, binary missing, spawn failure, handshake timeout, proto or
workspace-version skew (host and worker are different files now, so that check is load-bearing),
InitErr, a refused frame, and socket EOF mid-session — which respawns once, then pins inline.

Also: recv retries EINTR with the REMAINING deadline, not a fresh one. With SO_RCVTIMEO the kernel
returns EINTR rather than restarting, so a signal would otherwise read as a dead worker; re-arming
with the full budget would instead let a steady signal rate defer a real hang forever.
2026-08-09 12:50:26 +02:00
enricobuehler dcfba07803 refactor(pf-zerocopy): split the worker rails out of the zerocopy vocabulary so a second worker can reuse them
The encode worker (design/gpu-priority-capability-worker.md) needs exactly what the zerocopy worker
already has — SEQPACKET framing, fds as SCM_RIGHTS, a pinned-exe spawn that survives an on-disk
replacement, and a reaper that never blocks session teardown on a wedged child — but it must NOT
inherit the zerocopy protocol. Its messages are its own and version independently.

So `imp/proto.rs` keeps the vocabulary (PROTO_VERSION, ImportKind, Request, Reply, BufferDesc) and
all transport moves to `imp/ipc.rs`, reachable as `pf_zerocopy::ipc`. No behaviour change for the
zerocopy worker: client.rs now calls `ipc::self_exe()`/`ipc::spawn_worker()` and keeps the same fd-3
dup2 slot, PR_SET_PDEATHSIG, kill-then-reap-outside-the-lock, bounded reap with a D-state re-park,
and per-generation zombie sweep it had before.

Two real changes underneath the move:

  * The cmsg store was sized for exactly one fd (CMSG_SPACE(4) = 24 B). A multi-planar dmabuf can
    carry up to four, so it is now CMSG_SPACE(4*4); `send_fds`/`recv_fds` take a slice while `send`
    and `recv` keep their single-fd shapes as the fast path. An over-long fd list is rejected with
    io::Error rather than asserting — that is how MAX_MSG overflow is already handled — and the
    receive cap is enforced by the kernel through msg_controllen, so a 5-fd peer trips MSG_CTRUNC.

  * The old recv loop read only the FIRST i32 of each SCM_RIGHTS control message. Nothing sends two
    fds yet so it never fired, but every descriptor after the first in a multi-fd message would have
    leaked into the process. It now reads all of them.

Spawn takes the executable path as a parameter instead of assuming /proc/self/exe. The zerocopy
worker keeps self-exec; the encode worker passes its own binary, which must be a separate FILE and
never a subcommand — a shared inode shares the file capability.
2026-08-09 12:49:53 +02:00
enricobuehler 99f2130b28 feat(host/pads): the Windows Xbox backend, compiled and tested on Windows
Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under
the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so
hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped
before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich
plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID
contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops.

Rumble comes back off the driver's republished output reports. The Bluetooth rumble
report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently
costs 60% of the range — and the enable mask gates each motor independently.

The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches
_the_driver` caught the addition on its vacuity count, which is exactly what it is for.

Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100
green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note
`clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change —
tests/motion_contract.rs imports the linux-gated `switch_proto`.

Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send
an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather
than captured — diff it against a real pad before shipping.
2026-08-09 12:24:14 +02:00
enricobuehler f266636392 feat(host/pads): an Xbox pad on Windows becomes a real HID device, so Steam can see it
`pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so
Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad
at all — only classic XInputGetState via xinput1_4's interface walk ever does. A
reporter spent two weeks on a dead controller for exactly that reason; switching the
client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in
seconds.

This gives the Xbox pad that same footing: a new device_type 4 on the existing HID
minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired
ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with
no HID interface on real hardware, so a HID child claiming one is a device that has
never existed and has nothing for Windows to promote.

Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report,
strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec
mirroring that descriptor, with 11 layout tests.

One shared-path fix falls out. The timer completed every pended READ_REPORT with the
full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer
rather than truncating it — so a pad declaring a shorter report would have failed every
read and looked dead. Report length is now per-identity; it returns 64 for all four
pre-existing pads, so their behaviour is provably unchanged.

NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests
pass on macOS; the driver, the INF and the report descriptor have never been compiled,
infverif'd, or seen by a real pad. The descriptor is constructed rather than captured,
which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry
stock mappings keyed off it — diff it against a capture before shipping.
2026-08-09 12:11:04 +02:00
enricobuehler 516a295432 Merge pull request 'My gamescope gate withheld the host .deb it was meant to protect — the release still ships the KDE-breaking one' (#140) from worktree-gamescope-gate-placement into main
ci / web (push) Successful in 1m5s
ci / bun-nix (push) Successful in 17s
ci / rust-arm64 (push) Successful in 1m38s
ci / docs-site (push) Successful in 2m36s
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 8s
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 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 3m45s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 6m39s
ci / rust (push) Successful in 10m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
Reviewed-on: #140
2026-08-09 09:12:55 +00:00
enricobuehler 2c190b27b4 Merge pull request 'Switching audio device mid-stream killed the sound for the rest of the session — AVAudioEngine stops itself, and nothing ever restarted it' (#141) from worktree-audio-device-switch-silence 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
ci / bun-nix (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
apple / swift (push) Successful in 1m41s
release / apple (push) Successful in 10m9s
apple / screenshots (push) Successful in 6m10s
Reviewed-on: #141
2026-08-09 09:10:52 +00:00
enricobuehler 3cfa5ca194 Merge pull request 'The capability-hint test asserted the environment, not the code — main is red on a machine where nothing is wrong' (#139) from worktree-kwin-capability-test-env into main
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
ci / bun-nix (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
deb / build-publish (push) Canceled after 1m55s
deb / build-publish-host (push) Canceled after 1m10s
deb / build-publish-client-arm64 (push) Canceled after 50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
android / android (push) Successful in 6m20s
windows-host / package (push) Successful in 11m23s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
arch / build-publish (push) Successful in 11m50s
Reviewed-on: #139
2026-08-09 09:10:33 +00:00
enricobuehler bf913c5706 fix(apple): switching audio device mid-stream killed the sound for the rest of the session
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / rust (pull_request) Failing after 9m43s
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.

An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.

Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.

The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:

  - the engine's own configuration-change notification, every platform — the direct
    signal, but it can only be posted BY an engine, so it cannot report a rebuild that
    failed to start;
  - a CoreAudio HAL default-output-device listener on macOS — independent of any engine
    and of the engine's topology. This is what makes the recovery work for the
    voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
    cancellation both default on) and whose notification behaviour could not be verified:
    no Mac in the fleet can initialize VPIO at all;
  - route-change and media-services-reset on iOS/tvOS, where the session rather than the
    device is what moves. The route observer is now installed for mic-off (.playback)
    sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
    but every platform has engines a route change can stop.

They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.

A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.

The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.

AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
2026-08-09 11:03:10 +02:00
enricobuehler 5bd92dac5d fix(ci): my gamescope gate withheld the host .deb it was supposed to protect
ci / bun-nix (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 8m9s
The gate #135 added fails the job at the gamescope BUILD step. In deb.yml that
step runs before "Publish to the Gitea apt registry" and "Attach the host .deb
to the Gitea release", so failing it skipped both.

Consequence on the v0.26.0 tag, and it is the worst thing in this release so
far: the host .deb on the release is from 00:17 — re-point #1, BEFORE #136
revoked CAP_SYS_NICE. Every other .deb is from 08:29-08:31. So the published
Debian host still runs `setcap cap_sys_nice=ep` in its postinst, which is
exactly what makes the host unidentifiable to KWin and kills every KDE desktop
session. A gate meant to protect the release withheld the fix for it and left
the broken artifact in place.

rpm.yml has the identical latent bug and only escaped it because Fedora went
green: a gamescope failure there would skip the sysext image, the feed publish
and the release attach, withholding the punktfunk RPMs and .raw images too.

Both now warn at the build/package steps and gate as the LAST step of the job,
after everything has published. A missing EXTRA must never stop a good artifact
shipping — go red afterwards instead.

Also: name noble's dependencies outright. `apt-get build-dep gamescope` gives it
almost nothing (the distro has no comparable package), which is why this peeled
one dep per CI cycle — wayland-protocols, then xdamage. The full set is derived
from the Arch package's depends+makedepends, which is the build that demonstrably
works, plus wlroots' own (it is a forced fallback subproject).

One `apt-get` per name on purpose: a single transaction aborts wholesale on one
unknown package, installing NOTHING and hiding the real gap behind a name typo.
Per-package, best-effort, with the missing name echoed; the end-of-job gate is
what actually decides.

⚠ Verification: both YAML files parse; every gamescope-touching `run:` block is
`bash -n` clean with matrix placeholders substituted (9 blocks); the .deb glob
matches build-gamescope-deb.sh's documented output
(`dist/punktfunk-gamescope_<version>_<arch>.deb`) and the RPM glob excludes
debuginfo/debugsource exactly as the attach loop above it does. The noble dep
NAMES cannot be proven from macOS — that is what the next tag run decides, and
it now decides it without holding the host .deb hostage.
2026-08-09 10:52:42 +02:00
enricobuehler e8a4f54c07 fix(pf-vdisplay): the capability-hint test asserted the environment, not the code
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m0s
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 6m57s
`silent_without_capabilities` called the real `capability_denial_hint()` and
asserted it returns "", on the strength of a doc comment that read "The test
process has no capabilities."

That is true on a dev box and false in CI, where the runner container is root
with a full permitted set. main went red on 0f79587d with:

    left: " — NOTE: this process carries capabilities (CapPrm=0x000001ffffffffff) …"
   right: ""

Nothing was wrong: the hint fired correctly, on a process that really did hold
every capability. The test was reading the ambient environment and calling it a
property of the code.

`permitted_caps_from_status` had already been split out for exactly this reason
— "so that shape is testable without a capability-carrying process to point at"
— but only the PARSE half. The message half still went to /proc/self/status.
This finishes the split: `capability_denial_hint_for(Option<u64>)` holds the
formatting and takes the mask, `capability_denial_hint()` reads /proc and
delegates. Both keep their callers, so neither is dead code.

Also adds `names_the_mask_and_the_repair_when_capped`. Without it the silent
case passes just as well against a function that returns "" unconditionally —
which is the failure mode this repo has been bitten by before, and the reason
every decode fix carries a counterfactual.

No behaviour change: the three error paths call the same function and get the
same string.

⚠ Verification is CI. `kwin.rs` is `#[cfg(target_os = "linux")]`, so it does not
compile on the macOS host this was written from; `cargo fmt --all --check` is
clean and a Linux container check was attempted but the stock rust image has no
cmake for audiopus_sys, so it never reached the test. ci.yml going green on main
is the proof — and unlike the case it replaces, this test now fails or passes
for reasons that have nothing to do with the machine running it.

Does not touch the v0.26.0 tag: ci.yml runs on `push: branches: [main]` and
`pull_request` only, and no tag leg runs cargo test.
2026-08-09 10:39:41 +02:00
enricobuehler f80636f901 Merge pull request 'The release notes advertise a privilege 0.26.0 deliberately does not grant' (#138) from worktree-notes-capsysnice-correction into main
android-screenshots / screenshots (push) Successful in 1m29s
release / apple (push) Successful in 12m13s
decky / build-publish (push) Successful in 37s
windows-host / package (push) Successful in 11m48s
windows-host / canary-manifest (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 1m27s
deb / build-publish (push) Successful in 4m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
linux-client-screenshots / screenshots (push) Successful in 2m54s
sbom / sbom (push) Successful in 20s
deb / build-publish-host (push) Failing after 5m18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows-host / winget-source (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 9s
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 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 10m26s
arch / build-publish (push) Successful in 11m24s
web-screenshots / screenshots (push) Successful in 5m5s
flatpak / build-publish (push) Successful in 16m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 2m5s
ci / docs-site (push) Successful in 1m12s
ci / bun-nix (push) Successful in 38s
ci / rust (push) Canceled after 1m31s
2026-08-09 08:13:45 +00:00
enricobuehler 0f79587dd6 docs(release): the notes claimed a privilege 0.26.0 deliberately does not grant
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m51s
ci / bun-nix (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 8m47s
The user-facing v0.26.0 notes said, of the PyroWave GPU-priority lever:

    "it is now, and the package grants the host the permission that switch needs"

That was true of 0.26.0-1 and is now the opposite of true. Granting CAP_SYS_NICE
made the host unidentifiable to KWin and killed desktop streaming on every KDE
box across all five Linux channels, so 0.26.0-2 revokes it everywhere and must
keep doing so. The lever is wired natively on Linux for the first time — that
part stands — but it is dormant on an ordinary install, and the notes have to
say so rather than advertise a speed-up nobody gets.

CHANGELOG.md was already corrected in #136 (the 0.26.0-2 note under PW1 and the
qualifier on the owed A/B). This is the user-facing half, which #136 did not
touch:

  * the PyroWave bullet now leads with what DID land (two encoder handles, the
    capture buffer headroom) and describes the priority switch as present but
    dormant, with the reason.
  * a new Fixed entry for the KDE breakage itself. Worth telling users even
    though the release was never announced: 0.26.0-1 packages did reach the
    registries, and anyone who pulled one has a desktop session that fails with
    a missing-screencast error surviving a clean reinstall. It also explains the
    dormancy the bullet above now refers to.

Deliberately NOT written as a "Before you update" action: upgrading strips the
capability by itself on every channel, so there is nothing for a reader to do.

Commit count 47 -> 52.

Voice check clean (0 internal-vocabulary hits above "## For developers"); notes
67 lines.
2026-08-09 10:12:58 +02:00
enricobuehler 651a7a82a1 Merge pull request '0.26.0-1 gave the host CAP_SYS_NICE, which made it invisible to KWin — every KDE desktop session died, on five packaging channels' (#136) from worktree-kwin-capability-identification into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Failing after 2m20s
ci / rust (push) Failing after 2m21s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
android / android (push) Successful in 6m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
apple / screenshots (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m32s
deb / build-publish-host (push) Successful in 6m11s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Successful in 11m40s
windows-host / package (push) Successful in 12m30s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
nix / flake (push) Successful in 18m34s
windows-host / canary-manifest (push) Successful in 14s
Reviewed-on: #136
2026-08-09 08:04:43 +00:00
enricobuehler 4d383811c0 fix(packaging): the same CAP_SYS_NICE broke KDE on FIVE channels, not one — Bazzite included
ci / bun-nix (pull_request) Successful in 17s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m31s
ci / rust (pull_request) Failing after 9m2s
nix / flake (pull_request) Successful in 12m24s
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:

  * packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files  <- Fedora AND Bazzite
                                            via rpm-ostree layering
  * packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
  * packaging/debian/build-deb.sh ......... setcap in the postinst
  * packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
  * scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
                                            whose Exec= it thereby voids

Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.

NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:

    /proc/PID/exe ....... EPERM   <- KWin's identification. Desktop sessions die.
    /proc/PID/root/* .... EPERM   <- xdg-desktop-portal reads .flatpak-info here to resolve an
                                     app id; the wlroots and Hyprland backends go through it
    /proc/PID/environ ... EPERM
    /proc/PID/cgroup .... OK
    /proc/PID/status .... OK
    /proc/PID/cmdline ... OK

Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.

The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.

Docs corrected, because they advertised the capability as a feature:
  * docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
    capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
  * docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
  * packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
    before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
    note that 0.26.0-1 Desktop mode cannot be repaired in place
  * packaging/arch/README.md — the false "capabilities don't survive the sysext" line
  * CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
    owed PyroWave-under-load A/B now says it needs a gamescope-only box

Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
2026-08-09 09:56:36 +02:00
enricobuehler 42ee6c5628 fix(packaging): the host's CAP_SYS_NICE made it invisible to KWin, killing every KDE session
0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:

    KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client

reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

The two cannot coexist. KWin hands out its restricted protocols — zkde_screencast_unstable_v1,
which mints our virtual output, and org_kde_kwin_fake_input, which injects input — only to a client
it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
.desktop's Exec=. The kernel refuses that readlink to any reader whose effective set is not a
superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities.
So the instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.

Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:

    no capability .............................. readlink /proc/<pid>/exe OK
    capability ................................. EPERM
    capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM   <- dumpable is NOT the gate
    capability dropped + PR_SET_DUMPABLE(1) .... OK      <- only an uncapped process works

The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.

The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.

  * packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
    boxes that already ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode and file
    capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade paths.
  * pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
    /proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
    the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
    otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.

Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.

Ships as 0.26.0-2 — packaging plus one crate, no version bump.
2026-08-09 09:37:30 +02:00
enricobuehler 08eaf337e8 Merge pull request 'v0.26.0 promised a Fedora and an apt gamescope that were never built' (#135) from worktree-gamescope-rpm-deb-builddeps into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m35s
ci / bun-nix (push) Successful in 27s
ci / docs-site (push) Successful in 1m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
deb / build-publish-host (push) Successful in 5m53s
deb / build-publish (push) Successful in 6m9s
docker / builders-arm64cross (push) Successful in 7s
ci / rust (push) Successful in 11m22s
docker / deploy-docs (push) Failing after 6m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m20s
2026-08-09 07:22:59 +00:00
enricobuehler 39869031be fix(ci): the gamescope RPM and .deb never built, and a warning let the tag ship anyway
ci / bun-nix (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 11m20s
v0.26.0's notes and docs-site say the patched gamescope is now installable on
Fedora and on Debian/Ubuntu. Neither package exists on the release. Both builds
failed inside best-effort steps that emit `::warning::` and return 0, so every
job stayed green and the only evidence was a warning nobody reads. Arch built
fine, which is why it is the sole gamescope package attached.

Two distinct missing build deps, same root cause: `dnf builddep gamescope` /
`apt-get build-dep gamescope` resolve the DISTRO'S OLDER PACKAGED gamescope,
which does not need what the pinned master tree needs.

  Fedora (f43 AND f44)
    /usr/sbin/ld: cannot find -lstdc++
    have you installed the static version of the stdc++ library ?
    ERROR: Compiler sccache c++ cannot compile programs.

  build-punktfunk-gamescope.sh appends `-static-libstdc++ -static-libgcc` to
  LDFLAGS deliberately, so the binary still starts on SteamOS's older libstdc++.
  Without libstdc++-static that trips meson's very FIRST sanity check, so
  nothing builds at all.

  Debian/Ubuntu noble
    protocol/meson.build:7:17: ERROR: Neither a subproject directory nor a
    wayland-protocols.wrap file was found.

  The tree carries no wrap fallback for wayland-protocols.

Both proven deps are installed WITHOUT `|| true` so a rename is loud. The
remaining Arch makedepends the older packaged gamescope may not pull (glm,
cmake, libXcursor, wayland-protocols-devel on Fedora) stay best-effort, since
meson finds fallbacks and a name that moves between releases should not fail
the job.

And the part that actually matters: on `refs/tags/v*` a missing gamescope is
now an ERROR, not a warning. A release must not be able to make a claim its own
CI silently dropped. Gated in two places per platform — the build step, and the
packaging step that is authoritative and also covers the cache path (the build
step is skipped entirely on a cache hit, so a stale cache would otherwise reach
packaging and skip in silence). Canary keeps the old best-effort behaviour.

Deliberately NOT gated: the sysext leg. The notes make no claim about gamescope
inside the sysext, and with the build fixed gs-cache is populated so it gets the
binary anyway — gating it would add release-blocking risk with no matching
promise.

⚠ Verification is CI itself: both YAML files parse, and every gamescope-touching
`run:` block is `bash -n` clean with the matrix placeholders substituted. The
dep names cannot be proven from macOS; the rpm and deb legs on the next tag are
the proof, and they are now hard-gated, so a wrong name fails loudly instead of
shipping another empty promise.
2026-08-09 09:22:20 +02:00
enricobuehler 55f361cb92 Merge pull request 'The v0.26.0 tag went red on Windows — a Linux-only reader tripped dead_code' (#134) from worktree-pyrowave-wire-dead-code into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 4m54s
ci / web (push) Successful in 1m47s
release / apple (push) Successful in 10m42s
ci / rust (push) Successful in 8m51s
ci / docs-site (push) Successful in 1m44s
ci / bun-nix (push) Successful in 32s
apple / screenshots (push) Successful in 5m55s
android-screenshots / screenshots (push) Successful in 2m16s
deb / build-publish (push) Successful in 3m56s
decky / build-publish (push) Successful in 23s
deb / build-publish-client-arm64 (push) Successful in 2m39s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m32s
deb / build-publish-host (push) Successful in 6m49s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
linux-client-screenshots / screenshots (push) Successful in 2m55s
android / android (push) Successful in 11m36s
arch / build-publish (push) Successful in 13m24s
flatpak / build-publish (push) Successful in 8m4s
windows-host / winget-source (push) Successful in 35s
windows-host / package (push) Successful in 11m45s
windows-host / canary-manifest (push) Skipped
sbom / sbom (push) Successful in 35s
docker / deploy-docs (push) Failing after 6m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m57s
web-screenshots / screenshots (push) Successful in 4m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-08 23:44:44 +00:00
enricobuehler 2079411f4f fix(pf-encode): the Windows host could not compile — a Linux-only reader tripped dead_code
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 5m55s
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / bun-nix (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 21m44s
The v0.26.0 tag went red on windows-host at the clippy step, after a clean
build:

  error: function `wire_sequence` is never used
    --> crates\pf-encode\src\enc\pyrowave_wire.rs:68:15
     = note: `-D dead-code` implied by `-D warnings`

`pyrowave_wire` is cfg'd for linux OR windows and is genuinely shared —
`packet_boundary` and `stamp_color_bits` each have callers on both backends.
`wire_sequence` does not: every call site is in `enc/linux/pyrowave.rs`, which
is `#[cfg(all(target_os = "linux", feature = "pyrowave"))]`. Alternating
encoder handles are a Linux-side concern (PW5); the Windows backend drives
pyrowave's compat device with a single handle and never needs the counter. The
module's own `#[cfg(test)]` block does not reference it either, so on Windows
the item has zero callers in every target and dead_code is correct — it is the
`-D warnings` promotion to a hard error that stops the lib compiling.

Scoped to the one item rather than the file, and expressed as
`cfg_attr(not(target_os = "linux"), ...)` rather than a bare `allow`, so
dead_code stays LIVE on Linux — where the caller lives, and where this function
quietly losing its last caller would be a real finding rather than noise.

⚠ Not reproducible off a Windows box: cross-compiling to
x86_64-pc-windows-msvc from macOS dies in openh264-sys2's build script
(clang++ rejects `-fPIC` for that target) long before the lint stage. The
mechanism is nonetheless exact — one item, one cfg, zero callers behind it —
and the windows-host and windows-msix legs are the proof.

No behaviour change on any platform: this adds a lint attribute and eight
lines of comment.
2026-08-09 01:43:54 +02:00
enricobuehler 4d1a1348c0 Merge pull request 'chore(release): bump workspace version to 0.26.0' (#133) from worktree-release-0260 into main
apple / swift (push) Successful in 1m41s
audit / bun-audit (plugin-kit) (push) Successful in 1m1s
audit / bun-audit (sdk) (push) Successful in 33s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 27s
audit / pnpm-audit (push) Successful in 30s
ci / web (push) Successful in 2m25s
audit / license-gate (push) Successful in 4m21s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m26s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 6m20s
android-screenshots / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
deb / build-publish-client-arm64 (push) Canceled after 17s
decky / build-publish (push) Canceled after 5s
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
linux-client-screenshots / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 1m6s
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
sbom / sbom (push) Canceled after 0s
web-screenshots / screenshots (push) Canceled after 1s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m21s
audit / cargo-audit (push) Successful in 2m17s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
nix / flake (push) Successful in 20m20s
flatpak / build-publish (push) Successful in 21m23s
2026-08-08 23:29:38 +00:00
enricobuehler e5180a5b7d chore(release): bump workspace version to 0.26.0
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m15s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 3m39s
ci / rust-arm64 (pull_request) Successful in 7m12s
android / android (pull_request) Successful in 9m5s
ci / rust (pull_request) Successful in 20m42s
nix / flake (pull_request) Failing after 20m28s
47 commits since v0.25.0, most of them from field reports on 0.25.0 itself,
plus Wave 2 of the PyroWave Linux host-performance program.

Nothing breaks: the wire protocol stays at 2 and the C ABI stays at 17, so
this release adds no call, no message and no capability bit. pf-driver-proto
is byte-for-byte identical to v0.25.0 and to v0.24.0.

Four new environment variables (PUNKTFUNK_OVERLAY_MASK,
PUNKTFUNK_GAMESCOPE_REFRESH_RATES, PUNKTFUNK_PYROWAVE_CHUNK_KIB,
PUNKTFUNK_PYROWAVE_STREAMED_AU), verified new by git grep at the v0.25.0 tag
rather than assumed. plugin-kit goes 0.3.2 -> 0.4.0 for the `plugin` launch
kind; the SDK goes 0.1.2 -> 0.1.4; gamescope patch level +pfhdr2 -> +pfhdr4.

Two behaviour changes make a client advertise LESS than it used to, both
deliberate: VIDEO_CAP_444 is now probed against the driver rather than ridden
off the setting alone (every Steam Deck with "Full chroma" on was losing HEVC
entirely, not crispness — no AMD silicon decodes HEVC 4:4:4), and the Decky
client-update check now reports a failure instead of dressing it up as
"up to date".

Bump is the same four files as 0.25.0: Cargo.toml, Cargo.lock,
docs/releases/v0.26.0.md, docs/releases/whatsnew/v0.26.0.txt — plus the
CHANGELOG.md section, which the split at 0.25.0 made part of the ritual.

Gates run locally, all green:
  * cargo fmt --all --check          clean
  * cargo metadata --locked          resolves
  * Cargo.lock diff                  versions-only, 70/70 changed lines, 35 crates
  * Play whatsnew gate               398/500 chars, not byte-identical to any other release
  * notes voice check                0 internal-vocabulary hits above "## For developers"

Notes are 66 lines against 0.25.0's 83, covering 47 commits.

Still owed on glass and recorded in the CHANGELOG's verification table:
iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the
PyroWave-under-game-load A/B with CAP_SYS_NICE actually granted.
2026-08-09 01:28:39 +02:00
enricobuehler 4070d043d6 Merge pull request 'PyroWave on Linux: the GPU-priority knob never fired, a dmabuf timeout condemned the host, and the jumbo grow was dead code' (#132) from worktree-wave2-pyrowave into main
apple / swift (push) Successful in 1m35s
android / android (push) Canceled after 3m13s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m9s
ci / rust (push) Canceled after 2m42s
ci / rust-arm64 (push) Canceled after 1m10s
ci / web (push) Canceled after 3s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 2s
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 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
flatpak / build-publish (push) Canceled after 8s
nix / flake (push) Canceled after 7s
release / apple (push) Canceled after 5m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 3m27s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , 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
Reviewed-on: #132
2026-08-08 23:23:12 +00:00
enricobuehler ebf61cb448 Merge branch 'worktree-wave2-pw5-encode-overlap' into worktree-wave2-pyrowave
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
ci / bun-nix (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / web (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 5m4s
ci / rust-arm64 (pull_request) Successful in 6m29s
ci / rust (pull_request) Successful in 11m37s
nix / flake (pull_request) Successful in 18m17s
# Conflicts:
#	crates/pf-encode/src/enc/linux/pyrowave.rs
2026-08-09 01:14:59 +02:00
enricobuehler 4a92c64144 Merge branch 'worktree-wave2-pw7a-jumbo-shard' into worktree-wave2-pyrowave 2026-08-09 01:13:30 +02:00
enricobuehler 2426056465 Merge branch 'worktree-wave2-pw6-streamed-au' into worktree-wave2-pyrowave 2026-08-09 01:13:25 +02:00
enricobuehler d3aaa16a7d Merge branch 'worktree-wave2-pw3-dmabuf-latch' into worktree-wave2-pyrowave
# Conflicts:
#	packaging/arch/punktfunk-host.install
#	scripts/steamdeck/install.sh
2026-08-09 01:13:23 +02:00
enricobuehler 2dd65bdd41 Merge pull request 'Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it' (#131) from worktree-deck-overlay-input-mask into main
apple / swift (push) Successful in 1m38s
audit / cargo-audit (push) Successful in 1m45s
audit / bun-audit (plugin-kit) (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 30s
audit / bun-audit (web) (push) Failing after 26s
audit / docs-site-audit (push) Successful in 26s
audit / pnpm-audit (push) Successful in 11s
arch / build-publish (push) Successful in 9m2s
ci / rust-arm64 (push) Successful in 3m6s
android / android (push) Successful in 10m27s
audit / license-gate (push) Successful in 5m59s
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 58s
ci / docs-site (push) Successful in 1m56s
release / apple (push) Successful in 9m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m52s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
apple / screenshots (push) Successful in 5m48s
deb / build-publish-client-arm64 (push) Successful in 6m51s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
deb / build-publish-host (push) Successful in 11m34s
ci / rust (push) Canceled after 18m48s
deb / build-publish (push) Canceled after 12m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 8m0s
nix / flake (push) Canceled after 8m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m37s
windows-host / canary-manifest (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m30s
Reviewed-on: #131
2026-08-08 22:59:30 +00:00
enricobuehler 5cbaca7789 feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME
physical controller the client forwards, so opening either one moved the game
on the host as well as Steam's UI — a second, invisible player. Steam Input
masks a normal game here; it cannot mask us, because masking happens on Steam
Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the
virtual pad has no gyro, trackpads or paddles).

SDL ships the exact behaviour we want and it is on by default: presses are
dropped while the process has windows but no keyboard focus, releases still get
through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx
and the client sits alone in its own, so the Steam overlay — which lives in the
root ctx — never takes our X focus away and no FocusOut is ever generated.
Measured on glass: with the QAM open, X input focus inside the client's ctx
stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to
769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app.

So the signal is explicit. `overlay_focus` watches those two atoms on the
gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`,
hence the socket-directory walk and the flatpak filesystem line — and the
presenter ORs it with window focus into one `set_masked`.

Masking is deliberately not `set_forwarding`: that closes the slot and sends
GamepadRemove, so the game would see a controller UNPLUG every time somebody
opened the QAM. This keeps every slot open and only stops the transitions,
after flushing what the host believes is held so a stick deflected at
overlay-open stops steering instead of freezing at its last value. On the way
back, held buttons are adopted rather than replayed — the A that picked a QAM
row must not fire in the game as it closes — while axes are re-sent, since a
stick has no press to ghost and SDL only speaks on change.

Fails open throughout: no gamescope, no X, or an unreadable signal all leave
forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
2026-08-09 00:55:16 +02:00
enricobuehler 9c854893bc Merge pull request 'plugin-kit 0.4.0 — publish the launch surface #129 added, because rom-manager's main is red until it exists' (#130) from worktree-plugin-kit-040 into main
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / rust (push) Canceled after 7s
ci / rust-arm64 (push) Canceled after 2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1s
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
nix / flake (push) Canceled after 2s
plugin-kit-publish / publish (push) Successful in 39s
Reviewed-on: #130
2026-08-08 22:53:39 +00:00
enricobuehler 6b7997cace chore(plugin-kit): 0.4.0 — the launch surface a plugin needs to publish a tile the host cannot name
ci / docs-site (pull_request) Canceled after 30s
ci / web (pull_request) Canceled after 45s
ci / rust (pull_request) Canceled after 3m3s
ci / rust-arm64 (pull_request) Canceled after 1m52s
ci / bun-nix (pull_request) Canceled after 0s
nix / flake (pull_request) Canceled after 5s
`serveUi({launch})`, `PluginLaunchTarget` and `makeLaunchHandler` (#129) are new API, so this is a
minor bump rather than a patch. It also carries `SyncError.message`, without which a host refusal
reaches a plugin's own UI as the bare tag `SyncError` and nothing else.

Unblocks rom-manager, whose main is currently RED: it merged the consuming change while still
pinning `^0.2.0`, so `bun install --frozen-lockfile` there resolves a kit without these exports and
the typecheck fails on all three. Publishing this and then bumping that pin is the fix — in that
order, because the lockfile cannot resolve 0.4.0 until it exists on the registry.

Tag `plugin-kit-v0.4.0` to publish; the workflow asserts the tag matches this version.
2026-08-09 00:52:37 +02:00
enricobuehler 78ba2342b5 Merge pull request 'rom-manager has been putting 0 games in the library since 08-05 — a plugin launch kind, so a scanner can publish tiles the host cannot name' (#129) from worktree-rom-manager-plugin-launch into main
apple / swift (push) Successful in 1m39s
ci / web (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m35s
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 2m45s
deb / build-publish-client-arm64 (push) Successful in 1m4s
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 (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 3m55s
android / android (push) Successful in 6m31s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 30s
apple / screenshots (push) Successful in 6m8s
ci / rust (push) Canceled after 8m47s
deb / build-publish-host (push) Successful in 8m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m57s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 13m1s
Reviewed-on: #129
2026-08-08 22:44:44 +00:00
enricobuehler 7d37fe450d test(pf-encode): run PyroWave at depth 2 on real hardware — without shipping depth 2
Wave-2 PW5, the stage-6 experiment. Shipped behaviour is UNCHANGED: `max_inflight` is still 1.

Stage 6 is the frame-corruption stage, and its gate is an on-glass tear-hunt with a live compositor,
a real client and ten minutes of moving content. That is not runnable from here. But the depth-2
risk has two halves, and one of them lives entirely in this crate — the per-slot resources
(`cmd`/`fence`/`csc_set`/y/uv/cursor) and the alternating encoder handles — so that half can be
answered now, on the GPU, and the answer is worth having before anyone attempts the other.

The experiment drives the backend with two frames genuinely in flight (submit N+1, then poll N) and
compares the result against the encoder's OWN synchronous output over the same 16 moving frames.
Its own depth-1 decode is the honest reference: pyrowave's raw AU bytes are not reproducible
run-to-run (see the stage-3 commit), but its decoded planes are.

RESULT, .21 / RTX 5070 Ti (GPU idle at 180 MHz of 3090 — the slow-clock worst case on this card):

  depth-2 vs depth-1 over 16 frames: worst-case PSNR identical (inf)

Bit-identical luma, every frame, in order. So stages 4 and 5 between them are sufficient for the
encoder side: doubling the six single-slot resources and alternating two `pyrowave_encoder` handles
under one monotonic wire sequence really does make overlap invisible to the decoder.

The test is built to fail rather than to pass. Content MOVES every frame (flat fills are the
documented false-green trap — a torn frame stitched from two halves of a static card is invisible),
it asserts two frames were ACTUALLY in flight rather than silently proving nothing, it asserts the
AU count is unchanged, and it carries an off-by-one discriminator that raw PSNR would miss: each
overlapped frame must match its own reference BETTER than it matches the previous one, so a
pipeline delivering frames one position late fails even though every individual PSNR looks fine.

It reaches `max_inflight` directly instead of through a shipped knob, precisely so the shipped
value stays 1.

⚠ WHAT THIS DOES NOT COVER, stated here so the next person does not read it as a green light for
stage 6: the CAPTURE side. `.process` hands the SPA buffer back to the compositor at callback
return while the encode thread holds only a dup of its dmabuf fd, so a second frame in flight
widens the window in which the producer may overwrite a buffer we are still reading by a full frame
period. Nothing in this crate can test that — it needs a live producer. Stages 1 and 2 are what
make it answerable (the pool census says how deep the producer's ring is; the Choice range asks for
headroom), and the on-glass hunt is what would settle it.

Gates green at CI parity.
2026-08-09 00:30:25 +02:00
enricobuehler 077db416ec feat(pf-encode): two PyroWave encoder handles, and the 3-bit landmine that makes them work
Wave-2 PW5 stage 5. Depth is STILL 1 — the handles alternate per frame, one in flight.

PyroWave's `Encoder` cannot hold two frames. Not "probably not" — structurally not. `Encoder::Impl`
owns ONE each of `wavelet_img_high_res`, `bucket_buffer`, `meta_buffer`, `block_stat_buffer`,
`payload_data` and `quant_buffer`, and `Impl::encode` OPENS by discarding them: an image barrier
with `VK_IMAGE_LAYOUT_UNDEFINED` as the old layout — a written promise that nothing else is reading
it — plus three `fill_buffer` clears. Two encodes recorded into two command buffers and submitted
to one queue have no execution dependency in Vulkan (submission order orders the START, not the
completion), so N+1's DWT would overwrite the wavelet bands and zero the RDO buckets while N's
block packing still reads them. Content-dependent, silent.

So overlap means TWO handles on one device, alternated — one per slot. Every resource above is
then private per handle, and within a handle the encodes stay strictly serialized (a slot's next
frame is recorded only after that slot's previous one retired), which leaves patch 0004's
scratch-pool invariant intact without touching it.

THE LANDMINE, and it is the reason this stage is its own commit: `sequence_count` ALSO lives on
`Impl`, and it is the 3-bit counter stamped into every block header. Two handles each count
1,2,3... alone, so the wire sees 1,1,2,2,3,3.... The decoder restarts a frame only when the value
CHANGES (`diff = (hdr.sequence - last_seq) & 0x7; restart = diff != 0`), so a repeat reads as MORE
BLOCKS OF THE SAME FRAME: `clear()` never runs, `decoded_frame_for_current_sequence` stays true,
and the second frame of each pair is swallowed. Half frame rate, occasional mixed-frame blocks, no
error anywhere — on every client, since pf-client-core and the Apple Metal hand-port parse the same
field.

`patches/0007-encoder-sequence-override.patch` (new, ~38 lines) exposes
`Encoder::set_next_sequence` + a `pyrowave_encoder_set_next_sequence` C entry + a
`PYROWAVE_SEQUENCE_MASK` define, so ONE monotonic counter on the Rust side is stamped regardless of
which handle encodes. The setter stores `(seq - 1) & mask` because `Impl::encode` pre-increments —
its contract is about the next ENCODE, not the next store. Inert when unused, so the whole Windows
backend is untouched. No `.def` change: the C API is a static archive.

PREDICTED, THEN OBSERVED. A negative control on .21 (the override call removed, nothing else) reads
the wire out at exactly:

  [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 0, 0, 1, 1, 2, 2]

which is the analysis's prediction character for character, and with the override:

  +1 mod 8, all 20 frames, through the 3-bit wrap.

THE GATE, `wire_sequence_increments_across_alternating_handles`, checks three things over 20 frames
because any one alone could pass while the stream is broken: the wire counter advances by 1 mod 8;
ONE persistent decoder (its `last_seq` carried across every push, exactly like a client's) reports
every AU decodable; and consecutive decoded pictures DIFFER. Content moves every frame — and the
first run caught a trap in the harness itself rather than the encoder: `test_card` starts its LCG
at `seed | 1`, so seeds 2 and 3 build a byte-identical card and the test faked the very repeat it
hunts. Odd seeds only now, with the reason written down.

A runtime self-check backs the test up where the test cannot reach: after packetize, the stamped
sequence is compared against what we asked for, and a mismatch logs once per process naming patch
0007. A re-vendor that loses the patch would not fail to build — it would fail on glass, subtly,
and this makes it loud instead. Two byte reads per frame.

`reset()` rebuilds both handles and `Drop` destroys both, each with the same null-immediately
discipline the single handle had (`pyrowave_encoder_destroy` is a bare `delete` with no null
check, so a stale pointer left in the field is a double free).

Vendored-patch discipline: patch 0007 re-applies clean to a pristine vendor checkout (verified by
stashing the vendor tree and re-applying), and `git diff crates/pyrowave-sys/vendor/` touches
exactly the four intended files.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090): all 8 `#[ignore]`d GPU tests
pass, including the new gate and the 4:2:0 / 4:4:4 / 24-bpp PSNR smokes.

Gates green at CI parity.
2026-08-09 00:27:28 +02:00
enricobuehler fd98406868 Merge pull request 'The Decky plugin's "update the client" has never once detected an update' (#128) from worktree-decky-client-update into main
ci / web (push) Successful in 1m54s
ci / rust-arm64 (push) Successful in 3m56s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m32s
decky / build-publish (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
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 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
ci / rust (push) Successful in 8m12s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 46s
Reviewed-on: #128
2026-08-08 22:21:08 +00:00
enricobuehler 5c70a90358 Merge pull request 'A Steam Deck could lose HEVC entirely to a chroma switch nothing checked — and the tool you'd triage it with denied the queue it was decoding on' (#127) from worktree-deck-hevc-shape-gates into main
ci / rust (push) Canceled after 25s
ci / rust-arm64 (push) Canceled after 25s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (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
apple / swift (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m8s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m44s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
deb / build-publish (push) Successful in 5m35s
deb / build-publish-host (push) Successful in 6m5s
apple / screenshots (push) Successful in 6m15s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m20s
android / android (push) Successful in 9m35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
arch / build-publish (push) Successful in 12m29s
flatpak / build-publish (push) Successful in 10m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18m7s
Reviewed-on: #127
2026-08-08 22:20:39 +00:00
enricobuehler 29248dcab9 feat(pf-encode): PyroWave had six single-slot resources, not the two the plan named
Wave-2 PW5 stage 4. Pure capacity — `max_inflight` is STILL 1, nothing overlaps yet.

The plan named the y/uv images as the thing to double. Reading the backend found five more, and
each is a correctness problem under overlap rather than a performance one:

  * `csc_set` — ONE descriptor set, rewritten every frame by `bind_rgb`. Updating a set still bound
    by a PENDING command buffer violates VUID-vkUpdateDescriptorSets-None-03047, and on most
    drivers that is a wrong picture rather than an error.
  * `y_img`/`uv_img` — the CSC of N+1 storage-writes exactly the images pyrowave is still sampling
    for N. The barrier comment ("the previous frame's encode already completed under our
    synchronous fence") was load-bearing and said so.
  * `cursor_img` + `cursor_stage` — the struct comment stated the assumption outright: *"Single
    (not ring) because PyroWave encodes one frame synchronously — no in-flight overlap to race."*
  * `cmd` + `fence` — you cannot record into a PENDING command buffer at all.
  * `cpu_img`/`cpu_stage` (software capture / tests) — the host writes staging while the previous
    frame's copy is still pending.

All of it moves into a `Slot`, and the encoder now owns `SLOTS` of them. Two, because Granite caps
the overlap at two for us: the pyrowave device defaults to `init_frame_contexts(2)` and
`next_frame_context()` — called at the top of every `encode_gpu_synchronous` — waits the context it
rotates into. A third slot would need a vendored `init_frame_contexts(3)` that is not exposed.

`bitstream` and `import_cache` are deliberately NOT per-slot, and the `Slot` doc says why so a
later sweep does not "fix" it: `bitstream` is only touched during packetize, i.e. only on the poll
side one frame at a time, and `import_cache` retaining the VkImage/VkDeviceMemory per dmabuf inode
is precisely what makes it safe for two slots to sample the same imported buffer. `cpu_expand` is
shared for the same reason — it is copied into staging before `submit_frame` returns, so no GPU
work ever reads it.

Each frame carries its slot index in `InFlight` rather than recomputing it, so `wait_and_packetize`
cannot wait the wrong fence — the failure that would look like corruption rather than an error.
`reset()` now waits EVERY in-flight fence, not just one, which matters the moment depth rises.

WHAT IT COSTS, measured from the driver's own memory requirements rather than estimated (.21,
RTX 5070 Ti, and there is now an `#[ignore]`d test that prints it on any GPU):

  1080p 4:2:0   3872 KiB per slot    7744 KiB for both
  4K    4:2:0  12992 KiB per slot   25984 KiB for both
  4K    4:4:4  24992 KiB per slot   49984 KiB for both

So the extra slot costs ~3.8 MiB at 1080p and ~24 MiB at 4K 4:4:4 — an order of magnitude under
the plan's ~25-35 MB / 100-150 MB estimate, because that estimate included pyrowave's internal
wavelet and scratch buffers, which stage 5's second encoder handle will add and this stage does
not. Affordable on an iGPU. The open line now logs `slots`, `slot_kib` and `slots_kib` so this is
visible per session and not only in a test.

VERIFIED ON GLASS (.21, GPU idle at 195 MHz of 3090 — slow-clock, the worst case on this card):
all 6 `#[ignore]`d GPU tests pass, and all NINE decoded-plane hashes (`ref-dense-{y,cb,cr}`,
`ref-chunked-*`, `ref-dense444-*`) are bit-identical to the pre-PW5 base. Decode identity is the
meaningful gate here — the raw AU bytes are not reproducible run-to-run even from an unmodified
binary, which stage 3's message documents.

Gates green at CI parity.
2026-08-09 00:18:38 +02:00
enricobuehler 95962f55d0 refactor(pf-encode): PyroWave waited its fence inside submit — the one backend that did
Wave-2 PW5 stage 3. Depth is STILL 1; this is the shape change alone.

`encode_frame` recorded CSC+encode, queue-submitted, waited the fence and packetized, all inside
`Encoder::submit`. Every other backend in this crate puts the wait on the POLL side. That
difference is the whole reason the host loop's cadence folds around this encoder: with the wait
inline, `submit` returns only after the GPU is done, so the arrival-anchored floor absorbs the
encode only while it stays under 0.9x the frame interval.

Split into `submit_frame` (ingest -> CSC -> pyrowave encode -> queue-submit -> return) and
`wait_and_packetize` (fence wait -> packetize -> AU), with an `InFlight` deque between them capped
by `max_inflight`, which is 1. **One is the only value the resources can support today** — `cmd`,
`fence`, `csc_set` and the y/uv images are one each, so a second concurrent frame would record into
a PENDING command buffer and storage-write images pyrowave is still sampling. `submit` therefore
drains to `max_inflight - 1` before recording, which states that invariant in one place instead of
leaving it implicit in "the encode is synchronous".

The subtle part is the command-buffer state machine, and it is unchanged: the record-and-submit
closure still resets `cmd` on every PRE-submit failure (RECORDING/INVALID/EXECUTABLE, never
PENDING), and the fence wait still does NOT reset on failure, because a timeout leaves the buffer
PENDING where a reset violates VUID-vkResetCommandBuffer-commandBuffer-00045. What changed is that
a failed wait now also leaves the entry IN FLIGHT — which is precisely what tells `reset()` there
is live GPU work to re-wait before the pyrowave encoder object may be destroyed. `gpu_pending` is
gone; `!inflight.is_empty()` is the same fact, and cannot drift from it.

The split opened two windows that did not exist when everything ran inline, both closed here:
`reconfigure_bitrate` and `set_wire_chunking` can now land BETWEEN a submit and its poll, so the
packetize boundary and the bitstream cap are snapshotted into `InFlight` at submit time. Reading
the live fields would have let a mid-flight bitrate drop turn a perfectly good frame into
"unexpected packet count", and a mid-flight chunking change into an AU with the wrong
`chunk_aligned` flag.

`flush()` is no longer a no-op — it drains the in-flight frame, so the trait's poll-until-None
contract still returns every AU (the `spike` subcommand and the hardware smoke tests are the real
users).

The perf instrument still measures submit->AU, stamped at submit and taken when the AU becomes
readable, so `92326312`'s numbers stay directly comparable; the log line now carries `depth` and
says plainly that above depth 1 the number legitimately grows by about one loop period.

VERIFIED ON GLASS (.21, RTX 5070 Ti, GPU idle at 180 MHz of 3090 — so these are slow-clock runs,
which is the worst case on this card, not the best): all 6 `#[ignore]`d GPU tests pass — the
4:2:0, 4:4:4 and 24-bpp PSNR smokes, the mode-mismatch refusal, the fd-leak check and the golden
dump.

Byte-identity, honestly: the AU bytes are NOT reproducible, and were not before this commit
either. Three runs of the SAME unmodified binary produced three different `au-dense.bin` hashes
(ab7ecaf6 / 8735700e / 933b3d40) — the vendored 4:2:0 encoder emits run-varying bytes that the
decoder ignores. So the meaningful gate is DECODE identity, and that holds exactly: every decoded
plane (`ref-dense-{y,cb,cr}`, `ref-chunked-{y,cb,cr}`, `ref-dense444-{y,cb,cr}`) is bit-identical
between the pre-split base and this commit, across four runs. 4:4:4 AUs are additionally
bit-stable and match the checked-in Apple fixture exactly.

Gates green at CI parity.
2026-08-09 00:11:22 +02:00
enricobuehler 9e598f8595 fix(client): the 4:4:4 switch could cost a Deck its whole codec, and --probe-decode denied the queue it was decoding on
ci / bun-nix (pull_request) Successful in 41s
ci / web (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 3m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 13m6s
Two Steam Deck findings from a field report of "the decoder was not found, it
fell back to H.264 — but sometimes HEVC worked".

**The 4:4:4 advertisement was a promise nothing checked.** `VIDEO_CAP_444` rode
the "Full chroma" setting alone. That was safe while a software HEVC decoder
existed underneath it; M8 removed one (there is no permissively licensed HEVC
CPU decoder, so `software_decodable_codecs()` is H.264|AV1). The host grants
4:4:4 on HEVC ONLY, and answers the resolved chroma in the Welcome before the
client builds a decoder — so on a device with no 4:4:4 decode the toggle did not
cost crispness, it cost the entire codec: the Vulkan rung refuses the shape at
construction, VAAPI refuses it too, there is no CPU rung, and the session
reconnects on H.264. AMD has no HEVC 4:4:4 decode on any silicon, so every Deck
with that switch on lost HEVC. It is per-profile and default-off, which is
exactly why it looked intermittent — a "Work" profile lost HEVC where "Game"
kept it, same box, same host.

Gated on `hevc_444_hardware_decodable`, which asks the driver through the SAME
code the rung uses at construction (`VkH265Decoder::probe_stream_support`), so
the advertisement and the rung that must honour it cannot disagree. Both depths
are required, not either: with HDR on the host may resolve 4:4:4 10-bit, and a
device offering YUV444_8 but not YUV444_10 would land in the same hole.

Answering from the Vulkan rung alone is exact rather than approximate — it is
the only rung in this build that implements 4:4:4 at all (`pf_vaadec::profile_for`
errors on chroma_format_idc 3, pf-dxvadec refuses anything but 4:2:0, the CPU
rung is 8-bit 4:2:0). Deliberately NOT extended to VIDEO_CAP_10BIT/HDR: all
three rungs implement 10-bit 4:2:0, so a Vulkan-only probe there would withdraw
HDR from boxes whose VAAPI/DXVA rung decodes it perfectly — a real regression
against a case never observed.

The bit arithmetic moves into `video::video_caps_for` so the part that was
wrong is testable without a GPU, a host or a Hello; the test is verified
non-vacuous against the planted original defect.

**`--probe-decode` described a different device from the one that streams.** The
RADV video-decode opt-in sat AFTER the --list-adapters/--probe-decode/--list-audio
/--pair early exits, so the triage tool never had it. Measured on a Deck
(canary e22af40f), same binary back to back: bare `--probe-decode` printed
"vulkan video decode: no", "driver decode ops: none (0x0)", "no queue family
advertises VIDEO_DECODE"; with RADV_PERFTEST=video_decode in the environment,
"YES" and "H.264, H.265, AV1, VP9". Any Deck triage that consulted it reached
the opposite of the truth. Hoisted to the top of `run`, ahead of every early
exit — nothing touches Vulkan before it (`main` calls `run` directly).

Gates, in the Linux container: fmt, plain `cargo build` (not only
--all-targets), `clippy --all-targets -D warnings`, and 185 tests.
2026-08-09 00:10:46 +02:00
enricobuehler bd86598d97 fix(decky): the client update the plugin offers was never once detected
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 2m22s
ci / rust (pull_request) Successful in 5m23s
The QAM has offered to update the client since 0.24, and on every Deck it has
answered "up to date" — including right now, with a client a day out of date.

The check asks flatpak for the remote's commit and compares it to the installed
one, and it named the app id with no branch: `flatpak remote-info punktfunk-origin
io.unom.Punktfunk`. The punktfunk remote publishes `stable` AND `canary`, so that
ref is ambiguous and flatpak refuses it — "Multiple branches available" — rather
than picking one. One branch INSTALLED does not help; the ambiguity is on the
remote. The call failed on every box, every time, and the failure returned
`available=False`, which the panel renders as good news. Hence: the plugin
appeared to update only itself.

Every query now names the ref in full, resolved once by `_flatpak_ref()` off the
exported tree (no subprocess — `_client_argv` is on the path of every headless
call). That resolution also carries the SCOPE, so a system-wide install is no
longer invisible to a check that hardcoded `--user`, and the launcher pins the
same `--branch=`, so the client we start is the client we check and update.

A check that cannot run now says so instead of reporting up-to-date: the flatpak
leg reports `client_error` exactly as the native leg already did. Dressing that
failure up as good news is the whole reason this went a week unnoticed.

Also: the button no longer promises "+ client" when the client is manual-only and
the tap can only print a command.

Verified on the Deck (192.168.1.253, canary, user scope) by running both code
paths against the real install, minutes apart:

  pre-fix   available=False  remote=''
  post-fix  available=True   remote=ca010668  (installed e22af40f)

and `flatpak {info,remote-info,update}` all accept the `id//branch` form there.
37 backend checks pass, 6 of them new and about exactly this.
2026-08-09 00:08:24 +02:00
enricobuehler c3ecc29117 feat(pf-capture): the zero-copy path never asked the compositor for buffer headroom
Wave-2 PW5 stage 2, on the number stage 1 just made visible.

`build_dmabuf_buffers` set `SPA_PARAM_BUFFERS_dataType` and stopped there — no
`SPA_PARAM_BUFFERS_buffers` at all, so the pool depth the whole zero-copy safety argument rests on
was entirely the producer's choice, and we never even expressed a preference. This asks for 8
(min 2, max 16).

A **Choice Range**, deliberately, not a fixed count. SPA intersects the consumer's and producer's
Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection and
the link stalls in "negotiating" with no error anywhere — the exact trap that cost this codebase
the entire Linux cursor channel once, when a 256^2 cursor-meta max failed to intersect Mutter's
fixed 384^2 offer. With a range the producer clamps into it and negotiation still succeeds; the
min stays at 2 so nothing that works today stops working.

The numbers, and what they are not: 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz,
well past the ~3-4 ms capture-to-fence latency PW3/PW4 measured, with room for a second frame in
flight. 16 is a ceiling rather than a request — a 4K 4:4:4 buffer is ~25 MB, so 16 of them is
~400 MB of compositor allocation. These are the values we ASK for; what a producer actually
allocates is what stage 1's census line reports, and that line is the one to trust.

Scoped to the dmabuf pod only. The mappable and SHM-only builders are untouched: their consumers
copy out of the buffer inside `.process`, so pool depth is not part of their correctness argument.

A test pins the pod SHAPE — Choice, Range, Int children, values default-first — so a later
simplification cannot quietly turn the range back into a number and take the negotiation down with
it.

Gates green at CI parity; on-glass negotiation on each producer is stage 2's own gate and is
reported with the stage-1 census numbers.
2026-08-09 00:01:24 +02:00
enricobuehler 6d550530fe feat(pf-capture): nothing had ever counted the compositor's buffer pool — the number every zero-copy safety argument rests on
Wave-2 PW5 stage 1, and the one stage with no risk at all.

The zero-copy capture path dups the dmabuf fd, publishes the frame, and hands the SPA buffer
straight back to the producer at `.process` return — while the encode thread has not yet imported
it, let alone read it. The code says so itself ("content stability across the brief import/encode
window relies on the compositor's buffer-pool depth, like any zero-copy capture"). That depth is
therefore load-bearing: it is the ONLY thing standing between us and the producer overwriting a
buffer mid-read.

And it had never been measured. Not logged, not asserted, not even requested — `build_dmabuf_buffers`
set `SPA_PARAM_BUFFERS_dataType` and nothing else, so whatever the producer picked is what we got,
silently.

This adds the `add_buffer`/`remove_buffer` stream callbacks PipeWire has always offered and logs the
count once per distinct depth: `pool_depth`, `high_water`, and the latest-frame-only `drained`
count beside it. One line per session on a stable pool (`.process` runs at the capture rate — an
unconditional log would be 240 lines a second of the same number), a second line if a
renegotiation changes the depth.

`high_water` is tracked separately from `live` because a renegotiation frees the pool before
re-allocating it: any decision keyed on the live count would read that dip as "the pool shrank".
`remove` saturates at zero rather than wrapping, so an unmatched remove cannot report `u32::MAX`
buffers.

Measurement only — no behaviour change, and no consumer of the number yet. PW5's later stages need
it (a deeper encode pipeline widens the overwrite window by a full frame period), but the number is
worth having regardless of whether those stages ever land: it is the answer to "is our zero-copy
capture actually safe on this compositor", and until now the honest answer was "nobody knows".

3 tests pin the once-per-depth logging, the renegotiation dip, and the saturating remove.

Gates green at CI parity.
2026-08-09 00:01:08 +02:00
enricobuehler e22082ac2a Merge pull request 'No audio over Bluetooth on iOS: .defaultToSpeaker is an override that outranks A2DP' (#126) from worktree-ios-bluetooth-audio-route into main
ci / bun-nix (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
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 (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 14s
ci / web (push) Successful in 2m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
ci / docs-site (push) Successful in 2m37s
ci / rust-arm64 (push) Successful in 3m44s
apple / swift (push) Successful in 1m37s
ci / rust (push) Successful in 6m29s
docker / deploy-docs (push) Failing after 6m50s
release / apple (push) Successful in 9m45s
apple / screenshots (push) Successful in 5m52s
Reviewed-on: #126
2026-08-08 21:54:23 +00:00
enricobuehler 4f5ca5f9bc docs(pf-capture): KWin/RADV was the last untested producer — it has no fence either
Closes PW4's one remaining gap. The Steam Deck switched to Desktop Mode gives KWin on RADV, the
combination none of the earlier legs covered, and it reports no implicit fence like every other:

  gamescope + NVIDIA (RTX 5070 Ti)   NoFence
  Mutter    + NVIDIA (RTX 5070 Ti)   NoFence
  gamescope + RADV   (Deck VANGOGH)  300/300 NoFence, mean 23us, p99 <=100us
  KWin      + RADV   (Deck desktop)  no fence  (older build's wording: waited=false)

That is every compositor x vendor this fleet has. PW4 retires with no outstanding doubt rather
than "probably fine except one box we never tried".

Measured with the Deck's OWN already-authorized binary rather than a scratch build, because KWin
grants zkde_screencast_unstable_v1 per EXECUTABLE PATH: it resolves /proc/<pid>/exe against a
.desktop's Exec= and caches the grant on first connect, so an unregistered path is refused outright
and registering one needs a re-login. The fence probe is pre-existing capture-path code, so a build
from July answers the outcome question perfectly well — and nothing of the user's was modified to
get it.

Comment-only; no behaviour change. fmt + pf-capture clippy -D warnings green.
2026-08-08 23:52:54 +02:00
enricobuehler 07f6d6f324 fix(apple): .defaultToSpeaker outranks Bluetooth, so every headset lost the stream
ci / bun-nix (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m42s
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m44s
Field report on 0.25, iOS: "no audio over Bluetooth ... plays through speakers
if Mic input is enabled".

Both halves are one bug. `micEnabled` and `echoCancel` both default to true
(EffectiveSettings.swift), so the DEFAULT iOS session is `.playAndRecord` — and
that branch set `.defaultToSpeaker`. That option is not the polite preference it
reads as: it is an output OVERRIDE, and it outranks an A2DP route. Wired
headphones beat it, Bluetooth does not, so a cable is the one way to test it and
get the right answer — which is what the comment sitting on it asserted
("headphones/BT still win"). Every Bluetooth listener on the default settings got
the phone's own speaker instead. Turning the mic off was the accidental
workaround the reporter found: that path takes `.playback`, which routes to A2DP
happily and always did.

The earpiece problem `.defaultToSpeaker` was reaching for is real —
`.playAndRecord` really does park the built-in output on the receiver. So solve
it against the route we were ACTUALLY given rather than pre-emptively: after
activation, if the current output is `.builtInReceiver`, override to the speaker;
anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly alone.

That override is a property of the current route — iOS drops it whenever the
route changes, which is exactly what lets a newly-connected headset win — so it
has to be re-applied per route. Hence the route-change observer: without it,
dropping Bluetooth mid-stream would hand the game to the earpiece. Registered
only for a `.playAndRecord` session (a `.playback` one needs no steering),
removed in stop() before the session deactivate, with deinit as a backstop.

Deliberately NOT adding `.allowBluetooth`: it would make a headset's mic usable,
but buys that by dragging the whole route onto HFP/SCO and collapsing game audio
to narrowband. High-quality A2DP output plus the built-in mic is the better trade
for a game-streaming client.

Verified: builds clean on arm64-apple-ios17.0 (the triple that actually compiles
these `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them),
arm64-apple-tvos17.0, and macOS; 257 Swift tests pass, 0 failures.
On-glass iPhone + Bluetooth listen still owed.
2026-08-08 23:49:38 +02:00
enricobuehler 54666e66da Merge pull request 'The Apple TV client had no way to show its statistics overlay' (#125) from worktree-tvos-stats-shortcut into main
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 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / bun-nix (push) Successful in 53s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / web (push) Successful in 1m17s
ci / docs-site (push) Successful in 1m27s
apple / swift (push) Successful in 1m34s
docker / builders-arm64cross (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
ci / rust-arm64 (push) Successful in 2m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / deploy-docs (push) Failing after 1m49s
ci / rust (push) Successful in 5m6s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 4m52s
Reviewed-on: #125
2026-08-08 21:48:19 +00:00
enricobuehler 5872dfc649 feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.

So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).

The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.

- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
  characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
  `launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
  its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
  own UI as nothing but "Decode error"
2026-08-08 23:46:05 +02:00
enricobuehler bed58b75b6 feat(apple): the statistics overlay is reachable on tvOS
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / rust (pull_request) Successful in 6m27s
An Apple TV session had no way to the stats overlay at all. Every other client
cycles it in-stream — Ctrl+Alt+Shift+S on the desktops, a three-finger tap on
touch — and tvOS has neither a keyboard nor a screen to tap, so the only route
was Settings before connecting (or a profile). The docs' own "cycle with" table
simply had no row for it.

Two surfaces, because an Apple TV may have a controller in the room or only the
remote:

- Select + X on a controller, cycling one tier per completion. Built like
  Android's mic chord (Select + Y) and deliberately disjoint from the escape
  chord — X is none of its four buttons, so reaching for one can never trip the
  other. Read off the wire mask like the escape chord, so a Select the
  hold-Select gesture has turned into a guide can't cycle the overlay on its way
  past. Available on every Apple platform: a controller in both hands is exactly
  the case the keyboard combo and the three-finger tap can't serve.

- Hold Play/Pause on the Siri Remote. Its right-click is therefore deferred until
  the press resolves — a tap still right-clicks, delivered on release with the
  release trailing by TAP_PRESS — because a right button held for half a second
  is a context menu on every desktop this streams.

A non-forwarding slot now claims the stats chord's elements too, alongside the
escape chord's: on tvOS an unclaimed button's press stays the system's and the
chord would silently never complete.

Tests pin both chords' masks against their GameController alias lists, that the
two overlap only on Select, and that the claim list covers both without
duplicates — the failure mode is nothing happening, with nothing logged.
2026-08-08 23:24:09 +02:00
enricobuehler ce31a9ddfd Merge pull request 'The Steam Deck updater kept sabotaging its own next update, and hand-deleting a file was the only way through' (#122) from worktree-steamdeck-update-bunnix-dirt into main
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
ci / bun-nix (push) Successful in 20s
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 8s
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 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
docker / builders-arm64cross (push) Successful in 7s
ci / rust-arm64 (push) Successful in 2m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / deploy-docs (push) Successful in 28s
ci / rust (push) Successful in 12m23s
Reviewed-on: #122
2026-08-08 21:17:27 +00:00
enricobuehler 8fe834c89b Merge pull request 'Every gamescope session ended in a SIGSEGV at exit — the Vulkan device was being destroyed after the driver had gone' (#124) from worktree-gamescope-exit-segfault into main
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
ci / bun-nix (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
ci / web (push) Successful in 1m1s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 1m45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 29s
ci / rust (push) Canceled after 3m41s
ci / rust-arm64 (push) Canceled after 3m42s
arch / build-publish (push) Successful in 13m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m3s
Reviewed-on: #124
2026-08-08 21:13:43 +00:00
enricobuehler 744bcb468b feat(host/wire): a jumbo path can now be PROVEN — and the shipped grow never could
Wave-2 PW7a: a PyroWave session on a proven-jumbo LAN should START at the big shard, because it
is the one codec that can never be re-keyed mid-stream (its client parses chunk-aligned AUs in
windows of the `Welcome` value, read once over the C ABI). At an 8908-byte shard that is ~6×
fewer datagrams per frame — ~49k → ~8k pps at 550 Mb/s — and proportionally less window-tail
padding.

THE BLOCKER FOUND FIRST: the whole jumbo leg was dead code, not just the missing half. quinn
caps a peer's MTU-discovery search at `min(MtuDiscoveryConfig::upper_bound, the OTHER side's
advertised max_udp_payload_size)` (`quinn_proto::connection::mtud::SearchState::new`), and
`EndpointConfig::max_udp_payload_size` defaults to 1472. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's PROBE ceiling — all `stream_transport_idle` did — could
never make discovery settle above 1472, and the shipped mid-session grow's
`settled >= sealed_datagram_bytes(target)` gate was unreachable on every path that has ever
existed. Two smaller contributors, fixed here too: the watcher stopped sampling the moment
`settled >= 1472`, discarding the very climb the proof needs, and a session sealed ABOVE the
1500-byte default was never checked against the path at all.

The advertisement is raised on the CLIENT endpoint, under the same `jumbo_wire_mtu()` opt-in as
the probe ceiling, because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE`, so on a GRO-capable Linux/Android
client that is ~2.9 MiB at the default and ~18 MiB at jumbo (47 KiB → 288 KiB on Apple/Windows).
Consequence: jumbo now needs the opt-in on BOTH ends. Without it, every byte on the wire and
every byte of buffer is exactly what it was.

WHY THE GROW IS AS SAFE AS THE CLAMP, which is not obvious — the failure modes are opposite. A
stale clamp only makes datagrams smaller than they had to be; a stale grow seals an oversized
datagram onto a 1500-byte path, where it is silently dropped, and a PyroWave session cannot
recover from that for its whole life. Mirroring the clamp's keying is therefore NOT sufficient.
So the memory is demoted: the persisted verdict only decides whether it is worth WAITING for a
proof, and what authorises the grow is a LIVE re-proof on the very connection being welcomed —
`conn.stats().path.current_mtu` ≥ the sealed target, i.e. a datagram of exactly that size acked
by this client, on this connection, seconds ago. The moved laptop cannot inherit anything: its
new path's live MTU is 1472 and the grow does not happen, whatever the memory says.

The remembered half is keyed strictly anyway — `(local_ip, peer_ip)`, so a verdict earned over
the host's 10 GbE NIC does not apply to the same peer over Wi-Fi or a VPN — and carries the
operator target it was proven under plus a 6 h TTL. It is erased by any contrary evidence: a
lower settle, a session that ended before the window closed (what a client staring at black
does), a changed opt-in, or a constrained-path clamp that disagrees.

The proof-wait is on the bring-up critical path (`handshake.rs` sends the `Welcome` and only
then kicks the display prep), so it is bounded at 300 ms, exits the instant the proof lands, and
is entered ONLY for a path a previous session already proved. Its worst case is the moved
laptop, and that is self-limiting: that session's watcher erases the verdict.

MEASURED, NOT ARGUED: `mtu_discovery_climbs_only_as_high_as_the_peer_advertises` (`#[ignore]`d,
loopback — whose own MTU is 64 KiB, so configuration is the only thing that can stop the search),
on .21:

  leg A (server opted in, client NOT): settled at 1472 B UDP payload   <- the dead-code proof
  leg B (both opted in):               reached 8972 B in 5 ms          <- the fix, and its speed

Leg A is the finding restated as an experiment. Leg B says the climb costs ~5 ms once both sides
advertise it, so the 300 ms proof-wait is ~60x the loopback convergence time — enough headroom
for a real LAN's RTT and per-probe ack delay across the ~11 probes the search takes.

Still owed: the A/B on a real jumbo LAN segment (9000-MTU NIC + switch on both ends) — pps per
frame, wire/pin ratio, and a PyroWave session observed starting at 8908. Not runnable without
the hardware.
2026-08-08 21:53:02 +02:00
enricobuehler 20f4d23f2d test(pw6): the streamed-AU trap is real — and at 2 % loss it costs exactly nothing
PW6 shipped behind a knob because one pre-registered risk was unmeasured: a
streamed frame whose FINAL block is lost has no totals, so where the whole-AU
path hands the consumer a usable blurred partial, a streamed frame may deliver
nothing. PyroWave clients opt into partial delivery unconditionally, so this
would have been a live behaviour change for every one of them. Measured now,
three ways, instead of reasoned about.

`tools/loss-harness` gains a partial-delivery leg: FEC pinned OFF, chunk-aligned
AUs, deliver_partial ON, realistic 1408/200 geometry, and AU sizes swept across
the whole 1..=200-shard range of FINAL-block sizes — because the final block's
size is what bounds the exposure. Loss is injected per packet from a seeded
xorshift rather than through `loopback_drop_period`, whose deterministic 1-in-N
would systematically always-or-never hit the final block, which is the entire
question. `tc netem` on `lo` was deliberately not used: the in-process model
gives exact per-frame attribution, needs no sudo, cannot disturb a box running a
live desktop session, and — decisively — can drop precisely the final block.

Leg 1, deterministic (drop exactly the last block, 200 frames): whole-AU
delivers 200 partials and 0 losses; streamed delivers 0 partials and 200 total
losses. The trap is real and, when it fires, total.

Leg 2, random loss, 20 000 frames per cell, same seed and sizes for both shapes.
At 2 % the two are indistinguishable — 20000/20000 partials and ZERO vanished
frames on both, matching the analytic bound E[loss^k] over final-block sizes k
(~1e-4). The gap only appears at 30 % (99.94 % vs 100 % rescue) and 50 %
(99.79 %). `complete` is 0 throughout by construction: with FEC off and ~500
packets per AU, essentially every frame is damaged — which is the regime the
partial path exists for.

The spike gains `--wire-chunk` and a streamed loopback path, so the wire shape
is reachable end to end outside a real client: `poll_chunk` drains the AU,
`begin_streamed_frame_at`/`seal_streamed_chunk`/`seal_streamed_finish` seal each
piece, and the client byte-compares the reassembly. On 120 real PyroWave AUs the
streamed legs (56.5 and 2.0 chunks/AU) and the whole-AU control emit a
byte-identical 47 373 568-byte stream with 0 mismatches — the cut changes the
wire shape and not one byte of content, and with the knob unset it does not
engage at all.

A new `#[ignore]`d GPU test closes the picture question on real hardware with a
BUSY card (gradients + checker + noise), never a flat fill: chunks are whole
windows, exactly one `first` and one `last`, the AU decodes through the client's
own window walk, and luma PSNR lands at 40.2 dB. Unset the knob and the test
refuses to run, which is the default-off claim verified rather than asserted.

Verdict recorded in the plan: KEEP IT OFF. The 2 % tie is an argument about
typical loss, but the failure is not graceful when it fires and the measured win
is host send-side pipelining that nobody has yet put a millisecond number on.
2026-08-08 21:26:25 +02:00
enricobuehler 49f5c815ea feat(pf-encode): PyroWave can stream its AU to the wire — and newest-wins was never in the way
PW6 was gated on one question: what happens to the client's newest-wins
draining when a PyroWave AU arrives in pieces, given that
`Session::set_deliver_frame_parts` refuses to combine with an all-intra
stream. The answer is that the doc and the plan conflated two different
axes, and the question never applied to this package.

Host STREAMED_AU chunks change only the WIRE shape. The reassembler
completes such a frame exactly like a whole one (`block_count != 0 &&
blocks_ok == block_count`) and hands up ONE Frame, so the frame channel
still sees one entry per AU and the drain is untouched.

What newest-wins genuinely cannot survive is the client's SEPARATE prefix
delivery, and the mechanism is sharper than "assumes whole AUs" said:
`FrameChannel::pop` counts QUEUE ENTRIES and takes one entry to be one AU.
With parts on, one AU pushes several, so `len > 1` stops meaning "the
consumer is behind" — the drain fires mid-AU, returns a SUFFIX and clears
that same AU's prefixes. For PyroWave that is fatal rather than lossy: the
sequence header lives in window 0 of every AU (`au_dims` reads it there), so
every frame would arrive headerless, and `FramePart`'s own orphan contract
would have a correct consumer abandon essentially all of them. Written into
`pop`, `set_deliver_frame_parts` and the handshake, together with what a fix
would take (skip whole SUPERSEDED AUs, never split one).

That answer shrinks what this package may claim, so the code says so
plainly. `encode_frame` is synchronous: the whole AU exists before the first
chunk can be polled, so `poll_chunk` is not "emit as produced" and there is
no encode/send overlap here (PW6 ⟂ PW5, confirmed). And with the client
still receiving one whole Frame there is no decode-while-arriving either —
the "~7 ms, decouple e2e latency from AU size" framing needs client work
this commit does not do. What IS left is real and host-side: the whole-AU
path FEC-protects, packetizes and seals the entire ~830 KB AU before its
first datagram may leave the socket, while the streamed path seals and paces
each FEC block as it completes.

All of the cutting lives in the shared `pyrowave_wire` helper, which
compiles and unit-tests on every platform, so both backends' `poll_chunk` /
`supports_chunked_poll` are thin delegations — the Windows backend cannot be
compiled from a Linux box, and logic written into it directly would ship
unverified. Chunks are whole numbers of framing windows because `build_au`
gives each window exactly ONE kind; that also makes them shard-aligned for
free, which is what the sealer's sentinel bases require. Dense mode never
streams (no window framing to cut on). `poll()` now errors while a chunk
cursor is live — the trait's one-drain-method-per-AU contract, where
double-emitting would put the same bytes on the wire twice under one frame
index — and `reset()` drops the cursor so a rebuild cannot splice a dead
AU's tail onto a fresh one. No new Encoder trait method, so neither the
TrackedEncoder forwarding trap nor the EncoderCaps default trap is in play.

Shipped OFF: `PUNKTFUNK_PYROWAVE_STREAMED_AU=1` arms it,
`PUNKTFUNK_PYROWAVE_CHUNK_KIB` tunes the 256 KiB target. The pre-registered
partial-delivery trap is real and now has a named cost — an unpinned
streamed frame (final block lost) is excluded from partial delivery, where
the whole-AU path still hands the consumer a usable blur, and PyroWave
clients opt into partials unconditionally. The netem loss-harness leg is the
prerequisite for default-on and has not been run.
2026-08-08 19:23:05 +02:00
enricobuehler 9e7713eecf fix(gamescope): every session ended in a SIGSEGV at exit
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 4m47s
Each gamescope-backed session left a coredump behind. It happened after the
compositor had finished its work — "Primary child shut down!", then the crash —
so the stream itself looked fine and it surfaced only as a steady drip of
coredumps and a non-zero exit from the spawn.

It is a static-destruction-order bug, not a race and not anything gamescope does
wrong at runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were
plain globals, so glibc ran their destructors from `__run_exit_handlers` once
main() returned. Those destructors call back into the driver —
`~CVulkanCmdBuffer` -> `vk.FreeCommandBuffers`, `~CVulkanTexture` -> `vk.Destroy*`
— but the Vulkan ICD has already been torn down and unloaded by then, so each
call jumps through a function pointer into an unmapped page. The faulting address
equalling the instruction pointer is the signature:

  #0  0x00007fe8fd1d1070 in ?? ()
  #1  CVulkanCmdBuffer::~CVulkanCmdBuffer   at rendervulkan.cpp:1543
  #9  std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector  (g_device+1792)
  #10 CVulkanDevice::~CVulkanDevice         at rendervulkan.hpp:768
  #11 __run_exit_handlers / exit()

Patch 0006 gives both globals storage that is constructed exactly as before but
never destroyed; a union member is destroyed only if the union's destructor says
so, and ours deliberately does not. Nothing needs freeing there — the process is
exiting and the kernel reclaims the device, its command buffers and every GPU
allocation. Both objects are needed: pinning only the device relocated the fault
into ~VulkanOutput_t.

The `.pfhdrN` level deliberately stays at 4. It is a capability tier the host
probes before it spawns, and this patch adds no capability — bumping it would
advertise a tier that does not exist. Per the PKGBUILD's own rule this ships as a
`pkgrel` bump instead.

Verified on an NVIDIA box, all six patches `git am`-ing onto the pinned upstream
commit and then a RELEASE build (the shipped configuration):

  version banner   3.16.25-7-gea635c1+pfhdr4   (marker intact)
  patched, real spawn shape (2752x2064@120 --steam --xwayland-count 1)
                   6/6 exit 0
  distro control, same shape
                   SIGSEGV

Not filed upstream, though it is not punktfunk-specific and would apply as-is.

Unrelated and left alone: `--xwayland-count 0` dies much earlier, in main() at
wlserver.cpp:3215, dereferencing a null `gamescope_xwayland_server_t`. Punktfunk
always spawns with `--xwayland-count 1`, so that path is never taken here.
2026-08-08 19:21:39 +02:00
enricobuehler 9232631299 feat(pf-encode): PyroWave had no encode split — so the one cost this program protects was unmeasurable
Wave-2 PW1's exit criterion, and the instrument it needed.

VAAPI and direct NVENC both log a PUNKTFUNK_PERF submit split. PyroWave did not — which meant the
single encoder the GPU-priority work exists to defend was the one you could not put a number on.
Adds per-frame timing of the synchronous encode (whole `submit`: CSC + encode + fence wait +
packetize, which for this backend IS the encode), summarised every 2 s as mean/p50/p99/max.

p99 rather than mean-only on purpose. The failure patch 0005 describes is a TAIL event — frames
going ~2 ms to 15-18 ms at 95 % game load while the mean barely moves — so a mean-only readout
would report "fine" straight through the thing being measured.

WHAT IT MEASURED — .21, RTX 5070 Ti (610.57.04), GRID 2 benchmark loop saturating the GPU at
54-87 %, PyroWave 1080p, same binary both arms (only CAP_SYS_NICE differs), 30-frame windows with
the warm-up window dropped:

  arm                        p50        p99        worst frame
  default priority (refused) ~2.6 ms    ~6.4 ms    9.5 ms
  REALTIME granted           ~3.2 ms    ~4.4 ms    5.4 ms
  REALTIME granted (repeat)  ~3.35 ms   ~4.8 ms    5.1 ms

p99 down ~30 %, worst frame roughly halved, for ~0.6 ms on the median. For a streaming encoder
that is the right side of the trade — the tail is what becomes a visible hitch.

This CONTRADICTS the patch's only prior datum (RTX 4090 / Windows / WDDM: "did not reduce the
spikes"), so patch 0005's header now records the Linux/NVIDIA result beside it, with an explicit
"do NOT delete this patch on the strength of the WDDM result — the two stacks disagree". Header
prose only; the diff hunks stay byte-identical and `git diff crates/pyrowave-sys/vendor/` is
untouched by this commit.

Caveats recorded rather than buried: the arms were not interleaved and the game load drifted
between them, capture was frame-starved (~2.5 fps) so this is encode latency under contention and
not a full-rate stream, and it is two granted runs against one refused run. The direction held
across all 25 windows.

Also worth knowing for anyone repeating this: `encode_fps` is a VACUOUS metric on this rig. A
headless gamescope with no real content emits ~12 fps, so both arms simply report the capture rate.
Measure latency, not throughput.

Gates green at CI parity.
2026-08-08 18:40:52 +02:00
enricobuehler 0cd946acb5 Merge pull request 'The virtual Steam Deck pad's udev rule names a group that four of six install paths never create' (#123) from worktree-steamdeck-group-and-secret-gaps into main
ci / web (push) Successful in 2m26s
ci / docs-site (push) Successful in 1m49s
ci / bun-nix (push) Successful in 42s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 29s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 4m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m32s
ci / rust (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m12s
docker / deploy-docs (push) Successful in 6m40s
arch / build-publish (push) Successful in 13m48s
nix / flake (push) Successful in 13m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 28m30s
Reviewed-on: #123
2026-08-08 16:00:47 +00:00
enricobuehler 62a6fa9fac fix(packaging): create the punktfunk group everywhere the udev rule needs it
ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m37s
ci / web (pull_request) Successful in 2m39s
ci / rust-arm64 (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m50s
nix / flake (pull_request) Failing after 23m28s
60-punktfunk.rules chgrp's the usbip vhci attach/detach nodes to a dedicated
`punktfunk` group (security-review 2026-08-05 M-4: writing `attach` materialises
an arbitrary emulated USB device, so it must not ride on `input`). Four of the
six install paths shipped that rule in 0.25.0 without ever creating the group.
chgrp then failed, the nodes stayed root:root 0644, and the virtual Steam Deck
pad silently never attached — while `usermod -aG punktfunk` failed outright with
"group 'punktfunk' does not exist".

Affected and fixed:

  * arch  — post_upgrade() called only _ensure_update_group, so every box that
            reached 0.25.0 by `pacman -Syu` missed it; post_install was correct.
  * nix   — no users.groups.punktfunk at all, though host.users' own description
            already promised the usbip/vhci pad. Declares it now and adds
            host.users to both groups.
  * bazzite sysext — a group is host state and cannot ride an image, and the
            deb/rpm scriptlets that would create it never run there.
  * steamdeck install.sh/update.sh — handled `input` only. Both now create the
            group and join it: running that script IS the statement "make my
            Deck a host with native pad passthrough".

deb and rpm were correct throughout (one postinst/%post for install + upgrade).

Also on the Deck path: web.env secret hygiene. install.sh's `chmod 600` sat
inside the create-only branch despite a comment calling it "the idempotent belt
for a pre-existing file", and update.sh never touched the config dir at all — so
an install set up once and only updated since kept web.env world-readable
(0644) with the console password and session secret in it. Both scripts now
harden ~/.config/punktfunk to 0700 and web.env to 0600 on every run, and say so
loudly, because a chmod does not un-leak an already-readable secret: the
password still needs rotating.

Both group blocks are `if ensure_group ...` rather than `ensure_group || true`:
a failed groupadd must not fall through to a usermod against a nonexistent
group, which under `set -e` aborted install.sh after the long build and
update.sh before the service restart (verified: exit 6, no restart).

Docs: the group is now documented where people actually look — the per-distro
guides, install.md, steamos-host.md, a new troubleshooting entry for "pad
arrives as an Xbox 360 controller", and the uninstall pages. The 0.25.0 notes
gain the "group does not exist" caveat and turn the password bullet from
"consider rotating" into a real instruction, and CHANGELOG records the known
issue against the breaking change that introduced it.

Verified: bash -n on all four scripts; the arch scriptlet's post_upgrade driven
in a container (creates the group, idempotent on re-run); the ensure_group
helper and both membership branches, including a control that reproduces the
original bug (chgrp to a missing group leaves the node root:root 0644); the
find -perm /0077 probe across 0644/0640/0604/0600/0400 on GNU findutils;
`nix flake check --no-build` (the exact CI gate) and a NixOS eval showing
alice.extraGroups == ["input","punktfunk"]; docs-site build + typecheck.
2026-08-08 17:53:36 +02:00
enricobuehler d402e9b996 Merge pull request 'A compositor pin silently vetoed dedicated game sessions — and a mid-bring-up mode switch was killing GNOME outright' (#121) from worktree-dedicated-session-pin-and-recovery into main
apple / swift (push) Successful in 1m41s
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m53s
ci / bun-nix (push) Successful in 34s
ci / rust (push) Successful in 5m31s
ci / rust-arm64 (push) Successful in 5m16s
android / android (push) Successful in 6m30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 39s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m31s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 47s
apple / screenshots (push) Successful in 6m18s
windows-host / package (push) Successful in 11m16s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m0s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish-host (push) Successful in 6m46s
arch / build-publish (push) Successful in 12m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m37s
Reviewed-on: #121
2026-08-08 15:38:39 +00:00
enricobuehler f23e0df64c fix(host): a compositor pin silently vetoed dedicated game sessions
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m46s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 2m31s
android / android (pull_request) Successful in 7m23s
ci / rust (pull_request) Successful in 8m5s
`PUNKTFUNK_COMPOSITOR` is documented as "which backend to drive", but it also
quietly discarded `game_session=dedicated`: `resolve_compositor` gated the
dedicated route on `!overridden` and logged nothing either way. A host whose pin
was a forgotten validation leftover therefore went on displaying "dedicated" in
the console while every launch landed in the desktop instead — for 30 days on the
box that surfaced this, the only evidence being the ABSENCE of a log line.

The pin still wins, since it is the operator's explicit hand-set knob, but it now
says so and names itself.

Two further holes the same triage turned up:

- The pin put its backend into `available()` unconditionally AND skipped
  `apply_session_env`'s `XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could
  never return `None` — the one place `try_recover_session()` is called from. A
  pinned host whose gnome-shell had segfaulted therefore spent every connect on 8
  doomed `RemoteDesktop.CreateSession: ServiceUnknown` retries while the
  operator's configured `PUNKTFUNK_RECOVER_SESSION_CMD` sat unreachable behind
  that arm. Liveness is now read on both paths, and a pin aimed at a dead session
  takes the recovery exit with an error naming the pin. `needs_live_session()`
  exempts gamescope, which stands its own session up — pinning it on a headless
  box stays supported.

- A mode switch accepted before the pipeline existed was served the long way
  round: build at the now-stale mode, then immediately rebuild at the new one in
  the stream loop. That burns a display create, capture attach and encoder open
  on every such connect, and because the rebuild is deliberately
  create-before-drop it stands up two Mutter `RecordVirtual` monitors ~400 ms
  apart — which segfaults mutter 50.4 inside `meta_monitor_manager_rebuild` and
  takes down the whole desktop session, along with the game just launched into
  it (so the GAME looks like what crashed). Bring-up now adopts the newest queued
  mode and builds once, carrying over the H2/H3 correction ack that the replaced
  rebuild would have sent.

Verified on a real Linux host (192.168.1.21, x86_64): `cargo clippy --workspace
--all-targets --locked -- -D warnings`, `cargo fmt --all --check` and the
punktfunk-host + pf-vdisplay test suites all clean. The gate was proved
non-vacuous against a planted `compile_error!`.
2026-08-08 17:37:02 +02:00
enricobuehler 12f39e1967 fix(steamdeck): the updater stops dirtying the checkout it just pulled into
ci / web (pull_request) Successful in 57s
ci / bun-nix (pull_request) Successful in 25s
ci / rust-arm64 (pull_request) Successful in 2m56s
ci / docs-site (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 6m8s
`update.sh --pull` could abort with "Your local changes to the following files
would be overwritten by merge: web/bun.nix" — before a single service was
restarted — and the only way past it was to delete the file by hand.

The updater did it to itself. web/bun.nix is generated (bun2nix, a pure
function of web/bun.lock) but committed, because the Nix build fetches
node_modules only from it. The web step ran `bun install --frozen-lockfile`
without --ignore-scripts, so web's `postinstall` (`bun2nix -o bun.nix`)
rewrote that tracked file on every update. Harmless while the committed file
is in sync — but main carried a stale web/bun.nix from 1db8f763 to b79d90b4,
so any Deck updated in that window had it rewritten to the *correct* content
and has been sitting dirty ever since. The SDK step has always passed
--ignore-scripts, which is why only web/bun.nix ever went dirty.

Two changes, both in install.sh and update.sh:

  * the web install now passes --ignore-scripts and runs `bun run codegen`
    explicitly. web has two install lifecycle scripts and we want exactly one:
    `prepare` IS `bun run codegen` (orval + paraglide + the i18n check) and is
    required, since src/api/gen, src/paraglide and src/routeTree.gen.ts are
    gitignored and `prebuild` only re-runs orval; `postinstall` is the one that
    writes a committed file. Equivalent to the old behaviour minus bun2nix.

  * --pull restores web/bun.nix and sdk/bun.nix before pulling, which unsticks
    the installs already broken out there. Deliberately NOT `git reset --hard`:
    $SRC is the operator's own checkout and may carry real local work, so a
    still-dirty tree now fails with a message that names the files and the way
    out instead of git's raw abort. Discarding these two is provably lossless —
    regenerating them from the lockfiles is exactly what bun2nix does.

CI already gates the drift that made this visible (scripts/ci/check-bun-nix.sh,
ci.yml), so main cannot ship a stale bun.nix again.
2026-08-08 17:15:49 +02:00
enricobuehler 8387e48ac6 docs(pf-capture): the fence wait is already free — PW4 retires into this comment
Wave-2 PW4's outcome. The package proposed moving the producer-fence wait off the PipeWire loop
thread, and was pre-registered to be ABANDONED if the wait turned out to already be free. It is,
on every producer and vendor measured — including the one where implicit sync actually exists.

Steam Deck, RADV VANGOGH, gamescope producer (built in distrobox pf2, run on the host):

  samples=300  mean_us=23  max_us=48  p50=<=100us  p99=<=100us
  signaled=0   no_fence=300  timed_out=0  failed=0

p99 in the first bucket is the plan's own abandonment condition, and the outcome split explains
why: 300 of 300 buffers reported NoFence. Same on both NVIDIA producers (gamescope and Mutter's
virtual output — the exact no-explicit-sync case the comment cites as the reason the wait exists).

So `wait_read_ready` here is one ioctl and a return, not a block. Moving it to the consumer side
would buy nothing measurable and would take on the hazard the package itself names — a slot holding
a not-yet-ready dmabuf, and `repeat_last` re-waiting a fence it already consumed. Not a trade worth
making for 23 microseconds.

The 100 ms budget stays: it guards a producer that DOES fence, which is a real thing even if
nothing in this fleet does it. KWin/AMD is the one combination still unmeasured, and the histogram
from the previous commit is deliberately kept as the way to re-check — run with PUNKTFUNK_PERF=1
and read the p99 bucket.

Comment-only; no behaviour change. Gates green at CI parity.
2026-08-08 16:19:34 +02:00
enricobuehler fb60bf653e feat(pf-capture): instrument the fence wait PW4 wants to move, before moving it
Wave-2 PW4, step one of one-so-far. The package's own first line is "investigation step first
(measure, then decide)", and it is pre-registered to be ABANDONED if the wait's p99 is ~0 — so the
instrument ships before the change, not after.

A per-session histogram of `wait_read_ready`, taken on the PipeWire loop thread, which is exactly
where the wait is expensive: that thread is the compositor's consumer, so time blocked there delays
buffer recycling for the NEXT frame. Logged under PUNKTFUNK_PERF at the same cadence and gate the
encode backends use for their submit splits, so a perf run reads as one instrument: samples, mean,
max, p50/p99 bucket, and the Signaled/NoFence/TimedOut/failed split.

Buckets are coarse on purpose (100us -> 10ms, plus overflow). The decision this feeds is binary —
a p99 in the first bucket means the wait is already free and PW4 becomes a comment correction; a
p99 past 1ms is a real stall against a 16.6ms frame budget. Edges are placed so those two worlds
cannot be confused, and anything past the last edge reports as overflow rather than clamping into
the top bucket ("worse than 10ms" is a distinct finding).

The outcome split sits next to the timings because "the wait is short" and "there is nothing to
wait for" are different results with different consequences, and one data point already shows the
second: on gamescope/NVIDIA the probe reports NoFence, i.e. that producer attaches no implicit
fence at all.

5 tests pin the arithmetic, including that an empty histogram reports "no answer" rather than a
decisive-looking zero — the failure mode that would retire the package on no evidence.

No behaviour change: the wait still happens where it always did. Gates green at CI parity.
2026-08-08 15:47:25 +02:00
enricobuehler 2a1c968a0e Merge pull request 'A gamescope session told every game its display was 60 Hz — and Fedora had no way to install the build that knows better' (#120) from worktree-gamescope-virtual-display into main
apple / swift (push) Successful in 1m37s
ci / web (push) Successful in 1m42s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 5m17s
deb / build-publish-client-arm64 (push) Successful in 1m48s
apple / screenshots (push) Successful in 6m27s
android / android (push) Successful in 9m56s
windows-host / package (push) Successful in 10m52s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m20s
arch / build-publish (push) Successful in 16m0s
windows-host / canary-manifest (push) Failing after 32s
deb / build-publish-host (push) Successful in 12m7s
ci / rust (push) Successful in 17m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m8s
docker / deploy-docs (push) Successful in 33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m51s
nix / flake (push) Failing after 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
Reviewed-on: #120
2026-08-08 13:41:30 +00:00
enricobuehler b815e00a87 fix(pf-zerocopy): one dmabuf timeout condemned every later capture on the host, forever
Wave-2 PW3.

The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:

  * the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
    driver fact, and the reason this latch was written (it stops the encode-stall recovery
    rebuilding the same doomed encoder five times and then ending the session, on every connection,
    forever);
  * the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
    mid-restart.

Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.

Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:

  * Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
  * Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
    costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
    mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
    latches on the second try, one extra stall per host lifetime versus the old behaviour.
  * A capture that negotiates credits the budget back, so an evening of reconnects against a
    compositor that failed once cannot accumulate its way into a latch.
  * BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
    compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
    earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
    capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
    for.

The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.

Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.

One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
2026-08-08 15:40:55 +02:00
enricobuehler eb8c943572 feat(packaging): ship punktfunk-gamescope on RPM and apt too
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 26s
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m3s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 2m44s
android / android (pull_request) Successful in 4m50s
nix / flake (pull_request) Failing after 16m12s
Until now the patched gamescope reached exactly four kinds of box: the
Bazzite/Fedora-Atomic sysext, the Arch package, the SteamOS installer and a NixOS
option. Everyone else was told to build gamescope from source. A traditional
Fedora-family box — Nobara, plain Fedora, the HTPCs people actually stream from —
therefore ran stock gamescope by default, which streams SDR, cursorless, and
tells every game its display is 60 Hz. That is not a user error; there was no
package to install.

Both new packages REPACK the binary CI already builds rather than building
gamescope again: it is a ~10-minute meson compile of an unrelated tree, cached
per distro base because the binary is soname-coupled to it. The Arch PKGBUILD
stays the one recipe that builds from source, because that is what makepkg is
for.

- packaging/gamescope/punktfunk-gamescope.spec + build-gamescope-rpm.sh. Version
  is derived from the binary's own banner (3.16.25.pfhdr4) — the only source that
  cannot drift from what is in the package. rpmbuild's automatic ELF Requires are
  what stop an f43 build installing on f44.
- packaging/debian/build-gamescope-deb.sh, same shape, with dpkg-shlibdeps for
  Depends.
- rpm.yml packages and publishes it beside the host RPMs; deb.yml gains a cached
  gamescope build (keyed on packaging/gamescope/** alone) and packages it into the
  existing publish loop. Both legs are best-effort, matching the sysext's existing
  rule: no binary, no package, and the host stays on its current SDR path.

Neither package Provides or Conflicts with gamescope — it installs as
/usr/bin/punktfunk-gamescope and only the sessions the host starts itself resolve
it, so a box's own Game Mode keeps using the distro binary.

Both refuse to package a binary without the +pfhdr marker. That marker is the
host's entire capability probe, so a build that lost the patches would install
fine and then silently stream SDR with no cursor.

Verified: build-gamescope-deb.sh produces an installable .deb from a stand-in
binary (correct version derived from the banner, 0755 tree, control fields) and
exits 1 on an unmarked one. The .spec is not yet exercised — no rpm tooling on
the box I had; CI's Fedora leg is its first run.
2026-08-08 15:36:19 +02:00
enricobuehler 102f550bba feat(host): use the new gamescope capabilities, and say so when the mode is lost
Pass --custom-refresh-rates (patch level 3+) and
--pipewire-composite-external-overlay (level 4+) on both spawn paths, with the
same probe-then-pass shape the HDR and cursor flags already use. A stock
gamescope has neither flag and gets neither, which is exactly today's behaviour.

New knob PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120 widens the set a session
offers in Steam's in-session display settings. The rate the session actually runs
at is always included, so it can only add options; junk entries are skipped
rather than failing the host, because the worst a typo can cost is the extra
option the operator wanted.

And the part that would have turned a week of field triage into one log line:
warn_if_mode_lost(). --nested-refresh is the ONLY refresh a headless gamescope
has, and it reaches a gamescope-session-plus solely through the GAMESCOPE_BIN
wrapper, which the session script is free to lose — a sessions.d file sourced
with `set -a` can reassign GAMESCOPE_BIN, and one that sets GAMESCOPECMD outright
skips the whole builder. When that happens the stream still runs, still looks
right, and the client's own fps counter still reads the negotiated rate (the
encode loop repeats the held frame), while the game underneath is capped to 60.
Nothing anywhere said so.

It warns rather than refusing, deliberately: verify_managed_spawn_flags refuses
because its retry resolves a different plan, but a relaunch here would hand the
session the same environment and lose the mode the same way, so refusing would
only loop. Fails open on the same rule as the flag check — nothing to compare
against says nothing.

Also corrects the comment above the launch env, which claimed
CUSTOM_REFRESH_RATES "generates the mode the session ADVERTISES … what makes
games see the real refresh". It never did: no upstream gamescope has
--custom-refresh-rates, so gamescope_has_option gated it off and the variable was
inert. That belief is why the real lever went unexamined.

configuration.md gains the new knob and a warning on PUNKTFUNK_MAX_FPS, which
also lowers the refresh the session REPORTS on gamescope — the docs said it does
not cap the stream, which is true of the wire and not of what games are told.

Linux-verified on Ubuntu: cargo check --all-targets, clippy -D warnings, 133
tests (2 new), cargo fmt --check.
2026-08-08 15:35:58 +02:00
enricobuehler 818531a26e feat(gamescope): a headless session now reports its own mode, and the perf overlay reaches the stream
Two new patches on the pinned upstream, and the marker patch moves last so the
banner is stamped after the capabilities it advertises.

0003 — headless: advertise the virtual display's mode and refresh rates.
A headless gamescope is how we give a game a display: we pass the client's exact
mode and the session runs at it. It never told anyone. CHeadlessConnector
returned empty spans from GetModes() and GetValidDynamicRefreshRates() and
reported GAMESCOPE_SCREEN_TYPE_INTERNAL, so update_mode_atoms DELETED the
mode-list atom (no resolution list) and wlserver fell through to a one-entry
refresh list built from g_nOutputRefresh (no refresh list). With --nested-refresh
absent that entry is Init()'s 60 Hz default — which is why a field report on a
1920x1080@120 client saw "gamescope only shows 60hz, and there's no other
option", and why Overwatch capped itself to 60 while the stream ran at 120.
Populate both from the resolved mode, report EXTERNAL, and add
--custom-refresh-rates so the offered set can be widened. gamescope-session-plus
has probed for that flag for years; upstream never had it, so the
CUSTOM_REFRESH_RATES env it plumbs was a no-op everywhere.

0004 — pipewire: optionally composite the external overlay into the capture
stream. That layer is mangoapp: the fps/frametime readout the Deck UI turns on.
paint_pipewire has never referenced it on any version, so a consumer whose only
view of the session is the node sees the overlay it just enabled not appear, with
nothing to configure. Behind --pipewire-composite-external-overlay, off by
default, same argument as the cursor flag. Its commit id joins the repaint test —
the numbers change while the picture behind them is static, exactly the case the
existing test skips.

Verified: the series git-am's cleanly onto the pinned 8c676c39, and both new
functions were extracted verbatim and compiled with -Wall -Wextra under C++23
against stubs, with unit assertions for the parser and the mode/rate publication
(sorting, dedup, the running rate always present, re-entrancy, zero rejected).
A full gamescope build was not run — no box here has its dependency set; CI's
per-Fedora-major leg is the first real compile.
2026-08-08 15:35:37 +02:00
enricobuehler 608baf63be Merge pull request 'Post-sleep sessions still failed on 0.25.0 — the host was holding open the very device its recovery asks PnP to cycle' (#119) from worktree-vdisplay-reap-pnputil into main
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m21s
ci / rust-arm64 (push) Successful in 2m46s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 26s
android / android (push) Successful in 6m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
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 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / screenshots (push) Successful in 6m8s
deb / build-publish-host (push) Successful in 4m27s
deb / build-publish-client-arm64 (push) Successful in 2m0s
deb / build-publish (push) Successful in 5m36s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust (push) Successful in 9m44s
docker / deploy-docs (push) Successful in 36s
arch / build-publish (push) Successful in 12m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m14s
windows-host / package (push) Canceled after 11m59s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #119
2026-08-08 13:29:09 +00:00
enricobuehler 767e67caf4 feat(packaging): grant the host CAP_SYS_NICE, without which the GPU-priority lever does nothing
Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux
PyroWave device; this is what makes it work on a packaged host.

Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the
plan predicted — without the capability there is no elevated priority at all, on any vendor, and
the knob is decoration.

Worth being precise about what is being granted, because it is a network-facing daemon.
CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing
else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The
repo already ships exactly this capability on its gamescope binary for the same reason. Two side
effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE,
so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly
such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and
core dumps are suppressed by default.

Per packaging path, because none of them are the same:

- Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade —
  a replaced binary is a new inode, so the capability does not survive an upgrade by itself.
- Debian: the same setcap in the postinst `configure` branch.
- RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then
  applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of
  those.
- NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd.
  The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and
  the service still runs the uncapped store path, which is the whole failure this fixes.
- Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU
  shared between the game and the encode). The binary lives under $HOME, so unlike the /etc
  drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it
  does need re-applying after each rebuild, which re-running the installer does.
- Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a
  merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — rpm keeps
  capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so the staged file
  arrives with none. mksquashfs does record security.capability (only security.selinux is
  excluded), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP;
  a plain-user CI build warns and ships without it rather than failing a release over a
  performance lever.

Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem
that cannot store capabilities, simply runs at default priority exactly as it does today.

Documented in the same PR — the configuration row now says the packages grant it, and
running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to
remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side
effects.

Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman
would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM
spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under
nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this
commit, so the CI-parity Rust gates from the companion commit still stand.
2026-08-08 15:24:54 +02:00
enricobuehler 8f9c72877e Merge pull request 'An SDK fix could never reach an installed plugin — the runner now carries it' (#117) from worktree-runner-sdk-reconcile into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-client-arm64 (push) Successful in 1m35s
deb / build-publish-host (push) Successful in 4m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m59s
sdk-publish / publish (push) Successful in 1m52s
docker / builders-arm64cross (push) Successful in 15s
deb / build-publish (push) Successful in 7m16s
ci / rust (push) Successful in 6m52s
arch / build-publish (push) Successful in 7m29s
docker / deploy-docs (push) Failing after 3m54s
windows-host / package (push) Successful in 15m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
nix / flake (push) Successful in 14m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
Reviewed-on: #117
2026-08-08 12:41:58 +00:00
enricobuehler 8f32976349 Merge pull request 'Library source settings never opened — the drawer still called the old plugin origin' (#118) from worktree-library-settings-origin-split into main
ci / bun-nix (push) Successful in 31s
arch / build-publish (push) Canceled after 1m8s
ci / rust (push) Canceled after 52s
ci / docs-site (push) Canceled after 1m12s
ci / rust-arm64 (push) Canceled after 1m15s
ci / web (push) Canceled after 1m15s
deb / build-publish (push) Canceled after 55s
deb / build-publish-host (push) Canceled after 45s
deb / build-publish-client-arm64 (push) Canceled after 8s
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) Canceled after 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 17s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26s
windows-host / package (push) Canceled after 1m54s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
Reviewed-on: #118
2026-08-08 12:40:44 +00:00
enricobuehler 2bf571a5ad feat(pf-encode): PyroWave's Linux encode device never asked for the priority its own patch requests
Wave-2 PW1, first half = Wave-1 WP14 step 4, executed as specced.

PyroWave encodes on the same GPU shader cores a game saturates, and that is measured to hurt:
patch 0005's header records `encode_gpu_synchronous` going from ~2 ms to 15-18 ms at 95 % game
load, with the stream frame rate collapsing. NVENC is immune because it has its own ASIC. The
lever for a compute workload is an elevated global-priority QUEUE — a process-priority raise only
reorders submission, not hardware preemption.

The vendored patch requests exactly that. It is gated `if (!inherit_info)`, and only Windows
leaves `inherit_info` null (`pyrowave_create_device_by_compat`, where Granite builds the device
itself). Linux passes its own create-infos into `pyrowave_device_create_info`, Granite's
`get_existing_create_info()` hands them back, `create_device` takes the inherit branch — and the
whole block is skipped. On Linux the knob has never done anything at all. Meanwhile pf-zerocopy's
VkBridge has shipped the identical ladder on Linux for some time and calls it "the actual NVIDIA
compute-preemption lever"; the encoder that needs it most did not have it.

This wires it natively in `open_inner`'s `DeviceHold`:

- The extension probe reuses the `dev_ext_props` already fetched for queue_family_foreign, and
  takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
- `queue_priority_candidates` is a pure fn with the grammar copied from the C patch: unset →
  realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
  the ladder rather than to off. One env var must not mean two things on two platforms — that is
  the documentation trap this package exists to close — so the grammar is unit-tested against the
  patch's, including where they are both deliberately un-clever (neither trims).
- The create ladder is REALTIME → HIGH → no-priority, stepping only on a refusal. A refused class
  can never fail the open, which matters more here than on Windows: this path is reached only by a
  NEGOTIATED PyroWave session, so a hard error is a dead stream, not a fallback to another encoder.

The subtle part is the write-back. `pyrowave_create_device` RETAINS `device_create_info` for the
device's lifetime and Granite reads the chain back. If the ladder ends on the no-priority attempt
while `_queue_ci[0].p_next` still points at the global-priority struct, Granite is handed a chain
the device was not created with. The `None` arm therefore nulls `p_next` before the final create,
and the field's doc says why. The enabled extension deliberately STAYS in the list: it really is
enabled on the device, it just carries no request.

One deviation from the plan, stated because it is a deviation: the ladder also steps down on
`ERROR_INITIALIZATION_FAILED`, not only `ERROR_NOT_PERMITTED_KHR`. The plan and the C patch handle
only the latter; pf-zerocopy's shipped ladder accepts both. Given a hard error here kills a
negotiated session, treating one extra driver-specific refusal as a downgrade is the cheap side of
that asymmetry.

Also corrects the two vendored notes, which claimed a Linux behaviour the gate made impossible,
and records that patch 0005's negative RTX-4090 result is Windows/WDDM and does not transfer to a
different driver stack. Patch hunks are byte-identical (header prose only) and
`git diff crates/pyrowave-sys/vendor/` is PUNKTFUNK-VENDOR.txt alone.

`PYROWAVE_QUEUE_PRIORITY` is now reachable on Linux, so it is documented in the same PR.

MEASURED ON GLASS, and it changes what this package is worth on its own — .21, RTX 5070 Ti,
NVIDIA 610.43.02, same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

So the lever is INERT on an unprivileged host, and that is not the RADV-specific downgrade the
plan predicted — on NVIDIA it is a downgrade to nothing at all. The ladder itself is proven good
across all three legs (unset / high / off): a refused class never fails the open, and `off`
enables no extension and logs nothing. It simply has nothing to grant yet.

The privilege needed is CAP_SYS_NICE on the host binary, which is NOT what Wave-1 WP3 ships
(RLIMIT_NICE, PAM limits, CPUWeight — all different things). That grant is a security-posture
change on a network-facing daemon, so it is deliberately NOT in this commit; the warn line now
names the capability so an operator is not left guessing, and the docs row says the setting has no
effect on most hosts today rather than implying it works.

The loaded-GPU encode_us p99 A/B is therefore not run: it needs a GPU-saturating game (hence a
desktop session the box does not currently have) and it is pointless before the capability lands,
since the unprivileged arm has no priority to measure.

NO unit test is possible for the device-create ladder itself — it needs a real Vulkan device. Its
coverage is the clippy pass, the grammar tests, and the on-glass log line. Stated here rather than
left for a reviewer to wonder about.
2026-08-08 14:37:41 +02:00
enricobuehler deef5e4382 fix(console): library source settings 404'd — the drawer still called the old plugin origin
ci / bun-nix (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 4m30s
Opening a library source's settings did nothing, for every library plugin. Confirmed on
`.21` against the running console:

    console origin :47992  /plugin-ui/lutris/__config -> 404
    plugin  origin :47993  /plugin-ui/lutris/__config -> 401

The drawer fetches a RELATIVE `/plugin-ui/<id>/__config`, so it resolves against the
console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**`
unconditionally and by design. That refusal is the 2026-08-05 review's origin split
(H-3): plugin UIs moved to their own listener, and neither origin may serve the other's
paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every
other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the
one thing the split broke, and nothing failed loudly enough to notice.

The fix is deliberately not to point the drawer at the plugin origin. That needs CORS
plus cross-site cookies, and it would put a plugin-controlled response inside a
credentialed cross-origin fetch — reopening exactly the hole the split closed. What
this drawer needs is DATA, not an embedded UI: `/api/plugin-config/<id>` reads the
plugin's `__config` server-side over loopback and returns the JSON same-origin, so no
plugin markup or script is ever served from the console origin and the per-boot secret
stays on the server, as with the `/plugin-ui` proxy.

`/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate
and answers 401 as JSON rather than redirecting to /login — which is what a `fetch`
needs and what the old path could never give it. It forwards only GET and PUT, reads
the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a
retried PUT would have saved `{}` over the operator's config), and passes the plugin's
own body through untouched so a 400's decode issue still reaches the operator.

Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the
route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it —
while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the
split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build
clean, i18n 633 messages across en+de, biome clean on both touched files (the one
warning in SourceSettings.tsx pre-dates this change).
2026-08-08 14:17:58 +02:00
enricobuehler 9c24569db6 fix(spike): --codec pyrowave encoded PyroWave off a capture negotiated for somebody else
Found while taking PW2's on-glass measurement, and it is what made the measurement possible.

`spike` built its capture request from `OutputFormat::resolve`, the constructor shared with the
GameStream path, which hard-codes `pyrowave: false` ("GameStream never negotiates PyroWave").
On Linux that flag is not cosmetic: `capture_virtual_output` feeds it to `zero_copy_policy` as
`ZeroCopyPolicy::pyrowave_session`, which is what puts the capture on the raw-dmabuf passthrough.
So `--codec pyrowave` opened a PyroWave encoder over a capture negotiated for a different
consumer, and the only way to exercise the real path was the host-global
`PUNKTFUNK_ENCODER=pyrowave` lever.

That lever cannot stand in for the per-session flag, which is the part that matters here: it
resolves the backend to `Pyrowave`, and `linux_zero_copy_is_vaapi_for` returns true for that —
so it ALSO flips `backend_is_vaapi` on. A per-session PyroWave negotiation on an auto/NVENC host,
where `backend_is_vaapi` is false, was therefore unreachable from the CLI — and that is exactly
the configuration whose CPU downgrade logged nothing at all.

The spike now sets the flag from its own codec, the same comparison `session_plan::output_format`
makes for a real session. With it, the before/after on .21 is unambiguous: origin/main logs zero
capture-path lines on that configuration, this branch logs two (the resolved arm, and the named
downgrade with its cause and fix).
2026-08-08 14:08:27 +02:00
enricobuehler 32cc8dd529 fix(runner): an SDK fix could never reach an installed plugin
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / rust (pull_request) Successful in 4m31s
nix / flake (pull_request) Failing after 11m38s
Publishing `@punktfunk/host@0.1.3` — the release that lets a library scanner register
`category`, so Lutris and Heroic stay out of the console nav — reached **no existing
install**. Measured on `.21`: the only thing that moved it was deleting `bun.lock` by
hand over ssh. A fix that needs an ssh session is not a fix.

**Why nothing reached it.** Every plugin resolves the SDK from the plugins tree, and
`bun.lock` pins it to an exact version with an integrity hash. Nothing in any
user-facing flow re-resolves that pin: installing a plugin, reinstalling it, and even
updating it to a newer release all leave the SDK alone, because the plugin's `^0.1.x`
range is already satisfied by what is locked. `bun update` does not help either — the
plugins are pinned exactly in the root manifest, so there is no direct dependency to
update through.

**Where the fix belongs.** The runner. It is bundled from this same `sdk/` at the
host's release commit (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the
punktfunk-scripting package), so `SDK_VERSION` is by construction the SDK matching the
host now on disk. A host upgrade is therefore the one moment that can carry an SDK fix
to already-installed plugins, and now it does — before any plugin loads, and with no
operator action at all.

**Why it re-resolves the whole lockfile** rather than pinning the SDK at the root: a
targeted `bun add @punktfunk/host@<v>` does NOT work while plugins declare the SDK in
their own `dependencies` (all six scanners do, though none import it). bun honours
their locked resolution and gives each a private nested copy that then SHADOWS the
root — measured, 5 nested copies, which is how I first "fixed" the box while leaving
every plugin still importing 0.1.2. A lockless resolve hoists one copy for everyone.
Once the plugins drop that spurious dependency this can become the targeted form.

Safety, because this runs unattended at boot on a tree the operator's plugins load
from: plugin versions are pinned exactly in the root manifest so a re-resolve cannot
move them (verified — lutris stays 0.1.0); the lockfile is backed up and restored if
the install fails or fails to deliver; and every failure is logged and swallowed, so a
dependency refresh can never stop working plugins from starting. The no-op path is the
one that runs on every healthy box, so it is tested first: same version, or no SDK at
all, touches nothing and logs nothing.

The SDK is bumped to 0.1.4 because its published content changed. Republishing 0.1.3
is impossible, and letting source drift from a published version is precisely the
defect that produced this whole chain — 0.1.2 was published before it forwarded
`category`, then the source changed underneath it without a bump. `version.test.ts`
fails if `SDK_VERSION` and `package.json` ever disagree.

Verified end to end on `.21` against a tree seeded from the operator's real pre-fix
backup: 0.1.2 → 0.1.3 automatically, one hoisted copy, no nested copies, plugin
versions preserved, and a second run is a silent no-op. SDK 79 tests pass (5 new),
typecheck clean.
2026-08-08 14:07:11 +02:00
enricobuehler fba22c6c64 fix(host/vdisplay): the host no longer vetoes its own wake-from-sleep recovery — control handles close on retire
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 8m16s
ci / rust (pull_request) Successful in 9m59s
The control-device sharing contract was 'bare HANDLE copies, never
closed for the process lifetime': retired handles were deliberately kept
alive because the pinger/linger threads and the capture delivery
closures held raw copies whose soundness depended on no-close. The cost
surfaced in the 2026-08-08 field log: after a wake left the driver
hostless, every adapter reload came back REFUSED (Generic failure) —
and an open control handle is exactly what vetoes the PnP disable (and
can wedge the pnputil restart) the recovery leans on.
reset-pf-vdisplay.ps1 stops the whole host service precisely to get
those handles closed; the in-process recovery could not, because the
process could never close them.

Ownership is now Arc all the way out: ensure_device/device_handle/
control_device_handle hand out Arc<OwnedHandle> clones, every consumer
holds its clone across its IOCTLs (the capture closures each own one —
Arc<OwnedHandle> is Send+Sync, ending the isize smuggling), and
retiring drops only the manager's reference, so the handle CLOSES when
the last in-flight user drains. DeviceSlot::retired is gone. The
recovery path now releases the manager's reference at the first absent
sighting — the 3 s ABSENT_SETTLE doubles as the drain window — and
again before a not-ready-deadline reload, so the PnP cycle finally runs
against a device the host is no longer holding open.

The driver attaches no meaning to the control file closing (host-gone
is the IOCTL-liveness watchdog, EvtFileClose deliberately unhooked), so
the close has no driver-side side effects. Lock order note: RECOVERY →
device is now taken (the release hooks); the forbidden inverse still
never occurs — VdisplayDriver::open never reloads.
2026-08-08 13:37:05 +02:00
enricobuehler 2aa763ce70 feat(pf-capture): a PyroWave session could drop to CPU capture and log nothing at all
Wave-2 PW2 (design/linux-host-performance-wave2-pyrowave.md). Observability only — no
behaviour change to any capture decision — and it lands first because every later package
in the program is measured by an A/B whose "before" is currently unreadable.

The defect: the capture path's CPU-fallback warning was gated on `backend_is_vaapi`, which
reads the HOST-GLOBAL encoder pref. A PyroWave session is negotiated PER SESSION, so on an
NVIDIA/auto host that gate is false — and the session then fell out of every arm of the
negotiation log chain, emitting nothing whatsoever while paying a full-resolution CPU pixel
touch on every frame. A degraded host and a healthy one produced identical logs.

Four sites, matching PW2.1-2.4:

1. The CPU-path warning now asks the per-session question (`consumer_kind`) instead of the
   pref, and names the consumer. Its gate widened to every GPU consumer and excludes only
   the software encoder, whose native input IS CPU frames — an NVENC session silently on
   the CPU path is the same defect, not a different one. `pyrowave_session` deliberately
   outranks `backend_is_vaapi`, because a PyroWave pref flips `backend_is_vaapi` on too
   (`linux_zero_copy_is_vaapi_for`'s `Pyrowave` arm), so testing vaapi first would swallow
   every PyroWave session.

2. The raw-passthrough block in `consume_frame` had four silent exits — no format, an
   SHM/MemFd buffer, no DRM fourcc, a failed `F_DUPFD_CLOEXEC` — each falling out of three
   nested `if`s into the CPU de-pad path. It is now a labeled block that breaks with a named
   `PassthroughFallback`, logged once per distinct reason per session with a running count,
   so a persistent downgrade is distinguishable from a hiccup at renegotiation. `.process`
   runs per frame, so the rate limit is the shippable part and is what the tests pin.

   Note `NoFormat` does NOT fall back — the CPU path needs `ud.format` too and returns — so
   the line says DROPPED for that one. Three of four downgrade; one loses the frame.

3. `force_cpu_for_nvenc_444` told a 4:4:4 PyroWave session it was "on the NVENC path", which
   is false in every particular: the wavelet encoder never touches NVENC, never swscales to
   YUV444P, and what it actually loses is the raw-dmabuf passthrough its design assumes.

4. One INFO line at pipeline build states the resolved arm and consumer
   (`capture pipeline resolved: dmabuf-passthrough → pyrowave`). Nothing stated it before;
   the 2026-08-08 triage reconstructed it from four files, and for the arm that matters most
   there was no detail line to reconstruct it from.

Also: `spike --codec pyrowave`, so a PyroWave capture→encode pass can be driven without a
client. That is the harness the rest of this program measures on, and it did not exist.

Gates on .21 at CI parity: fmt, workspace clippy -D warnings, pf-encode clippy with
nvenc,vulkan-encode,pyrowave and without, workspace tests.
2026-08-08 13:36:51 +02:00
enricobuehler 4b514cc07c Merge pull request 'An OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears' (#116) from worktree-oled-theme-gamepad-ui-split into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m51s
ci / web (push) Successful in 3m13s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m45s
ci / bun-nix (push) Successful in 45s
ci / docs-site (push) Successful in 1m30s
ci / rust (push) Successful in 4m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m3s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 2m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m17s
deb / build-publish-host (push) Successful in 6m16s
docker / builders-arm64cross (push) Successful in 11s
release / apple (push) Successful in 9m52s
docker / deploy-docs (push) Successful in 36s
android / android (push) Successful in 13m40s
deb / build-publish (push) Successful in 9m17s
arch / build-publish (push) Successful in 14m36s
flatpak / build-publish (push) Successful in 7m20s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m21s
Reviewed-on: #116
2026-08-08 11:22:34 +00:00
enricobuehler f242b2d2fc fix(host/vdisplay): a refused adapter reload now says WHY, and never targets a phantom devnode
Field log 2026-08-08 (0.25.0, wake from sleep): every session died on
'the adapter devnode could not be reloaded (Generic failure)' — the WMI
catch-all — because the REFUSED branch reported only the Disable
exception and threw away everything that would identify the failure
mode: why the pnputil /restart-device fallback ALSO failed (its exit
code — 3010 'needs a reboot' is its own diagnosis), what state the
devnode was in, and whether the right devnode was even targeted.

That last one is a real trap, not just missing telemetry: Get-PnpDevice
lists not-present PHANTOM devnodes (upgrade/reinstall leftovers), and
Select-Object -First 1 could hand every recovery attempt a phantom —
whose disable and restart both fail exactly like the field log — while
a live node sat unexamined. The selector now prefers present nodes (OK
before problem-state), and a phantom-only state gets a truthful
refusal: no reload can revive a devnode record whose device is gone;
only reinstalling re-creates it.

The REFUSED line now carries devnode counts, the chosen node's PnP
Status + ConfigManager problem code, and the restart exit code, so the
next field log decides between handle-veto, phantom, and problem-state
instead of reading 'Generic failure'. Decode pinned by test.
2026-08-08 13:22:05 +02:00
enricobuehler 27ceab2f6c Merge pull request 'The SDK could not be published at all — bun publish runs prepare, and prepare needs bun2nix' (#115) from worktree-sdk-publish-prepare-hook into main
ci / bun-nix (push) Successful in 43s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 2m13s
ci / rust (push) Successful in 5m9s
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 7s
ci / rust-arm64 (push) Successful in 5m35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 29s
deb / build-publish-client-arm64 (push) Successful in 3m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
arch / build-publish (push) Successful in 7m39s
deb / build-publish-host (push) Successful in 6m34s
docker / builders-arm64cross (push) Successful in 7s
sdk-publish / publish (push) Successful in 1m10s
deb / build-publish (push) Successful in 7m36s
docker / deploy-docs (push) Failing after 1m45s
plugin-kit-publish / publish (push) Successful in 1m5s
windows-host / package (push) Successful in 15m11s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m27s
nix / flake (push) Failing after 16m48s
Reviewed-on: #115
2026-08-08 11:04:33 +00:00
enricobuehler 30bd10e301 feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.

A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.

A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.

The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.

Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.

That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.

Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
2026-08-08 12:57:12 +02:00
enricobuehler 1df39d9617 fix(ci/sdk): the SDK could not be published at all — bun publish runs prepare
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 1m23s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 4m45s
nix / flake (pull_request) Successful in 15m6s
`sdk-v0.1.3` failed at the publish step with `bun2nix: command not found`, exit 127.
Nothing was published, so 0.1.3 is still free.

`bun publish` runs the `prepare` lifecycle script, and sdk's `prepare` is
`bun2nix -o bun.nix` — regenerating the nix dependency file. That tool is a
devDependency of the repo, not something the `oven/bun:1` publish container has, and
the workflow's own install is `--ignore-scripts`, so nothing put it on PATH either.

This was latent, not new. `prepare` gained the bun2nix call on 2026-07-27 (1db8f763,
"move the bun packages to bun2nix"), while the last SDK publish was 0.1.2, bumped
2026-07-20. So the hook has been broken for every SDK release since it landed, and
0.1.3 is simply the first one to try. `@punktfunk/plugin-kit` has no `prepare` and was
never affected, which is why kit 0.3.2 published fine in that window and hid this.

The fix is NOT to copy `web/package.json`, which does the same job from `postinstall`.
That is right for web — it is never published — and would be worse here: a published
package's `postinstall` runs in every CONSUMER's install, so every plugin depending on
`@punktfunk/host` would try to run bun2nix and fail. `prepare` is the correct hook for
a published package (it does not run for consumers); it just must not assume a
repo-maintenance tool exists wherever a publish happens.

So the script skips when bun2nix is absent — and ONLY then. A present-but-failing
bun2nix still fails the script, because swallowing that would publish with a silently
stale bun.nix, which is the exact hand-maintained-hash problem 1db8f763 set out to end.
Both directions measured against the same `sh -e` bun and the Gitea runner use:
absent → exit 0, present-and-failing → exit 3.

`bun publish --dry-run` now completes and reports `+ @punktfunk/host@0.1.3`.
2026-08-08 12:55:37 +02:00
enricobuehler e4f8c64b9f Merge pull request 'Library scanners sat in the nav and could not sync local art — and you can now hide one game' (#113) from worktree-plugin-nav-category-and-art into main
audit / bun-audit (plugin-kit) (push) Successful in 19s
apple / swift (push) Successful in 1m38s
audit / bun-audit (sdk) (push) Successful in 48s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 1m8s
audit / bun-audit (web) (push) Failing after 1m14s
apple / screenshots (push) Successful in 5m46s
ci / rust-arm64 (push) Successful in 4m32s
audit / license-gate (push) Successful in 5m12s
ci / bun-nix (push) Successful in 38s
arch / build-publish (push) Successful in 8m1s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m28s
android / android (push) Successful in 9m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
deb / build-publish-client-arm64 (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 28s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
audit / cargo-audit (push) Failing after 10m5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
ci / rust (push) Successful in 7m55s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
sdk-publish / publish (push) Failing after 31s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m20s
docker / deploy-docs (push) Successful in 35s
windows-host / package (push) Successful in 16m9s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 31s
deb / build-publish (push) Successful in 12m39s
nix / flake (push) Canceled after 14m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m18s
Reviewed-on: #113
2026-08-08 10:39:25 +00:00
enricobuehler 690ff7016b Merge pull request 'The config page missed the whole 0.25 env-var wave — jumbo frames and ten other knobs documented' (#114) from worktree-docs-config-page-0250-vars into main
ci / rust (push) Canceled after 11s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 9s
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
Reviewed-on: #114
2026-08-08 10:37:14 +00:00
enricobuehler 6cffe29b13 feat(host,console): hide individual library titles
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m40s
ci / bun-nix (pull_request) Successful in 21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m25s
ci / rust (pull_request) Successful in 6m26s
nix / flake (pull_request) Successful in 15m40s
The library had one visibility control and it was all-or-nothing: turn a SOURCE off
and every one of its games goes. There was no way to drop a single title — a Proton
tool the filter missed, a demo, a game someone doesn't want on the TV — short of
hiding the whole launcher it came from.

**Where the setting lives.** Not on the entry. Only manual custom entries are stored;
a scanner's and a plugin's titles are rebuilt from scratch on every scan and every
reconcile, so a flag written onto one would be erased by the next sync — silently, and
minutes later, which is the worst possible shape for a setting. So `library-hidden.json`
holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is
stable by construction (D2: a claimed store's entries keep `<store>:<external_id>`
across reconciles), so a hide survives a re-scan, a plugin restart, and a store's
built-in→plugin migration.

**Where it takes effect.** In `all_games`, which is the one place every play surface
already funnels through — the grid on a client, native clients, the GameStream app
list, and launch resolution. Putting it there rather than at each call site is
deliberate: a per-surface filter is a rule someone has to remember, and forgetting one
is precisely the class of bug the `file://` art asymmetry in the previous commit was.
Hiding is curation, not access control — nothing is deleted, and un-hiding is instant.

**The console is the one surface that still sees them**, or a hidden title could never
be brought back. That exception is a TYPE, not a flag: `GET /library` answers
`Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on
theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting
a filter — there is no field there to leak. `hidden` is skipped when false, so the
response is byte-identical to today's for a library with nothing hidden.

`PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert,
unlike the scanner toggle. A plugin has no business deciding what its operator sees, and
a client must not be able to hide a game on the host it is streaming from. The id is not
validated against the current library on purpose: a title can be legitimately absent at
that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the
operator's choice in that window is worse than storing an id that matches nothing today.

On the card, the poster dims and a Hidden badge says why — a faded tile with no label
reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary
card's, are not hover-revealed: the un-hide button is the only way out of the state, and
hiding it behind a hover would strand anyone on a touch screen.

Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The routing test is the one that earns its keep — every
library id contains a colon and Heroic's contain two, so a router that split on it would
404 the console against ids the host itself produced. Console: tsc clean, production
build clean, i18n 633 messages across en+de, biome clean on the touched files.
2026-08-08 12:33:54 +02:00
enricobuehler 44c87d7ac1 docs(site): configuration page catches up to 0.25 — jumbo frames and seven other missing knobs
ci / web (pull_request) Successful in 1m8s
ci / rust-arm64 (pull_request) Successful in 2m27s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 8m15s
The env-var reference had fallen behind the v0.25.0 CHANGELOG table. Added, with
the semantics taken from the code rather than the changelog one-liners:

- PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU (Network & discovery), with a note
  explaining the ack-gated mid-session grow, the start-at-1500 behavior, the
  NIC/switch prerequisites, and the sub-1500 shrink direction of WIRE_MTU
- PUNKTFUNK_AUDIO_QUALITY / AUDIO_REDUNDANCY / AUDIO_OUTPUT_MODE — the legacy
  HOST_AUDIO / KEEP_DEFAULT rows are folded into the OUTPUT_MODE row as the
  aliases they now are (follow_default wins when both are set)
- PUNKTFUNK_NO_AUDIO_MINT (Windows minted-endpoint opt-out)
- PUNKTFUNK_PAD_AUDIO / PAD_AUDIO_SLOTS (Gamepads — DualSense speaker+haptics)
- PUNKTFUNK_NVENC_SPLIT_ARBITRATE (Advanced performance tuning)
- PUNKTFUNK_UI_PLUGIN_PORT / PUNKTFUNK_LIBRARY_ART_ROOTS (Auth, API & paths)
- PUNKTFUNK_VAAPI_DEVICE (client-side table)

Verified against the actual read sites (pf-host-config, wire_mtu.rs,
config.rs jumbo_wire_mtu, pad_audio.rs, minted.rs, art.rs, bun-https.mjs);
the page's remaining vars all still exist in code. MDX-compiles clean with GFM.
2026-08-08 12:31:25 +02:00
enricobuehler 975fef2048 fix(host/vdisplay): the ghost-monitor reap can no longer fail in silence
The reap that keeps departed virtual monitors from exhausting the IddCx
monitor-slot budget launched pnputil by BARE NAME — under the LocalSystem
service's PATH that can miss System32, SilentlyContinue swallowed the
miss, and the Rust side logged only when the count was positive: a reap
that removed nothing and a box with no ghosts were byte-identical
(silence). Ghosts then ratcheted up with every sleep cycle until
IOCTL_ADD wedged at 0x80070490 and every session black-screened — and
the wedge self-heal shipped in 0.25.0 retried an ADD behind a reap that
could never remove anything, which is exactly a persistent post-sleep
"no connection" surviving the b6acbd09 probe fix.

Same family and same cure as the adapter-reload path one function down:
resolve pnputil via $env:SystemRoot (a SYSTEM process must not trust
PATH anyway — a planted pnputil.exe would run elevated), pre-seed
$LASTEXITCODE to failure before every launch, and report found AND
removed unconditionally so "no ghosts" and "removed nothing" are
finally distinguishable in a field log. The report parse is split out
and pinned by tests like classify_reload_output.
2026-08-08 12:19:48 +02:00
enricobuehler 9089651406 Merge pull request 'The jitter ring only ever learned from clicks — it now grows on near-misses, un-does refused shrinks, and cashes growth on the click it already paid' (#111) from worktree-audio-jitter-lowwater into main
apple / swift (push) Successful in 1m36s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 1m59s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / rust (push) Failing after 3m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 1m55s
deb / build-publish-host (push) Successful in 4m38s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 32s
android / android (push) Successful in 10m35s
flatpak / build-publish (push) Successful in 7m16s
release / apple (push) Successful in 10m45s
windows-host / package (push) Successful in 12m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
arch / build-publish (push) Successful in 13m42s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
apple / screenshots (push) Successful in 6m9s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 24m41s
Reviewed-on: #111
2026-08-08 10:11:10 +00:00
enricobuehler 2a2427afc8 Merge pull request '"Native resolution" streamed the compositor's points, not the panel's pixels — and the window was never high-DPI either' (#112) from worktree-wayland-native-pixel-density into main
ci / bun-nix (push) Successful in 26s
ci / docs-site (push) Successful in 1m4s
android / android (push) Canceled after 1m19s
ci / web (push) Successful in 1m12s
apple / swift (push) Canceled after 1m24s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 1m31s
ci / rust (push) Canceled after 1m35s
ci / rust-arm64 (push) Canceled after 1m34s
deb / build-publish (push) Canceled after 1m15s
deb / build-publish-host (push) Canceled after 37s
deb / build-publish-client-arm64 (push) Canceled after 25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 4s
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 4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m45s
windows-msix / package (x64, , 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
Reviewed-on: #112
2026-08-08 10:09:55 +00:00
enricobuehler d237646c66 fix(host,sdk,kit): library scanners sat in the nav, could not sync local art, and so never got their settings
Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar
they explicitly opt out of; Lutris's settings were unreachable from the Library
screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`.

**The sidebar is a publish gap.** The console is correct — it keeps
`category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the
host reports no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to
0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's
0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3 — **inert until it is published**.

Because the field rides the untyped `pf.request` seam so an older host ignores it
rather than rejecting the registration, dropping it is silent by design. `serveUi` now
reads its own directory entry back and warns once when a requested category did not
land, the same way `defineLibraryPlugin` already warns when a store claim did not take.
That is what turns the next occurrence into a log line instead of a bug report.

**The missing settings and the failed sync are ONE defect: a write/read disagreement
about `file://`.** `local_art_bytes` decodes a `file://` value before testing
containment; `validate_art_paths` handed the raw value to `Path::new`, where
`file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It
canonicalized against the cwd, failed, and read as "outside every art root". So the
host refused every cover the kit's own `fileUrl` helper emits — the documented way for
a plugin to publish local art — while the read path would have served those same files.

That the two symptoms share a cause is not obvious and is why this is one commit: the
Library screen's settings control renders only for `origin: "plugin"`, and a source
becomes `plugin` only once it holds a store CLAIM, which is taken during a successful
reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its
store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic
reconciled (its art is http(s)) and has had its settings all along; rom-manager was
never affected because zero entries meant it never applied.

`art_path_is_servable` now decodes first, so both halves of the confinement judge the
same string. Confinement itself is unchanged: an out-of-root path is still refused in
`file://` clothing, which the test asserts alongside the accept case.

Diagnosing this took the HOST's journal, because both surfaces that should have
explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's
`${e.cause}` logged `HostRequestError` and discarded the method, the path and the
host's own message; it now renders all three, including an object-shaped cause that
used to print `[object Object]`. And the host logged "payload carries a field this lane
may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read
as an auth problem — it now logs the real reason and the entry title.

Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The new art test fails without the fix and passes with
it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.
2026-08-08 11:43:30 +02:00
enricobuehler 69728b6f4e fix(pf-presenter): "Native resolution" streamed the compositor's POINTS, not the panel's pixels
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m33s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 4m41s
ci / rust (pull_request) Successful in 6m52s
A CachyOS / KDE Plasma 6.7.4 Wayland client with its 2560x1600@165 laptop panel at
150 % scaling negotiated 1706x1066 for "Native resolution" and streamed a visibly
blurry image. Two independent defects, and they stack — which is why forcing the mode
to 2560x1600 by hand did not fully fix it either.

1. `SDL_GetDesktopDisplayMode` reports a mode in SCREEN COORDINATES and hands the
   pixels-per-point ratio back separately as `pixel_density`. We read `m.w`/`m.h` raw.
   KDE advertises that panel as 1707x1067 points with a density of ~1.4997,
   `render_scale::apply` even-floors both odd axes, and 1706x1066 goes on the wire —
   exactly the mode in the reporter's handshake log. Multiplying by the density
   recovers 2560x1600 to the pixel, because SDL derives it as the output's exact
   pixels/points ratio. On X11 and Windows SDL never sets a density and `SDL_video.c`
   normalizes the unset 0.0 to 1.0, so this is inert there: the bug needed a
   compositor doing FRACTIONAL scaling.

2. The SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland surface
   stayed at buffer scale 1 — the Vulkan swapchain was built at 1707x1067 and KWin
   upscaled it to the glass. Even a correct 2560x1600 stream was resampled down and
   then back up. The same flaw silently shrank "Match window", which asks the host for
   `size_in_pixels()`. The reporter's `SDL_VIDEO_WAYLAND_SCALE_TO_DISPLAY=1` workaround
   is this same fix applied from outside SDL, which is why it helped.

The surrounding code was already written for pixels != points — the swapchain,
match-window and pointer mapping all read `size_in_pixels()` while window-size
persistence reads logical `size()` — so the flag only makes those two stop being the
same number. `display_scale()` starts reporting 1.5 into a swapchain that is 1.5x
larger, leaving the OSD the size it already was.

Also closes a smaller hole on the way past: only an `Err` from SDL reached the
1920x1080 fallback, so a display that reported a 0x0 mode sent a 0x0 request.

Verified on home-worker-5 (CachyOS — the reporter's distro, real SDL 3.4.14):
`cargo clippy --all-targets -p pf-presenter -- -D warnings` clean and 18/18
pf-presenter tests pass, three of them new and pinned to the field-reported numbers.
2026-08-08 11:15:58 +02:00
enricobuehler 3bb87d260e fix(audio): detect jitter before it is audible, and stop re-probing a depth the link just refused
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / web (pull_request) Successful in 1m38s
android / android (pull_request) Successful in 4m52s
ci / docs-site (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 4m10s
ci / bun-nix (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 9m26s
The 0.25.0 MacBook field report — audio jitter 'at certain points' — is the
jitter policy learning exclusively from audible failures, on both of its
sides. Growth needed THREE audible underruns before deepening the ring; the
A/V sync loop re-tested a shallower ring every five quiet seconds and paid an
audible starvation event every time it was wrong, forever; and a grown target
was never re-banked — growth raises a threshold, only a re-prime deepens the
ring — so a bunching link rode the knife edge, clicking once per bunching
period with the 'grown' target sitting inert. A ten-minute simulation of the
Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm skew) measured ~2000
audible events under the shipped policy.

Three mechanisms, in JitterPolicy (Linux/Windows/Android) and mirrored in the
Swift AudioRing:

- NEAR-MISS: a read served with less than one protocol frame left over is the
  same evidence as an underrun, heard by no one. It grows the target one step
  per window, BEFORE the click — waiting for the third audible underrun means
  the user heard two.
- SHRINK PROBES: every shrink is armed for five seconds; answered by an
  underrun or near-miss it is undone on the spot, and a failed sync-driven
  shrink is not retried for a doubling backoff (60 s → 8 min). A probe that
  survives resets the backoff. Continuity outranks sync, now with a memory.
- HOLLOW RE-PRIME: an underrun while the depth AVERAGE runs more than a step
  below the target re-primes immediately, spending the click it already cost
  on the whole refill instead of limping. The average, not the instant, is
  what separates a hollow ring from one late packet, and it is seeded on
  prime so a fresh ring is never spuriously hollow.

Same simulation after: 9 audible events, tail clean but for the clock-skew
re-anchor (a genuinely slow host must re-bank every few minutes; only rate
adaptation would remove that, and no client has it). Neutralising the three
constants reproduces the ~2000 — the convergence tests fail against the old
behaviour.

Verified: 203 punktfunk-core tests, 254 Swift tests (5 skipped), clippy -D
warnings on punktfunk-core --all-features, cargo fmt --all --check.
2026-08-08 11:05:53 +02:00
enricobuehler be57587572 Merge pull request 'The release-rebuild prune called a helper that cannot exist in a release rebuild' (#110) from worktree-arch-rebuild-prune into main
apple / swift (push) Successful in 1m30s
ci / bun-nix (push) Successful in 19s
ci / web (push) Successful in 1m40s
ci / docs-site (push) Successful in 3m12s
ci / rust-arm64 (push) Successful in 6m8s
apple / screenshots (push) Successful in 6m19s
android / android (push) Successful in 10m12s
arch / build-publish (push) Successful in 9m5s
decky / build-publish (push) Successful in 55s
deb / build-publish-client-arm64 (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 32s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 28s
deb / build-publish (push) Successful in 9m25s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m28s
deb / build-publish-host (push) Successful in 7m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m3s
docker / builders-arm64cross (push) Successful in 14s
ci / rust (push) Successful in 17m40s
docker / deploy-docs (push) Failing after 3m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m52s
Reviewed-on: #110
2026-08-08 09:03:28 +00:00
enricobuehler 8f1c34c6bf fix(ci/arch): the release-rebuild prune called a helper that cannot exist there
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Failing after 1m51s
ci / web (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 6m47s
ci / rust (pull_request) Successful in 28m33s
The v0.25.0 rebuild published perfectly — registry has punktfunk-host 0.25.0-2 with
libavcodec.so=63-64, and it resolves on a real ffmpeg-9 box — then failed its last
step with

    prune_release_assets: command not found

`. scripts/ci/gitea-release.sh` sources from the CHECKED-OUT TREE, and a release
rebuild checks out the OLD TAG. So the step could only ever see the helpers that
existed when that tag was cut, and the prune is gated on exactly that path: the
helper was guaranteed absent in the only case that calls it. Adding it to a shared
script made it look available at review time while being unreachable at run time.

Only the workflow file is read from the dispatched ref, so the logic moves there,
inline. Same reasoning documented at both ends, including the corollary worth knowing
before the next rebuild: a PKGBUILD fix made after a tag does NOT reach a rebuild of
that tag either — the packaging comes from the tag too.

Verified by executing the one-liner's exact bytes out of arch.yml under /bin/sh (the
shell Gitea actually uses): keeps the new -2 set and gamescope, drops the superseded
-1 packages and their .sha256 sidecars, leaves other legs' .dmg/.deb untouched. The
`'\n'` survives the shell quoting, which was the part worth proving.

Also drops the now-dead helper from gitea-release.sh rather than leaving a function
no caller can reach, and leaves a warning there against the next one.
2026-08-08 10:57:49 +02:00
enricobuehler 1ef212a78d Merge pull request 'v0.25.0 shipped an Arch host no up-to-date box can install — and the pipeline had no way to tell' (#109) from worktree-arch-ffmpeg9-repackage into main
apple / swift (push) Successful in 1m39s
ci / rust-arm64 (push) Successful in 3m1s
ci / web (push) Successful in 1m32s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m16s
android / android (push) Successful in 6m21s
decky / build-publish (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 57s
apple / screenshots (push) Successful in 6m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 3m27s
arch / build-publish (push) Successful in 10m32s
deb / build-publish-host (push) Successful in 6m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 2m9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
deb / build-publish (push) Successful in 8m38s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 40s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4m19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 3m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m1s
docker / deploy-docs (push) Successful in 32s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m38s
docker / builders-arm64cross (push) Successful in 3m38s
ci / rust (push) Successful in 21m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m36s
Reviewed-on: #109
2026-08-08 08:39:32 +00:00
enricobuehler e044f68500 fix(ci/arch): v0.25.0 shipped a host no Arch box can install, and nothing could tell
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m47s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / web (pull_request) Successful in 1m53s
ci / docs-site (pull_request) Successful in 1m24s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 7m26s
Arch moved FFmpeg 8 -> 9 (every libav soname +1) hours before the release. PR #108
fixed the real bug — packaging/arch/PKGBUILD now binds punktfunk-host to the sonames
it actually linked, so pacman refuses an upgrade instead of bricking the install — and
re-keyed ci/arch-ci.Dockerfile so the builder would carry FFmpeg 9.

The tag was pushed four minutes later. arch.yml and docker.yml have no `needs:` between
them, and arch.yml deliberately runs no -Syu ("the image's snapshot IS the build
environment"), so the release build pulled the still-FFmpeg-8 `:latest` and published

    punktfunk-host 0.25.0-1  depends: libavcodec.so=62-64, libavutil.so=60-64,
                                      libavfilter.so=11-64, libavdevice.so=62-64,
                                      libswscale.so=9-64

against a world that had moved to 63/61/12/63/10. It fails safely — pacman refuses,
nothing bricks — but it fails broadly: pacman prepares one transaction, so an
unsatisfiable dependency of OURS stopped affected users' entire `pacman -Syu`.

Nothing in the pipeline could have caught it. The existing assert proves the dep is
VERSIONED; it cannot prove the version EXISTS. So two guards, plus the lever to repair
a release that has already shipped:

* Preflight parity — compare the builder's libav `provides` against the live repos and
  `-Syu` the container if they differ. The image is a cache and may lag; on this one
  axis it may not. Syncs into a throwaway --dbpath so the container never sits in the
  partial-upgrade state a bare `pacman -Sy` leaves.

* Publish gate — resolve every built package with `pacman -U --print` against a
  PRISTINE --dbpath. Empty db means "nothing is installed", so every dependency must
  come from the repos exactly as on a user's box. Resolving against the builder's own
  installed set is what would hide this: a stale ffmpeg satisfies a stale bound.
  gamescope stays best-effort (dropped from the upload with a warning, never fatal).

* workflow_dispatch(release_tag, pkgrel) — a published release cannot be repaired by
  re-running its tag: pkgrel would stay 1, which is invisible to a box that already
  recorded the broken build, and the workflow file at the tag can never carry inputs
  added after it. Dispatched from main it takes the WORKFLOW from main and the SOURCE
  from the tag, publishes to the stable repo at a higher pkgrel, and replaces the
  release-page assets (prune_release_assets: upsert replaces by NAME, and a rebuild's
  filenames differ, so the superseded package would otherwise stay one click away).

Verified on a real ffmpeg-9 box (.21, CachyOS) rather than reasoned about: the gate
rejects the published 0.25.0-1 host with the user-visible error verbatim, and passes
client, web, scripting and gamescope — 0 false positives across all five artifacts.
The parity snippet reads today's `provides` correctly (`-Si --dbpath` on an empty db
works; pacman does not wrap fields when piped). Version logic exercised on all four
paths: rebuild -> 0.25.0-2 stable, tag push and canary unchanged, pkgrel=1 refused.

Ships as punktfunk-host 0.25.0-2. README gains the pacman error and what to do about
it; CHANGELOG says plainly that 0.25.0's Arch packages were wrong.
2026-08-08 10:34:11 +02:00
310 changed files with 32365 additions and 2646 deletions
+196 -8
View File
@@ -48,7 +48,26 @@ on:
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
# whole transaction at once, so it did not merely block our package, it blocked those users'
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
workflow_dispatch:
inputs:
release_tag:
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
required: false
default: ''
pkgrel:
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
required: false
default: '2'
env:
REGISTRY: git.unom.io
@@ -94,7 +113,52 @@ jobs:
}
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
# on this one axis. So heal it in-job and shout, instead of building a dead package.
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
run: |
export LC_ALL=C # `Provides` is a localized field name
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
mkdir -p /tmp/pf-archsync
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
exit 0
fi
HAVE="$(pacman -Qi ffmpeg | sonames)"
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
if [ "$HAVE" = "$WANT" ]; then
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
exit 0
fi
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
pacman -Syu --noconfirm || true
HAVE="$(pacman -Qi ffmpeg | sonames)"
if [ "$HAVE" != "$WANT" ]; then
echo "::error::builder still links $HAVE while Arch ships $WANT."
echo "::error::Building on would publish a package no Arch box can install."
exit 1
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
with:
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
# = checkout's own default, i.e. the triggering ref, for every other trigger.
ref: ${{ github.event.inputs.release_tag }}
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
@@ -127,12 +191,30 @@ jobs:
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
# it to `1.` would sort canaries ABOVE the release and is not an option.)
env:
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
if [ -n "${RELEASE_TAG:-}" ]; then
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
# top): same upstream version, higher pkgrel, straight into the stable repo.
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
# advertises by STRING order (the same trap the canary zero-padding below exists for),
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
V="${RELEASE_TAG#v}"
R="${REBUILD_PKGREL:-2}"
REPO=punktfunk
case "$R" in
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
esac
else
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
fi
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
@@ -198,6 +280,21 @@ jobs:
done
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
# looked at what the built package would DO. The lesson recorded then was "verify the
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
# since capabilities live in the scriptlet rather than in package metadata.
#
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
# guard that has quietly lost the ability to fail takes the job down rather than approving a
# release. (Only the host package is checked: the client/web/scripting packages ship neither
# binary and the script skips them by itself.)
- name: Assert the capability matrix (Arch package)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
@@ -235,6 +332,63 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
# one transaction, their whole system upgrade stopped there. So ask the only question that
# matters before publishing: would a real, up-to-date Arch box install this?
#
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
- name: Assert every package installs on an up-to-date Arch box
run: |
export LC_ALL=C
mkdir -p /tmp/pf-instcheck
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
echo "::error::could not sync the Arch db — cannot prove these packages install"
exit 1
fi
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
}
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
rc=0
for pkg in dist/*.pkg.tar.zst; do
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
rc=1
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
echo "$out" | sed 's/^/ /'
fi
done
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
# install is dropped from the upload with a warning, never a reason to withhold the
# packages this workflow exists to publish. (It is also the one package that can be
# restored from a cache older than the current Arch snapshot.)
for pkg in dist-gamescope/*.pkg.tar.zst; do
[ -e "$pkg" ] || continue
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
echo "$out" | sed 's/^/ /'
rm -f "$pkg"
fi
done
if [ "$rc" != 0 ]; then
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
exit 1
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
@@ -262,14 +416,48 @@ jobs:
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
# rebuild attaches to that SAME release object: the release page is a distribution surface
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
# someone the exact break the rebuild exists to fix.
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RID=$(ensure_release "$TAG" "$TAG" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
# upsert_asset only replaces by name — the superseded set would survive untouched.
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
#
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
# so it can only ever offer the helpers that existed when that tag was cut. A helper
# added for this feature is therefore guaranteed ABSENT in the one code path that
# calls it: the first attempt failed with `prune_release_assets: command not found`
# after publishing perfectly. Only the workflow file itself is taken from the ref you
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
# that tag — the PKGBUILD is the tag's too.
if [ -n "${RELEASE_TAG:-}" ]; then
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
# An UNMATCHED glob would come through literally and match nothing in the keep set —
# i.e. "delete every pacman asset on the release". Skip entirely instead.
case "$KEEP" in *'*'*) KEEP="" ;; esac
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
if [ -n "$KEEP" ]; then
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
| while read -r id name; do
[ -n "$id" ] || continue
echo "dropping superseded release asset: $name"
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
-H "Authorization: token $GITEA_TOKEN" || true
done
fi
fi
+114 -1
View File
@@ -310,8 +310,14 @@ jobs:
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
#
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
# if the artifact is missing — building it here keeps it on the same sccache pass as the
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
# the worker links the same FFmpeg), so feature unification here is harmless.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
-p punktfunk-host -p punktfunk-encode-worker
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -320,6 +326,93 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
# capability metadata — the postinst applies them — so this reads the postinst that will
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
# from exactly that postinst and killed every KDE desktop session while every board stayed
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
# guard that can no longer fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (host .deb)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
#
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
# a half-built result impossible to ship.
run: |
set -x
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
# gamescope — where noble has one at all — so it misses what the master tree needs, and
# wayland-protocols is the gap that actually stops the build: meson dies in
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
# file was found", because the tree has no wrap fallback for it. That is what happened on
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
# shipped with no gamescope .deb while the notes said it had one.
apt-get install -y --no-install-recommends wayland-protocols
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
# releases should not fail the job. (No libstdc++ static package is needed here — g++
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
# purpose: a single transaction aborts wholesale on one unknown package, which would
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
# the missing one named; the end-of-job gate below is what actually decides.
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
cmake xwayland; do
apt-get install -y --no-install-recommends "$p" \
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
done
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
# skips the host .deb's own publish + release-attach steps below, which is how the
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
# meant to protect the release. Never let a missing EXTRA stop a good artifact
# shipping; go red afterwards instead.
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
- name: Build punktfunk-gamescope .deb
# Picked up by the publish loop below, which globs dist/*.deb.
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -347,6 +440,26 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
# because every failure on this path was a `::warning::` that returned 0.
#
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
# gate protecting the release withheld the fix for it. Everything good ships first; the job
# goes red afterwards.
- name: A stable tag must ship the gamescope .deb
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope_*.deb)
if [ ${#built[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope .deb present: ${built[*]}"
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
+38 -4
View File
@@ -7,10 +7,24 @@
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -22,6 +36,12 @@
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
@@ -66,6 +86,10 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -165,3 +189,13 @@ jobs:
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+131 -1
View File
@@ -103,7 +103,11 @@ jobs:
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
@@ -155,6 +159,20 @@ jobs:
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: bash packaging/rpm/sign-rpms.sh
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
# fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (rpm)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
# Only the main host package carries binaries; -debuginfo/-debugsource and the
# client/web/scripting subpackages ship neither and are skipped by the script itself.
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
- name: Publish to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -206,13 +224,89 @@ jobs:
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
# until it existed those users had no packaged route to the patched build at all, and a stock
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
#
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
shopt -s nullglob
for rpm in dist/punktfunk-gamescope-*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
echo "uploading $rpm"
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
@@ -236,6 +330,19 @@ jobs:
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# Read the capability matrix back OUT of the image that is about to be published — the one
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
# getcap on it. Doing it here means the .raw never reaches the feed.
#
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
- name: Assert the capability matrix (sysext image)
run: |
bash scripts/ci/assert-cap-matrix.sh \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
@@ -276,3 +383,26 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+782
View File
@@ -12,6 +12,756 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.27.0
87 commits since v0.26.0.
### Versions
| | v0.26.0 | v0.27.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 17 | **18** | `punktfunk_connection_next_rumble_cmd2` **added**; nothing removed or changed |
| Workspace crate dirs | 26 | **27** | `crates/punktfunk-encode-worker` (39 members; two `tools/` crates deliberately *excluded*) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged — three `device_type`s added additively |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.25.0 | **0.25.0** | unchanged — no management-API edits this release |
| gamescope patch level (`+pfhdrN`) | 4 | **5** | 6 patches → 7 (the PipeWire use-after-free); `pkgrel` resets 3 → 1 |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.0** | unchanged |
**`crates/pf-driver-proto` is no longer byte-identical to the previous release.** It was through
both v0.25.0 and v0.26.0, so if you ship the virtual-display driver or the gamepad channel and have
been skipping this crate, stop skipping it here. The change is purely additive — three `device_type`
constants, no field moved, no size changed.
### ⚠ Breaking changes
**None** for embedders or the wire. Every embedder, packager and plugin that works against v0.26.0
works against v0.27.0 unchanged; the C ABI moves, but by addition only (below).
Two things change shape for **packagers** and one **default** flips:
- **A second installed binary**, `punktfunk-encode-worker` — see the section below. It is the only
file that may carry `cap_sys_nice=ep`, and it must be a separate file.
- **`PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`** on Windows, so an Xbox pad is built as a real
HID device rather than the XUSB companion. `=xusb` is the escape hatch.
- **NixOS `scripting.autoStart` now defaults ON**, matching every other packaging (detailed below).
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
changes that — all three land the capability in the same permitted set.
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
compressed access units. It connects to no compositor, no D-Bus and no network, so its
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
channel, ever.
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
the sysext's host hard-fail.
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
best-effort everywhere.
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
mechanized, and it is what would have caught the original break.
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
host's `ExecStart` stays on the plain store path (the #136 fix stands).
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
are different files now, so the version check is load-bearing rather than decorative; they ship
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
still means one thing on both platforms.
### NixOS — session detection, module defaults, and a CI gate that was never running
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
the *detected* backend so a live KWin already in `available()` was never chosen, and a
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
applied to the foreign-gamescope probe, which had the same defect.
**Module changes** (`services.punktfunk`):
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
died with the old compositor: it still listens, still answers, and every session after that fails
at capture. Off by default because an appliance may never reach that target and would be left
permanently stopped.
-**`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
`systemctl --user mask punktfunk-scripting`.
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
console enabled before the host's first run stayed dead) and `Restart=always` rather than
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
had been running strictly less confined on NixOS than anywhere else.
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
### C ABI 17 → 18
**`punktfunk_connection_next_rumble_cmd2` is new.** The `0xCA` rumble plane carries the two Xbox
impulse-trigger motors (v3, below) and `punktfunk_connection_next_rumble_cmd`'s fixed out-params
have no room for them:
```c
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
```
**Added, not widened.** `_cmd` keeps its signature *and* its values bit-identical for handle-only
traffic; all four rumble entry points remain exported. An exported parameter list is part of the
contract, and growing one in place breaks every out-of-tree embedder at once — with a
stack-corruption signature rather than a link error. This follows the existing
`next_rumble``next_rumble2` precedent.
**One behavioural delta on the old symbol**, documented in `abi.rs` and pinned by a test: against
a host driving the trigger motors, a `_cmd` caller now receives commands with `low == high == 0`
where the demux previously dropped the update entirely. They are idempotent handle stops — the
command as a whole is not silent, so redundant-stop suppression cannot fold them. Zero cost today:
nothing sources non-zero trigger levels yet.
**Render trigger levels only on a pad that has trigger motors.** Do not fold them into the handles —
impulse-trigger content is continuous, so folding it drones the handle motors flat-out. Query
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN` or `GCDeviceHaptics.supportedLocalities`.
🛑 **This delivery path is deliberately built ahead of its producer and nothing here claims
otherwise.** Exactly one backend can ever source these levels — the Windows HID Xbox pad's output
report `0x03` — because `XINPUT_VIBRATION` and evdev `FF_RUMBLE` both have two members. That
producer is reachable only through GameInput, which does not enumerate an `xinputhid`-promoted Xbox
pad at all (measured against a real Microsoft Elite, equally invisible there while classic XInput
reads it live). The wire, the engine and this entry point are exercised by synthetic levels only.
### Gamepads
- **`PUNKTFUNK_GAMEPAD_XBOXELITE = 11`** — a new `GamepadPref` wire byte, appended to
`Hello`/`Welcome`. The `Auto` sentinel in the round-trip test moved 11 → 12. An older peer
degrades an unknown byte to `Auto`, so this is graceful in both directions.
- **`XboxOne` is now a distinct HID identity on Windows** (`045E:02FD`, Bluetooth Xbox One S)
through the UMDF minidriver. It used to fold to `Xbox360` there, because the only Windows Xbox
backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it.
- **Three new `pf_driver_proto::gamepad` device types**, contiguous and sharing one report
descriptor byte for byte (they are the same pad in HID terms; the descriptor is the report
*shape*, the identity is what the OS keys mappings off):
| const | value | identity |
|---|---|---|
| `DEVTYPE_XBOX` | 4 | `045E:0B13` Xbox Wireless Controller |
| `DEVTYPE_XBOX_ONE_S` | 5 | `045E:02FD` Xbox Wireless Controller (One S) |
| `DEVTYPE_XBOX_ELITE` | 6 | `045E:0B22` Xbox Elite Wireless Controller Series 2 |
⚠ The Xbox input report is **not** 64 bytes like its siblings — it is `XBOX_INPUT_REPORT_LEN`
(16). The driver serves per-identity report lengths, because hidclass sizes its buffer from the
descriptor and refuses an over-long source.
-**Elite paddles are not implemented.** `BTN_PADDLE1..4` still fold or drop exactly as on the
other Xbox classes. `DualSenseEdge` remains the only virtual pad with native back-button slots.
- **All three Xbox identities install `pfGamepadXbox`**, their own DDInstall section, which attaches
the `xinputhid` bus filter. Merging it back into the shared `pfGamepad` section is a one-line edit
that looks like tidying and would hand a DualSense, DualShock 4, Edge and Steam Deck to
Microsoft's Xbox translator. `only_the_xbox_identity_installs_the_xinputhid_section` asserts the
split in both directions.
**What actually promotes the pad — two registry values, and the pairing is the whole finding.**
`UpperFilters=xinputhid` is a `.HW` AddReg (hardware key); `DevicePropertyFlags=1` is a DDInstall
AddReg (software key). A one-value A/B on real hardware: removing `DevicePropertyFlags` alone
reverts everything — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters`
alone is completely inert. `1` = `BusDevice`, which Microsoft's own comment glosses as "a focused
bus filter driver for the IG_ problem". **This retracts an earlier in-tree conclusion that the
filter should never ship**: it was never broken, it had simply never been switched on.
⚠ Microsoft's allow-list contains `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — neither `02FD`
nor `0B22` is on it, and promotion happens anyway, because it comes from our own AddReg.
### Wire (no version change)
**The `0xCA` rumble datagram gains a v3 form**, `PUNKTFUNK_RUMBLE_V3_LEN = 14`:
```
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
v2 10 B: … [u8 seq][u16 ttl_ms]
v3 14 B: … [u16 left_trigger][u16 right_trigger]
```
v3 is built *from* v2's bytes, so the prefix relationship is structural rather than a convention two
encoders must keep agreeing on, and every reader gates with `>=`. All four levels share one `seq`
and one TTL deliberately: they are one statement of the pad's feedback at one instant, so the entire
v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the
triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a
level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the
stuck-rumble bug in a new costume.
**The two trigger `enable`-mask bits remain conjecture.** Bits 2/3 (the handles) are measured;
bits 0/1 are inferred from field order and nothing else. No test asserts them. XInput cannot settle
this; it has two motors.
### Packaging
- **gamescope pin `8c676c39``5fb8dce4`** (3.16.25-1 → 3.16.25-11), all six patches rebased, plus
a **seventh**: the PipeWire use-after-free that aborted a session on every connect. The marker
moves `+pfhdr4`**`+pfhdr5`**, so `pkgrel` resets to 1.
- **Patch 0001 offers `xBGR_210LE` before `xRGB_210LE`.** ⚠ Deliberately *not* done by calling
upstream's `vulkan_get_rgb10_capture_format()` — that symbol landed after 3.16.25 and would break
`packaging/nix/gamescope.nix` with an opaque C++ error instead of a patch conflict.
- **Every `punktfunk-gamescope` RPM ever published was unsigned.** `Sign RPMs` runs right after
`Build RPM`, while the gamescope RPM is built ~90 steps later behind its own cache, so it missed
the signing pass entirely — and the repo file we ship carries `gpgcheck=1`. A second pass signs it
before publish, fail-closed on a tag.
-**The v0.26.0 gamescope gate failed the job at the *build* step**, which in `deb.yml` runs before
both the apt publish and the release attach — so a missing *extra* withheld the host `.deb` itself,
and the `.deb` published on v0.26.0 still carries the `CAP_SYS_NICE` grant. `rpm.yml` had the
identical latent bug. Both now warn at build/package time and gate as the **last** step of the job.
- **`driver uninstall --audio`** — a third Inno `[UninstallRun]` entry that removes the MEDIA-class
devnodes the host mints at runtime. Marker-matched, never name-matched: our instances are
name-identical to Steam's, and a `ROOT\` enumeration guard means a marker-shaped value on a real
sound card can never cost the user their hardware.
- **The sysext `post_merge` step re-runs when already current, plus a new `reapply` verb.** A sysext
upgrade is driven by the script from the **old** image, so a `post_merge` step added in a release
is executed by nobody, permanently, on exactly the installs that need it.
### Host
- **HDR capture offers `xBGR_210LE` before `xRGB_210LE`.** gamescope's capture textures are
mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for
`A2R10G10B10_UNORM_PACK32` — so `imageStore` lands in XBGR order while the buffer is still
*labelled* `XRGB2101010`. Every mapping on both ends audits clean because the label was right and
only the content was wrong. Fixed host-side because the deployed gamescope cannot self-correct.
- **One NVENC open failure no longer kills every session on the box**, and the 10-bit capability
probe no longer wedges a direct-SDK host process-wide with `NV_ENC_ERR_INVALID_VERSION`.
- **`/api/v1/local/summary` reports the resolution the session actually got**, not the negotiated
one it was seeded with.
### Workspace
`crates/punktfunk-encode-worker` joins as a member (above). Two bring-your-own-hardware measurement
tools are added and **excluded** in the root manifest, so `cargo build --workspace` and CI never see
them: `tools/hid-descriptor-dump` (dumps and decodes a real HID report descriptor; pulls `hidapi`)
and `tools/win-input-matrix` (asks each Windows input API what it can see — ⚠ `wake_wgi()` is not
optional there: both WGI collections return a cache a console app has never started filling, so
without subscribing first they come back empty with real controllers attached).
### Host and client environment variables
- **`PUNKTFUNK_XBOX_BACKEND`** *(new, host, Windows)*`hid` (the new **default**) or `xusb` (the
escape hatch). The HID pad is now a superset of the XUSB companion: it keeps classic XInput while
gaining Steam, SDL, RawInput, DirectInput, `joy.cpl` and WGI, plus rumble, which XUSB could not
source at all. The escape hatch stays because promotion leans on Microsoft's inbox
`xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no
reinstall. An unrecognised value takes the **default**, not the opt-out, so a typo cannot silently
drop a user onto the path with no HID collection.
- **`PUNKTFUNK_GAMESCOPE_BIND`** *(new, host, Linux)* — unset = auto, `0` = never, `1` = force.
Governs whether the host binds the patched gamescope over the distribution's `/usr/bin/gamescope`
inside a session's mount namespace.
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
order: this variable → alongside `/proc/self/exe``PATH`. `off` forces the in-process encoder,
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
worker; the granted class comes back in the handshake and the host logs it centrally, so the
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
naming the worker binary rather than the host.
### Documentation
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
off "the packages deliberately do not grant this".
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
never to the host — lives in `docs/releases/v0.27.0.md`.
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
host with your desktop** gains the NixOS one-liner beside the drop-in.
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
`punktfunk` group added to the option tables; the "what the module configures" list gains the
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
plus the two rules for editing `module-check.nix`.
---
## v0.26.0
52 commits since v0.25.0.
### Versions
| | v0.25.0 | v0.26.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 17 | **17** | unchanged — no symbol added, removed or changed |
| Workspace crate dirs | 26 | **26** | unchanged (40 workspace members) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.24.0 | **0.25.0** | tracks API edits, lags one release by convention |
| gamescope patch level (`+pfhdrN`) | 2 | **4** | 3 patches → 6; `pkgrel` 1 → 2 |
| `@punktfunk/host` (SDK) | 0.1.2 | **0.1.4** | |
| `@punktfunk/plugin-kit` | 0.3.2 | **0.4.0** | the `plugin` launch kind |
`crates/pf-driver-proto` is byte-for-byte identical to v0.25.0 and to v0.24.0 — if you ship the
virtual-display driver or the gamepad channel, the last two releases have not touched you.
### ⚠ Breaking changes
**None.** This is a fixes release. Every embedder, packager and plugin that works against v0.25.0
works against v0.26.0 unchanged. Two behaviour changes are worth knowing about anyway, because both
make a client advertise *less* than it used to — see **Capability advertisement** below.
### Capability advertisement
- **`VIDEO_CAP_444` is now probed, not asserted.** It rode the "Full chroma" setting alone. That was
safe while a software HEVC decoder sat underneath it; M8 removed one (there is no permissively
licensed HEVC CPU decoder, so `software_decodable_codecs()` is `H264|AV1`). The host grants 4:4:4
on HEVC **only** and answers the resolved chroma in the `Welcome` *before* the client builds a
decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the whole
codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU
rung, and the session reconnects on H.264. No AMD silicon has HEVC 4:4:4 decode, so every Steam
Deck with that switch on lost HEVC. Per-profile and default-off, which is why it read as
intermittent.
Now gated on `hevc_444_hardware_decodable`, which asks the driver through the same code the rung
uses at construction (`VkH265Decoder::probe_stream_support`). **Both depths are required**, not
either: with HDR the host may resolve 4:4:4 10-bit, and a device offering `YUV444_8` but not
`YUV444_10` lands in the same hole. Answering from the Vulkan rung alone is exact rather than
approximate — it is the only rung in this build that implements 4:4:4 at all
(`pf_vaadec::profile_for` errors on `chroma_format_idc 3`, pf-dxvadec refuses anything but 4:2:0,
the CPU rung is 8-bit 4:2:0).
⚠ Deliberately **not** extended to `VIDEO_CAP_10BIT`/HDR: all three rungs implement 10-bit 4:2:0,
so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it
perfectly — a regression against a case never observed.
The bit arithmetic moved into `video::video_caps_for` so the part that was wrong is testable
without a GPU, a host or a `Hello`; the test is verified non-vacuous against the planted defect.
### Host and client environment variables
Four new, one clarified. Verified new by `git grep` at the v0.25.0 tag, not assumed —
`PUNKTFUNK_JUMBO`, `PUNKTFUNK_WIRE_MTU`, `PUNKTFUNK_STREAMED_AU`, `PUNKTFUNK_LIBRARY_ART_ROOTS`,
`PUNKTFUNK_RECOVER_SESSION_CMD`, `PUNKTFUNK_GAMESCOPE_SDR_NITS`, `PUNKTFUNK_MAX_FPS` and
`PUNKTFUNK_ON_CONNECT_CMD` all already existed.
- **`PUNKTFUNK_OVERLAY_MASK`** *(new, client)* — controls the Steam-overlay input mask below.
- **`PUNKTFUNK_PYROWAVE_CHUNK_KIB`** *(new)* and **`PUNKTFUNK_PYROWAVE_STREAMED_AU`** *(new)*
PyroWave AU chunking and the streamed-AU path.
- **`PYROWAVE_QUEUE_PRIORITY`** *(existed, but was inert on Linux — see below)* — grammar: unset →
realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
the ladder rather than to off. ⚠ **One env var must not mean two things on two platforms**, so
the Rust grammar is unit-tested against the C patch's, including where both are deliberately
un-clever (neither trims).
- **`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`** *(new)* — widens the set a gamescope session
offers in Steam's in-session display settings. The rate the session actually runs at is always
included, so it can only add options; junk entries are skipped rather than failing the host.
Requires gamescope patch level 3+.
- **`PUNKTFUNK_COMPOSITOR`** *(behaviour clarified, not changed)* — documented as "which backend to
drive", it also silently discarded `game_session=dedicated`: `resolve_compositor` gated the
dedicated route on `!overridden` and logged nothing either way. The pin still wins — it is the
operator's explicit knob — but it now says so and names itself. Two further holes closed with it:
the pin put its backend into `available()` unconditionally *and* skipped `apply_session_env`'s
`XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could never return `None` — the one call site of
`try_recover_session()`, which left `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable behind that arm.
Liveness is now read on both paths. `needs_live_session()` exempts gamescope, which stands up its
own session, so pinning it on a headless box stays supported.
### Client settings keys
All additive; an older client ignores what it does not know, and a newer value can never trap an
older client.
- **`gamepad_ui_mode`** — `"connected"` (default, and exactly what the previous lone Bool meant) or
`"always"`. Splits *whether* the controller UI is offered from *when* it appears.
`GamepadUIEnvironment.isActive` takes the mode with **no default argument** on purpose: a call
site that forgot it would silently strand everyone who chose Always. An unrecognized value waits
for a controller.
- **`ui_palette`** gains `oled` at **index 1**, directly after the brand default — keeping
`PALETTES[0]` the unknown-id fallback and the dark-to-pale cycling order intact. Hand-mirrored in
three languages (`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`); each
port carries an `oled_is_actually_black` test that measures the claim (mean cell luminance 0.019
against Violet's 0.254) rather than restating the table.
- **`library-hidden.json`** — per-title hide list, mirroring how `library-scanners.json` holds
disabled sources. Deliberately **not** stored on the entry: a scanner's and a plugin's titles are
rebuilt from scratch on every scan and reconcile, so a flag written onto one would be erased
minutes later. Applied in `all_games`, the single funnel every play surface already goes through
(client grid, native clients, the GameStream app list, launch resolution).
### gamescope patches
Three → six, and the marker patch moves last so the banner is stamped after the capabilities it
advertises.
- **0003 — headless: advertise the virtual display's mode and refresh rates.** `CHeadlessConnector`
returned empty spans from `GetModes()` and `GetValidDynamicRefreshRates()` and reported
`GAMESCOPE_SCREEN_TYPE_INTERNAL`, so `update_mode_atoms` **deleted** the mode-list atom and
wlserver fell through to a one-entry refresh list built from `g_nOutputRefresh` — which, with
`--nested-refresh` absent, is `Init()`'s 60 Hz default. That is why a 1920x1080@120 client saw
"gamescope only shows 60hz" and Overwatch capped itself to 60 while the stream ran at 120. Now
populates both from the resolved mode, reports `EXTERNAL`, and adds `--custom-refresh-rates`.
gamescope-session-plus has probed for that flag for years; upstream never had it, so the
`CUSTOM_REFRESH_RATES` env it plumbs was a no-op everywhere.
- **0004 — pipewire: optionally composite the external overlay into the capture stream.** That layer
is mangoapp. `paint_pipewire` has never referenced it on any version. Behind
`--pipewire-composite-external-overlay`, off by default.
- **0006 — never destroy the Vulkan device or output.** `g_device` (`CVulkanDevice`) and `g_output`
(`VulkanOutput_t`) were plain globals, so glibc ran their destructors from `__run_exit_handlers`
once `main()` returned — calling back into an ICD that had already been torn down and unloaded.
Faulting address equalling the instruction pointer is the signature. Reproducible with
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true` (exit 139, every
time). Both globals get storage constructed exactly as before but never destroyed; pinning only
the device relocated the fault into `~VulkanOutput_t`, hence a shared `CNoDestroy<T>`.
**`+pfhdrN` deliberately does not move for 0006.** The marker is a capability tier the host
probes via `gamescope_patch_level()` *before* it spawns; this patch adds no capability, so bumping
it would advertise a tier that does not exist. Ships as a `pkgrel` bump instead.
⚠ gamescope CI legs are best-effort — a broken patch is a **missing package**, not a red run.
### Virtual-display handle ownership (Windows)
The control-device sharing contract was "bare `HANDLE` copies, never closed for the process
lifetime": retired handles were kept alive because pinger/linger threads and capture closures held
raw copies whose soundness depended on no-close. An open control handle is exactly what vetoes the
PnP disable — and can wedge the `pnputil` restart — that wake-from-sleep recovery leans on, so every
post-wake adapter reload came back REFUSED. `reset-pf-vdisplay.ps1` stops the whole host service
precisely to get those handles closed; the in-process recovery could not.
Ownership is now `Arc` all the way out: `ensure_device` / `device_handle` / `control_device_handle`
hand out `Arc<OwnedHandle>` clones, every consumer holds its clone across its IOCTLs (ending the
`isize` smuggling — `Arc<OwnedHandle>` is `Send + Sync`), and retiring drops only the manager's
reference. `DeviceSlot::retired` is gone.
**Nothing may store a bare control `HANDLE` again.** The whole fix is that the handle closes when
the last in-flight user drains.
### Presenter — points are not pixels
`SDL_GetDesktopDisplayMode` reports a mode in **screen coordinates** and hands the pixels-per-point
ratio back separately as `pixel_density`; `m.w`/`m.h` were read raw. KDE advertises a 2560x1600 panel
at 150 % as 1707x1067 points with a density of ~1.4997, `render_scale::apply` even-floors both odd
axes, and 1706x1066 went on the wire. Multiplying by the density recovers 2560x1600 to the pixel.
⚠ Inert on X11 and Windows: SDL never sets a density there and `SDL_video.c` normalizes the unset
0.0 to 1.0. **This bug needed a compositor doing fractional scaling.**
Second, independent defect: the SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland
surface stayed at buffer scale 1 and the swapchain was built at 1707x1067 for KWin to upscale. That
one also silently shrank "Match window", which asks the host for `size_in_pixels()`.
### Apple audio session
`micEnabled` and `echoCancel` both default to `true`, so the **default** iOS session is
`.playAndRecord` — and that branch set `.defaultToSpeaker`. That option is an output **override**,
not a preference, and it outranks an A2DP route. ⚠ **Wired headphones beat it, Bluetooth does not**,
so testing with a cable returns the wrong answer — which is what the comment sitting on it asserted.
Now solved against the route actually given: after activation, if the current output is
`.builtInReceiver`, override to speaker; anything external (Bluetooth, wired, CarPlay, AirPlay) is
left strictly alone. The override is a property of the current route — iOS drops it on every route
change, which is what lets a newly-connected headset win — so it is re-applied per route via an
observer, registered only for `.playAndRecord`, removed in `stop()` before deactivate, `deinit` as
backstop. Without it, dropping Bluetooth mid-stream lands on the earpiece.
⚠ Deliberately **not** adding `.allowBluetooth`: it would make a headset's mic usable but drag the
whole route onto HFP/SCO and collapse game audio to narrowband.
### Audio jitter policy
`JitterPolicy` (`punktfunk-core/src/audio.rs`, used by Linux/Windows/Android) and its mirror in
Swift `AudioRing`. The policy learned exclusively from audible failures on both sides: growth needed
**three** audible underruns; the A/V sync loop re-tested a shallower ring every five quiet seconds
and paid an audible starvation event every time it was wrong, forever; and a grown target was never
re-banked (growth raises a threshold — only a re-prime deepens the ring), so a bunching link rode
the knife edge with the "grown" target sitting inert.
Three mechanisms: **near-miss** (a read served with less than one protocol frame left over is the
same evidence as an underrun, heard by no one — grows one step per window, *before* the click);
**shrink probes** (every shrink armed for 5 s, undone on the spot if answered by an underrun or
near-miss, with a doubling backoff 60 s → 8 min on a failed sync-driven shrink; a surviving probe
resets it); **hollow re-prime** (an underrun while the depth *average* runs more than a step below
target re-primes immediately — the average, not the instant, separates a hollow ring from one late
packet, and it is seeded on prime so a fresh ring is never spuriously hollow).
Measured on a ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, 50 ppm
skew): **~2000 audible events → 9.**
### Plugins, SDK and the runner
- **`category` never shipped.** The console correctly keeps `category: "library"` plugins out of the
nav; the host reported no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it — what shipped did not: `@punktfunk/host` was bumped to 0.1.2 on
2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's 0.1.2 is the
pre-category build. ⚠ **Inert until published.** `serveUi` now reads its own directory entry back
and warns once when a requested category did not land.
- **Local art sync failed on a `file://` disagreement.** `local_art_bytes` decodes a `file://` value
before testing containment; `validate_art_paths` handed the raw value to `Path::new`. Same defect
produced both the unreachable settings and `sync (startup) failed: HostRequestError`.
- **The runner now carries SDK updates.** The copy each installed plugin runs was pinned at install
time, so an SDK fix could never reach it.
- **`bun publish` runs `prepare`, and `prepare` needs bun2nix** — the SDK could not be published at
all. Also fixed: a corrupt committed `bun.lock` in plugin-kit.
- **Decky client update.** `flatpak remote-info punktfunk-origin io.unom.Punktfunk` names no branch;
the remote publishes `stable` **and** `canary`, so the ref is ambiguous and flatpak refuses it —
⚠ one branch being *installed* does not disambiguate, the ambiguity is on the remote. The call
failed on every box, every time, and returned `available=False`, which the panel rendered as good
news. Every query now names the ref in full via `_flatpak_ref()` (no subprocess), carrying the
**scope** too, so a system-wide install is no longer invisible to a check that hardcoded `--user`.
A check that cannot run now reports `client_error`.
### Packaging
- **The `punktfunk` group is created everywhere the udev rule needs it.** `60-punktfunk.rules`
chgrp's the usbip vhci attach/detach nodes to a dedicated group (security review 2026-08-05 M-4:
writing `attach` materialises an arbitrary emulated USB device, so it must not ride on `input`).
**Four of six install paths shipped that rule in 0.25.0 without creating the group** — chgrp
failed, nodes stayed `root:root 0644`, the virtual Deck pad silently never attached, and
`usermod -aG punktfunk` failed outright. Fixed in arch `post_upgrade()` (only `post_install` was
correct, so every box that reached 0.25.0 by `pacman -Syu` missed it), nix (`users.groups.punktfunk`
did not exist), the bazzite sysext (a group is host state and cannot ride an image), and the Steam
Deck scripts. deb and rpm were correct throughout.
- **`punktfunk-gamescope` now builds for RPM and apt**, not Arch only.
- **Arch release-rebuild prune** called a helper that cannot exist in a release rebuild. Together
with the FFmpeg 9 repackage this closes the 0.25.0-1 → 0.25.0-2 episode in the pipeline rather
than by hand.
- **Steam Deck `update.sh` / `install.sh`.** The web step ran `bun install --frozen-lockfile` with
no `--ignore-scripts`, so web's `postinstall` (`bun2nix -o bun.nix`) rewrote a **tracked** file on
every update; the SDK step below it had always passed `--ignore-scripts`, and that asymmetry is
the whole bug. Now `--ignore-scripts` plus an explicit `bun run codegen` — provably equivalent,
since web's `prepare` is literally `"bun run codegen"` and `src/api/gen`, `src/paraglide` and
`src/routeTree.gen.ts` are gitignored. `--pull` restores `web/bun.nix` and `sdk/bun.nix` before
pulling, which is lossless by construction. ⚠ Deliberately **not** `git reset --hard`: `$SRC`
defaults to the operator's own checkout. Also: `web.env` secret hygiene — `chmod 600` sat inside
the create-only branch, so an install set up once and only updated since kept it world-readable.
`packaging/debian/build-web-deb.sh`, `packaging/arch/PKGBUILD` and `packaging/rpm/punktfunk.spec`
still lack `--ignore-scripts` for web — harmless (throwaway build trees), left as follow-up.
### Triage tooling
**`--probe-decode` described a different device from the one that streams.** The RADV
video-decode opt-in sat *after* the `--list-adapters` / `--probe-decode` / `--list-audio` / `--pair`
early exits, so the triage tool never had it. Measured on a Deck, same binary back to back: bare
`--probe-decode` printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue
family advertises VIDEO_DECODE"; with `RADV_PERFTEST=video_decode` in the environment, "YES" and
"H.264, H.265, AV1, VP9". ⚠ **Any Deck triage that consulted it reached the opposite of the truth.**
Hoisted to the top of `run`, ahead of every early exit.
### PyroWave on Linux — Wave 2
The program's own measurement, from patch 0005's header: `encode_gpu_synchronous` goes from ~2 ms
to **1518 ms at 95 % game load**, with the stream frame rate collapsing. PyroWave encodes on the
same shader cores a game saturates; NVENC is immune because it has its own ASIC.
- **PW1 — the GPU-priority lever had never fired on Linux.** The vendored patch requests an elevated
global-priority queue, gated `if (!inherit_info)` — and **only Windows leaves `inherit_info` null**
(`pyrowave_create_device_by_compat`, where Granite builds the device itself). Linux passes its own
create-infos, Granite's `get_existing_create_info()` hands them back, `create_device` takes the
inherit branch, and the whole block is skipped. Now wired natively in `open_inner`'s `DeviceHold`,
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
**Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
nothing.
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
that readlink to any reader whose effective set is not a superset of the target's **permitted**
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
and is opt-in for gamescope-only hosts, which have no such identity check.
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
old layout, plus three `fill_buffer` clears). Two encodes submitted to one queue have **no**
execution dependency in Vulkan — submission order orders the start, not the completion — so N+1's
DWT would overwrite N's wavelet bands while N's block packing still reads them. Content-dependent
and silent. Overlap therefore means two handles alternated, one per slot. ⚠⚠ **The landmine:**
`sequence_count` also lives on `Impl`, and it is the **3-bit** counter stamped into every block
header. Two handles each counting 1,2,3… put 1,1,2,2,3,3… on the wire, and the decoder restarts a
frame only when the value *changes* — so a repeat reads as more blocks of the same frame. Depth is
**still 1**; the handles alternate with one in flight.
- **PW3 — the fence wait moved out of submit.** PyroWave was the one backend waiting its fence inside
`submit`.
- **PW7a — the jumbo leg was dead code.** quinn caps a peer's MTU-discovery search at
`min(MtuDiscoveryConfig::upper_bound, the other side's advertised max_udp_payload_size)`, and
`EndpointConfig::max_udp_payload_size` **defaults to 1472**. Nothing in the repo had ever touched
`EndpointConfig`, so raising the host's probe ceiling could never make discovery settle above 1472
— and the shipped mid-session grow's `settled >= sealed_datagram_bytes(target)` gate was
unreachable on **every path that has ever existed**. Two smaller contributors fixed with it: the
watcher stopped sampling the moment `settled >= 1472`, discarding the very climb the proof needs;
and a session sealed above the 1500-byte default was never checked against the path at all.
The advertisement is raised on the **client** endpoint under the same `jumbo_wire_mtu()` opt-in,
because it is not free: quinn sizes its endpoint receive buffer
`max_udp_payload_size × max_receive_segments × BATCH_SIZE` — on a GRO-capable Linux/Android client
that is ~2.9 MiB at the default and **~18 MiB at jumbo** (47 KiB → 288 KiB on Apple/Windows).
PyroWave is the codec that most wants this: it can never be re-keyed mid-stream (its client parses
chunk-aligned AUs in windows of the `Welcome` value, read once over the C ABI), so it should
*start* at the big shard. At an 8908-byte shard that is ~6× fewer datagrams per frame — **~49k → ~8k
pps at 550 Mb/s**.
### Zero-copy capture
- **The dmabuf latch conflated two causes with different lifetimes.** One `AtomicBool` served both
"the encoder repeatedly failed to import what this compositor allocates" (unrecoverable, a driver
fact) and "the dmabuf-only capture offer never negotiated" (which can just mean the compositor was
mid-restart). Sharing it made the second as permanent as the first: **one timeout, and every later
session on that host captured CPU frames until the process restarted** — including sessions against
a different compositor and a different node that had never failed at anything, with nothing said.
Now a `RawDmabufLatch` owning both: import failures stay sticky (unchanged 3-consecutive threshold);
negotiation timeouts get a retry budget of **2** — deliberately small, since each failure costs a
~10 s stall the user pays in dead air; a capture that negotiates credits the budget back; and both
are keyed to a capture identity (node id + portal bit).
- **The zero-copy path never asked for buffer headroom.** `build_dmabuf_buffers` set
`SPA_PARAM_BUFFERS_dataType` and stopped — no `SPA_PARAM_BUFFERS_buffers` at all, so the pool depth
every zero-copy safety argument rests on was entirely the producer's choice and we never expressed
a preference. Now asks for 8 (min 2, max 16) as a **Choice Range, deliberately not a fixed count**:
SPA intersects consumer and producer params, so a fixed 8 against a producer that can only afford 4
empties the intersection and the link stalls in "negotiating" with no error anywhere — ⚠ the exact
trap that once cost this codebase the entire Linux cursor channel, when a 256² cursor-meta max
failed to intersect Mutter's fixed 384². 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz;
16 is a ceiling, not a request (a 4K 4:4:4 buffer is ~25 MB).
- **A PyroWave session could drop to CPU capture and log nothing.** The CPU-fallback warning was gated
on `backend_is_vaapi`, which reads the **host-global** encoder pref — but a PyroWave session is
negotiated **per session**, so on an NVIDIA/auto host that gate is false and the session fell out of
every arm of the negotiation log chain while paying a full-resolution CPU pixel touch every frame.
A degraded host and a healthy one produced identical logs. Now asks the per-session question
(`consumer_kind`), widened to every GPU consumer and excluding only the software encoder, whose
native input *is* CPU frames. ⚠ `pyrowave_session` must outrank `backend_is_vaapi`, because a
PyroWave pref flips `backend_is_vaapi` on too.
### Steam-overlay input masking (Steam Deck)
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the **same physical controller** the
client forwards, so opening either moved the game on the host as well — a second, invisible player.
Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's
virtual pad and we deliberately forward the **real** one (the virtual pad has no gyro, trackpads or
paddles).
**SDL's own gate cannot fire on a Deck.** SDL drops presses while a process has windows but no
keyboard focus, and it is on by default — but gamescope resolves focus per Xwayland ctx and the client
sits alone in its own, so the Steam overlay (which lives in the root ctx) never takes our X focus and
no `FocusOut` is ever generated. Measured on glass: with the QAM open, X input focus inside the
client's ctx stayed on its window for the whole 4 s while `GAMESCOPE_FOCUSED_APP` flipped to 769
(Steam) and `GAMESCOPE_FOCUSED_APP_GFX` stayed on the app. **That pair of atoms is the signal.**
`overlay_focus` watches them on the gamescope **root** ctx, which is *not* our own `$DISPLAY` under
`--xwayland-count 2` — hence the socket-directory walk and the flatpak filesystem line.
⚠⚠ Masking is deliberately **not** `set_forwarding`: that closes the slot and sends `GamepadRemove`,
so the game would see a controller **unplug** every time somebody opened the QAM. Every slot stays
open and only transitions stop, after flushing what the host believes is held (so a stick deflected at
overlay-open stops steering instead of freezing at its last value). On the way back, held buttons are
**adopted rather than replayed** — the A that picked a QAM row must not fire in the game as it closes
— while axes *are* re-sent, since a stick has no press to ghost and SDL only speaks on change.
### The `plugin` launch kind
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses on the
**first** offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`, stopped
putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind
the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured,
with the core and flags they chose", so that trick does not generalise.
The entry now carries an **opaque key and nothing executable**, and the host asks the owning plugin
what to run at launch time, over the loopback UI port and per-boot secret it already registered.
**A stolen plugin token stops being command execution:** planting an entry is not enough, because
the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to
a client, and an emulator that moved is picked up on the next launch rather than leaving a dead tile
(same reasoning as `xbox` resolving its AUMID at launch time).
**The host still spawns it**, because only the host can put the process where the stream can see it:
on Linux that is either gamescope's own argv or a spawn carrying the session's compositor env, and the
returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the
emulator itself would land it outside both.
### Verification status
| | |
|---|---|
| gamescope 0006 | 6/6 exit 0 on a release build at the real spawn shape (`2752x2064@120 --steam --xwayland-count 1`); distro control SIGSEGVs |
| Decky client update | on the Deck against the real install — pre-fix `available=False remote=''`, post-fix `available=True remote=ca010668` |
| `--probe-decode` | on a Deck, same binary back to back, with and without the RADV opt-in |
| Apple audio | builds on arm64-apple-ios17.0 (the triple that compiles the `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them), arm64-apple-tvos17.0, macOS; 257 Swift tests |
| Audio jitter | 10-minute Wi-Fi power-save simulation, ~2000 → 9 audible events |
| 4:4:4 gate | test verified non-vacuous against the planted original defect |
| Steam Deck scripts | `bash -n` + shellcheck 0.11.0 clean at `-S warning`; exec bits preserved |
| Steam-overlay masking | on glass on a Deck — atom flip and X-focus non-flip both measured over a 4 s QAM open |
| PyroWave depth 2 | exercised on real hardware **without shipping depth 2** (dedicated test, shipped depth stays 1) |
| PW6 streamed AU | the trap is real, and at 2 % loss it costs exactly nothing |
**Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
---
## v0.25.0
407 commits since v0.24.0.
@@ -79,6 +829,20 @@ capability rode on `input`, which every gamepad guide tells users to join — bu
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
attaching. Ordinary virtual gamepads are unaffected.
> **Known issue in 0.25.0, fixed after it.** Four of the six install paths shipped
> `60-punktfunk.rules` — whose `RUN+=` does `chgrp punktfunk` on the vhci `attach`/`detach` nodes —
> without ever creating the group, so the `chgrp` failed, the nodes stayed root-only, and the pad
> silently never attached. The `usermod` above also fails outright on those boxes with *group
> 'punktfunk' does not exist*. Affected: **Arch/CachyOS upgraded** rather than freshly installed
> (`post_upgrade` called only `_ensure_update_group`), the **NixOS module** (no
> `users.groups.punktfunk`), the **Bazzite sysext** (a group is host state and cannot ride an
> image), and **Steam Deck source installs** (`scripts/steamdeck/install.sh`/`update.sh` handled
> only `input`). The deb and rpm scriptlets were correct throughout — they run one `%post`/`postinst`
> on install and upgrade alike. All four now create the group, and the two that know which user
> runs the host (the Deck scripts and the NixOS module's `host.users`) add that user to it as well.
> Workaround on an unpatched box:
> `sudo groupadd --system punktfunk`, then the `usermod`, then re-login.
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
@@ -437,6 +1201,24 @@ refuses the upgrade instead of bricking the install. All seven libs are listed e
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
the release tag was pushed four minutes later, while the CI builder image was still being
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
time and could not disagree with itself this way.
Two guards now stand where only a convention did. arch.yml compares the builder's libav
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
cheap path; the guards are the backstop.
### Linux playback filled the buffer ceiling
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
Generated
+47 -35
View File
@@ -994,7 +994,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2358,7 +2358,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.25.0"
version = "0.27.0"
[[package]]
name = "lazy_static"
@@ -2463,7 +2463,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"punktfunk-core",
]
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3047,11 +3047,12 @@ dependencies = [
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
"x11rb",
]
[[package]]
name = "pf-clipboard"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3069,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3090,7 +3091,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3100,7 +3101,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3117,6 +3118,8 @@ dependencies = [
"pf-zerocopy",
"punktfunk-core",
"pyrowave-sys",
"serde",
"serde_json",
"tracing",
"tracing-subscriber",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -3124,7 +3127,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"libc",
@@ -3136,7 +3139,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3150,11 +3153,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.25.0"
version = "0.27.0"
[[package]]
name = "pf-inject"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3183,14 +3186,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3205,7 +3208,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"serde",
"serde_json",
@@ -3213,7 +3216,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"base64",
@@ -3225,7 +3228,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3234,7 +3237,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3267,7 +3270,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3278,7 +3281,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3290,7 +3293,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ash",
@@ -3513,7 +3516,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3524,7 +3527,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"android_logger",
"jni",
@@ -3542,7 +3545,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3559,7 +3562,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3574,7 +3577,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3593,7 +3596,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3623,9 +3626,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "punktfunk-encode-worker"
version = "0.27.0"
dependencies = [
"pf-encode",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "punktfunk-host"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3710,7 +3722,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3724,7 +3736,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"anyhow",
"ksni",
@@ -3747,7 +3759,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.25.0"
version = "0.27.0"
dependencies = [
"bindgen",
"cmake",
+9 -1
View File
@@ -4,6 +4,9 @@ members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
"crates/punktfunk-encode-worker",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
@@ -46,6 +49,11 @@ members = [
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
"tools/hid-descriptor-dump",
"tools/win-input-matrix",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
@@ -57,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.25.0"
version = "0.27.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+125 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.24.0"
"version": "0.25.0"
},
"paths": {
"/api/v1/clients": {
@@ -997,7 +997,7 @@
"library"
],
"summary": "List the game library",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).\n\n**The operator's own lane additionally sees the titles they have HIDDEN**, each carrying\n`hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The\nconsole needs them to offer \"un-hide\", and it is the only surface that does.",
"operationId": "getLibrary",
"parameters": [
{
@@ -1021,13 +1021,13 @@
],
"responses": {
"200": {
"description": "Unified library across all stores",
"description": "Unified library across all stores (the operator's lane also gets hidden entries, flagged)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GameEntry"
"$ref": "#/components/schemas/OperatorGameEntry"
}
}
}
@@ -1301,6 +1301,79 @@
}
}
},
"/api/v1/library/hidden/{id}": {
"put": {
"tags": [
"library"
],
"summary": "Hide or un-hide one library title",
"description": "Curation, not access control: a hidden title disappears from every play surface — the console\ngrid on a client, native clients, the GameStream app list, and launch resolution — while nothing\nis deleted and un-hiding restores it immediately. The operator's own console still lists it\n(flagged `hidden`) so it can be brought back.\n\nKeyed by the entry's stable `<store>:<external_id>` id, which survives re-scans and reconciles by\nconstruction (D2). The id is **not** validated against the current library on purpose: a title\ncan be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted),\nand refusing the operator's choice in that window would be worse than storing an id that\ncurrently matches nothing. Emits `library.changed` (source = the store) only on a real change.",
"operationId": "setLibraryEntryHidden",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The library entry id (e.g. `steam:70`)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenToggle"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Stored; the entry's visibility after the call",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HiddenState"
}
}
}
},
"400": {
"description": "Empty entry id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/provider/{provider}": {
"put": {
"tags": [
@@ -5553,6 +5626,37 @@
}
}
},
"HiddenState": {
"type": "object",
"description": "What `setLibraryEntryHidden` echoes back.",
"required": [
"id",
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Its visibility after the call."
},
"id": {
"type": "string",
"description": "The entry id the call addressed."
}
}
},
"HiddenToggle": {
"type": "object",
"description": "Request body for `setLibraryEntryHidden`.",
"required": [
"hidden"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "Whether this title should be hidden from every play surface."
}
}
},
"HookEntry": {
"type": "object",
"description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs.",
@@ -6339,6 +6443,23 @@
}
}
},
"OperatorGameEntry": {
"allOf": [
{
"$ref": "#/components/schemas/GameEntry"
},
{
"type": "object",
"properties": {
"hidden": {
"type": "boolean",
"description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden."
}
}
}
],
"description": "A library entry plus the operator's own view of it — today, whether they hid it.\n\nA separate type rather than a field on [`GameEntry`] for two reasons. It keeps the visibility\nanswer out of the providers entirely: a store parser has no opinion on what the operator hid, and\nadding `hidden: false` to all eight construction sites would imply it does. More importantly it\nmakes the lane rule a TYPE guarantee instead of a discipline — `GET /library` answers\n`Vec<GameEntry>` on every lane but the operator's, so a hidden entry cannot leak to a paired\nclient by someone forgetting a filter; there is no field there to leak.\n\n`flatten` keeps the wire shape identical to a plain entry with one extra key, so the console\nparses one model either way."
},
"PairedClient": {
"type": "object",
"description": "A paired (certificate-pinned) Moonlight client.",
+10
View File
@@ -19,6 +19,16 @@
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
#
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
# backstop, not the plan.
FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
val gamepadUi = gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
@@ -67,7 +67,7 @@ class GamepadPalette(
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
*/
val ALL = listOf(
@@ -77,6 +77,26 @@ class GamepadPalette(
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
// Named for the look, not the panel technology — black with a thin violet corona
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
// choices and desync the clients.
"oled", "Eclipse",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
Triple(0.120, 0.024, 0.130),
),
ground = Triple(0.0, 0.0, 0.0),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
) + listOfNotNull(
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
// this very screen and the row under the cursor would otherwise be one deciding nothing,
// on a screen that is itself about to disappear.
if (s.gamepadUiEnabled) {
choice(
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
"With a controller: the touch interface comes back when the last one " +
"disconnects. Always keeps this layout either way — for a device that lives " +
"docked to a TV. A TV itself is always in this mode regardless.",
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
) { update(s.copy(gamepadUiMode = it)) }
} else {
null
},
)
}
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
* the switch meant when it was a lone Boolean.
*/
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
/**
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
* always awake.
*/
const val GAMEPAD_UI_ALWAYS = "always"
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
* the only input, so it's always the console UI (as long as the setting is on), which is why the
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
* client wrote can never strand this one in a layout it has no way back out of.
*/
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
fun gamepadUiActive(
enabled: Boolean,
mode: String,
controllerConnected: Boolean,
tv: Boolean,
forced: Boolean,
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
@@ -94,11 +94,20 @@ data class Settings(
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
* is on, which is why both settings screens hide the row when the switch is off. Anything
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
*/
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
@@ -107,9 +116,10 @@ data class Settings(
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
* client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing — a newer client may have shipped
* a palette this build doesn't know.
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
)
}
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
val GAMEPAD_UI_MODE_OPTIONS = listOf(
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
GAMEPAD_UI_ALWAYS to "Always",
)
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
subtitle = "Swap the touch home for the console home — the host carousel and " +
"gamepad chrome. A TV always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
// Only decides anything while the switch above is on, so it is HIDDEN rather than
// dimmed when it isn't — a picker whose every option changes nothing is worse than
// no picker, and this group is short enough that nothing jumps far.
if (s.gamepadUiEnabled) {
SettingDropdown(
label = "Show it",
options = GAMEPAD_UI_MODE_OPTIONS,
selected = s.gamepadUiMode,
caption = "With a controller: the touch home comes back when the last one " +
"disconnects. Always keeps the console home either way — for a device " +
"that lives docked to a TV.",
) { v -> update(s.copy(gamepadUiMode = v)) }
}
}
}
}
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertEquals(7, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
}
}
/**
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
* not merely the darkest of the dark fields. The blob field this client draws samples the
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
*/
@Test
fun oledIsActuallyBlack() {
val oled = GamepadPalette.named("oled")
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
val darkestOther = GamepadPalette.ALL
.filter { it.id != "oled" && it.stops.isNotEmpty() }
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
/**
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
* be one last thing to step past on the way out.
*/
@Test
fun `the activation-mode row follows the switch it belongs to`() {
fun ids(enabled: Boolean) = buildSettingsRows(
Settings(gamepadUiEnabled = enabled),
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) {}.map { it.id }
val on = ids(enabled = true)
assertTrue("the mode row is missing", "gamepadUIMode" in on)
assertEquals(
"the mode belongs directly under the switch it qualifies",
on.indexOf("gamepadUI") + 1,
on.indexOf("gamepadUIMode"),
)
val off = ids(enabled = false)
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
}
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
@Test
fun `the activation-mode row steps the shared key`() {
var s = Settings()
fun mode() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }.first { it.id == "gamepadUIMode" }
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertEquals("With a controller", mode().value)
assertFalse("already the first = thud", mode().adjust(-1))
assertTrue(mode().adjust(1))
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
// A from the last entry wraps home.
mode().activate()
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
}
}
@@ -0,0 +1,53 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
* disagreement here is a device that behaves differently from the same setting.
*/
class GamepadUiTest {
/** The default mode is what the switch meant when it was a lone Boolean. */
@Test
fun whenConnectedWaitsForAPad() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
// A TV is in console mode whatever the mode says — its remote is the only input.
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
}
/** Always drops the controller from the decision — but never the switch, which is the one
* way back to the touch UI. */
@Test
fun alwaysIgnoresThePadButNotTheSwitch() {
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
}
/** A value a newer client wrote waits for a pad rather than stranding this build in a
* layout it has no way back out of. */
@Test
fun anUnknownModeWaitsForAPad() {
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
}
/** The shipped default: the console UI still waits for a controller. */
@Test
fun theDefaultIsUnchangedBehaviour() {
val s = Settings()
assertTrue(s.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
}
}
@@ -77,6 +77,7 @@ class ProfilesTest {
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
+13
View File
@@ -0,0 +1,13 @@
{
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
}
],
"version" : 2
}
+17 -1
View File
@@ -16,6 +16,17 @@ let package = Package(
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
],
dependencies: [
// Progressive (gradient) backdrop blur for the form screens' trays a real blur with no
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
// GlurBackdrop product exists only on main no release carries it (the newest tag,
// `1.1`, predates it, and is not three-component semver anyway, so version-based
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
// pin stays reproducible when the branch moves.
.package(
url: "https://github.com/joogps/Glur.git",
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
],
targets: [
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
// No dependencies by design an extension process links this alone.
@@ -51,7 +62,12 @@ let package = Package(
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
.executableTarget(
name: "PunktfunkClient",
dependencies: [
"PunktfunkKit",
.product(name: "GlurBackdrop", package: "Glur"),
]),
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
.testTarget(
@@ -11,6 +11,12 @@
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -88,6 +94,8 @@
buildActionMask = 2147483647;
files = (
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000012 /* Glur in Frameworks */,
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -96,6 +104,8 @@
buildActionMask = 2147483647;
files = (
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000014 /* Glur in Frameworks */,
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -105,6 +115,8 @@
files = (
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
EE0000000000000000000016 /* Glur in Frameworks */,
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -175,6 +187,8 @@
name = Punktfunk;
packageProductDependencies = (
AA0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000002 /* Glur */,
EE0000000000000000000003 /* GlurBackdrop */,
);
productName = Punktfunk;
productReference = AA0000000000000000000001 /* Punktfunk.app */;
@@ -201,6 +215,8 @@
name = "Punktfunk-iOS";
packageProductDependencies = (
BB0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000004 /* Glur */,
EE0000000000000000000005 /* GlurBackdrop */,
);
productName = "Punktfunk-iOS";
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
@@ -226,6 +242,8 @@
packageProductDependencies = (
CC0000000000000000000006 /* PunktfunkKit */,
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
EE0000000000000000000006 /* Glur */,
EE0000000000000000000007 /* GlurBackdrop */,
);
productName = "Punktfunk-tvOS";
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
@@ -283,6 +301,7 @@
packageReferences = (
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000008 /* Products */;
@@ -848,6 +867,14 @@
minimumVersion = 0.18.0;
};
};
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/joogps/Glur.git";
requirement = {
kind = revision;
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -868,6 +895,36 @@
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions;
};
EE0000000000000000000002 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000003 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000004 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000005 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000006 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000007 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
@@ -1,6 +1,14 @@
{
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
},
{
"identity" : "objc-runtime-tools",
"kind" : "remoteSourceControl",
@@ -0,0 +1,202 @@
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
// a saved host in the widget's configuration (long-press Edit Widget the picker is
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
// deep-links into that host's game library via `punktfunk://browse/<uuid>` the app's onOpenURL
// routes it to the same library presentation every internal surface drives. No session starts
// until a title is picked there.
//
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
// leads with). A configured host that no longer exists shows the empty state rather than silently
// following a different host a widget that says "Studio" must never open someone else's library.
//
// Timeline is a single `.never` entry the app pushes reloads on store changes (HostStore
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
import AppIntents
import SwiftUI
import WidgetKit
import PunktfunkShared
// MARK: - Configuration intent
/// The widget's per-instance configuration. Executes in the EXTENSION process which is why
/// `HostEntity` and its query live in PunktfunkShared, not the app.
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Choose Host"
static let description = IntentDescription("Pick whose game library this widget opens.")
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
var host: HostEntity?
}
// MARK: - Timeline
struct LibraryEntry: TimelineEntry {
let date: Date
/// The resolved target: the configured host if it still exists, the most recent one when
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
let host: StoredHost?
}
struct LibraryProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> LibraryEntry {
LibraryEntry(date: .now, host: nil)
}
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> LibraryEntry {
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
}
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> Timeline<LibraryEntry> {
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
policy: .never)
}
/// The configured host by id nil (NOT a fallback) when it's gone; most-recent when nothing
/// was configured.
static func resolve(_ configured: HostEntity?) -> StoredHost? {
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
guard let configured else { return hosts.first }
return hosts.first { $0.id == configured.id }
}
}
// MARK: - Widget
struct LibraryWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
provider: LibraryProvider()
) { entry in
LibraryWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Game Library")
.description("Jump straight into a host's game library.")
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Views
/// Deep link that opens a stored host's library.
private func browseURL(_ host: StoredHost) -> URL {
DeepLink.browse(host: host.id).url
}
struct LibraryWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: LibraryEntry
var body: some View {
switch family {
case .accessoryCircular:
CircularLibraryView(host: entry.host)
case .accessoryRectangular:
RectangularLibraryView(host: entry.host)
default: // systemSmall + fallback
SmallLibraryView(host: entry.host)
}
}
}
private struct SmallLibraryView: View {
let host: StoredHost?
var body: some View {
if let host {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "square.grid.2x2.fill")
.font(.title2)
.foregroundStyle(Color.brand)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
.lineLimit(2)
Text("Game Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(browseURL(host))
} else {
EmptyLibraryView()
}
}
}
private struct CircularLibraryView: View {
let host: StoredHost?
var body: some View {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "square.grid.2x2.fill")
}
.widgetURL(host.map(browseURL))
}
}
private struct RectangularLibraryView: View {
let host: StoredHost?
var body: some View {
HStack {
Image(systemName: "square.grid.2x2.fill")
VStack(alignment: .leading) {
Text(host?.displayName ?? "Punktfunk")
.lineLimit(1)
Text("Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.widgetURL(host.map(browseURL))
}
}
private struct EmptyLibraryView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "square.grid.2x2")
.font(.title2)
.foregroundStyle(.secondary)
Text("Open Punktfunk to pick a host.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Previews (Xcode canvas)
//
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
// so the canvas works without a paired device or saved hosts. The small preview's second entry
// shows the empty state one timeline click away.
private let previewHost = StoredHost(
name: "Studio", address: "192.168.1.20",
lastConnected: .now.addingTimeInterval(-40 * 60))
#Preview("Small", as: .systemSmall) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
LibraryEntry(date: .now, host: nil)
}
#Preview("Lock Screen circular", as: .accessoryCircular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
@@ -15,6 +15,7 @@ import WidgetKit
struct PunktfunkWidgetBundle: WidgetBundle {
var body: some Widget {
HostsWidget()
LibraryWidget()
PunktfunkSessionLiveActivity()
}
}
@@ -56,6 +56,12 @@ struct ContentView: View {
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@State private var liveActivity = SessionActivityController()
/// The window's bottom safe-area inset (the home-indicator strip), reported by
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
/// HERE and read through the environment because asking UIKit for it during a body severs
/// the asking view's updates on device (see the probe).
@State private var displayBottomInset: CGFloat = 0
#endif
@State private var pairingTarget: StoredHost?
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
@@ -99,6 +105,10 @@ struct ContentView: View {
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over "connected" (default) or "always". See
/// `GamepadUIEnvironment`.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@@ -111,9 +121,27 @@ struct ContentView: View {
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers resign-active
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier for this window, published from HERE the app's root.
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
/// every one of them (including the ones presented as sheets and covers, which inherit the
/// environment) read its own window's tier instead of the bare default.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
// The body is split in two `driven` (the screen plus its lifecycle drivers and sheets) and
@@ -176,6 +204,36 @@ struct ContentView: View {
}
private var driven: some View {
drivenBase
.environment(\.gamepadMetrics, gamepadMetrics)
#if os(iOS)
.environment(\.displayBottomInset, displayBottomInset)
// The probe is UIKit's, not any screen's: mounted once here as a background so the
// legend-pinning screens can READ the inset from the environment without ever asking
// UIKit during their own body (which severs their updates see the probe).
.background {
DisplayBottomInsetProbe { displayBottomInset = $0 }
}
#endif
#if os(iOS) || os(macOS)
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
// renders only while `model.connection == nil`: a connection exists through the
// pair-required and approval handshakes, which is precisely when these prompts fire.
// It sits above the connect takeover too the delegated-approval wait is raised
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
// never poll the pad at once.)
.overlay {
if let prompt = consolePrompt {
GamepadPromptView(prompt: prompt)
.gamepadPaletteInk()
.transition(.opacity)
}
}
#endif
}
private var drivenBase: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
@@ -363,8 +421,21 @@ struct ContentView: View {
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
.sheet(item: $pairingTarget) { host in
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
// instead there the pair screen is one of the shell's in-place layers, exactly like
// settings and add-host (see `touchPairingTarget`).
.sheet(item: touchPairingTarget) { host in
#if os(macOS)
if gamepadUIActive {
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
.frame(width: 660, height: 620)
} else {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
}
#else
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
#endif
}
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
@@ -404,9 +475,102 @@ struct ContentView: View {
// budget (inline, they tip SwiftUI's per-expression limit see the split sections idiom).
private var deepLinkNoticePresented: Binding<Bool> {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
Binding(
get: { deepLinkNotice != nil && !consolePromptShowing },
set: { if !$0 { deepLinkNotice = nil } })
}
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
/// tvOS, whose alerts the focus engine drives natively.
private var consolePromptShowing: Bool {
#if os(iOS) || os(macOS)
consolePrompt != nil
#else
false
#endif
}
#if os(iOS) || os(macOS)
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
/// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a
/// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a
/// connect that may never complete. One at a time, most-urgent first a system alert stack
/// would layer these, but a console shows one screen.
///
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
/// the one case that must stay with the system alert there the pad belongs to
/// `GamepadCapture` and is being forwarded to the host.
private var consolePrompt: GamepadPrompt? {
guard gamepadUIActive, model.phase != .streaming else { return nil }
if let req = approvalChoice {
return GamepadPrompt(
id: "pairing-required",
title: "Pairing required",
message: "\(req.host.displayName) requires pairing. Request access and approve "
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
+ "needed. Or pair with the 4-digit PIN it can display.",
actions: [
// The follow-on presentation is deferred a tick exactly as the system dialog
// does it, so this prompt is fully torn down before the next screen mounts
// two controller pollers overlapping for a frame is how one A press reaches
// both.
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
approvalChoice = nil
DispatchQueue.main.async { requestAccess(req) }
},
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
approvalChoice = nil
DispatchQueue.main.async { pairingTarget = req.host }
},
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
approvalChoice = nil
},
])
}
if let req = awaitingApproval {
return GamepadPrompt(
id: "awaiting-approval",
title: "Waiting for approval",
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
+ "web console (port 47992 → Pairing). This device connects automatically "
+ "once you approve it — no need to reconnect.",
actions: [
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
awaitingApproval = nil
model.disconnect()
},
],
busy: true)
}
if connectionErrorReady {
return GamepadPrompt(
id: "connection-failed",
title: "Connection failed",
message: model.errorMessage ?? "",
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
model.errorMessage = nil
},
])
}
if let notice = deepLinkNotice {
return GamepadPrompt(
id: "cant-open",
title: "Can't open",
message: notice,
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
deepLinkNotice = nil
},
])
}
return nil
}
#endif
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
@@ -415,19 +579,37 @@ struct ContentView: View {
set: { libraryTarget = $0 })
}
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
/// in place the same proxy the library uses, and for the same reason: every writer keeps
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
private var touchPairingTarget: Binding<StoredHost?> {
#if os(macOS)
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
#else
Binding(
get: { gamepadUIActive ? nil : pairingTarget },
set: { pairingTarget = $0 })
#endif
}
private var approvalChoicePresented: Binding<Bool> {
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
Binding(
get: { approvalChoice != nil && !consolePromptShowing },
set: { if !$0 { approvalChoice = nil } })
}
private var awaitingApprovalPresented: Binding<Bool> {
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
Binding(
get: { awaitingApproval != nil && !consolePromptShowing },
set: { if !$0 { awaitingApproval = nil } })
}
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: {
guard model.errorMessage != nil else { return false }
#if os(macOS)
/// Whether the "Connection failed" state is ready to be shown at all shared by the system
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
private var connectionErrorReady: Bool {
guard model.errorMessage != nil else { return false }
#if os(macOS)
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
// presenting it now strands the window fullscreen on the home screen after a
@@ -436,10 +618,14 @@ struct ContentView: View {
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenForSession && isFullscreen { return false }
#endif
return true
},
if fullscreenForSession && isFullscreen { return false }
#endif
return true
}
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: { connectionErrorReady && !consolePromptShowing },
set: { if !$0 { model.errorMessage = nil } })
}
@@ -482,10 +668,20 @@ struct ContentView: View {
?? "That link is malformed and was ignored."
return
}
guard link.route == .connect else {
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
// neither, and saying so beats silently connecting instead.
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
switch link.route {
case .connect:
break
case .browse:
// The reserved library route, now real: open the host's game library without starting
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
// title is picked, and that connect resolves its own profile) ignored, not refused,
// per the unknown-parameter rule.
openLibrary(from: link)
return
case .wake:
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
// the Wake Host intent, which never round-trips through a URL.)
deepLinkNotice = "Punktfunk links can't do “wake” yet."
return
}
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
@@ -539,6 +735,38 @@ struct ContentView: View {
}
}
/// `punktfunk://browse/<host-ref>` jump into a host's game library. Drives the SAME
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
/// refuses, a live session is never preempted, and an unsaved host can't be browsed the
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
return
}
guard model.phase == .idle else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
case .ambiguous:
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
+ "Rename one, or link to it by its address."
case .unresolvable:
deepLinkNotice = "That host isn't saved on this device."
}
}
private var home: some View {
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
// still `home`, so it covers the whole dial wake online connect sequence): instant
@@ -571,9 +799,11 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
launchTitle: launchTitle,
promptActive: consolePromptShowing)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -588,9 +818,11 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
launchTitle: launchTitle,
promptActive: consolePromptShowing)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
@@ -901,6 +1133,7 @@ struct ContentView: View {
private var shortcutHintText: String {
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
}
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
@@ -13,6 +13,8 @@ import SwiftUI
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
@@ -48,7 +50,7 @@ struct GamepadAddHostView: View {
isActive: controllerActive && editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -61,25 +63,28 @@ struct GamepadAddHostView: View {
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayScrim(edge: .bottom) }
.background { GamepadTrayBlur(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
@@ -148,17 +153,28 @@ struct GamepadAddHostView: View {
// binding on appear new identity forces a rewire to the new field.
.id(editing)
GamepadHintBar(hints: [
// "Type" names what A does to the key under the keyboard's cursor. There is
// no tap equivalent a touch user types by tapping the keycap itself so
// this one cell stays a label.
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -191,7 +207,7 @@ struct GamepadAddHostView: View {
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
@@ -268,6 +284,15 @@ struct GamepadAddHostView: View {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
/// reaching into its state to trigger it would need a whole callback channel for one edit.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
private func editingBinding(_ id: String) -> Binding<String> {
switch id {
case "name": return $name
@@ -191,6 +191,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// A hardware keyboard drives the same cursor as the pad arrows step, Return activates,
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
// focus engine instead, which owns navigation there.
.gamepadKeyNavigation(
active: isActive,
onMove: { move($0) },
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -432,8 +442,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
/// itself only grows, since it is already facing you. Each card carries its own delay (see
/// `entrance(_:)`) that stagger is what makes the strip read as one gesture instead of a
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
/// speaks, so the arrival and the scrolling feel like one object.
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
/// arrival and the scrolling feel like one object.
///
/// APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
@@ -442,6 +452,23 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// collapsed into its focused look as the entrance ended arriving as a jump. Underneath, the
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
///
/// NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
/// offscreen layer, and a card carries translucent glass, which resolves differently in there
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
///
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
/// moved, then stepped to #423970 (23 blue) in one. It is the ANIMATION ending, not the motion:
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
/// launcher appeared the same 0.32 s before the end both times. Removing the rotation removed
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
///
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
/// so no offscreen pass and no layer to drop and it reads as the same gesture, losing only the
/// perspective trapezoid, which at these card sizes was never what sold the motion.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping and the tvOS
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
@@ -485,14 +512,15 @@ struct CardEntrance: ViewModifier, Animatable {
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
// it instead of swinging the opposite way.
let away = reduceMotion ? 0 : 1 - travel
// The turn, projected rather than rendered in 3D see the type's note on the flash.
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
// on the edge the card fans from restores the direction that the rotation's sign carried
// (cos is even, so the sign alone would read the same both ways).
let turn = cos(Angle.degrees(64 * away).radians)
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.rotation3DEffect(
.degrees(side * -64 * away),
axis: (x: 0, y: 1, z: 0),
anchor: .center,
perspective: 0.65)
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
.offset(y: 34 * away)
}
@@ -5,20 +5,36 @@
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
import Glur
import GlurBackdrop
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense , )
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
/// (`GamepadManager.lastKnownKind` `GamepadGlyphs`), else the caller's generic fallback.
///
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
/// runs flat and permanently under `gamepadUIMode == "always"`, which puts the console UI up
/// with no pad by design and the fallbacks are letter glyphs, so a DualSense user's / legends
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
/// `GamepadButtonRole` names.
///
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
@MainActor
func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
let manager = GamepadManager.shared
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
return live
}
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
}
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
@@ -68,43 +84,251 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// GamepadAddHostView) one set of numbers so the screens read as the same surface, at the size
/// the screen they are on calls for.
///
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
/// a settings list at iPhone scale in the middle of a 13" display the field verdict was that the
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
///
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
/// Manager or Split View window correctly gets the in-hand numbers the window is what the user
/// is reading, not the panel it sits on.
struct GamepadFormMetrics {
/// Which set this is, for the few things that are a KIND of layout rather than a number.
enum Tier { case phone, pad, tv }
let tier: Tier
let headerFont: CGFloat
let labelFont: CGFloat
let valueFont: CGFloat
let iconFont: CGFloat
let iconWidth: CGFloat
let chevronFont: CGFloat
let rowHPad: CGFloat
let rowVPad: CGFloat
let rowCorner: CGFloat
let rowMaxWidth: CGFloat
let detailFont: CGFloat
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
let bandWidth: CGFloat
/// The settings screen's section-tab pills.
let tabFont: CGFloat
/// The pinned controls legend (GamepadHintBar).
let hintGlyphFont: CGFloat
let hintTextFont: CGFloat
let hintPad: CGFloat
/// In-hand: a phone, or any window narrow enough to read like one.
static let phone = GamepadFormMetrics(
tier: .phone,
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
detailFont: 13, bandWidth: 240,
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
/// A tablet-sized window an arm's length away rather than in the palm.
static let pad = GamepadFormMetrics(
tier: .pad,
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
detailFont: 16, bandWidth: 320,
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
/// 10-foot.
static let tv = GamepadFormMetrics(
tier: .tv,
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
detailFont: 19, bandWidth: 380,
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
/// What a screen gets before anything publishes a tier and the only tier tvOS and macOS ever
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
static var platformDefault: GamepadFormMetrics {
#if os(tvOS)
tv
#else
phone
#endif
}
#if os(iOS)
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
static func forWindow(
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
) -> GamepadFormMetrics {
h == .regular && v == .regular ? .pad : .phone
}
#endif
}
private struct GamepadMetricsKey: EnvironmentKey {
static let defaultValue = GamepadFormMetrics.platformDefault
}
extension EnvironmentValues {
/// The form metrics for the screen currently drawing. Published from ContentView the app
/// ROOT rather than only from `gamepadPaletteInk`, because a screen that applies that
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
/// it would read the bare default instead of its own window's tier.
var gamepadMetrics: GamepadFormMetrics {
get { self[GamepadMetricsKey.self] }
set { self[GamepadMetricsKey.self] = newValue }
}
}
private struct DisplayBottomInsetKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
/// The display's bottom safe-area inset the home-indicator strip measured by
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
/// macOS/tvOS, where nothing publishes it.
var displayBottomInset: CGFloat {
get { self[DisplayBottomInsetKey.self] }
set { self[DisplayBottomInsetKey.self] = newValue }
}
}
#if os(iOS)
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks never
/// during a SwiftUI render.
///
/// This number has a history of wrong spellings, each failing silently:
/// - a `GeometryReader` carrying `.ignoresSafeArea()` a proxy reports NO insets for an edge it
/// has been told to ignore, so that spelling can only ever answer 0;
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
/// move content the inset mechanism itself placed; and
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes`) DURING body,
/// which answered correctly and then KILLED the calling view: on an iPad (never the
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
/// silently severed every later `@State` write lands in storage without ever re-running
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
/// answer hops out of the current update before anyone in SwiftUI reads it.
struct DisplayBottomInsetProbe: UIViewRepresentable {
let onChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ProbeView {
let view = ProbeView()
view.onChange = onChange
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ view: ProbeView, context: Context) {
view.onChange = onChange
}
final class ProbeView: UIView {
var onChange: ((CGFloat) -> Void)?
private var last: CGFloat?
override func didMoveToWindow() {
super.didMoveToWindow()
report()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
report()
}
// Rotation reshuffles the window's insets without necessarily touching this view's own.
override func layoutSubviews() {
super.layoutSubviews()
report()
}
private func report() {
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
// own inset is 0 the number the legend needs is the strip the window reserves.
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
last = bottom
let onChange = onChange
// Out of the current UIKit/SwiftUI update before any state write.
DispatchQueue.main.async { onChange?(bottom) }
}
}
}
#endif
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
/// as it sits from the leading edge so it lands on the diagonal of the display's rounded corner,
/// which is what the corner asks for.
///
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
/// bar in the middle, so the two never meet.
///
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
///
/// `displayBottom` is `\.displayBottomInset` measured by `DisplayBottomInsetProbe`, NEVER asked
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
/// view's updates (see the probe's comment). Pure arithmetic only.
func gamepadLegendBottomPadding(
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
) -> CGFloat {
guard tier == .pad else { return margin }
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
return max(-displayBottom, margin - displayBottom)
}
/// The tray gradient blur, back as a real progressive BACKDROP blur this time.
///
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
/// (`GlurBackdrop`) the public `.glur()` modifier is a shader on a view's own content and
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
///
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
struct GamepadTrayBlur: View {
let edge: VerticalEdge
var body: some View {
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
// in the open field is touched which is why, unlike the scrim, this band takes NO
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
// strength lands at 60% of the band, so the tray's own text always sits on the strong
// region while the ramp still reads as a gradient, not an edge.
GlurView(
radius: 14, offset: 0, interpolation: 0.6,
direction: edge == .top ? .up : .down)
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which reads as a visible pop. 80 pt clears every inset on every
// device, and backgrounds never clip the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
let glyph: String
let text: String
/// What tapping/clicking this cell does the same thing its button does. Optional because a
/// few legend cells NAME an input rather than an action (" Adjust" is the stick itself;
/// there is no single thing a tap on it could mean), and those stay inert labels.
var action: (() -> Void)? = nil
var id: String { glyph + text }
}
@@ -114,39 +338,75 @@ struct GamepadHint: Identifiable {
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
/// Sized with the screen it pins to a legend at phone scale on a 13" iPad is the same
/// mismatch the form rows had (see GamepadFormMetrics).
@Environment(\.gamepadMetrics) private var metrics
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View {
HStack(spacing: 18) {
ForEach(hints) { hint in
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
cell(hint)
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.padding(Self.pad)
.padding(metrics.hintPad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
// The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
// Spelled out rather than left to defaults, because a swallowed touch in this bar is
// invisible the legend simply stops doing anything.
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
}
/// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
/// comment for why tvOS is always the latter).
@ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
#if os(tvOS)
label(hint)
#else
if let action = hint.action {
Button(action: action) { label(hint) }
.buttonStyle(HintCellStyle())
.accessibilityLabel(hint.text)
} else {
label(hint)
}
#endif
}
private func label(_ hint: GamepadHint) -> some View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: metrics.hintGlyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
// The tappable area covers the gap between glyph and label, not just their painted
// pixels a legend cell is small enough already.
.contentShape(Rectangle())
}
}
#if !os(tvOS)
/// Press feedback for a legend cell. Deliberately quiet the bar is chrome, and a cell that lit
/// up like a primary button would pull the eye off the content it describes.
///
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
/// animation that shrinks the artwork must never move the target out from under a resting finger,
/// or the touch-up lands outside and SwiftUI discards the tap.
private struct HintCellStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.55 : 1)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
.contentShape(Rectangle())
}
}
#endif
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids finished with an
@@ -220,14 +480,20 @@ struct GamepadScreenBackground: View {
colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
// lerps toward it, so this layer alone IS the whole calm mix.
.opacity(1 - 0.4 * calmMix)
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
// Mounted unconditionally at opacity 0 a plusLighter layer contributes nothing,
// and an always-present layer is what lets the mix animate instead of popping.
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
// come down to meet its ground rather than merely fading toward it.
//
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
// already mixed 0.4 toward that same ground saturated the form screens to flat white
// the field ask was "in bright mode the sub-screens are basically just white". Written
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
// animating instead of popping when a screen is pushed.
Self.color(palette.ground)
.opacity(0.4 * calmMix)
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
.blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
@@ -363,59 +629,6 @@ private struct LegacyBlobField: View {
}
}
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
/// render interleaved. Pure blur a dark material faded out by a gradient mask, no dark tint so
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Sink the material's grey luminance lift toward the palette's shade (black on a
// dark field field ask: the frost read GREY over the aurora). Inside the mask, so
// the tint dissolves with the blur.
.overlay(ink.shade(0.35))
// Fade the whole blur out toward the content so it dissolves rather than ending on a
// line. The strong region sits deep (0.65) because the first stretch of the gradient
// now runs over the fixed 80 pt outer overhang below.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the strong part, rows blur out before they reach it. The bottom
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
// the field verdict on the short reach was rows colliding visibly with the legend.
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which is exactly the pop the field kept seeing vertically first,
// then, once the vertical runway became padding, on the X axis alone (the landscape
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
// so the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
@@ -65,10 +65,24 @@ private struct HomeTile: Identifiable {
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
/// ContentView an environment READ is safe in body; asking UIKit for it here is not (see
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
@Environment(\.displayBottomInset) private var displayBottomInset
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
/// one thing a console-UI user simply could not do. See GamepadPairView.
@Binding var pairingTarget: StoredHost?
/// Pin the verified fingerprint and connect ContentView's `handlePaired`.
let onPaired: (StoredHost, Data) -> Void
/// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker
@@ -77,6 +91,11 @@ struct GamepadHomeView: View {
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
/// modal and a single A press reaches both.
var promptActive = false
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@@ -213,19 +232,35 @@ struct GamepadHomeView: View {
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
legend
}
}
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
/// the DISPLAY see `gamepadLegendBottomPadding` for why the bottom number is not simply the
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
/// worked.
private var legend: some View {
GamepadHintBar(hints: hints)
.padding(.leading, legendMargin)
.padding(
.bottom,
gamepadLegendBottomPadding(
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 4 : 8)
}
/// The legend pill's distance from the screen's leading and bottom edges.
private var legendMargin: CGFloat { compact ? 12 : 18 }
#if os(iOS)
/// The screen the shell shows over the launcher derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
// it has to win. Backing out of it reveals whatever it interrupted.
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
@@ -248,6 +283,12 @@ struct GamepadHomeView: View {
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .pair(let host):
GamepadPairView(
host: host,
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
@@ -294,11 +335,14 @@ struct GamepadHomeView: View {
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning
topScreen == nil && !transitioning && !promptActive
&& waker.waking == nil && model.phase != .connecting
#else
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
// either way the launcher underneath must stop consuming the pad the pair screen's own
// list is polling the same controller.
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
&& !promptActive && waker.waking == nil && model.phase != .connecting
#endif
}
@@ -412,13 +456,22 @@ struct GamepadHomeView: View {
case .rescan: "Rescan"
default: nil
}
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
// the same frame as a carousel move would otherwise activate the tile that was selected a
// moment ago the one failure mode a launcher cannot afford.
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
action: { tiles.first { $0.id == selection }?.activate() })]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
hints.append(.init(
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
action: { openLibraryForSelected() }))
}
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
hints.append(.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
action: { showSettings = true }))
return hints
}
@@ -573,11 +626,21 @@ private struct GamepadHostTile: View {
}
.padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
// Console tile a brand wash marks a saved host as primary; discovered / Add-Host tiles
// stay neutral with a dashed edge. The surface clips to the shape itself.
//
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
// animates `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
// that under a 3D transform, so it drew one way through the swing and snapped to another
// as the card landed on glass it read as the tiles being swapped out for different ones
// at the end of their entrance. A material composites flat, so the card looks the same at
// every frame of the travel. (tvOS already takes this path for its own reasons.)
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil)
tint: tile.filled ? ink.accent(0.20) : nil,
forceMaterial: true)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
@@ -85,16 +85,40 @@ extension EnvironmentValues {
}
extension View {
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
/// gamepad screens' common root so no individual view has to read the setting.
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
/// Resolve the stored `ui_palette` and publish its ink AND the matching colour scheme to
/// everything below. Applied by the gamepad screens' common root so no individual view has to
/// read the setting.
///
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
/// palette's scheme would invert their own system colours instead of matching them.
func gamepadPaletteInk(_ active: Bool = true) -> some View {
modifier(GamepadInkModifier(active: active))
}
}
private struct GamepadInkModifier: ViewModifier {
var active = true
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
/// The ambient scheme from ABOVE this modifier what gets republished unchanged when the
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
/// that would change this view's identity.
@Environment(\.colorScheme) private var systemScheme
func body(content: Content) -> some View {
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
let palette = GamepadPalette.named(paletteID)
return content
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
// screens `.secondary` in a placeholder, a `.bordered` button's chrome, a
// NavigationStack's title, a material's frost resolves against the DEVICE's
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
// the interface was unreadable. Publishing the scheme here once, beside the ink it
// has to agree with is what makes a pale palette mean "light" to UIKit too.
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
}
}
@@ -0,0 +1,79 @@
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
// activate, Esc backs out.
//
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
// for a screen driven from a distance with a fixed set of directional inputs and the whole
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
// keyboard is just a third input onto it, alongside the pad poll and touch.
//
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
// there, and these screens hand it navigation authority on purpose.
//
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
// exactly these screens because the gamepad UI has no system text fields to steal it from
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
extension View {
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
///
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
/// to something on top must not keep eating key presses either, or a covered launcher
/// navigates behind the screen in front of it.
func gamepadKeyNavigation(
active: Bool = true,
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
onConfirm: @escaping () -> Void,
onBack: (() -> Void)? = nil
) -> some View {
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
}
}
private struct GamepadKeyNav: ViewModifier {
let active: Bool
let onMove: (GamepadMenuInput.Direction) -> Void
let onConfirm: () -> Void
let onBack: (() -> Void)?
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focusable(active)
// No focus ring: these screens draw their own cursor (the centred card, the focused
// row), and a system ring around the whole scroll view on top of it reads as a bug.
.focusEffectDisabled()
.focused($focused)
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
// again a pushed screen popping off leaves the one underneath unfocused.
.onAppear { focused = active }
.onChange(of: active) { _, nowActive in
if nowActive { focused = true }
}
.onKeyPress(.upArrow) { handle { onMove(.up) } }
.onKeyPress(.downArrow) { handle { onMove(.down) } }
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
.onKeyPress(.return) { handle(onConfirm) }
.onKeyPress(.space) { handle(onConfirm) }
.onKeyPress(.escape) {
guard let onBack else { return .ignored }
return handle(onBack)
}
}
/// Run a handler only while this screen owns input, and report back whether the press was
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
private func handle(_ action: () -> Void) -> KeyPress.Result {
guard active else { return .ignored }
action()
return .handled
}
}
#endif
@@ -38,7 +38,7 @@ struct GamepadLibraryScreen: View {
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
// A hardware keyboard's Esc still closes, without chrome.
.background {
@@ -119,6 +119,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
.sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
// value (exactly what the stick does), Return activates, Esc backs out.
.gamepadKeyNavigation(
active: isActive,
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left: adjust(by: -1)
case .right: adjust(by: 1)
}
},
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -0,0 +1,230 @@
// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
//
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
// through their buttons or press one so on iOS/macOS every prompt in the connect path was a dead
// end for a pad-only user, and they are not incidental prompts:
//
// - "Pairing required" (Request Access / Pair with PIN) is the FIRST thing an unpaired host
// shows. Pairing was unreachable before it even got to the PIN.
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
//
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
// reason this gap was tvOS-invisible.
//
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
// unknown length, wrong for two buttons in a card, where it would need an invented height and
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
/// One choice in a console prompt.
struct GamepadPromptAction: Identifiable {
let id: String
let title: String
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
/// action should carry it `GamepadPrompt` falls back to the LAST action when none does,
/// which matches how a system alert treats its cancel role.
var isCancel = false
/// Drawn as the primary, accent-tinted row. At most one.
var isPrimary = false
let run: () -> Void
}
/// A prompt to show over the console UI: what happened, and what can be done about it.
struct GamepadPrompt: Identifiable {
let id: String
let title: String
let message: String
let actions: [GamepadPromptAction]
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title
/// the prompt is the UI for something still in flight, not a report that it finished.
var busy = false
}
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
/// actions, and the same legend every other gamepad screen carries.
struct GamepadPromptView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
let prompt: GamepadPrompt
@State private var cursor = 0
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// `.sensoryFeedback` counters device ticks for confirm and for a refused move at an end.
@State private var activateTick = 0
@State private var boundaryTick = 0
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
var body: some View {
ZStack {
// Swallows touch to the launcher behind it, which is also gated out of the controller
// poll for as long as this is up (ContentView's `promptActive`).
Rectangle()
.fill(.black.opacity(0.55))
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
.gamepadKeyNavigation(
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left, .right: break
}
},
onConfirm: { activate() },
onBack: { back() })
.onAppear {
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
wire()
input.start()
}
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
// than rely on a remount: the stored closures captured the OLD actions array.
.onChange(of: prompt.actions.map(\.id)) { _, _ in
cursor = min(cursor, max(prompt.actions.count - 1, 0))
wire()
}
.onDisappear {
input.stop()
haptics.stop()
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
if prompt.busy {
ProgressView().controlSize(.small).tint(ink.fg(0.8))
}
Text(prompt.title)
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
.foregroundStyle(ink.fg)
}
Text(prompt.message)
.font(.geist(metrics.detailFont, relativeTo: .callout))
.foregroundStyle(ink.fg(0.62))
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 6) {
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
actionRow(action, focused: idx == cursor)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
}
}
.padding(.top, 2)
GamepadHintBar(hints: hints)
}
.padding(compact ? 20 : 26)
.frame(maxWidth: 460)
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.padding(24)
}
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
let m = metrics
return Text(action.title)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
.frame(maxWidth: .infinity)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
private var hints: [GamepadHint] {
var hints: [GamepadHint] = [.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { activate() })]
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
// action, and B does it too naming it twice would just be noise.
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
action: { back() }))
}
return hints
}
// MARK: - Input
private func wire() {
input.onMove = { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
// silently treating them as up/down would make a nudged stick pick a different button.
case .left, .right: break
}
}
input.onConfirm = { activate() }
input.onBack = { back() }
}
private func step(by delta: Int) {
let target = cursor + delta
guard target >= 0, target < prompt.actions.count else {
boundaryTick &+= 1
haptics.boundary()
return
}
cursor = target
haptics.move()
}
private func activate() {
guard cursor >= 0, cursor < prompt.actions.count else { return }
activateTick &+= 1
haptics.confirm()
prompt.actions[cursor].run()
}
/// B: the cancel action, else the last one the same fallback a system alert applies when
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
private func back() {
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
else { return }
activateTick &+= 1
haptics.confirm()
action.run()
}
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
private func tap(_ idx: Int) {
guard idx >= 0, idx < prompt.actions.count else { return }
cursor = idx
activate()
}
}
#endif
@@ -21,12 +21,14 @@ import SwiftUI
enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
}
}
@@ -35,7 +37,7 @@ enum GamepadScreen: Identifiable {
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
var isForm: Bool {
switch self {
case .settings, .addHost: return true
case .settings, .addHost, .pair: return true
case .library: return false
}
}
@@ -204,13 +204,19 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if onLaunch != nil {
if let onLaunch {
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
hints.append(.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
// Reads `selection` when it fires, not when the legend was built (see the
// launcher's twin) and does nothing with no title centred, which is exactly
// what A does.
action: { if let id = selection { onLaunch(id) } }))
}
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
action: { onDismiss?() }))
return hints
}
}
@@ -27,14 +27,24 @@ struct LibraryView: View {
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
/// they didn't ask for.
@State private var keyCursor: String?
@State private var gridWidth: CGFloat = 0
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
#endif
@@ -78,6 +88,16 @@ struct LibraryView: View {
}
}
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Published HERE, not just inside the coverflow, because the coverflow is only one of
// four things this view renders: the loading spinner, the error state and the empty
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
// directly in a NavigationStack, so under a pale palette every one of them kept the
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
// UI isn't drawing the plain grid belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
#endif
}
@ViewBuilder private var content: some View {
@@ -107,34 +127,103 @@ struct LibraryView: View {
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
return ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
.padding()
#if os(iOS) || os(macOS)
// The grid's own width, reported without affecting layout a GeometryReader
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
// measured where that cursor exists.
.background {
GeometryReader { geo in
Color.clear
.onAppear { gridWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in gridWidth = w }
}
}
#endif
}
.padding()
#if os(iOS) || os(macOS)
// Hardware keyboard: arrows pick a title, Return launches it a field ask from an
// iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the
// controller all along; this is the same thing for the plain grid, which is what an
// iPad with a keyboard and NO pad actually sees.
.gamepadKeyNavigation(
active: onLaunch != nil,
onMove: { direction in
guard let next = gridNav(launchers: launchers, titles: titles)
.move(from: keyCursor, direction) else { return }
keyCursor = next
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
},
onConfirm: {
guard let onLaunch, let id = keyCursor else { return }
onLaunch(id)
})
#endif
}
}
#if os(iOS) || os(macOS)
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
/// sections so it can never point into a stale list.
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
LibraryGridNav(
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
columns: columnCount)
}
/// How many columns `.adaptive(minimum:spacing:)` fits into the measured width the same
/// arithmetic the layout does, so up/down move exactly one visual row rather than a guess.
/// Falls back to one column before the first measurement lands.
private var columnCount: Int {
let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS
let spacing: CGFloat = 18
// The VStack's `.padding()` is inside the measured width, so take it back off.
let usable = gridWidth - 32
guard usable > 0 else { return 1 }
return max(1, Int((usable + spacing) / (minimum + spacing)))
}
#endif
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
.buttonStyle(.plain)
Button { onLaunch(game.id) } label: {
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
}
.buttonStyle(.plain)
.id(game.id)
} else {
GameCard(game: game, artLoader: artLoader)
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
.id(game.id)
}
}
}
}
/// Whether the keyboard cursor is on this tile (always false where there is no keyboard
/// navigation to have moved it).
private func isKeyCursor(_ game: GameEntry) -> Bool {
#if os(iOS) || os(macOS)
keyCursor == game.id
#else
false
#endif
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
@@ -251,6 +340,9 @@ private struct LibraryBackCatcher: View {
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
/// The hardware-keyboard cursor is on this tile drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -258,6 +350,12 @@ private struct GameCard: View {
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
if selected {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.tint, lineWidth: 3)
}
}
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
@@ -1,7 +1,8 @@
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
// Deliberately thin: every action already has an internal entry point the deep-link router
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
// the existing Wake-on-LAN path so these intents only wrap them.
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
// library), the in-process end-session hook, and the existing Wake-on-LAN path so these
// intents only wrap them.
//
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
@@ -51,6 +52,28 @@ struct ConnectToHostIntent: AppIntent {
}
}
/// Jump straight into a host's game library no session. Foregrounds the app and routes the
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
/// `libraryTarget` every surface shares so the shortcut lands in whichever library presentation
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
/// touch/desktop library otherwise. A session starts only when a title is picked there.
struct OpenLibraryIntent: AppIntent {
static let title: LocalizedStringResource = "Open Game Library"
static let description = IntentDescription(
"Open a host's game library in Punktfunk, without starting a stream.")
static let openAppWhenRun = true
@Parameter(title: "Host") var host: HostEntity
func perform() async throws -> some IntentResult {
let url = DeepLink.browse(host: host.id).url
await MainActor.run {
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
}
return .result()
}
}
/// Wake a sleeping host (magic packet). No `openAppWhenRun` usable in automations ("when I get
/// home, wake the tower") without foregrounding the app.
struct WakeHostIntent: AppIntent {
@@ -97,6 +120,13 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
"Stream \(\.$host) with \(.applicationName)",
],
shortTitle: "Connect", systemImageName: "play.tv.fill")
AppShortcut(
intent: OpenLibraryIntent(),
phrases: [
"Open \(\.$host) library in \(.applicationName)",
"Show \(\.$host) games in \(.applicationName)",
],
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
AppShortcut(
intent: WakeHostIntent(),
phrases: [
@@ -43,10 +43,27 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
/// an iPad capture renders every gamepad screen at iPhone scale a capture that doesn't look
/// like the app.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
var body: some View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.environment(\.gamepadMetrics, gamepadMetrics)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
@@ -242,7 +242,8 @@ private struct ShotGamepadHome: View {
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
}
}
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
} else {
ShotHome()
@@ -54,6 +54,15 @@ struct AcknowledgementsView: View {
Divider()
Text("Swift packages")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("Punktfunk uses Glur (progressive backdrop blur), "
+ "© 2023 João Gabriel, under the MIT License.")
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
Divider()
Text("Third-party software")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
rotation: drumPosition,
target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
radius: width * 0.72)
radius: width * 0.72,
width: width)
}
}
.frame(width: width)
.clipped()
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
.mask {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.12),
.init(color: .black, location: 0.88),
.init(color: .clear, location: 1),
],
startPoint: .leading, endPoint: .trailing)
}
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
// mask RASTERISES what it covers which flattens `rotation3DEffect`'s perspective, so the
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
// always right, and the mask was throwing the projection away every frame.
//
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
// which costs nothing and leaves the projection intact.
.onChange(of: selection) { old, new in step(from: old, to: new) }
// The options list itself can mutate under the drum (a custom resolution appears, a
// controller connects, the buffer options re-derive from a new refresh rate) re-seat
@@ -131,6 +129,9 @@ private struct Drum: View, Animatable {
let target: Double
/// Drum radius in points (from the band width see the caller).
let radius: Double
/// The band's own width the stage the options turn on, and what the edge fade is measured
/// against now that the container no longer carries a mask.
let width: Double
var animatableData: Double {
get { rotation }
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
/// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0
// Neighbours exist only while the drum is MOVING, and that is not a compromise it is the
// documented field fix this file was written around. Showing them at rest was tried (to make a
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
//
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
// away every frame, so the turn never looked like a turn.
var body: some View {
let flight = min(1, abs(rotation - target) * 3)
let content = ZStack {
@@ -147,14 +159,18 @@ private struct Drum: View, Animatable {
// Plain signed distance the band is linear, so option i has ONE home and the
// ends are the ends (nothing waits beyond the last option).
let d = Double(i) - rotation
// Only the facing option at rest; its neighbours join it for the travel (see the
// note on `restingNeighbour`'s removal above).
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
option(i, distance: d, gate: flight)
}
}
}
#if os(tvOS)
// Flatten the transform stack while travelling the 10-foot GPU already made these
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
// Flatten the transform stack the 10-foot GPU already made these rows drop Liquid
// Glass, and several projected texts per step is the same class of cost. It costs the
// projection (a rasterised layer has no perspective), which is the trade tvOS already
// makes elsewhere on this screen.
content.drawingGroup()
#else
content
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle
let depth = cos(angle)
let x = radius * sin(angle)
// The facing option never gates: a resting row still shows its value.
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
Text(options[i])
.lineLimit(1)
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
.scaleEffect(0.70 + 0.30 * depth)
// Foreshorten the label as it turns away this is what sells the cylinder.
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
.offset(x: radius * sin(angle))
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
.offset(x: x)
.opacity(alpha)
.zIndex(depth)
}
/// The soft edge, per option, replacing the container mask that used to flatten the
/// projection: full strength through the middle of the band, dissolving to nothing by the
/// time an option reaches its rim, so the drum never ends on a cut.
private func edgeFade(_ x: Double) -> Double {
let halfWidth = width / 2
guard halfWidth > 0 else { return 1 }
let fadeStart = halfWidth * 0.55
guard abs(x) > fadeStart else { return 1 }
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
}
}
#endif
@@ -46,6 +46,8 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
@@ -81,6 +83,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// When the switch above takes over the row is only built while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background colour family the backdrop BEHIND this screen re-colours as
/// the row steps, which is why the picker lives here and not in a sheet.
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -139,7 +144,7 @@ struct GamepadSettingsView: View {
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -158,12 +163,12 @@ struct GamepadSettingsView: View {
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
@@ -172,10 +177,13 @@ struct GamepadSettingsView: View {
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(.bottom, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
.background { GamepadTrayBlur(edge: .bottom) }
}
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
@@ -251,10 +259,18 @@ struct GamepadSettingsView: View {
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
// `onAccent`, not `fg` the selected pill is FILLED with the palette accent, and
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
// a light grey (luma 0.80), so its selected tab was unreadable.
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
// Proportional to the row metrics rather than fixed, so the strip grows with the
// fields under it a tab bar at phone scale above iPad-scale rows was half the
// "does not adapt to larger screens" complaint.
.padding(.horizontal, metrics.rowHPad * 0.8)
.padding(.vertical, metrics.rowVPad * 0.55)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did. A Liquid Glass
@@ -325,26 +341,39 @@ struct GamepadSettingsView: View {
// shoulders exist at all (see `showsSectionHint`).
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section")]
text: "Section", action: { step(tabBy: 1) })]
: []
// 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 sections
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
+ [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() })]
}
return sections + [
// The stick itself, not an action nothing to tap (see GamepadHint.action).
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() }),
]
}
guard !store.hosts.isEmpty else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
return [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() })]
}
return [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() }),
]
}
@@ -362,7 +391,7 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
return VStack(alignment: .leading, spacing: 6) {
@@ -440,9 +469,9 @@ struct GamepadSettingsView: View {
/// narrows the stage.
private var bandWidth: CGFloat {
#if os(iOS)
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
#else
GamepadFormMetrics.bandWidth
metrics.bandWidth
#endif
}
@@ -659,6 +688,21 @@ struct GamepadSettingsView: View {
detail: "Turn off to use the touch interface even with a controller connected.",
value: $gamepadUIEnabled),
]
// WHEN the switch above takes over. Built only while it is on: with the switch off this
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
// that decides nothing would exist purely to be found in a screenshot.
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
list.insert(
choiceRow(
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
label: "Show it",
detail: "With a controller: the touch interface comes back when the last one "
+ "disconnects. Always keeps this layout either way — for a device that "
+ "lives on a TV.",
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
) { gamepadUIMode = $0 },
at: at + 1)
}
#if os(macOS)
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
// the Video tab) macOS only, mirroring the touch SettingsView's Presentation row
@@ -707,6 +751,14 @@ struct GamepadSettingsView: View {
at: anchor + 1)
}
#endif
// The smoothness buffer only decides anything under Smoothness. Every other settings
// surface touch, tvOS, the GTK and WinUI shells hides it under Lowest latency; this
// screen alone left it live and steppable, which is a row that thuds or silently stores
// a value nothing reads. Removed here rather than omitted from the literal above so the
// macOS safe-present insertion can still anchor on it.
if presentPriority != "smooth" {
list.removeAll { $0.id == "smoothBuffer" }
}
return list + profileRows
}
@@ -53,6 +53,14 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) only meaningful while
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
/// is off rather than showing a picker that decides nothing.
static let gamepadUIModes: [(label: String, tag: String)] = [
("With a controller", GamepadUIEnvironment.modeWhenConnected),
("Always", GamepadUIEnvironment.modeAlways),
]
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
@@ -724,11 +724,24 @@ extension SettingsView {
#endif
#if !os(tvOS)
if !inProfileScope {
described("With a controller connected, the host list and library switch to a "
+ "controller-friendly layout — larger focus targets, a swipeable cover "
+ "browser.") {
described("The host list and library switch to a controller-friendly layout — "
+ "larger focus targets, a swipeable cover browser.") {
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
}
// Only meaningful while the switch above is on, so it is HIDDEN rather than
// disabled when it isn't: a picker whose every option decides nothing is worse
// than no picker, and this Section is short enough that nothing jumps far.
if gamepadUIEnabled {
described("With a controller: the touch interface comes back when the last "
+ "one disconnects. Always keeps the controller-friendly layout either "
+ "way — for a device that lives on a TV.") {
Picker("Show it", selection: $gamepadUIMode) {
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
}
}
#endif
#if DEBUG && !os(tvOS)
@@ -75,6 +75,13 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
/// When the switch above takes over read (and shown) only while it is on.
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
GamepadUIEnvironment.modeWhenConnected
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) every other
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
/// controller cannot open.
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
@@ -488,6 +495,22 @@ struct SettingsView: View {
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
// Hidden while the switch above is off see the touch settings' identical gate.
if gamepadUIEnabled {
TVSelectionRow(
title: "Show it",
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
// Background row lives on the gamepad settings screen, which is reached from
// the gamepad launcher and on tvOS that launcher needs an extended-profile
// controller, so an Apple TV driven by the Siri Remote alone could not reach
// the palettes at all. It belongs beside "Show it" because both describe the
// same interface: this row is what that interface looks like once it is up.
TVSelectionRow(
title: "Background",
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
selection: $uiPalette)
}
tvCaption(Self.controllersFooter)
NavigationLink("About") { AboutView() }
.padding(.top, 8)
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
}
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
/// `.never`-refresh entries and relies on this push.
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
/// `.never`-refresh entries and rely on this push.
private func reloadHostsWidget() {
#if canImport(WidgetKit) && os(iOS)
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
#endif
}
}
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
var interactive = false
/// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get
/// transformed while they animate: glass samples the backdrop through its own layer, and under
/// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to
/// another the instant the transform ends on glass that reads as the tile being SWAPPED for a
/// different one as it lands. A material is a flat composite and looks identical throughout.
var forceMaterial = false
/// The console surface follows the background palette: a PALE field needs the material to
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
@@ -95,24 +101,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
func body(content: Content) -> some View {
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
// against the device appearance which is how the pale palettes came out light-on-light
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
// tvOS and pre-26 branches were the odd ones out.
#if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The wash and tint ride overlays two flat fills, no GPU cost.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content.background {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
@@ -120,6 +121,31 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
#else
if #available(iOS 26, macOS 26, *), !forceMaterial {
content
// The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass
// value is opaque to SwiftUI's animation system: changing its tint swaps one
// effect for another, which is why a focused row's accent used to appear (and,
// worse, disappear a beat late) as a hard jump while the row's scale animated
// smoothly beside it. A plain fill interpolates, so `.animation(value: focused)`
// at the call site now covers the whole row. Sits between the glass and the
// content: `.background` is behind the label, `glassEffect` behind both.
.background { shape.fill(tint ?? .clear) }
.glassEffect(glass, in: shape)
.environment(\.colorScheme, scheme)
} else {
content
.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
.environment(\.colorScheme, scheme)
}
#endif
}
@@ -127,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass {
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
// it: mixed 60 % toward the caller's (the focused row must still read accented on
// every palette) over the palette base. If device QA finds the mixed focus wash too
// weak, the escape hatch is `tint ?? wash` today's focused look, bit for bit.
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
var g: Glass = .regular.tint(
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
// The glass carries the PALETTE wash only the caller's focus tint is an animatable fill
// above it now (see `body`).
//
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
// over `.regular` which is already a bright, high-body material even a light white
// wash lands as a flat white slab: the refraction and the blurred field behind stop
// reading entirely, which is the "opaque fully white bg" on every row, pill and legend.
// Lowering the tint alone did NOT fix it, because the opacity was coming from the glass
// BODY rather than from the tint. `.clear` is the variant meant for exactly this a
// surface over content that must stay visible through it and a small white wash on top
// of it is enough to keep the dark ink legible without closing the surface up.
let wash = ink.glass(ink.isLight ? 0.18 : 0.45)
// Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members
// gives the compiler no base type to infer from.
var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash)
if interactive { g = g.interactive() }
return g
}
@@ -144,8 +178,13 @@ extension View {
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
/// pre-26 both washed with the palette's own glass colour, both frosting to the palette's
/// scheme. Pass the surface's shape explicitly glass defaults to a Capsule.
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
///
/// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property.
func consoleGlass<S: Shape>(
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
) -> some View {
modifier(ConsoleGlass(
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
}
}
@@ -173,11 +212,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
in: shape)
.environment(\.colorScheme, scheme)
} else {
content.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
content
.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
.environment(\.colorScheme, scheme)
}
}
}
@@ -0,0 +1,328 @@
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) the controller counterpart of
// PairSheet, and the reason a console-UI user can pair at all.
//
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
// underneath so for anyone in the console UI, pairing (the ONE thing standing between a fresh
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
//
// Structure deliberately mirrors GamepadAddHostView field for field the two screens are the same
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadPairView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let host: StoredHost
/// Called with the verified host fingerprint after a successful ceremony the caller pins it
/// and connects (ContentView's `handlePaired`).
let onPaired: (Data) -> Void
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
/// environment dismiss.
var close: (() -> Void)?
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadAddHostView's twin).
var controllerActive = true
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
#endif
@StateObject private var ceremony = PairCeremony()
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
var body: some View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { performClose() },
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
// background thread and its result rewrites this screen, so letting B peel a layer
// or A fire a second ceremony underneath it would race the completion.
isActive: controllerActive && editing == nil && !ceremony.busy
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see
// GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayBlur(edge: .bottom) }
}
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
.onChange(of: pin) { _, value in
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
}
// Any dismissal path abandons an in-flight ceremony a late success must not pin and
// connect to a host the user backed out of.
.onDisappear { ceremony.abandon() }
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
private static let maxPINLength = 12
private var header: some View {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Pair with \(host.displayName)")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if !compact {
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
}
/// The keyboard tray while editing, the status line + controls legend otherwise.
@ViewBuilder private var bottomTray: some View {
if let editing {
VStack(spacing: 10) {
GamepadKeyboard(
text: editingBinding(editing),
allowed: allowedCharacters(editing),
onDone: { closeKeyboard() })
// Fresh keyboard per field (see GamepadAddHostView) the tray's input wiring
// captured the previous binding on appear.
.id(editing)
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
VStack(alignment: .leading, spacing: 8) {
statusLine
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
])
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
/// its space so the legend never jumps when a failure arrives.
@ViewBuilder private var statusLine: some View {
Group {
if ceremony.busy {
HStack(spacing: 8) {
ProgressView().controlSize(.small).tint(ink.fg(0.7))
Text("Pairing with \(host.displayName)").foregroundStyle(ink.fg(0.7))
}
} else if let error = ceremony.errorText {
Text(error).foregroundStyle(.red)
} else {
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
Text(" ").foregroundStyle(.clear)
}
}
.font(.geist(metrics.detailFont, relativeTo: .caption))
.lineLimit(2, reservesSpace: true)
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet.
private func performClose() {
ceremony.abandon()
if let close { close() } else { dismiss() }
}
// MARK: - Rows
private struct Row: Identifiable {
let id: String
let label: String
var value = ""
var placeholder = ""
var isAction = false
}
private var rows: [Row] {
[
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
Row(
id: "name", label: "Device name", value: clientName,
placeholder: "How the host lists this device"),
Row(id: "pair", label: "Pair & Connect", isAction: true),
]
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Pair & Connect", systemImage: "lock.shield")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long name visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(ink.accent)
.frame(width: 2, height: m.labelFont + 2)
}
}
}
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
// MARK: - Actions
private func activate(id: String) {
guard !ceremony.busy else { return }
switch id {
case "pair":
guard canPair else {
// Not pairable yet jump straight to what's missing instead of a dead press,
// matching the add-host screen's Add row.
focusID = "pin"
openKeyboard("pin")
return
}
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
fingerprint in
onPaired(fingerprint)
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
// success. Closing is all that's left to do.
if let close { close() } else { dismiss() }
}
default:
openKeyboard(id)
}
}
private var canPair: Bool {
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
}
private func openKeyboard(_ id: String) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
}
private func closeKeyboard() {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
private func editingBinding(_ id: String) -> Binding<String> {
id == "pin" ? $pin : $clientName
}
/// The legend's Delete cell see GamepadAddHostView's twin for why this edits the binding
/// rather than reaching into the keyboard.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
private func allowedCharacters(_ id: String) -> CharacterSet? {
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
}
}
#endif
@@ -0,0 +1,86 @@
// The SPAKE2 PIN ceremony itself, with no opinion about how it's presented. Two screens run it:
// `PairSheet` (the touch/desktop Form, and tvOS's focus-engine layout) and `GamepadPairView` (the
// controller-driven console screen). The ceremony is the part that must not diverge between them
// it decides what counts as a wrong PIN, what a rejection means, and which failures are worth
// telling the user apart so it lives here once rather than being copied into the second caller.
//
// Threading: `pair()` and the identity load both BLOCK, so they run off the main actor; every
// published mutation lands back on it.
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class PairCeremony: ObservableObject {
/// A ceremony is in flight callers disable their commit action and show a spinner.
@Published private(set) var busy = false
/// The last failure, in user-facing terms; cleared when a new attempt starts.
@Published var errorText: String?
/// Dismissing the presenting screen must abandon an in-flight ceremony: the blocking `pair()`
/// call can't be interrupted, so its completion checks this token and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. A fresh token
/// per attempt, so abandoning one attempt can't silence the next.
private var token = Token()
private final class Token: @unchecked Sendable {
var cancelled = false
}
/// Run the ceremony. `onPaired` receives the host's now-VERIFIED fingerprint the caller pins
/// it and connects; no manual fingerprint comparison is needed, because the host proved itself
/// with the same PIN.
func run(
host address: String, port: UInt16, pin rawPIN: String, clientName rawName: String,
onPaired: @escaping (Data) -> Void
) {
busy = true
errorText = nil
let pin = rawPIN.trimmingCharacters(in: .whitespaces)
let name = rawName.trimmingCharacters(in: .whitespaces)
token = Token()
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
self.busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
case .failure(PunktfunkClientError.wrongPIN):
self.errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
self.errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
self.errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
self.errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
}
}
/// The presenting screen went away discard whatever is still in flight. Called from every
/// dismissal path (an explicit Cancel, a swipe, B on a controller), which is why it is safe to
/// call when nothing is running.
func abandon() {
token.cancelled = true
}
}
@@ -5,19 +5,15 @@
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
// client's identity in return.
//
// This is the TOUCH/desktop presentation (and tvOS's, where the focus engine drives the same
// fields). A controller can't reach a `Form`'s text fields on iOS/macOS, so the console UI
// presents `GamepadPairView` instead same ceremony, via the shared `PairCeremony`.
import Foundation
import PunktfunkKit
import SwiftUI
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
/// can't be interrupted, so its completion checks this flag and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
/// touched on the main actor.
private final class CeremonyToken: @unchecked Sendable {
var cancelled = false
}
struct PairSheet: View {
@Environment(\.dismiss) private var dismiss
let host: StoredHost
@@ -30,9 +26,10 @@ struct PairSheet: View {
#else
@State private var clientName = UIDevice.current.name
#endif
@State private var busy = false
@State private var errorText: String?
@State private var token = CeremonyToken()
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
private var errorText: String? { ceremony.errorText }
#if os(tvOS)
private enum EditField: String, Identifiable {
case pin, clientName
@@ -64,7 +61,7 @@ struct PairSheet: View {
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
token.cancelled = true
ceremony.abandon()
dismiss()
}
if busy {
@@ -78,7 +75,7 @@ struct PairSheet: View {
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { token.cancelled = true }
.onDisappear { ceremony.abandon() }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
@@ -142,7 +139,7 @@ struct PairSheet: View {
#endif
HStack {
Button("Cancel", role: .cancel) {
token.cancelled = true
ceremony.abandon()
dismiss()
}
#if !os(tvOS)
@@ -180,7 +177,7 @@ struct PairSheet: View {
.presentationDragIndicator(busy ? .hidden : .visible)
#endif
.interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path
.onDisappear { ceremony.abandon() } // any other dismissal path
#endif
}
@@ -195,47 +192,11 @@ struct PairSheet: View {
}
private func runCeremony() {
busy = true
errorText = nil
let pin = pin.trimmingCharacters(in: .whitespaces)
let name = clientName.trimmingCharacters(in: .whitespaces)
let address = host.address
let port = host.port
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
dismiss()
case .failure(PunktfunkClientError.wrongPIN):
errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
ceremony.run(
host: host.address, port: host.port, pin: pin, clientName: clientName
) { fingerprint in
onPaired(fingerprint)
dismiss()
}
}
}
@@ -1,6 +1,12 @@
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
// or drops this and runs the PIN pairing ceremony instead.
//
// Controller-drivable on iOS/macOS (A trust, B cancel, X pair instead). It had no controller
// wiring at all, which made it a dead end for a pad-only user at the worst possible moment: the
// card appears mid-connect with capture disabled (ContentView blurs the stream and stops
// forwarding), so the pad in their hands genuinely did nothing and the only way past was to reach
// for the screen. tvOS needs none of this the focus engine drives the buttons natively.
import Foundation
import PunktfunkKit
@@ -13,6 +19,12 @@ struct TrustCardView: View {
let onTrust: () -> Void
let onPairInstead: () -> Void
#if os(iOS) || os(macOS)
/// Observed so the legend appears the moment a pad wakes up mid-prompt and so it stays
/// absent for the mouse/touch users this card is otherwise for.
@ObservedObject private var gamepads = GamepadManager.shared
#endif
var body: some View {
VStack(spacing: 14) {
Image(systemName: "lock.shield")
@@ -60,12 +72,35 @@ struct TrustCardView: View {
.buttonStyle(.borderless)
#endif
.font(.geist(16, relativeTo: .callout))
#if os(iOS) || os(macOS)
// Only with a pad attached: controller glyphs in front of a trackpad user would be
// naming buttons they don't have.
if gamepads.active != nil {
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Trust",
action: onTrust),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Pair with PIN",
action: onPairInstead),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: onCancel),
])
.padding(.top, 2)
}
#endif
}
.padding(28)
.frame(maxWidth: 440)
// Floating trust card over the blurred stream Liquid Glass on 26+, .regularMaterial
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
.glassBackground(RoundedRectangle(cornerRadius: 18))
#if os(iOS) || os(macOS)
.background {
TrustControllerInput(onTrust: onTrust, onCancel: onCancel, onPairInstead: onPairInstead)
}
#endif
}
/// 64 hex chars four groups per line, two lines easy to eyeball against the log.
@@ -80,6 +115,35 @@ struct TrustCardView: View {
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the trust prompt: A trusts, B cancels, X runs the PIN ceremony instead.
/// The same zero-size-backing-view shape as `ConnectOverlay`'s `ConnectControllerInput` mounted
/// for exactly as long as the card is up, and `GamepadMenuInput`'s snapshot-on-start swallows
/// whatever button was still held when it appeared (the A press that started the connect is
/// usually still down).
///
/// Nothing else is polling the pad here: capture is off for the duration of the prompt, and the
/// home screens are unmounted behind the session view.
private struct TrustControllerInput: View {
let onTrust: () -> Void
let onCancel: () -> Void
let onPairInstead: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onConfirm = onTrust
input.onBack = onCancel
input.onTertiary = onPairInstead
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
private extension Array {
func chunks(of size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
@@ -0,0 +1,129 @@
// "The audio output moved under us" the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
// which needs a host, and punktfunk-host does not build on macOS so the wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}
@@ -43,8 +43,21 @@ public enum AudioDevices {
}
private static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
/// The device the system is currently playing to what an engine with no pinned speaker UID
/// follows, and so what `SessionAudio` compares its live output device against when the
/// default moves (AirPods in or out, a headset unplugged).
static func defaultOutputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
}
private static func systemDevice(
_ selector: AudioObjectPropertySelector
) -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
@@ -16,11 +16,17 @@ import os
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
///
/// **Adaptive depth.** The target is a floor, not a constant: repeated genuine underruns grow it
/// a step at a time (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`, and a
/// long quiet spell relaxes it back toward the base so a session on Wi-Fi that bunches arrivals
/// deepens until it stops crackling, while a clean LAN keeps the tight base latency. Keep the
/// constants here in step with `JitterTuning.COREAUDIO`.
/// **Adaptive depth.** The target is a floor, not a constant: a NEAR-MISS a read served with
/// less than one frame left over grows it a step BEFORE anything was audible, repeated genuine
/// underruns grow it too (`noteRead`, mirroring `JitterPolicy::note_read`) up to `maxTargetMS`,
/// and a long quiet spell relaxes it back toward the base so a session on Wi-Fi that bunches
/// arrivals deepens until it stops crackling, while a clean LAN keeps the tight base latency.
/// Growth only raises a promise; the one thing that re-banks real depth is a re-prime, so an
/// underrun while the ring is HOLLOW (depth average far below the target) re-primes at once,
/// spending the click it already cost on the whole refill. Every shrink is armed as a PROBE:
/// answered by an underrun or near-miss within its window, it is undone on the spot, and a
/// failed sync-driven shrink is not retried for a growing backoff. Keep the constants here in
/// step with `JitterTuning.COREAUDIO`.
///
/// **A/V sync.** On top of all that the depth can be STEERED, by `setSyncTarget` from the drain
/// thread's `AvSync` because a ring that is the right depth for the link is not thereby the
@@ -58,9 +64,29 @@ final class AudioRing: @unchecked Sendable {
/// target normally relaxes only after a long spell because, absent other evidence, the only
/// thing that can justify giving up hard-won slack is time; a sync request IS that evidence
/// a measurement saying the extra depth is costing alignment right now so a smaller target
/// gets tested sooner. Wrong guesses are cheap and self-correcting (one underrun and the
/// growth path takes it straight back). Mirrors `SHRINK_QUIET_SYNC_MS`.
/// gets tested sooner. Mirrors `SHRINK_QUIET_SYNC_MS`.
private static let shrinkQuietSyncMS = 5_000
/// Post-read depth below which a served callback counts as a NEAR-MISS: the device got its
/// samples, but with less than one protocol frame left in hand the same evidence as an
/// underrun, except nobody heard it yet, so the target grows BEFORE the click instead of
/// after the third one. Mirrors `NEAR_MISS_MARGIN_MS`.
private static let nearMissMarginMS = frameMS
/// How long a shrink remains a PROBE, in consumed audio: an underrun or near-miss inside
/// this window means the shrink was wrong, and the previous target is restored at once.
/// Mirrors `SHRINK_PROBE_MS`.
private static let shrinkProbeMS = 5_000
/// How long a failed probe keeps the sync loop from driving another shrink without it the
/// loop pays an audible starvation event every `shrinkQuietSyncMS` on any link whose jitter
/// genuinely needs the depth, forever. Doubles per consecutive failure, capped; a probe that
/// survives its window resets it. Mirror `SYNC_BACKOFF_MS` / `SYNC_BACKOFF_MAX_MS`.
private static let syncBackoffMS = 60_000
private static let syncBackoffMaxMS = 480_000
/// A ring is HOLLOW when its depth AVERAGE sits this far below the target: growth only ever
/// raises the promise, and the one thing that re-banks real depth is a re-prime so an
/// underrun in a hollow ring re-primes AT ONCE, spending the click it already cost on the
/// whole refill instead of riding the knife edge one click per bunching period. Mirrors
/// `DEPRIME_DEBT_MS`.
private static let deprimeDebtMS = growStepMS
private var buf: [Float]
private var readIdx = 0
@@ -87,6 +113,24 @@ final class AudioRing: @unchecked Sendable {
/// `nil` the default, and what an un-wired session keeps reproduces the pre-sync
/// behaviour exactly, so this ring could adopt sync without the other three diverging.
private var syncTarget: Int?
/// This read was served with less than `nearMissMarginMS` left over (set in `read`,
/// consumed by `noteRead`).
private var nearMiss = false
/// A near-miss already grew the target this window one step per window, so a bunching
/// episode (a RUN of consecutive near-misses while the ring refills) buys one measured
/// step, not a sprint to the ceiling.
private var nearMissGrown = false
/// The depth average runs a `deprimeDebtMS` debt against the target (set in `read`): an
/// underrun should re-prime at once instead of waiting out the hysteresis.
private var hollow = false
/// Interleaved samples left in the current shrink-probe window (0 = no probe outstanding).
private var probeRun = 0
/// The live target before the probed shrink, restored if the probe fails.
private var probePrevTarget = 0
/// Interleaved samples before the sync loop may drive another shrink (0 = allowed now).
private var syncBackoffRun = 0
/// Length of the NEXT backoff, in ms doubles per consecutive failed probe, capped.
private var syncBackoffLenMS = AudioRing.syncBackoffMS
/// The sync loop's smoothed offset in ms, STORED not computed: the ring owns the depth but has
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
@@ -121,8 +165,15 @@ final class AudioRing: @unchecked Sendable {
/// then return the CAP i.e. quietly below the continuity floor, inverting the very ordering
/// this exists to guarantee, on exactly the awkward hardware it exists to survive. (Rust's
/// `Ord::clamp` announces the same condition by panicking; Swift would just get it wrong.)
private var target: Int {
let floor = max(targetLive, renderQuantum + Self.frameMS * perMS)
private var target: Int { target(lift: renderQuantum) }
/// The effective target with an explicit quantum lift. The property above uses the high-water
/// `renderQuantum` (priming must survive the biggest callback seen); the hollow check in
/// `read` passes the CURRENT callback instead, mirroring the Rust side's `want` a one-off
/// oversized read would otherwise inflate the debt threshold forever and turn the very next
/// late packet into a full re-prime.
private func target(lift quantum: Int) -> Int {
let floor = max(targetLive, quantum + Self.frameMS * perMS)
guard let want = syncTarget else { return floor }
let cap = max(Self.hardCapMS * perMS, floor)
return min(max(want, floor), cap)
@@ -211,12 +262,24 @@ final class AudioRing: @unchecked Sendable {
if available >= target {
primed = true
emptyReads = 0
// The refill just banked this much: seed the average with it rather than letting
// it climb from wherever the drought left it a freshly-primed ring would
// otherwise read as hollow for the EWMA's whole settling time, and the FIRST
// late packet would re-prime a ring that is actually full.
depthAvg = Double(available)
} else {
for i in 0..<count { out[i] = 0 }
return
}
}
// Hollow: the depth AVERAGE runs a debt against the target the promise has been raised
// but the depth was never re-banked (see `deprimeDebtMS`). Judged on the average, not
// this instant: a single late packet empties the ring for a callback without making it
// hollow, and must keep the consecutive-empties hysteresis. Lifted by THIS callback's
// size, not the high-water quantum see `target(lift:)`.
hollow = depthAvg + Double(Self.deprimeDebtMS * perMS) < Double(target(lift: count))
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
@@ -240,6 +303,9 @@ final class AudioRing: @unchecked Sendable {
if n < count {
for i in n..<count { out[i] = 0 }
}
// Near-miss: served in full, but with less than one frame left over the next callback
// starves unless a packet lands within one frame time.
nearMiss = n == count && writeIdx - readIdx < Self.nearMissMarginMS * perMS
noteRead(ranShort: n < count, count: count)
}
@@ -254,32 +320,84 @@ final class AudioRing: @unchecked Sendable {
if windowRun >= Self.growWindowMS * perMS {
windowRun = 0
underrunsInWindow = 0
nearMissGrown = false
}
syncBackoffRun = max(0, syncBackoffRun - count)
var restored = false
if probeRun > 0 {
probeRun = max(0, probeRun - count)
if ranShort || nearMiss {
// The probe FAILED: the link answered a shrink with (nearly) starving the ring.
// Take the depth straight back re-learning it three audible underruns at a
// time is what made the sync-vs-growth tug-of-war audible and keep the sync
// loop from probing again for a while, doubling per consecutive failure. The
// residual A/V offset is reported instead; continuity outranks sync. The
// restore CONSUMES this event as growth evidence: it answered a depth the ring
// is no longer at, so growing past the proven target on top would overshoot.
probeRun = 0
targetLive = max(targetLive, probePrevTarget)
syncBackoffRun = syncBackoffLenMS * perMS
syncBackoffLenMS = min(syncBackoffLenMS * 2, Self.syncBackoffMaxMS)
restored = true
} else if probeRun == 0 {
// Survived the whole window: the shallower depth is genuinely safe here, so the
// next probe starts from a clean slate.
syncBackoffLenMS = Self.syncBackoffMS
}
}
if ranShort {
quietRun = 0
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter {
if emptyReads >= Self.deprimeAfter || hollow {
// The consecutive-empties hysteresis protects a FULL ring from one late packet.
// A hollow ring is the opposite case: the target has been raised but the depth
// never re-banked (growth is a promise; only a re-prime cashes it), and riding
// that out is a click per bunching period, forever. The click just heard has
// already paid for the refill take it now.
primed = false
emptyReads = 0
}
underrunsInWindow += 1
if !restored {
underrunsInWindow += 1
}
if underrunsInWindow >= Self.growUnderruns {
underrunsInWindow = 0
windowRun = 0
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
}
} else if nearMiss {
// Came within one frame of an underrun the same evidence as one, heard by no one.
// Growing here, BEFORE the click, is what "no audible jitter" means: waiting for
// the third audible underrun means the user heard two. One step per window (a
// bunching episode is a RUN of near-misses while the ring refills, and must buy one
// measured step, not a sprint to the ceiling); if it worsens into real underruns
// the path above takes over. A near-miss is pressure, not quiet.
quietRun = 0
emptyReads = 0
if !nearMissGrown, !restored {
nearMissGrown = true
targetLive = min(targetLive + Self.growStepMS * perMS, Self.maxTargetMS * perMS)
}
} else {
emptyReads = 0
quietRun += count
// Without a sync request, time is the only evidence that hard-won slack is no longer
// needed, so a grown target waits out the long window. A request for less IS evidence,
// and without this branch a ring that ratcheted to the ceiling during a transient would
// hold audio a ceiling's worth late for minutes after the cause had gone.
let quietNeeded = syncWantsLess ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
// hold audio a ceiling's worth late for minutes after the cause had gone. Every shrink
// is armed as a PROBE answered by an underrun or near-miss it is undone at once (see
// above), and a failed sync-driven guess is not retried for a backoff.
let syncShrink = syncWantsLess && syncBackoffRun == 0
let quietNeeded = syncShrink ? Self.shrinkQuietSyncMS : Self.shrinkQuietMS
if quietRun >= quietNeeded * perMS {
quietRun = 0
let prev = targetLive
targetLive = max(targetLive - Self.growStepMS * perMS, Self.targetMS * perMS)
if targetLive < prev {
probeRun = Self.shrinkProbeMS * perMS
probePrevTarget = prev
}
}
}
}
@@ -21,6 +21,10 @@
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes).
//
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
// device changes underneath a running engine, the engine stops itself and stays stopped. The
// session therefore watches for that and rebuilds its engines see "Device changes" below.
import AVFoundation
import os
@@ -79,6 +83,52 @@ public final class SessionAudio {
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
#if !os(macOS)
/// Token for the route-change observer: it revives an engine the route change stopped, and on
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
private var routeObserver: NSObjectProtocol?
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
/// Token for the interruption observer a phone call or a non-mixable app stops the engines,
/// and ending the interruption restarts nothing by itself (see
/// `installInterruptionObserver`). Guarded by `stateLock`.
private var interruptionObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
let micUID: String
let micChannel: Int
let micEnabled: Bool
let echoCancel: Bool
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private static let rebuildAttempts = 3
public init(connection: PunktfunkConnection) {
self.connection = connection
@@ -89,6 +139,15 @@ public final class SessionAudio {
/// Engine teardown still belongs to stop().
deinit {
flag.stop()
// The observers only hold self weakly, so we can be deinited with them still registered;
// drop them here too rather than leaking them when an owner skips stop().
deviceWatcher?.stop()
#if !os(macOS)
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
if let mediaResetObserver {
NotificationCenter.default.removeObserver(mediaResetObserver)
}
#endif
}
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
@@ -108,6 +167,12 @@ public final class SessionAudio {
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -138,11 +203,37 @@ public final class SessionAudio {
do {
#if os(iOS)
if micEnabled {
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
// affects the built-in route (headphones/BT still win).
// NO .defaultToSpeaker here, deliberately. It reads like "prefer the speaker over
// the earpiece", and the comment that used to sit here claimed headphones and
// Bluetooth still won. That is true of WIRED headphones and false of Bluetooth
// a cable is the one way to test this and see the right answer. It is an
// OVERRIDE, and it outranks an A2DP route: with it set, every Bluetooth headset
// lost the stream to the phone's own speaker. That is the 0.25 field report ("no
// audio over Bluetooth ... plays through speakers if Mic input is enabled") mic
// and echo cancellation both default to ON, so this branch is the DEFAULT path
// and every Bluetooth listener hit it; turning the mic off was the accidental
// workaround, because that lands on `.playback` below, which routes to A2DP
// happily.
//
// The earpiece problem it was reaching for is real, so it is solved after
// activation instead, against the route we were ACTUALLY given
// see `steerBuiltInOutputToSpeaker`.
//
// `.allowBluetoothA2DP` alone, also deliberately: adding `.allowBluetooth` would
// make a headset's MIC usable, but it buys that by dragging the whole route onto
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
// the built-in mic is the better trade for a game-streaming client.
// `.mixWithOthers`, both branches: without it this session is EXCLUSIVE merely
// activating it paused the user's Music at connect, and Music's RESUME took the
// session right back, which read as "stream audio stops when I resume Music"
// (field report; the interruption observer below is the other half of that fix).
// A game stream mixing over someone's playlist is the behavior a console has,
// and what this client's peers do. The trade is real but right: a mixable
// session is nobody's Now Playing app, so the lock screen shows the music, not
// the stream which is exactly how it should read.
try session.setCategory(
.playAndRecord, mode: .default,
options: [.allowBluetoothA2DP, .defaultToSpeaker])
options: [.allowBluetoothA2DP, .mixWithOthers])
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
// quantum is most of the mic path's burst latency). Best-effort the hardware
// has the final word (a Bluetooth route will ignore both), and whatever quantum
@@ -150,24 +241,91 @@ public final class SessionAudio {
try? session.setPreferredIOBufferDuration(0.005)
try? session.setPreferredSampleRate(48_000)
} else {
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
}
#else // tvOS no app-accessible mic
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
#endif
try session.setActive(true)
#if os(iOS)
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
// output override so the mic-off (`.playback`) path deliberately does neither.
// (The route OBSERVER that re-applies this per route is installed by
// `installDeviceChangeRecovery`, for every session a `.playback` session steers
// nothing but still has engines a route change can stop.)
if micEnabled { steerBuiltInOutputToSpeaker(session) }
#endif
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
}
}
#endif
#if os(iOS)
/// `.playAndRecord` parks the BUILT-IN output on the earpiece right for a phone call,
/// useless for a game. Move it to the speaker, but ONLY when the route we were actually given
/// is the receiver: anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly
/// alone. That "look first" is the whole difference between this and the `.defaultToSpeaker`
/// option it replaced, which forced the speaker unconditionally and so beat Bluetooth.
///
/// Idempotent and cheap, so the route observer can simply call it again.
private func steerBuiltInOutputToSpeaker(_ session: AVAudioSession) {
// An override already in force shows up as `.builtInSpeaker`, not `.builtInReceiver`, so
// re-running this never fights its own previous result.
guard session.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver })
else { return }
do {
try session.overrideOutputAudioPort(.speaker)
} catch {
log.warning("could not move audio off the earpiece: \(error.localizedDescription)")
}
}
#endif
#if !os(macOS)
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
/// the stream back to the built-in output. Two things follow from that.
///
/// iOS drops an output override whenever the route changes which is what lets a newly-
/// connected headset win so the earpiece steer is a property of the CURRENT route and has to
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
/// earpiece.
///
/// And on every platform a route change can take the engines down with it (see
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
/// on tvOS too, where there is no earpiece to steer away from.
private func installRouteObserver() {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(), queue: nil
) { [weak self] _ in
// Arrives on whatever thread AVFoundation posts it from, and the session API blocks
// on the audio server so do the work on the shared session queue, like every
// other call into it.
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
#if os(iOS)
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
#endif
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
}
}
stateLock.lock()
let stale = routeObserver
routeObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Build + start the engines combined (voice-processed) or split, per `wantsCombined`
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
/// iOS/tvOS the session is already active by the time this runs.
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -241,26 +399,34 @@ public final class SessionAudio {
public func stop() {
flag.stop() // before taking the engines see stateLock's comment
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
let watcher = deviceWatcher
deviceWatcher = nil
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
let route = routeObserver
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
let interruption = interruptionObserver
interruptionObserver = nil
#endif
stateLock.unlock()
// Every watcher goes before the engines do: a device change landing during teardown must
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
// guards that, but not arming the trigger is better than catching it.) On iOS this is
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
watcher?.stop()
#if !os(macOS)
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
if let interruption { NotificationCenter.default.removeObserver(interruption) }
#endif
tearDownEngines()
#if !os(macOS)
// Release the session. (A mixable session interrupts nobody, so the resume cue below is
// now a courtesy for the edge where an OLD non-mixable install interrupted something
// harmless either way, and deactivating promptly is still what orders a reconnect.) Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
// (off the main thread). Enqueued HERE engines already stopped, and BEFORE the drain wait
// below so across a reconnect it lands ahead of the next session's activate on the shared
@@ -279,6 +445,267 @@ public final class SessionAudio {
}
}
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
/// the audio session alone the teardown half shared by `stop()` and a rebuild. Safe from any
/// thread; the engines are taken under the lock before any of them is touched.
private func tearDownEngines() {
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
}
// MARK: - Device changes
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
/// running engine AirPods taken out of an ear, a headset unplugged, the default switched in
/// System Settings the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
/// device changes, and both restarting the same engine and building a fresh one resume them.
///
/// Three triggers feed one rebuild, because no single one of them covers the ground:
///
/// - the engine notification, everywhere the direct signal, but only an engine that still
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
/// - the HAL default-output-device listener, macOS independent of any engine and of the
/// engine's topology. It is what makes the recovery work for the voice-processing engine
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
/// a VPIO engine posts the notification the plain one demonstrably does;
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
stateLock.lock()
let already = deviceWatcher != nil
stateLock.unlock()
guard !already else { return } // a second start() on one SessionAudio: keep the first set
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
watcher.start()
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
installInterruptionObserver(micEnabled: micEnabled)
#endif
}
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
/// our business identity only, the object is never resurrected.
private func ownsEngine(_ posted: AnyObject?) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
}
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
defaultOutputChanged()
#else
break // the watcher only raises this one on macOS
#endif
}
}
/// Restart the engines if and only if playback is down. The conservative trigger: it is
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
/// that followed the change on its own must not be interrupted for it.
///
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
/// difference between recovering a session whose very first `startPlayback` failed no
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
/// before asking AVAudioEngine anything the lock guards our handles, not the framework.
private var playbackIsLive: Bool {
stateLock.lock()
let playback = playbackEngine
let combined = combinedEngine
stateLock.unlock()
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
}
/// Coalesce: one device switch produces a burst the old device leaving, the default moving,
/// the new device settling, and each engine we own posting its own change and one rebuild
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
/// from spinning the session. Main thread.
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
/// renegotiates its own. The RING is deliberately not touched it is the one thing carried
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
// Did playback actually come back? A device caught mid-transition can refuse to start, and
// a rebuild that fails leaves no engine to post the next notification so this is the one
// path that must not just give up. (`startEngines` has logged the reason already.)
if playbackIsLive {
log.info("audio engines restarted on the current device")
return
}
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
audio did not come back after the device change the default-output watcher will \
try again when a device appears
""")
#else
log.error("audio did not come back after the route change")
#endif
return
}
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
}
}
#if os(macOS)
/// The system's output device moved. Rebuild only when it actually concerns this session: the
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
/// listener block is registered against it).
private func defaultOutputChanged() {
guard !flag.isStopped, let config = startConfig else { return }
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
let playingOn = Self.currentDevice(of: unit)
else {
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
reviveStoppedEngines("the default output device moved")
return
}
// Empty UID = follow the system default; a pinned UID only moves if that device itself
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
let shouldBeOn = config.speakerUID.isEmpty
? AudioDevices.defaultOutputDevice()
: AudioDevices.deviceID(forUID: config.speakerUID)
guard let shouldBeOn, shouldBeOn != playingOn else { return }
scheduleEngineRebuild(reason: "the output device changed under the session")
}
#endif
#if !os(macOS)
/// The audio server can die and restart. It takes the session's configuration and every engine
/// with it, and the documented recovery is to build all of it again the same rebuild a route
/// change uses, with the session activation back in front of it.
private func installMediaResetObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
) { [weak self] _ in
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.scheduleEngineRebuild(reason: "the audio services were reset")
}
}
}
stateLock.lock()
let stale = mediaResetObserver
mediaResetObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
/// Interruptions still happen to a mixable session a phone call, Siri, an app that claims
/// a NON-mixable session of its own. iOS stops the engines, and when the interruption ends it
/// restarts NOTHING by itself; before this observer the stream just stayed silent (under the
/// old exclusive category, Music itself was such an interrupter, which is how "resume Music,
/// lose the stream" was ever possible). Reactivate and revive on `.ended` unconditionally,
/// not only when iOS hints `.shouldResume`: a live stream is the one case where the user's
/// intent to keep hearing it is not in doubt, and `reviveStoppedEngines` already declines
/// when playback never went down.
private func installInterruptionObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(), queue: nil
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
// The full activation, not a bare `setActive`: an interruption can drop the
// category configuration too, and on iOS the earpiece steer is per-route.
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.reviveStoppedEngines("an audio interruption ended")
}
}
}
stateLock.lock()
let stale = interruptionObserver
interruptionObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
@@ -344,6 +771,21 @@ public final class SessionAudio {
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
#if os(macOS)
/// Whether playback is rendering, and the device it is rendering to. The device-change
/// recovery has exactly one observable signature from outside "running again, on the device
/// the system just moved to" and nothing else here could tell the two halves apart: a
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
/// Used by `AudioDeviceSwitchTests`.
var playbackState: (running: Bool, device: AudioDeviceID?) {
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine else { return (false, nil) }
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
}
#endif
// MARK: - Playback (host speaker)
/// The playback jitter ring + the source node draining it shared by the plain playback
@@ -1002,22 +1002,34 @@ public final class PunktfunkConnection {
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine the
/// uniform replacement for per-platform rumble policy. The engine owns every decision
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
/// so apply commands verbatim: all-zero = stop now, non-zero = run at this level.
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs the
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
///
/// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger
/// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA
/// plane's v3 tail. This calls the core's `_cmd2` entry point `_cmd` is the frozen
/// two-handle form kept for out-of-tree embedders, and there is no reason for this client to
/// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply
/// has no engine for those levels and they go nowhere, which is the normal case.
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
-> (
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
backstopMs: UInt32
)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
var lt: UInt16 = 0, rt: UInt16 = 0
let rc = punktfunk_connection_next_rumble_cmd2(
h, &pad, &low, &high, &lt, &rt, &backstop, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, backstop)
return (pad, low, high, lt, rt, backstop)
case statusNoFrame:
return nil
case statusClosed:
@@ -112,6 +112,24 @@ public final class GamepadCapture {
static let escapeChordElements = [
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
]
/// The stats-overlay chord: Select + X, one tier per completion (off compact normal
/// detailed off). It exists because a controller in both hands has no other way to the
/// numbers the S combo needs a keyboard and the three-finger tap needs a free screen
/// and on tvOS there is no other way AT ALL, which is what this fixes.
///
/// Built like Android's mic chord (`GamepadRouter.MIC_CHORD`, Select + Y) and deliberately
/// not overlapping `escapeChord`: X is none of its four buttons, so no way of reaching the
/// exit chord passes through this one on the way, and vice versa. Select is a menu button
/// rather than a twitch action, which keeps the pair out of real play. Y is left free so the
/// mic chord can be ported onto it later without moving this one.
static let statsChord: UInt32 = GamepadWire.back | GamepadWire.x
/// `statsChord`'s elements by GameController alias same mirror-the-mask rule (and same
/// invisible failure) as `escapeChordElements`; the same test pins both.
static let statsChordElements = [GCInputButtonOptions, GCInputButtonX]
/// Every element some chord reads what a NON-forwarding slot claims (see `openSlot`). The
/// escape chord's four plus the stats chord's X; Select is shared, so it appears once.
static let chordElements: [String] =
escapeChordElements + statsChordElements.filter { !escapeChordElements.contains($0) }
/// 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
@@ -288,14 +306,15 @@ public final class GamepadCapture {
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
//
// 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.)
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the CHORDS are
// still read off this slot on tvOS the escape chord is the only controller way out of
// a stream, and the stats chord the only way to the overlay so their own 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] }
: Self.chordElements.compactMap { c.physicalInputProfile.elements[$0] }
for element in claimed {
element.preferredSystemGestureState = .disabled
}
@@ -437,10 +456,24 @@ public final class GamepadCapture {
let newButtons = raw | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
let was = slot.buttons
for bit in GamepadWire.allButtons where changed & bit != 0 {
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
// The stats chord, edge-triggered on the press that COMPLETES it: one cycle per
// chord rather than one per press, since a third button pressed on top finds the
// mask already complete and can't re-fire it. Read off the wire mask like the escape
// chord, which means a Select the hold-Select gesture has turned into a guide is not
// in it a guide hold can't cycle the overlay on its way past. The buttons still
// forward (the chord is a local overlay change, not an input the host must not see).
if was & Self.statsChord != Self.statsChord,
newButtons & Self.statsChord == Self.statsChord {
// Straight to the shared tier default, like TouchMouse's three-finger tap: every
// reader (the HUD, the Settings pickers, the live session) observes it through
// @AppStorage, so no wiring back to the app is needed.
StatsVerbosity.cycle()
}
}
let newAxes: [Int32] = [
Int32(g.leftThumbstick.xAxis.value * 32767),
@@ -172,7 +172,8 @@ public final class GamepadFeedback {
while rumbleBurst < 64, !flag.isStopped,
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
self?.routeRumble(
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
rumbleBurst += 1
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
@@ -225,12 +226,21 @@ public final class GamepadFeedback {
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
private func routeRumble(
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high)
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
//
// HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so
// the trigger levels would have to be folded to arrive at all and folding continuous
// impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor
// this mirror has would buzz the phone flat-out for the whole race at a level the game
// never requested. Dropping them matches the core engine's policy for every pad without
// trigger motors.
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
}
@@ -0,0 +1,109 @@
// Button glyphs for the gamepad UI's legends, for a controller that ISN'T currently attached.
//
// While a pad is connected the truth is GameController's own `sfSymbolsName` on the live element
// nothing here competes with that. The problem this file solves is the other half of the time: the
// instant `GamepadManager.active` goes nil (the pad slept, its battery died, it was unplugged, or
// `gamepadUIMode == "always"` put the console UI up with no pad at all) there is no element left to
// ask, and every legend fell back to the generic letter glyphs which read as an Xbox pad. A
// DualSense user watched their / legends turn into A/B the moment the controller dozed off.
//
// So: `GamepadManager` remembers the KIND of the last controller that was actually attached
// (`DefaultsKey.lastGamepadKind`, never cleared on disconnect) and the legends resolve through this
// table instead. Deliberately NOT a user-facing setting a "glyph style" picker is one more row in
// a settings screen to answer a question the app can answer itself, and the remembered pad is right
// essentially always: people own the controller they last plugged in.
//
// Positional, not nominal. `GCExtendedGamepad`'s buttonA/B/X/Y are POSITIONS (A = bottom, B =
// right, X = left, Y = top), so each family maps its own labels onto those positions which is why
// the Nintendo column looks transposed: a Switch pad's bottom button is B and its right one is A.
import Foundation
import GameController
/// A face/shoulder button by POSITION, which is what `GCExtendedGamepad` exposes and what a legend
/// actually means ("press the bottom button"). The label drawn for it is the family's business.
public enum GamepadButtonRole: Sendable {
/// Bottom face button Xbox A, PlayStation , Nintendo B.
case a
/// Right face button Xbox B, PlayStation , Nintendo A.
case b
/// Left face button Xbox X, PlayStation , Nintendo Y.
case x
/// Top face button Xbox Y, PlayStation , Nintendo X.
case y
case leftShoulder
case rightShoulder
/// The role a `GCExtendedGamepad` key path names, so a caller that already spells its buttons
/// as key paths (every legend in the gamepad UI does it reads `sfSymbolsName` off the live
/// element through one) can reach this table without restating itself. nil for any other
/// button: the legends only ever name these six, and a role invented for, say, the menu button
/// would have no honest glyph on half the families.
///
/// Compared with `==` rather than matched with `switch`: key paths are reference-typed and
/// their pattern-matching goes through the generic `Equatable` `~=`, which is easy to send to
/// an unintended overload. This spelling has exactly one meaning.
public init?(keyPath: KeyPath<GCExtendedGamepad, GCControllerButtonInput>) {
if keyPath == \GCExtendedGamepad.buttonA { self = .a }
else if keyPath == \GCExtendedGamepad.buttonB { self = .b }
else if keyPath == \GCExtendedGamepad.buttonX { self = .x }
else if keyPath == \GCExtendedGamepad.buttonY { self = .y }
else if keyPath == \GCExtendedGamepad.leftShoulder { self = .leftShoulder }
else if keyPath == \GCExtendedGamepad.rightShoulder { self = .rightShoulder }
else { return nil }
}
}
public enum GamepadGlyphs {
/// The SF Symbol a `role` wears on a `kind` of pad. Every name here is asserted to resolve on
/// the running OS by `GamepadGlyphTests` a symbol name that doesn't exist renders as NOTHING
/// (SwiftUI draws an empty image rather than failing), so a typo would silently blank a legend
/// on real hardware and never show up in a build.
public static func symbol(_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType)
-> String {
switch role {
case .leftShoulder: return "l1.rectangle.roundedbottom"
case .rightShoulder: return "r1.rectangle.roundedbottom"
case .a, .b, .x, .y: return faceSymbol(role, for: kind)
}
}
private static func faceSymbol(
_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType
) -> String {
switch kind {
// PlayStation shapes. is the BOTTOM button, so it belongs to role `.a` the mapping
// people mean when they say "the PlayStation glyphs".
case .dualSense, .dualSenseEdge, .dualShock4:
switch role {
case .a: return "xmark.circle"
case .b: return "circle.circle"
case .x: return "square.circle"
case .y: return "triangle.circle"
default: return "circle.circle"
}
// Nintendo's labels sit transposed on the same positions (bottom = B, right = A,
// left = Y, top = X) printing Xbox letters on a Switch pad would name the wrong
// physical button, which is worse than a generic glyph.
case .switchPro:
switch role {
case .a: return "b.circle"
case .b: return "a.circle"
case .x: return "y.circle"
case .y: return "x.circle"
default: return "a.circle"
}
// Xbox, the Steam pads (Deck included its ABXY is the Xbox layout), and `.auto`, which
// is what a client with no remembered pad has. Xbox letters double as the neutral default
// because they ARE the positional names in `GCExtendedGamepad`.
case .auto, .xbox360, .xboxOne, .steamController, .steamDeck, .steamController2:
switch role {
case .a: return "a.circle"
case .b: return "b.circle"
case .x: return "x.circle"
case .y: return "y.circle"
default: return "a.circle"
}
}
}
}
@@ -87,6 +87,17 @@ public final class GamepadManager: ObservableObject {
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
/// The kind of the last controller that was actually attached persisted under
/// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad
/// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense
/// user's / hints don't turn into A/B the moment the pad sleeps, and so the legends are
/// right at all under `gamepadUIMode == "always"`, which puts the console UI up with no pad
/// attached by design. `.auto` = nothing has ever been seen on this device ( neutral glyphs).
///
/// @Published so the legends re-render when a pad of a different family arrives; the screens
/// already observe this object for `active`.
@Published public private(set) var lastKnownKind: PunktfunkConnection.GamepadType
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
/// reselects immediately, so a Settings Picker can bind straight to this.
@Published public var preferredID: String {
@@ -97,12 +108,19 @@ public final class GamepadManager: ObservableObject {
}
private static let preferredKey = DefaultsKey.gamepadID
private static let lastKindKey = DefaultsKey.lastGamepadKind
/// Connect order (identity-keyed) drives both twin de-dup suffixes and auto-pick.
private var connectOrder: [ObjectIdentifier] = []
private var observers: [NSObjectProtocol] = []
private init() {
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
// Stored as an Int (what UserDefaults round-trips losslessly) and validated back into a
// real case: a value written by a NEWER client a pad family this build has no case for
// must fall back to the neutral glyphs, not trap on an invalid raw value.
lastKnownKind = (UserDefaults.standard.object(forKey: Self.lastKindKey) as? Int)
.flatMap { UInt32(exactly: $0) }
.flatMap(PunktfunkConnection.GamepadType.init(rawValue:)) ?? .auto
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { [weak self] n in
@@ -212,6 +230,13 @@ public final class GamepadManager: ObservableObject {
// (list is in connect order). A stale pin falls back to automatic.
let pinned = candidates.last { $0.id == preferredID }
active = pinned ?? candidates.last
// Remember the family for the legends (see `lastKnownKind`). Only ever WRITTEN, never
// cleared: `active` going nil is precisely the moment the memory has to survive, and a
// pad whose `kind` is genuinely unknown never becomes active in the first place.
if let active, active.kind != lastKnownKind {
lastKnownKind = active.kind
UserDefaults.standard.set(Int(active.kind.rawValue), forKey: Self.lastKindKey)
}
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
// (explicit single-player); Automatic forwards every extended controller in connect order
// (oldestnewest), so a game's player numbers are stable across hot-plug churn.
@@ -3,20 +3,40 @@
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
import Foundation
import PunktfunkShared
public enum GamepadUIEnvironment {
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
/// and what the switch meant when it was a lone Bool.
public static let modeWhenConnected = "connected"
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad asked
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
/// one they want and the pad is not always awake.
public static let modeAlways = "always"
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) off means
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
/// value a newer client wrote) waits for a controller.
///
/// `gamepadConnected` is `GamepadManager.shared.active != nil` active only once a usable
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
/// without a real `GCController` (which XCTest can't construct).
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
enabledSetting && (gamepadConnected || forced)
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
/// (which XCTest can't construct).
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
/// everyone who picked Always back on "only with a controller", which is exactly the bug
/// this parameter exists to make impossible.
public static func isActive(
gamepadConnected: Bool,
enabledSetting: Bool,
mode: String
) -> Bool {
guard enabledSetting else { return false }
return mode == modeAlways || gamepadConnected || forced
}
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
@@ -0,0 +1,74 @@
// Where the arrow keys go in the library's plain poster grid (LibraryView's touch layout on
// iOS/iPadOS/macOS) the model behind "select games with keyboard arrows, enter to launch".
//
// The grid is up to TWO sections (launcher entries above titles), each rendered as its own
// `LazyVGrid`. A single flat index across both would step by the wrong amount at the boundary
// whenever the first section's last row is partial up from the second section's first row would
// land in the middle of the first section rather than on the row above. So moves happen WITHIN a
// section, with an explicit hand-off at its edges that preserves the column.
//
// Lives in PunktfunkKit rather than beside the view because this is arithmetic with edge cases
// partial rows, section hand-offs, empty sections and PunktfunkKit is the target the tests can
// reach (the app is an executable target). Pure values in, pure value out: no SwiftUI.
import Foundation
public struct LibraryGridNav {
/// Game ids per RENDERED section, in display order. Callers drop empty sections before
/// constructing this, so `sections` never contains one.
public let sections: [[String]]
/// How many columns the grid actually laid out the caller derives it from the measured
/// width using `.adaptive`'s own fitting rule, so a vertical move is exactly one visual row.
public let columns: Int
public init(sections: [[String]], columns: Int) {
self.sections = sections
// A zero or negative count would divide by zero below; one column is the degenerate grid.
self.columns = max(1, columns)
}
/// The id `direction` leads to from `current`, or nil when there is nowhere to go (so the
/// caller leaves the cursor where it is). A nil `current` nothing selected yet lands on
/// the very first tile, so the first arrow press always produces a visible cursor rather than
/// appearing to do nothing.
public func move(from current: String?, _ direction: GamepadMenuInput.Direction) -> String? {
guard !sections.isEmpty else { return nil }
guard let (s, i) = locate(current) else { return sections[0].first }
switch direction {
case .left:
if i > 0 { return sections[s][i - 1] }
return s > 0 ? sections[s - 1].last : nil
case .right:
if i + 1 < sections[s].count { return sections[s][i + 1] }
return s + 1 < sections.count ? sections[s + 1].first : nil
case .up:
if i >= columns { return sections[s][i - columns] }
// Off the top of this section: the section above, same column, its LAST row
// clamped, because that row may be partial.
guard s > 0 else { return nil }
let above = sections[s - 1]
let lastRowStart = ((above.count - 1) / columns) * columns
return above[min(lastRowStart + (i % columns), above.count - 1)]
case .down:
if i + columns < sections[s].count { return sections[s][i + columns] }
// Off the bottom: the section below, same column, its first row.
if s + 1 < sections.count {
let below = sections[s + 1]
return below[min(i % columns, below.count - 1)]
}
// Nothing below. A press from a full row above the last (partial) one still settles
// on the final tile rather than refusing the row IS down from here, just short.
let lastRowStart = ((sections[s].count - 1) / columns) * columns
return i < lastRowStart ? sections[s].last : nil
}
}
/// (section, index within it) for an id, or nil when it isn't in the grid any more.
private func locate(_ id: String?) -> (Int, Int)? {
guard let id else { return nil }
for (s, section) in sections.enumerated() {
if let i = section.firstIndex(of: id) { return (s, i) }
}
return nil
}
}
@@ -36,7 +36,9 @@ enum RumbleTuning {
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
/// proven mid value. The impulse-trigger motors are small and light the same character as
/// the high/right buzzer so they reuse `sharpnessHigh` rather than introduce a number
/// nobody has measured on real trigger hardware.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
private var controller: GCController?
private var low: Motor?
private var high: Motor?
/// Wire-truth target (raw wire units) the engine command's level, applied verbatim; the
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
private var target: (low: UInt16, high: UInt16) = (0, 0)
/// The two Xbox impulse-trigger motors, when the pad offers
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** every pad but
/// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads
/// on Apple exposing no haptics engine at all so their absence is never logged and never
/// counts as a setup failure. Independent of the handle split: a pad may offer trigger
/// localities with or without split handles, and losing one does not implicate the other.
private var leftTrigger: Motor?
private var rightTrigger: Motor?
/// Wire-truth target (raw wire units) the engine command's four levels, applied verbatim;
/// the core policy engine owns when it ends (explicit zero commands), so no deadline lives
/// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every
/// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and
/// evdev's `FF_RUMBLE` each carry exactly two magnitudes).
private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) =
(0, 0, 0, 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
/// Called with every 0xCA state the host sends level changes AND renewals and the core
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
///
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
/// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what
/// you had".
func apply(
low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0,
rightTrigger rtAmp: UInt16 = 0
) {
queue.async {
let active = lowAmp != 0 || highAmp != 0
let next = (lowAmp, highAmp, ltAmp, rtAmp)
let active = next != (0, 0, 0, 0)
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
}
guard (lowAmp, highAmp) != self.target else { return }
self.target = (lowAmp, highAmp)
guard next != self.target else { return }
self.target = next
self.render()
}
}
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.target = (0, 0, 0, 0)
self.wasActive = false
self.teardown()
self.closeHID()
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
let audible = target != (0, 0, 0, 0)
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
@@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
// Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into
// the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them
// silently drops the levels which is the correct degrade and the common case.
//
// Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear
// down the handle engines (which are what the pad's rumble mostly is) nor flip
// `preferCombined`, which is a statement about the handle split and nothing else. Nothing
// is orphaned by that a failed reconcile leaves the slot's Motor in place, so the next
// tick simply retries it, and an engine that is genuinely dead fires its
// stopped/reset handler, which tears down all four slots for a lazy rebuild.
_ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger))
_ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger))
if !ok {
let wasSplit = high != nil
teardown()
@@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable {
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
let needed = target != (0, 0, 0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
@@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
// Return before the trigger engines: the retry path re-enters setup() on the same
// `low == nil, high == nil` condition, so building them here would leak a fresh pair
// on every attempt (teardown() only runs on the failure paths above, and this is not
// one of them).
return
}
// Impulse-trigger motors, built last and best-effort. Independent of the handle split
// the localities are separate and a pad can offer either, both or neither and NOT part
// of the failure test above: nil here is the ordinary state of every pad that is not an
// Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle
// engines, or produce a log line on a path that runs per controller attach.
//
// Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass.
// The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil,
// and `reconcile` no-ops on them.
if localities.contains(.leftTrigger) {
leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
if localities.contains(.rightTrigger) {
rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
}
@@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable {
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
}
low = nil
high = nil
leftTrigger = nil
rightTrigger = nil
}
private func seconds(since t: DispatchTime) -> TimeInterval {
@@ -624,6 +680,16 @@ final class RumbleRenderer: @unchecked Sendable {
/// 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).
///
/// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to
/// invent.** A DualSense has *adaptive* triggers force resistance on a trigger you press,
/// driven by the separate 0xCD `HidOutput.Trigger` plane and no trigger *motors*. The two
/// features are unrelated hardware that only share a word: an Xbox Series pad has trigger
/// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble
/// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger
/// parameter block would fabricate feedback the game never asked for. This path returning
/// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above,
/// which is correct for the same reason.
private func renderHID() -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
@@ -34,10 +34,26 @@ public final class SiriRemotePointer {
private var heldButtons: Set<UInt32> = []
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
private var menuDownAt: Date?
/// Counts a held Play/Pause down to `statsHold`; nil when the button is up or already
/// resolved. See `playPauseChanged`.
private var playPauseTimer: Timer?
/// The held Play/Pause has already been spent on a stats cycle, so its release must not also
/// right-click.
private var statsHoldFired = false
/// Trails a delivered right-click tap by `tapPress` to release it see `deliverRightClick`.
private var rightReleaseTimer: Timer?
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
/// controller chord's 1.5 s the remote has no way to trip this during gameplay.
private static let disconnectHold: TimeInterval = 1.0
/// Hold Play/Pause this long to cycle the stats overlay instead of right-clicking. It is the
/// remote's only spare button, and on an Apple TV with no controller in the room this is the
/// ONLY route to the numbers (S wants a keyboard, the three-finger tap a touchscreen).
/// Shorter than `disconnectHold`: nothing destructive rides on it.
private static let statsHold: TimeInterval = 0.5
/// pf-client-core's `TAP_PRESS`, borrowed for the deferred right-click: its release trails
/// the press by this much, so the two transitions can't fold into nothing downstream.
private static let tapPress: TimeInterval = 0.05
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
/// small; two comfortable swipes should cross a 1080p desktop.
private static let pointerScale: Float = 1100
@@ -95,6 +111,9 @@ public final class SiriRemotePointer {
old.buttonX.pressedChangedHandler = nil
old.buttonMenu.pressedChangedHandler = nil
}
// Timers first, then the lift: a tap whose release is still owed is held state, so
// `releaseHeld` below is what sends its button-up.
cancelPlayPause()
releaseHeld()
lastTouch = nil
menuDownAt = nil
@@ -109,12 +128,13 @@ public final class SiriRemotePointer {
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
}
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
// Surface click = left button; Play/Pause = right (the remote's only spare face button),
// or held the stats-overlay cycle. See `playPauseChanged`.
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
}
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
MainActor.assumeIsolated { self?.playPauseChanged(pressed: pressed) }
}
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
@@ -149,6 +169,76 @@ public final class SiriRemotePointer {
connection.send(.mouseButton(button, down: down))
}
/// Play/Pause: a TAP right-clicks, a HOLD (`statsHold`) cycles the stats overlay instead.
///
/// The right button is therefore DEFERRED until the press resolves, rather than going down on
/// contact: once the host has seen a button-down there is no taking it back, and a right
/// button held for half a second is a context menu on every desktop this streams. The shape
/// is the hold-Select gesture's (`GamepadCapture.gestureFiltered`) suppress, then deliver a
/// tap on release or the gesture past the threshold so the two behave alike.
private func playPauseChanged(pressed: Bool) {
if pressed {
statsHoldFired = false
let timer = Timer(timeInterval: Self.statsHold, repeats: false) { [weak self] _ in
Task { @MainActor in self?.statsHoldElapsed() }
}
RunLoop.main.add(timer, forMode: .common)
playPauseTimer?.invalidate()
playPauseTimer = timer
return
}
playPauseTimer?.invalidate()
playPauseTimer = nil
// The hold already spent this press on a cycle its release clicks nothing.
guard !statsHoldFired else {
statsHoldFired = false
return
}
deliverRightClick()
}
/// The threshold passed with Play/Pause still down cycle the overlay and consume the press.
/// Writes the shared `statsVerbosity` default every reader observes through @AppStorage the
/// same cycle as S, the three-finger tap and the controller's Select + X.
private func statsHoldElapsed() {
playPauseTimer = nil
statsHoldFired = true
StatsVerbosity.cycle()
}
/// A Play/Pause tap, delivered now that it resolved as one: the right button down, its
/// release `tapPress` behind so the pair can't collapse into nothing downstream.
private func deliverRightClick() {
// A previous tap's owed release goes out FIRST two taps inside `tapPress` would
// otherwise send the host two downs in a row (the rule GamepadCapture's held-back Select
// tap follows for the same reason).
finishRightClick()
setButton(3, down: true)
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self] _ in
Task { @MainActor in self?.finishRightClick() }
}
RunLoop.main.add(timer, forMode: .common)
rightReleaseTimer = timer
}
/// Release a tap's right button if one is still owed; nothing otherwise.
private func finishRightClick() {
guard rightReleaseTimer != nil else { return }
rightReleaseTimer?.invalidate()
rightReleaseTimer = nil
setButton(3, down: false)
}
/// Drop any in-flight Play/Pause state (unbind / stop). Timers only a right button already
/// sent down is held state, and `releaseHeld` is what lifts it.
private func cancelPlayPause() {
playPauseTimer?.invalidate()
playPauseTimer = nil
rightReleaseTimer?.invalidate()
rightReleaseTimer = nil
statsHoldFired = false
}
private func menuChanged(pressed: Bool) {
if pressed {
menuDownAt = Date()
@@ -191,6 +191,13 @@ public struct DeepLink: Equatable, Sendable {
profile: (profile?.isEmpty ?? true) ? nil : profile)
}
/// A library link for a saved host the shape the library widget and the Open Library intent
/// emit. Opens the host's game library without starting a session; a session begins only when
/// the user picks a title there, through the normal connect path.
public static func browse(host: UUID) -> DeepLink {
DeepLink(route: .browse, hostRef: host.uuidString)
}
/// The self-emitted form for a saved host: id first (address-independent), with the address
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
/// the record is gone ("Copy link", and any shortcut written from a card).
@@ -32,6 +32,15 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// The `PunktfunkConnection.GamepadType` raw value of the last controller that was actually
/// attached written by `GamepadManager` whenever one becomes active, never cleared on
/// disconnect. It exists so the gamepad UI's button legends keep speaking the pad the user
/// owns: the live controller's own `sfSymbolsName` is authoritative while it's connected, but
/// the moment it sleeps or disconnects there is nothing left to ask, and the legends used to
/// snap back to generic letter glyphs (i.e. Xbox) under a DualSense user's hands. Also what
/// makes the legends right at all under `gamepadUIMode == "always"`, where the console UI is
/// up with no pad attached by design. See `GamepadGlyphs`.
public static let lastGamepadKind = "punktfunk.lastGamepadKind"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
@@ -176,16 +185,23 @@ public enum DefaultsKey {
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement"
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default only while a
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
/// settings rows hide it when the switch is off. Anything unrecognized reads as
/// `"connected"`. A device preference, never part of a stream profile.
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
/// Which colour family the gamepad UI's living backdrop drifts through a
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
/// Android client carry the same table under the same names. Presentation only, so it is
/// a device preference and never part of a stream profile. An unknown value reads as the
/// default rather than failing a newer client may have shipped a palette this build
/// doesn't know.
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
/// console and the Android client carry the same table under the same names. Presentation
/// only, so it is a device preference and never part of a stream profile. An unknown value
/// reads as the default rather than failing a newer client may have shipped a palette this
/// build doesn't know.
public static let uiPalette = "punktfunk.uiPalette"
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
/// device's own Taptic Engine for phone-clip pads that ship without rumble motors, where
@@ -65,13 +65,31 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
]
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
/// ones. Cycling order runs dark light, so stepping the row walks the whole range one way.
public static let all: [GamepadPalette] = [
// --- dark fields (white ink) ---
GamepadPalette(
id: "violet", name: "Violet", stops: [],
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off no glow,
// no power. The first two stops are literally (0,0,0), so the shaded half of the
// field is genuinely off rather than "very dark grey", and the ground is pure black
// too: the calm mix on the form screens lifts toward nothing. What is left is a
// faint indigoviolet ember in the bright corner. The accent stays the brand violet
// focus has to be findable on black.
//
// Named for the look, not the panel technology: black with a thin violet corona is an
// eclipse, and it belongs beside Nebula and Abyss rather than reading as a spec sheet.
// The ID stays "oled" it is the stored `ui_palette` value AND the cross-client key
// (pf-console-ui's library.rs, the Android GamepadPalette.kt), so renaming it would
// orphan every saved choice and desync the three clients. Only the label moved.
id: "oled", name: "Eclipse",
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
SIMD3(0.120, 0.024, 0.130)],
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -0,0 +1,102 @@
// The device-switch regression, end to end against a real session.
//
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
// output moved mid-session AirPods taken out of an ear, a headset unplugged, the default
// changed in System Settings played silence from that moment on: nothing on the speakers the
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
//
// This drives the real `SessionAudio` against the loopback host and moves the system's default
// output device out from under it, twice out and back, the exact shape of the field report.
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
// permission and the voice processor into a test that is about neither.
//
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
#if os(macOS)
import AVFoundation
import CoreAudio
import XCTest
@testable import PunktfunkKit
final class AudioDeviceSwitchTests: XCTestCase {
/// Set the system default output device. Test-local on purpose: nothing in the app ever
/// changes the user's device, it only follows it.
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
/// sleeping test would block the very thing it is waiting for.
private func waitForPlayback(
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let state = audio.playbackState
if state.running, state.device == device { return true }
}
return false
}
func testPlaybackFollowsAnOutputDeviceChange() throws {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
let audio = SessionAudio(connection: conn)
// "" speaker UID = follow the system default, which is what the report was running and
// the only configuration a default-device change is supposed to move.
audio.start(
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
defer {
audio.stop()
_ = setDefaultOutput(original)
}
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 5),
"playback never started on the current default output device")
// Out: the device the stream was playing to goes away underneath it.
XCTAssertEqual(setDefaultOutput(target), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: target, timeout: 10),
"playback did not come back after the output device changed — this is the field "
+ "report: no sound on the device the system moved to, until the stream is "
+ "restarted")
// And back: the second half of the report, where putting the AirPods back in produced a
// second stop rather than a recovery.
XCTAssertEqual(setDefaultOutput(original), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 10),
"playback did not come back after the output device changed back")
}
}
#endif
@@ -0,0 +1,121 @@
// The trigger half of surviving a device change: does the session actually get TOLD?
//
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
// everything downstream of these notifications is dead code if the notification never arrives. The
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
// but the wiring does not and the wiring is where a silent failure costs a session all of its
// audio, which is exactly the shape of the bug this watcher exists to fix.
import AVFoundation
import XCTest
#if os(macOS)
import CoreAudio
#endif
@testable import PunktfunkKit
final class AudioDeviceWatcherTests: XCTestCase {
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
/// for. Pumps until `predicate` holds or the deadline passes.
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
return predicate()
}
/// The identity gate is the one line that could swallow every notification silently: get it
/// wrong and the recovery compiles, installs, runs and never fires.
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
XCTAssertTrue(
pump(until: { reasons.contains(.engineConfiguration) }),
"the session was never told its engine's configuration changed")
}
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
/// process are not ours to restart rebuilding for either would interrupt healthy playback.
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
let ours = AVAudioEngine()
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: stranger)
// Give it the same grace the positive case gets, then require silence.
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
}
func testStopSilencesTheWatcher() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
watcher.stop()
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
}
#if os(macOS)
/// The backstop, against the real HAL: move the system's default output device the thing that
/// happens when AirPods come out of an ear and require that the session hears about it. This
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
watcher.stop()
}
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
XCTAssertTrue(
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
"the session was never told the default output device moved")
}
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
#endif
}
@@ -53,11 +53,22 @@ final class AudioRingDriftTests: XCTestCase {
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
}
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
/// "corrected" into a stutter.
func testNegativeDriftKeepsPlaying() {
/// The mirror case: a host clock running SLOW is a genuine deficit no depth is ever deep
/// enough forever so the ring must spend it on RARE, clean re-banks (a hollow ring
/// re-primes on its first click and refills the whole target) rather than riding the knife
/// edge in permanent sub-frame chatter, which is what "silence-free" used to hide: every
/// callback a fraction of a frame short, none of them fully silent, all of them audible.
/// 200 ppm is an exaggeration of real DAC skew (tens of ppm); even so, two minutes may
/// cost at most a couple of refills' worth of silent callbacks.
func testNegativeDriftBanksRarelyInsteadOfChattering() {
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
XCTAssertLessThanOrEqual(
silent, 24,
"a draining ring re-banks a few times; a silent-callback stream means it is thrashing")
XCTAssertGreaterThan(
silent, 0,
"a persistent deficit cannot be ridden out silence-free — if this is zero the ring "
+ "is back to sub-frame chatter, which is audible without ever being silent")
}
/// A device that pulls a large quantum cannot sustain a target below it the ring must lift
@@ -79,11 +90,14 @@ final class AudioRingDriftTests: XCTestCase {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
// already holding it exclusively is an exclusivity violation.
var huge = [Float](repeating: 0, count: 200 * perMS)
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
// Drain it dry at the device's own quantum an oversized read would count as ITS OWN
// huge callback and legitimately read as hollow then starve one callback and feed a
// normal quantum again. The ring is freshly primed, so its depth average is nowhere near
// hollow, and one short read must ride on the hysteresis.
while ring.bufferedMS > 0 {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
let feed = [Float](repeating: 0.5, count: want)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
@@ -92,14 +106,17 @@ final class AudioRingDriftTests: XCTestCase {
"a single short read must not force a full re-prime")
}
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`: clustered genuine
/// underruns raise the target floor (that session needs the slack), a long quiet spell gives
/// it back and the floor never dips below the base.
/// Mirror of the Rust `target_grows_on_underruns_and_relaxes_when_quiet`, updated for
/// near-miss growth: the drain's LAST full read (less than a frame left over) already grows
/// the floor before anything was audible, clustered genuine underruns raise it further, and
/// a long genuinely quiet spell gives it back, never below the base. The quiet refill
/// runs DEEP: a knife-edge refill (exactly what each read takes) leaves the ring within a
/// frame of empty every callback, which now correctly reads as pressure, not quiet.
func testTargetGrowsOnUnderrunsAndRelaxesWhenQuiet() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
@@ -108,20 +125,27 @@ final class AudioRingDriftTests: XCTestCase {
}
XCTAssertEqual(ring.stats.targetMS, 20, "base target must match JitterTuning.COREAUDIO")
// Prime, drain dry, then alternate starve/refill: each dry read is a genuine underrun,
// each full read in between keeps the de-prime hysteresis from tripping.
// Prime, then drain: the 5th read is still served in full but leaves nothing over a
// near-miss, and the floor grows BEFORE any click.
write(ms: 25)
for _ in 0..<5 { read() } // drains to zero
for _ in 0..<5 { read() }
XCTAssertEqual(ring.stats.targetMS, 30, "a near-miss must grow the floor pre-click")
XCTAssertEqual(ring.stats.underruns, 0, "nothing was audible yet")
// Then alternate starve/refill: each dry read is a genuine underrun, each full read in
// between keeps the de-prime hysteresis from tripping. (The refills land as further
// near-misses, but growth is one step per window the cluster is what grows it again.)
read() // short underrun 1
write(ms: 5); read() // full hysteresis reset
read() // short underrun 2
write(ms: 5); read() // full
read() // short underrun 3 the floor grows one step
XCTAssertEqual(ring.stats.targetMS, 30, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.targetMS, 40, "3 clustered underruns must grow the target 10 ms")
XCTAssertEqual(ring.stats.underruns, 3)
// A long clean run (30 s of consumed audio) relaxes the growth back to the base
for _ in 0..<(30_000 / 5 + 10) {
// A long clean run at a healthy depth relaxes the growth back to the base
write(ms: 60)
for _ in 0..<(90_000 / 5 + 10) {
write(ms: 5)
read()
}
@@ -435,10 +459,14 @@ final class AudioRingDriftTests: XCTestCase {
write(ms: 5); read()
read()
}
/// Quiet (full) reads needed before the grown target relaxes one step.
/// Quiet (full) reads needed before the grown target relaxes one step. The ring is
/// refilled DEEP first: a knife-edge refill (exactly what each read takes) leaves less
/// than a frame over every callback, which now correctly reads as pressure near-misses
/// and pressure never relaxes anything.
func quietToRelax(_ ring: AudioRing) -> Int {
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 5 * perMS)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 60 * perMS) }
let start = ring.stats.targetMS
var reads = 0
while ring.stats.targetMS == start, reads < 200_000 {
@@ -466,6 +494,118 @@ final class AudioRingDriftTests: XCTestCase {
"sync pressure should relax sooner: \(fastReads) vs \(slowReads) quiet reads")
}
/// A shrink answered by an underrun or near-miss inside its probe window is undone AT ONCE,
/// and the sync loop is backed off mirrors the Rust `a_failed_shrink_probe_is_undone_at_once`
/// and `a_failed_probe_backs_the_sync_shrink_off`. Before this, the loop re-probed a proven
/// depth every five quiet seconds and paid an audible starvation event each time it was wrong,
/// forever the 0.25.0 MacBook field report.
func testAFailedShrinkProbeIsUndoneAtOnceAndBacksTheSyncLoopOff() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
// Grow the floor (near-miss + a cluster of genuine underruns), as the usual pattern does.
write(ms: 25)
for _ in 0..<5 { read() }
read()
write(ms: 5); read()
read()
write(ms: 5); read()
read()
let grown = ring.stats.targetMS
XCTAssertGreaterThan(grown, 20, "the test needs a GROWN floor to probe")
// Sync asks for less; a deep, genuinely quiet spell later the shrink probes.
ring.setSyncTarget(perMS)
write(ms: 60)
var reads = 0
while ring.stats.targetMS == grown, reads < 10_000 {
write(ms: 5)
read()
reads += 1
}
XCTAssertEqual(ring.stats.targetMS, grown - 10, "the sync-driven shrink must have probed")
// Drain to the knife edge: the last full read leaves nothing over a near-miss, nobody
// heard anything and the probe must be undone on the spot.
while ring.bufferedMS > 5 { read() }
read()
XCTAssertEqual(
ring.stats.targetMS, grown,
"a failed probe must restore the target on the first near-miss")
XCTAssertEqual(ring.stats.underruns, 3, "and nothing audible may have paid for it")
// Backed off: two accelerated windows of clean, deep audio must NOT shrink again
write(ms: 60)
for _ in 0..<(2 * 5_000 / 5) {
write(ms: 5)
read()
}
XCTAssertEqual(
ring.stats.targetMS, grown,
"the five-second cadence must be suspended after a failure")
// while the slow, pre-sync window eventually still tests one backoff is not a freeze.
for _ in 0..<(2 * 30_000 / 5) {
write(ms: 5)
read()
}
XCTAssertLessThan(
ring.stats.targetMS, grown,
"the slow window must still be allowed to test a shrink")
}
/// Growth raises a promise; only a re-prime banks real depth. An underrun while the ring is
/// HOLLOW its depth AVERAGE far below the target re-primes immediately, spending the click
/// it already cost on the whole refill, instead of riding the knife edge and clicking once per
/// bunching period indefinitely. The average, not the instant, is what separates a hollow ring
/// from one late packet (`testSingleShortReadDoesNotDeprime` pins that side).
func testAHollowRingReprimesOnItsFirstClick() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
// Grow the floor to 40 the usual way
write(ms: 25)
for _ in 0..<5 { read() }
read()
write(ms: 5); read()
read()
write(ms: 5); read()
read()
XCTAssertEqual(ring.stats.targetMS, 40)
// then ride the knife edge for ~2 s of audio, so the depth average genuinely sinks far
// below the promised 40 ms.
for _ in 0..<400 {
write(ms: 5)
read()
}
// One dry read the click. The ring is hollow, so this single click must re-prime.
read()
// A packet arrives, but the ring stays SILENT: it is re-priming toward the full target
// rather than playing the packet and clicking again at the next bunch.
write(ms: 10)
read()
XCTAssertTrue(
scratch.allSatisfy { $0 == 0 },
"a hollow ring must spend its click on the whole refill, not keep limping")
// And once the refill reaches the target, it plays again.
write(ms: 40)
read()
XCTAssertTrue(scratch.contains { $0 != 0 }, "refilled to target — playback resumes")
}
/// The four client rings adopt sync one at a time; an un-wired one must behave exactly as it
/// did. `nil` is the default, so this pins the initializer too and every other test in this
/// file runs without a sync target, which is the real guard that nothing moved underneath them.
@@ -5,8 +5,9 @@ import XCTest
/// 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.
/// is on, but only of `chordElements` `escapeChordElements` plus the stats chord's while it is
/// off, so if this alias list ever stops covering the mask, the missing button's press stays the
/// system's and the chord never completes. (`GamepadStatsChordTests` pins the claim list itself.)
///
/// 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.
@@ -0,0 +1,89 @@
// The remembered-controller glyph table.
//
// The load-bearing assertion here is that every SF Symbol name RESOLVES. `Image(systemName:)`
// renders a name the OS doesn't know as NOTHING at all no crash, no log, no red build so a
// typo in the table would silently blank a legend cell on real hardware and be invisible until
// someone looked at a device. This test is the only thing standing between that and a release.
import GameController
import XCTest
@testable import PunktfunkKit
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
final class GamepadGlyphTests: XCTestCase {
private let roles: [GamepadButtonRole] = [.a, .b, .x, .y, .leftShoulder, .rightShoulder]
/// Does the running OS actually have this symbol?
private func symbolExists(_ name: String) -> Bool {
#if canImport(UIKit)
return UIImage(systemName: name) != nil
#elseif canImport(AppKit)
return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
#else
return true
#endif
}
func testEveryGlyphNameResolvesOnThisOS() {
for kind in PunktfunkConnection.GamepadType.allCases {
for role in roles {
let name = GamepadGlyphs.symbol(role, for: kind)
XCTAssertTrue(
symbolExists(name),
"SF Symbol \"\(name)\" (\(role), \(kind)) does not resolve — the legend cell "
+ "would render blank on device")
}
}
}
/// is the BOTTOM button on a PlayStation pad, which is `GCExtendedGamepad.buttonA` the
/// whole point of the table being positional. Getting this backwards would print where the
/// user has to press .
func testPlayStationFaceButtonsAreShapesInPositionalOrder() {
for kind in [PunktfunkConnection.GamepadType.dualSense, .dualSenseEdge, .dualShock4] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "xmark.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "circle.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "square.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "triangle.circle")
}
}
/// Nintendo's labels sit transposed on the same physical positions: the bottom button (role
/// `.a`) is labelled B, and the right one (role `.b`) is labelled A.
func testSwitchFaceButtonsAreTransposed() {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: .switchPro), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: .switchPro), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: .switchPro), "y.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: .switchPro), "x.circle")
}
/// `.auto` is what a device that has never seen a controller reports, and Xbox letters are the
/// neutral default they are also the positional names `GCExtendedGamepad` itself uses.
func testUnknownAndXboxFamiliesUseLetters() {
for kind in [PunktfunkConnection.GamepadType.auto, .xbox360, .xboxOne, .steamDeck] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "x.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "y.circle")
}
}
/// The key-path bridge the legends reach this table through (`buttonGlyph` spells its buttons
/// as key paths). A wrong mapping here would print the wrong button on every family at once.
func testRolesResolveFromExtendedGamepadKeyPaths() {
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonA), .a)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonB), .b)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonX), .x)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonY), .y)
XCTAssertEqual(GamepadButtonRole(keyPath: \.leftShoulder), .leftShoulder)
XCTAssertEqual(GamepadButtonRole(keyPath: \.rightShoulder), .rightShoulder)
// A button outside the six the legends name has no honest glyph on every family, so it
// falls through to the caller's own fallback rather than guessing.
XCTAssertNil(GamepadButtonRole(keyPath: \.leftTrigger))
}
}
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
func testTableMatchesTheOtherClients() {
XCTAssertEqual(
GamepadPalette.all.map(\.id),
["violet", "nebula", "abyss", "ember", "moss", "graphite",
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
let firstLight = GamepadPalette.all.firstIndex { $0.light }
XCTAssertEqual(firstLight, 6)
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
XCTAssertEqual(firstLight, 7)
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
/// not merely the darkest of the dark fields.
func testOLEDIsActuallyBlack() {
let oled = GamepadPalette.named("oled")
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
let cells = oled.meshColors
XCTAssertGreaterThanOrEqual(
cells.filter { luma($0) == 0 }.count, 3,
"the shaded corner has to be switched off, not dimmed")
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
let darkestOther = GamepadPalette.all
.filter { $0.id != "oled" }
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
.min() ?? 0
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
}
/// A palette must read as SEVERAL hues, not one hue at several brightnesses that was
@@ -0,0 +1,79 @@
import GameController
import XCTest
@testable import PunktfunkKit
/// The stats chord (Select + X) has the same drift hazard as the escape chord it sits beside: its
/// mask and its GameController alias list must describe the same buttons, and every element some
/// chord reads has to appear in the list a NON-forwarding slot claims otherwise that button's
/// press stays the system's and the chord silently never completes.
///
/// It matters most on tvOS, where this is the only way to the statistics overlay at all (no
/// keyboard for S, no touchscreen for the three-finger tap). The failure looks like nothing
/// happening, so it is pinned here rather than left to the comments.
@MainActor
final class GamepadStatsChordTests: XCTestCase {
/// The intended aliasbit pairing, spelled out independently of the implementation.
private let pairing: [(alias: String, bit: UInt32)] = [
(GCInputButtonOptions, GamepadWire.back),
(GCInputButtonX, GamepadWire.x),
]
func testChordMaskIsExactlyTheTwoPairedButtons() {
XCTAssertEqual(
pairing.reduce(UInt32(0)) { $0 | $1.bit },
GamepadCapture.statsChord,
"the chord mask and the alias pairing describe different buttons")
}
func testAliasListMirrorsTheMask() {
XCTAssertEqual(
GamepadCapture.statsChordElements.count,
GamepadCapture.statsChord.nonzeroBitCount,
"alias list and chord mask differ in size")
XCTAssertEqual(GamepadCapture.statsChordElements, pairing.map(\.alias))
}
/// The two chords must not be reachable through one another: pressing toward the exit chord
/// may not cycle the overlay on the way, and holding the stats chord may not arm a disconnect.
/// Select is the one button they share by design everything else has to be disjoint.
func testChordsOverlapOnlyOnSelect() {
XCTAssertEqual(
GamepadCapture.statsChord & GamepadCapture.escapeChord,
GamepadWire.back,
"the stats and escape chords share a button other than Select")
// Neither is a subset of the other, so completing one can never complete the other.
XCTAssertNotEqual(
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.statsChord)
XCTAssertNotEqual(
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.escapeChord)
}
/// `chordElements` is what `openSlot` claims when forwarding is OFF. It must cover BOTH
/// chords' aliases and repeat none of them (a duplicate would mean a bit with no element).
func testClaimListCoversBothChordsWithoutDuplicates() {
let claim = GamepadCapture.chordElements
for alias in GamepadCapture.escapeChordElements + GamepadCapture.statsChordElements {
XCTAssertTrue(claim.contains(alias), "\(alias) is read by a chord but never claimed")
}
XCTAssertEqual(Set(claim).count, claim.count, "a repeated alias in the claim list")
// Shared Select means the union is one shorter than the two lists laid end to end.
XCTAssertEqual(
claim.count,
GamepadCapture.escapeChordElements.count + GamepadCapture.statsChordElements.count - 1)
}
/// A cycle is a pure rotation through the four tiers the chord fires `StatsVerbosity.cycle`,
/// and a tier that dead-ended would strand a tvOS user with no other way back.
func testCycleReachesEveryTierAndReturns() {
var tier = StatsVerbosity.off
var seen: [StatsVerbosity] = []
for _ in 0..<StatsVerbosity.allCases.count {
seen.append(tier)
tier = tier.next()
}
XCTAssertEqual(Set(seen).count, StatsVerbosity.allCases.count, "a tier is unreachable")
XCTAssertEqual(tier, .off, "the cycle does not return to where it started")
}
}
@@ -1,14 +1,58 @@
// GamepadUIEnvironment.isActive is a pure AND table-tested exhaustively over its 2x2 inputs.
// GamepadUIEnvironment.isActive is pure table-tested exhaustively over its inputs.
import XCTest
@testable import PunktfunkKit
final class GamepadUIEnvironmentTests: XCTestCase {
func testActiveOnlyWhenEnabledAndConnected() {
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
private let connected = GamepadUIEnvironment.modeWhenConnected
private let always = GamepadUIEnvironment.modeAlways
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
/// that never sees the new row is exactly where it was.
func testWhenConnectedIsAPlainAnd() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: connected))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: connected))
}
/// Always drops the controller from the decision entirely but NOT the switch, which stays
/// the one way back to the touch UI.
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: always))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: false, mode: always))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: false, mode: always))
}
/// A value a newer client wrote must wait for a controller, never strand this build in a
/// layout it has no way back out of.
func testUnknownModeWaitsForAController() {
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertTrue(
GamepadUIEnvironment.isActive(
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
XCTAssertFalse(
GamepadUIEnvironment.isActive(
gamepadConnected: false, enabledSetting: true, mode: ""))
}
}
@@ -0,0 +1,93 @@
// Arrow-key navigation over the library's two-section poster grid. The cases that matter are the
// ones a flat index gets wrong: a PARTIAL last row, and the hand-off between the launcher section
// and the titles below it.
import XCTest
@testable import PunktfunkKit
final class LibraryGridNavTests: XCTestCase {
/// Two sections, 3 columns:
/// launchers L0 L1 (one partial row)
/// titles T0 T1 T2
/// T3 T4
private let nav = LibraryGridNav(
sections: [["L0", "L1"], ["T0", "T1", "T2", "T3", "T4"]], columns: 3)
func testFirstPressSelectsTheFirstTile() {
XCTAssertEqual(nav.move(from: nil, .right), "L0")
XCTAssertEqual(nav.move(from: nil, .down), "L0")
}
func testHorizontalMovesWithinARow() {
XCTAssertEqual(nav.move(from: "T0", .right), "T1")
XCTAssertEqual(nav.move(from: "T1", .left), "T0")
}
/// Left/right run through the whole grid in display order, crossing the section boundary
/// the launchers are simply the first tiles.
func testHorizontalCrossesTheSectionBoundary() {
XCTAssertEqual(nav.move(from: "L1", .right), "T0")
XCTAssertEqual(nav.move(from: "T0", .left), "L1")
}
func testVerticalMovesOneRowWithinASection() {
XCTAssertEqual(nav.move(from: "T0", .down), "T3")
XCTAssertEqual(nav.move(from: "T3", .up), "T0")
}
/// Down from the launcher row lands in the titles' first row at the SAME column this is the
/// move a flat index gets wrong, because the launcher row is partial.
func testDownFromLaunchersKeepsTheColumn() {
XCTAssertEqual(nav.move(from: "L0", .down), "T0")
XCTAssertEqual(nav.move(from: "L1", .down), "T1")
}
/// Up out of the titles' first row lands in the launcher row, clamped to what is actually
/// there: column 2 has no launcher above it, so it settles on the last one rather than
/// running off the end.
func testUpIntoAPartialLauncherRowClamps() {
XCTAssertEqual(nav.move(from: "T0", .up), "L0")
XCTAssertEqual(nav.move(from: "T1", .up), "L1")
XCTAssertEqual(nav.move(from: "T2", .up), "L1")
}
/// Down from a full row into a SHORTER last row still moves landing on the final tile but
/// there is nothing below the last row itself.
func testDownIntoAPartialLastRow() {
XCTAssertEqual(nav.move(from: "T2", .down), "T4") // column 2 has no T5
XCTAssertNil(nav.move(from: "T4", .down))
}
func testEdgesRefuseRatherThanWrap() {
XCTAssertNil(nav.move(from: "L0", .left))
XCTAssertNil(nav.move(from: "L0", .up))
XCTAssertNil(nav.move(from: "T4", .right))
}
/// A library with no launcher entries renders ONE section the common case, and it must
/// behave like a plain grid.
func testSingleSectionGrid() {
let single = LibraryGridNav(sections: [["A", "B", "C", "D"]], columns: 2)
XCTAssertEqual(single.move(from: "A", .down), "C")
XCTAssertEqual(single.move(from: "D", .up), "B")
XCTAssertNil(single.move(from: "A", .up))
}
/// An id that is no longer in the grid (the list reloaded under the cursor) re-seeds rather
/// than returning nil forever.
func testStaleCursorReseeds() {
XCTAssertEqual(nav.move(from: "gone", .down), "L0")
}
func testEmptyGridHasNowhereToGo() {
let empty = LibraryGridNav(sections: [], columns: 3)
XCTAssertNil(empty.move(from: nil, .down))
}
/// A degenerate column count must not divide by zero.
func testZeroColumnsIsClampedToOne() {
let single = LibraryGridNav(sections: [["A", "B"]], columns: 0)
XCTAssertEqual(single.columns, 1)
XCTAssertEqual(single.move(from: "A", .down), "B")
}
}
@@ -178,6 +178,18 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
}
/// The library widget's and the Open Library intent's emitter the reserved `browse` route
/// with a bare UUID path. Same backward-compatibility stakes as connect: a Home-Screen widget
/// keeps sending yesterday's URL.
func testDeepLinkBrowseRoundTrips() throws {
let id = UUID(uuidString: "11111111-2222-4333-8444-555555555555")!
let link = DeepLink.browse(host: id)
XCTAssertEqual(link.route, .browse)
XCTAssertEqual(
link.urlString, "punktfunk://browse/11111111-2222-4333-8444-555555555555")
XCTAssertEqual(try DeepLink(url: link.url), link)
}
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
/// both a re-addressed host and a wiped store.
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
+5 -2
View File
@@ -26,8 +26,11 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
# spends tens of seconds moving the system's output device around, long after the 300 frames the
# round-trip test needs.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
--allow-tofu &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
@@ -61,4 +64,4 @@ cd clients/apple
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
PUNKTFUNK_TEST_FEEDBACK=1 \
swift test --filter LoopbackIntegrationTests
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
+111 -26
View File
@@ -303,6 +303,58 @@ def _native_client() -> str | None:
return None
# The one architecture the flatpak client is built for.
_FLATPAK_ARCH = "x86_64"
def _flatpak_ref() -> dict | None:
"""The INSTALLED client flatpak resolved to a SCOPE and a BRANCH, or None when there is none.
``{"scope": "--user"|"--system", "branch": "canary", "ref": "io.unom.Punktfunk//canary"}``.
**Naming no branch is not a shorthand for "the only one".** flatpak refuses an ambiguous
ref rather than guessing at one, and the ambiguity does not need two branches *installed*:
the punktfunk remote publishes `stable` AND `canary`, so an unqualified
``flatpak remote-info <origin> io.unom.Punktfunk`` errors with "Multiple branches available"
on a Deck that has exactly one. That error is why the client update check silently answered
"up to date" on every Deck so every query downstream now names the ref in full.
Read off the exported tree rather than by shelling out to ``flatpak list``, because
:func:`_client_argv` is on the path of every headless call and a subprocess per call would be
absurd (the same reason :func:`_flatpak_installed` reads the filesystem). ``active`` is the
symlink flatpak points at the deployed commit its presence is what makes a branch directory
an INSTALL rather than the leftovers of one.
With more than one branch installed, `stable` wins, because that is the branch a plain
``flatpak run`` resolves to: the check has to describe the client the launcher really starts,
or a stale `stable` silently beats a current `canary` in both places at once.
"""
if not _flatpak():
return None
for root, scope in (
(Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak", "--user"),
(Path("/var/lib/flatpak"), "--system"),
):
try:
branches = sorted(
p.name for p in (root / "app" / APP_ID / _FLATPAK_ARCH).iterdir()
if (p / "active").exists()
)
except OSError:
continue # not installed in this scope
if not branches:
continue
branch = "stable" if "stable" in branches else branches[0]
if len(branches) > 1:
decky.logger.warning(
"%s is installed on %d branches (%s) — using %s, the one `flatpak run` resolves "
"to; uninstall the others so the client you launch is the client we update",
APP_ID, len(branches), ", ".join(branches), branch,
)
return {"scope": scope, "branch": branch, "ref": f"{APP_ID}//{branch}"}
return None
def _flatpak_installed() -> bool:
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
@@ -310,10 +362,7 @@ def _flatpak_installed() -> bool:
because this is on the path of every headless call and a subprocess per call would be absurd.
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
"""
if not _flatpak():
return False
user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID
return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists()
return _flatpak_ref() is not None
def _client_argv() -> list[str] | None:
@@ -323,15 +372,21 @@ def _client_argv() -> list[str] | None:
behaving exactly as it did. A native binary is the fallback and on a machine with no
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
forces one when a machine has both.
The branch is PINNED (`--branch=`, which keeps the app id last :func:`_cli_argv` appends
`--command=` and flatpak treats everything after the id as the app's own argv), so the client
this launches is the exact ref :func:`_client_update_state` checks and :meth:`Plugin.
update_client` updates.
"""
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
native = _native_client()
if forced == "native":
return [native] if native else None
if forced != "flatpak" and not _flatpak_installed() and native:
ref = _flatpak_ref()
if forced != "flatpak" and not ref and native:
return [native]
if _flatpak_installed():
return [_flatpak(), "run", "--arch=x86_64", APP_ID]
if ref:
return [_flatpak(), "run", f"--arch={_FLATPAK_ARCH}", f"--branch={ref['branch']}", APP_ID]
return [native] if native else None
@@ -575,27 +630,44 @@ def _looks_outdated(stderr: str) -> bool:
async def _client_update_state() -> dict:
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
it versions independently of this plugin so we compare the installed commit against the
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
(not installed, no flatpak, offline).
"""Is a newer commit of the flatpak client available in the remote it tracks? The client
versions independently of this plugin, so we compare the installed commit against the
remote's here and let the QAM offer an update in the scope the client is actually installed
in a per-user install is one ``sudo flatpak update`` (system-scope) never reaches.
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
flatpak built from main between releases carries the release's crate version, so the
signed-manifest comparison the native path uses would call it up to date when it isn't.
Native installs have no commit to compare and go through :func:`_native_update_state`."""
state = {"available": False, "installed": "", "remote": ""}
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
Native installs have no commit to compare and go through :func:`_native_update_state`.
Every query names the ref IN FULL (see :func:`_flatpak_ref`) the remote publishes both
`stable` and `canary`, and an unqualified one is an error, not a default."""
state = {"available": False, "installed": "", "remote": "", "error": ""}
ref = _flatpak_ref()
if not ref:
return state # no flatpak client in either scope
scope, full = ref["scope"], ref["ref"]
rc, info = await _flatpak_capture(["info", scope, full], timeout=10.0)
if rc != 0:
return state # client not installed as a user app / no flatpak
decky.logger.warning("flatpak info %s %s failed (rc=%s): %s", scope, full, rc, info[-200:])
state["error"] = "client-unavailable"
return state
state["installed"] = _field_from(info, "Commit")
origin = _field_from(info, "Origin")
if not origin:
state["error"] = "no-origin" # a sideloaded bundle tracks no remote to compare against
return state
rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0)
rc, rinfo = await _flatpak_capture(["remote-info", scope, origin, full], timeout=25.0)
if rc != 0:
return state # remote unreachable — treat as "up to date", retry next check
# ⭐ NOT "up to date". Silently swallowing this is precisely how the whole leg stayed
# broken in the field: an unqualified ref made every one of these calls fail, and
# returning `available=False` dressed the failure up as good news. A check that could
# not run says so, and the panel says so too.
decky.logger.warning(
"flatpak remote-info %s %s failed (rc=%s): %s", origin, full, rc, rinfo.strip()[-200:]
)
state["error"] = "fetch-failed"
return state
state["remote"] = _field_from(rinfo, "Commit")
state["available"] = bool(
state["installed"] and state["remote"] and state["installed"] != state["remote"]
@@ -946,8 +1018,9 @@ class Plugin:
async def update_client(self) -> dict:
"""Update the **client**, by whichever route this box's install actually supports.
* **flatpak** ``flatpak update --user`` in the USER installation, the scope a Steam
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
* **flatpak** ``flatpak update`` against the FULL ref, in the scope the client is
installed in (a per-user install is one ``sudo flatpak update`` never reaches, and an
unqualified ref is an error on a remote publishing more than one branch).
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
@@ -960,18 +1033,22 @@ class Plugin:
"""
if not _client_is_flatpak():
return await self._update_native_client()
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
ref = _flatpak_ref()
if not ref:
return {"ok": False, "updated": False, "error": "client-unavailable"}
scope, full = ref["scope"], ref["ref"]
_, before = await _flatpak_capture(["info", scope, full], timeout=10.0)
before_commit = _field_from(before, "Commit")
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
rc, out = await _flatpak_capture(["update", scope, "-y", full], timeout=300.0)
if rc != 0:
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
return {"ok": False, "updated": False, "error": "update-failed"}
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
_, after = await _flatpak_capture(["info", scope, full], timeout=10.0)
after_commit = _field_from(after, "Commit")
updated = bool(before_commit and after_commit and before_commit != after_commit)
decky.logger.info(
"flatpak client update: %s -> %s (updated=%s)",
before_commit[:10], after_commit[:10], updated,
"flatpak client update (%s %s): %s -> %s (updated=%s)",
scope, full, before_commit[:10], after_commit[:10], updated,
)
_update_cache["data"] = None # invalidate the cached "update available" snapshot
return {"ok": True, "updated": updated}
@@ -1018,12 +1095,20 @@ class Plugin:
try:
if _client_is_flatpak():
cu = await _client_update_state()
ref = _flatpak_ref()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
result["client_install"] = "flatpak"
result["client_applier"] = "flatpak"
result["client_command"] = f"flatpak update --user {APP_ID}"
# The line a user could actually run — same scope, same full ref we use. The old
# unqualified one errored out ("Multiple branches available") when pasted, too.
result["client_command"] = (
f"flatpak update {ref['scope']} -y {ref['ref']}" if ref else ""
)
if cu["error"]:
# Same contract as the native leg: "couldn't tell" is never "up to date".
result["client_error"] = cu["error"]
else:
nu = await _native_update_state()
result["client_update_available"] = bool(nu.get("update_available"))
+58
View File
@@ -30,6 +30,10 @@ sys.modules["decky"] = decky
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import main # noqa: E402 (the plugin backend)
# The argv fixtures below monkey-patch `_client_argv` to pin one install shape; the
# _flatpak_ref block wants the REAL resolver back, so keep a handle on it.
_real_client_argv = main._client_argv
failures = 0
@@ -75,6 +79,60 @@ 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")])
# ---- _flatpak_ref: the branch must be NAMED, always ---------------------------------------
#
# The bug this exists to prevent: every client-update query used to name no branch, and the
# punktfunk remote publishes `stable` AND `canary` — so `flatpak remote-info <origin>
# io.unom.Punktfunk` failed with "Multiple branches available", the check swallowed the failure,
# and the panel reported the client up to date forever. One branch INSTALLED is not enough to
# make the query unambiguous; the ambiguity lives on the remote.
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
_fp_root = Path("/tmp/pf-test-home/.local/share/flatpak/app/io.unom.Punktfunk/x86_64")
main._flatpak = lambda: "/usr/bin/flatpak"
main._client_argv = _real_client_argv # undo the fixture patches above
check("ref: nothing installed => None", main._flatpak_ref() is None)
def _install_branch(name: str):
"""A deployed branch: the `active` symlink is what distinguishes an install from leftovers."""
commit = _fp_root / name / "deadbeef"
commit.mkdir(parents=True, exist_ok=True)
(_fp_root / name / "active").symlink_to("deadbeef")
(_fp_root / "canary").mkdir(parents=True, exist_ok=True)
check("ref: a branch dir without `active` is leftovers, not an install", main._flatpak_ref() is None)
_install_branch("canary")
ref = main._flatpak_ref()
check("ref: the single installed branch is used", ref == {
"scope": "--user", "branch": "canary", "ref": "io.unom.Punktfunk//canary",
})
check(
"ref: the launcher pins that branch, app id still LAST",
main._client_argv() == [
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary", "io.unom.Punktfunk",
],
)
# The pin must survive _cli_argv's rewrite, or the CLI runs a different build than the GUI.
check(
"ref: --command= is inserted before the app id, keeping the pin",
main._cli_argv() == [
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary",
"--command=punktfunk", "io.unom.Punktfunk",
],
)
# Two installed: `stable` is what a plain `flatpak run` resolves to, so it must be what we
# check and update too — otherwise a leftover stale `stable` wins the launch while `canary`
# gets the update, and the two halves disagree about which client is even running.
_install_branch("stable")
check("ref: with both installed, stable wins (what `flatpak run` picks)",
main._flatpak_ref()["branch"] == "stable")
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
# ---- _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
+3 -1
View File
@@ -120,7 +120,9 @@ export interface UpdateInfo {
client_applier: string;
client_command: string; // one copy-pastable line that updates this install by hand
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
// The client check couldn't complete — NEVER rendered as "up to date". "client-outdated" |
// "client-unavailable" | "no-origin" | "fetch-failed" (flatpak: the remote was unreachable).
client_error?: string;
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
}
+6 -2
View File
@@ -33,6 +33,7 @@ import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
clientUpdateIsOneTap,
hasUpdate,
HostView,
needsPair,
@@ -183,8 +184,11 @@ const QamPanel: FC = () => {
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
? // "+ client" only when this tap will really install it. A manual-only
// client rides along as a toast with the command, and promising it in the
// label would make that read as a failure.
`Plugin v${update!.current} → v${update!.latest}${
clientUpdateIsOneTap(update) ? " + client" : ""
}`
: "New client version"
}
+6 -1
View File
@@ -1274,13 +1274,18 @@ async fn session(args: Args) -> Result<()> {
}
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level.
// envelope tail (seq + TTL) arrived, not just the level. `lt`/`rt` are the v3
// impulse-trigger levels: printed beside the envelope because the wire-leg
// check for trigger rumble is exactly "non-zero lt/rt AND the envelope still
// present" — i.e. the trigger tail did not displace the seq/TTL tail.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
lt = u.left_trigger,
rt = u.right_trigger,
envelope = ?u.envelope,
"rumble (0xCA)"
);
+46 -24
View File
@@ -325,6 +325,22 @@ mod session_main {
};
// Before the struct literal — `vulkan` moves into it below.
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
// …and the 4:4:4 promise, for the same reason: asked while the device bundle is
// still borrowable. `&&` short-circuits, so a box that never enabled Full chroma
// pays no capability queries for a feature it does not want.
let want_444 = settings.enable_444
&& pf_client_core::video::hevc_444_hardware_decodable(vulkan.as_ref());
if settings.enable_444 && !want_444 {
// Loud, because the user turned a switch on and is not getting it. The
// alternative is what this replaces: the host grants 4:4:4, the decode ladder
// has no rung that can take it, and the session drops HEVC entirely.
tracing::warn!(
"Full chroma (4:4:4) requested but this device has no 4:4:4 HEVC decode — \
asking for 4:2:0 instead. Advertising it would cost the whole codec: 4:4:4 \
is granted on HEVC only, and there is no software HEVC decoder to fall back \
to (PyroWave carries 4:4:4 on any GPU, if the link can take it)."
);
}
SessionParams {
host: addr,
port,
@@ -356,30 +372,16 @@ mod session_main {
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit says
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
// with the decoder ladder demoting on its own. No capability probe gates the
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
// refuses anything else rather than mis-scaling it, so on a box whose hardware
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
// Welcome BEFORE we build a decoder. It is now ALSO gated on this device being
// able to decode 4:4:4 (`want_444`, computed above); the rule and its reasoning
// live in `video::video_caps_for`, which is where they get tested.
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
// frames actually took.
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
| if settings.hdr_enabled {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
}
| if settings.enable_444 {
punktfunk_core::quic::VIDEO_CAP_444
} else {
0
},
video_caps: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
// This panel's HDR colour volume → the host's virtual-display EDID, so host
// apps tone-map to the real glass. Windows reads it from DXGI (the
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
@@ -496,6 +498,12 @@ mod session_main {
/// decode is already the default just no-ops. Append rather than clobber so a user's own
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
///
/// ⚠⚠ Called from the TOP of [`run`], ahead of the `--list-adapters` / `--probe-decode`
/// early exits — not merely "before `run_session` creates the instance". Those flags
/// create Vulkan instances of their own and RADV latches `RADV_PERFTEST` when its ICD
/// initialises, so a call placed after them leaves the triage tool describing a device
/// that cannot decode while the streaming path decodes on it.
#[cfg(target_os = "linux")]
fn enable_radv_video_decode() {
const TOKEN: &str = "video_decode";
@@ -579,6 +587,23 @@ mod session_main {
)
.init();
// Before ANY Vulkan call — and that includes the two probe flags below, which is the
// whole reason this sits at the top of `run` instead of beside the session setup it
// was written for. Make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated
// RADV). Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
//
// ⚠⚠ It USED to sit after the `--list-adapters` / `--probe-decode` / `--list-audio` /
// `--pair` early exits, which meant the triage tool answered a DIFFERENT question from
// the one the streaming path asks. Measured on a Steam Deck (2026-08-08, canary
// `e22af40f`), same binary, back to back: bare `--probe-decode` printed `vulkan video
// decode: no`, `driver decode ops: none (0x0)`, `no queue family advertises
// VIDEO_DECODE`; the same call with `RADV_PERFTEST=video_decode` in the environment
// printed `YES` and `H.264, H.265, AV1, VP9`. The tool exists to be believed, so any
// Deck triage that consulted it reached the opposite of the truth.
#[cfg(target_os = "linux")]
enable_radv_video_decode();
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
// line, discrete first) for the desktop shells' GPU picker, then exit.
if arg_flag("--list-adapters") {
@@ -753,11 +778,8 @@ mod session_main {
return headless_pair(&pin);
}
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
#[cfg(target_os = "linux")]
enable_radv_video_decode();
// (The RADV video-decode opt-in that used to live here now runs at the very top of
// `run` — it has to precede the probe flags too, not just the session.)
// The Settings device picks → env, unless the user already forced one by hand:
// the GPU (the shells' pickers store the adapter's marketing name) for the
+23
View File
@@ -168,6 +168,10 @@ pub struct PortalCapturer {
/// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild
/// retries on the CPU offer instead of failing identically forever.
vaapi_dmabuf: bool,
/// PW3: this capture's dmabuf offer has been confirmed to negotiate (a frame arrived), so the
/// negotiation retry budget has already been credited back. One-shot — the credit is per
/// capture, not per frame.
negotiation_confirmed: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
@@ -412,6 +416,7 @@ impl PwHandles {
signals: self.signals,
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
negotiation_confirmed: false,
hdr_offer: self.hdr_offer,
hdr_source,
node_id,
@@ -468,6 +473,13 @@ fn spawn_pipewire(
} else {
want_hdr
};
// PW3: tell the raw-dmabuf latch which capture this is BEFORE reading its verdict below. A
// different node id is a different question — a fresh virtual output, a compositor restart,
// the Bazzite Gaming↔Desktop switch — and inheriting "dmabuf does not work here" from an
// unrelated capture is how one transient timeout used to cost a host CPU capture until it was
// restarted. The portal bit is in the key because a portal-fd capture and a virtual-output
// capture with the same node number are genuinely different sources.
pf_zerocopy::note_raw_dmabuf_capture(u64::from(node_id) | (u64::from(fd.is_some()) << 32));
// THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1).
// Every environment/latch read the decision depends on happens at this single point.
let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs {
@@ -705,6 +717,7 @@ impl PortalCapturer {
// The slot before the wakeup: a publish that coalesced its edge (or landed while we were
// not waiting) is still visible here.
if let Some(f) = self.take_frame() {
self.note_negotiation_confirmed();
return Ok(f);
}
let slice = Duration::from_millis(500)
@@ -728,6 +741,16 @@ impl PortalCapturer {
self.slot.lock().ok().and_then(|mut s| s.take())
}
/// PW3: a frame arrived, so this capture's dmabuf-only offer DID negotiate — credit the
/// negotiation retry budget back. Only meaningful for a capture that actually made that offer,
/// and only once per capture (the budget counts consecutive failed BUILDS, not frames).
fn note_negotiation_confirmed(&mut self) {
if self.vaapi_dmabuf && !self.negotiation_confirmed {
self.negotiation_confirmed = true;
pf_zerocopy::note_raw_dmabuf_negotiation_ok();
}
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
File diff suppressed because it is too large Load Diff
+155 -6
View File
@@ -121,6 +121,38 @@ pub(super) fn build_dmabuf_format(
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
/// The two 10-bit PQ formats an HDR session offers, **in negotiation order**. The order is not a
/// style choice — on NVIDIA it is the difference between correct colour and red/blue swapped.
///
/// `xBGR_210LE` (DRM `XBGR2101010`, Vulkan `A2B10G10R10_UNORM_PACK32`) comes FIRST because the
/// first compatible consumer pod wins, and it is the only one gamescope fills correctly on every
/// vendor:
///
/// * `A2R10G10B10_UNORM_PACK32` **linear-tiled storage** is an optional Vulkan feature that
/// NVIDIA does not implement. gamescope's capture textures are mappable, hence linear, so on
/// NVIDIA its composite `imageStore` into that image lands in XBGR order — the bytes come out
/// byte-reversed while the buffer is still LABELLED `XRGB2101010`.
/// * The host believes the label: `xRGB_210LE → PixelFormat::X2Rgb10 →`
/// `NV_ENC_BUFFER_FORMAT_ARGB10`. Every mapping in that chain is individually correct, which is
/// exactly why the bug is invisible from this side — the *content* is what's wrong.
/// * Upstream gamescope hit the same wall and fixed it with `vulkan_get_rgb10_capture_format()`,
/// which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to `XBGR2101010`.
/// That landed AFTER 3.16.25, so the pinned `punktfunk-gamescope` (3.16.25-7-g60561e2 +pfhdr4)
/// predates it and cannot self-correct — hence fixing the preference host-side, where it ships
/// in the host binary with no gamescope rebuild.
///
/// Preferring xBGR costs nothing anywhere else: `A2B10G10R10_UNORM_PACK32` is the universally
/// supported packed-10 format (it is the standard HDR10 swapchain format), it is what upstream
/// falls back to, and `X2Bgr10` has a first-class encoder path (NVENC `ABGR10`, VAAPI
/// `X2BGR10LE`). `xRGB_210LE` stays as the second pod so a producer that somehow offers only it
/// can still negotiate HDR rather than falling off to the SDR downgrade.
///
/// ⚠ The real fix belongs upstream in the patch set: `spa_format_to_drm()` should offer only the
/// format `vulkan_get_rgb10_capture_format()` reports. Until the gamescope pin moves past that
/// commit, THIS ORDER is what keeps NVIDIA HDR sessions correct — do not "tidy" it.
pub(super) const HDR_FORMAT_ORDER: [VideoFormat; 2] =
[VideoFormat::xBGR_210LE, VideoFormat::xRGB_210LE];
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
@@ -288,16 +320,57 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
})
}
/// Build a Buffers param requesting dmabuf-only buffers.
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
///
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
///
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
/// and the link silently stalls in "negotiating" — the exact failure mode the cursor-meta `size`
/// property already cost this codebase once (see `build_cursor_meta_param`). With a range the
/// producer clamps into it and negotiation still succeeds.
///
/// The numbers: `min` stays at 2 so nothing that works today stops working; `default` 8 is ~133 ms
/// of buffer at 60 Hz and ~33 ms at 240 Hz, comfortably past the ~3-4 ms capture→fence latency
/// measured in PW3/PW4 even with a second frame in flight; `max` 16 is a ceiling, not a request
/// (a 4K 4:4:4 buffer is ~25 MB, so 16 is ~400 MB of compositor allocation and worth capping).
/// **What the producer actually picks is logged by the stage-1 census — trust that line, not
/// these constants.**
const POOL_MIN: i32 = 2;
const POOL_DEFAULT: i32 = 8;
const POOL_MAX: i32 = 16;
/// Build a Buffers param requesting dmabuf-only buffers, with pool headroom (see [`POOL_DEFAULT`]).
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
serialize_pod(pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
properties: vec![pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
}],
properties: vec![
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
},
pw::spa::pod::Property {
key: pw::spa::sys::SPA_PARAM_BUFFERS_buffers,
flags: pw::spa::pod::PropertyFlags::empty(),
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Range {
default: POOL_DEFAULT,
min: POOL_MIN,
max: POOL_MAX,
},
),
)),
},
],
})
}
@@ -512,4 +585,80 @@ mod tests {
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
);
}
/// PW5 stage 2: the pool request must be a **Choice Range**, never a fixed Int.
///
/// This is the whole safety argument for asking at all: SPA intersects the two sides' Buffers
/// params, so a fixed count a producer cannot afford empties the intersection and the link
/// stalls in "negotiating" with no error anywhere — the same trap that cost this codebase the
/// entire Linux cursor channel once (see `build_cursor_meta_param`). Asserting the pod shape
/// is what keeps a later "simplify" from turning the range back into a number.
#[test]
fn the_dmabuf_pool_request_is_a_range_not_a_fixed_count() {
let pod = build_dmabuf_buffers().unwrap();
let key = spa::sys::SPA_PARAM_BUFFERS_buffers.to_ne_bytes();
let at = pod
.windows(4)
.position(|w| w == key)
.expect("the dmabuf Buffers pod must carry a buffers count");
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
// Property = { key, flags, value_pod }; value_pod = { size, type, body }. A Choice body
// is { type: u32, flags: u32, child_size: u32, child_type: u32, values… }.
assert_eq!(
word(at + 12),
spa::sys::SPA_TYPE_Choice,
"the buffers count must be a Choice, not a bare Int — a fixed count can fail \
negotiation outright"
);
assert_eq!(
word(at + 16),
spa::sys::SPA_CHOICE_Range,
"the Choice must be a Range (default, min, max)"
);
assert_eq!(word(at + 24), 4, "Choice child pods are 4-byte Ints");
assert_eq!(word(at + 28), spa::sys::SPA_TYPE_Int, "…of type Int");
let vals: Vec<i32> = (0..3)
.map(|i| i32::from_ne_bytes(pod[at + 32 + i * 4..at + 36 + i * 4].try_into().unwrap()))
.collect();
assert_eq!(
vals,
vec![POOL_DEFAULT, POOL_MIN, POOL_MAX],
"Range values are serialized default-first"
);
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
const { assert!(POOL_MIN <= 2) };
}
/// xBGR_210LE must be offered FIRST, and this is a correctness test, not a style one.
///
/// The first compatible consumer pod wins the negotiation. Leading with `xRGB_210LE` makes an
/// NVIDIA gamescope session land on `XRGB2101010`, whose linear-tiled `A2R10G10B10` storage
/// NVIDIA does not support — gamescope's composite `imageStore` writes XBGR bytes under an
/// XRGB label and the whole stream comes out with red and blue swapped. Every format mapping
/// on the host side is individually correct, so nothing downstream can detect it.
///
/// Field-confirmed 2026-08-09 on the RTX 5070 Ti Bazzite host with 0.26.0. See the
/// [`HDR_FORMAT_ORDER`] docs for the upstream fix this predates.
#[test]
fn hdr_offers_xbgr_before_xrgb() {
assert_eq!(
HDR_FORMAT_ORDER[0],
VideoFormat::xBGR_210LE,
"xBGR_210LE must be offered first — leading with xRGB_210LE swaps red and blue on \
every NVIDIA gamescope HDR session"
);
assert_eq!(
HDR_FORMAT_ORDER[1],
VideoFormat::xRGB_210LE,
"xRGB_210LE stays as the fallback pod so a producer offering only it can still \
negotiate HDR instead of dropping to the SDR downgrade"
);
// Both must still build: the order is a preference, never a removal.
for fmt in HDR_FORMAT_ORDER {
assert!(
!build_hdr_dmabuf_format(fmt, None).unwrap().is_empty(),
"{fmt:?} must still produce a format pod"
);
}
}
}
+2 -1
View File
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
_keepalive: Box<dyn Send>,
}
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
// `Send + Sync` on their own). It is
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
+6
View File
@@ -134,6 +134,12 @@ pf-vaadec = { path = "../pf-vaadec" }
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
libloading = "0.8"
# The gamescope overlay watcher (`overlay_focus`): read two CARDINAL properties off a
# gamescope root window and block on PropertyNotify. `default-features = false` keeps the
# pure-Rust `RustConnection` — no libxcb link, so no new C dependency on any client package
# — the same stance pf-capture and pf-vdisplay already take on this crate. No extension
# features: root-window properties and an event mask are core X11.
x11rb = { version = "0.13", default-features = false }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
+167 -1
View File
@@ -276,6 +276,9 @@ impl PadInfo {
GamepadPref::DualSenseEdge => "DualSense Edge",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
// Unreachable from `pref_for_type` today — SDL has no Elite `GamepadType` — but a
// pinned setting can carry it, and an empty label there reads as a plain Xbox pad.
GamepadPref::XboxElite => "Xbox Elite Series 2",
GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
GamepadPref::SteamController2 => "Steam Controller 2",
@@ -381,6 +384,7 @@ enum Ctl {
PadAudioPrefs(u8),
MenuMode(bool),
MenuRumble(MenuPulse),
Mask(bool),
}
#[derive(Clone)]
@@ -548,6 +552,31 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
/// A system overlay owns the controller right now — hold every forwarded pad NEUTRAL
/// until it closes. This is the Steam Input behaviour a streaming client has to
/// reproduce by hand: while the Deck's Steam menu or QAM is up, the same physical
/// sticks and buttons drive Steam's UI, and anything we keep forwarding lands in the
/// game underneath as a second, invisible player.
///
/// **Masking is not [`set_forwarding`](Self::set_forwarding).** Forwarding-off closes the
/// slot and sends the host a [`GamepadRemove`](InputKind::GamepadRemove) — the game sees a
/// controller *unplug*, which is a hardware event with real in-game consequences (pause
/// menus, "reconnect your controller", player-slot churn). Opening the QAM must not look
/// like that. Masking keeps every slot open and merely stops the transitions, after
/// flushing what the host believes is held so a stick held at overlay-open stops steering
/// instead of freezing at its last value.
///
/// SDL has this gate of its own — it drops presses while the process has windows but no
/// keyboard focus — and on a desktop it fires. It CANNOT fire on a Deck in Gaming Mode:
/// gamescope resolves focus per Xwayland ctx, and the client sits alone in its own ctx, so
/// its X input focus never moves when the overlay takes over (measured). That is why this
/// exists as an explicit lever rather than something inherited for free.
///
/// Held state is adopted, not replayed, on the way back — see [`Ctl::Mask`]'s handling.
pub fn set_masked(&self, on: bool) {
let _ = self.ctl.send(Ctl::Mask(on));
}
/// The session's system-button policy, resolved from
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
@@ -1069,6 +1098,9 @@ struct Worker {
menu_mode: bool,
menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>,
/// A system overlay owns input ([`GamepadService::set_masked`]): forwarded pads are held
/// neutral and menu translation is paused, with every slot still OPEN.
masked: bool,
}
impl Worker {
@@ -1519,6 +1551,87 @@ impl Worker {
}
}
/// Re-adopt what the pads are physically holding when an overlay mask lifts.
///
/// Buttons are taken back into `held_buttons` **without** a wire press: a button pressed
/// inside the overlay (the A that picked a QAM row) must not fire in the game the instant it
/// closes — releasing it and pressing again is what arms it. Same rule menu mode already
/// applies across a screen handoff ([`MenuNav::reset`]), for the same reason.
///
/// Axes ARE re-sent, because a stick has no press semantics to ghost — it is deflected or it
/// is not. The mask flushed them to zero, and SDL only speaks on *change*, so a stick still
/// held when the overlay closes would stay dead host-side until the user happened to move it.
///
/// Neither half can run against a pad that is gone: this only walks open slots, and every SDL
/// read here is a state query on a handle the slot owns.
fn readopt_held(&mut self) {
use sdl3::gamepad::{Axis, Button};
// Every button `button_bit` maps — the same surface the press path forwards.
const BUTTONS: [Button; 21] = [
Button::South,
Button::East,
Button::West,
Button::North,
Button::Back,
Button::Start,
Button::Guide,
Button::LeftStick,
Button::RightStick,
Button::LeftShoulder,
Button::RightShoulder,
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::Touchpad,
Button::RightPaddle1,
Button::LeftPaddle1,
Button::RightPaddle2,
Button::LeftPaddle2,
Button::Misc1,
];
const AXES: [Axis; 6] = [
Axis::LeftX,
Axis::LeftY,
Axis::RightX,
Axis::RightY,
Axis::TriggerLeft,
Axis::TriggerRight,
];
// Copied out: the slot walk below borrows `self` mutably.
let system_forward = self.system_forward;
let attached = self.attached.clone();
for slot in &mut self.slots {
slot.held_buttons.clear();
for b in BUTTONS {
let Some(bit) = button_bit(b) else {
continue;
};
// The press path returns before `held_buttons` for un-forwarded system
// buttons; tracking them here would invent state it never keeps.
if !system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
continue;
}
if slot.pad.button(b) {
slot.held_buttons.push(bit);
}
}
let Some(c) = &attached else {
continue;
};
for a in AXES {
let (id, v) = axis_value(a, slot.pad.axis(a));
if slot.last_axis[id as usize] != v {
slot.last_axis[id as usize] = v;
send(c, InputKind::GamepadAxis, id, v, slot.index);
}
}
}
// The chord latch was cleared on the way in; drop it again if what we just adopted
// doesn't actually hold it.
self.rearm_escape();
}
/// True when any one forwarded pad holds the entire escape chord (any player can leave).
fn chord_held(&self) -> bool {
self.slots
@@ -1785,6 +1898,34 @@ impl Worker {
.push((pad, bit, Instant::now() + TAP_PRESS));
}
}
Ok(Ctl::Mask(on)) => {
if self.masked == on {
continue;
}
self.masked = on;
if on {
// Neutral NOW, and while the slots stay open: a stick held when the
// overlay opened must stop steering, but the host must not see the pad
// unplug (that is `close_slot_at`'s job, and a game reacts to it).
if let Some(c) = self.attached.clone() {
for slot in &mut self.slots {
Self::flush_slot(&c, slot);
}
}
// Nothing can be mid-chord across the flip: the transitions that would
// complete or break it are about to be dropped.
self.reset_chord();
} else {
// Coming back. Whatever is still physically held was never delivered —
// adopt it silently rather than replay it as a fresh press, the same
// rule menu mode uses across a screen handoff (`MenuNav::reset`). A
// button you pressed *inside* the overlay must not fire in the game the
// instant it closes; releasing and pressing again is what arms it.
self.readopt_held();
self.menu_nav.reset();
}
tracing::info!(masked = on, "overlay input mask");
}
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
@@ -1846,6 +1987,28 @@ impl Worker {
/// "is a session live".
fn handle_event(&mut self, event: sdl3::event::Event) {
use sdl3::event::Event;
// A system overlay owns the controller ([`GamepadService::set_masked`]): drop every
// input transition. The pads were flushed neutral when the mask went on, so dropping
// the ups as well as the downs is what keeps the two in agreement — `readopt_held`
// rebuilds the held set from the hardware when it lifts.
//
// Device add/remove deliberately still count: a controller genuinely plugged in or
// pulled out behind an overlay is a fact about the world, not an input, and losing it
// would leave the slot table lying about what exists.
if self.masked
&& matches!(
event,
Event::ControllerButtonDown { .. }
| Event::ControllerButtonUp { .. }
| Event::ControllerAxisMotion { .. }
| Event::ControllerTouchpadDown { .. }
| Event::ControllerTouchpadMotion { .. }
| Event::ControllerTouchpadUp { .. }
| Event::ControllerSensorUpdated { .. }
)
{
return;
}
match event {
Event::ControllerDeviceAdded { which, .. } => {
if !self.order.contains(&which) {
@@ -2074,7 +2237,9 @@ impl Worker {
/// on and no session is attached (attach supersedes; SDL events merely wake the loop,
/// so a press is translated the iteration it arrives).
fn menu_poll(&mut self) {
if !self.menu_mode || self.attached.is_some() {
// Masked covers the launcher too: with the Deck's Steam menu up over our console, the
// same stick that scrolls Steam's UI would otherwise also be scrolling ours behind it.
if !self.menu_mode || self.attached.is_some() || self.masked {
return;
}
let Some((_, pad)) = self.menu_open.as_ref() else {
@@ -2301,6 +2466,7 @@ impl Worker {
menu_mode: false,
menu_nav: MenuNav::new(),
menu_tx,
masked: false,
}
}
}
+4
View File
@@ -46,6 +46,10 @@ pub mod orchestrate;
// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string
// logic, built everywhere (the Apple/Android ports mirror it rather than link it).
pub mod os;
// "A system overlay owns the controller" for gamescope Gaming Mode — the signal behind the
// gamepad input mask, which SDL's own focus gate structurally cannot provide there.
#[cfg(target_os = "linux")]
pub mod overlay_focus;
// 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.
+284
View File
@@ -0,0 +1,284 @@
//! "A system overlay owns the controller right now" — the gamescope half of the input mask.
//!
//! On a Steam Deck in Gaming Mode the Steam menu and the QAM are drawn by Steam and driven by
//! the *same physical controller* the client is forwarding. Steam does not mask us the way it
//! masks a normal game: masking happens on Steam Input's virtual pad, and the client
//! deliberately forwards the REAL pad instead (28DE:1205 — the virtual one has no gyro,
//! trackpads or paddles). So while the QAM is up, one thumbstick drives Steam's UI *and* the
//! game on the host. This watcher is what tells [`crate::gamepad::GamepadService::set_masked`]
//! to stop that.
//!
//! **Why the free mechanism can't do it.** SDL already drops gamepad presses while the process
//! has windows but no keyboard focus (`SDL_PrivateJoystickShouldIgnoreEvent`, on by default —
//! we never set `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS`), and on a desktop that fires. It cannot
//! fire here: gamescope resolves focus **per Xwayland ctx** (`determine_and_apply_focus` scans
//! only that ctx's window list), the Steam overlay lives in the root ctx, and the client sits
//! alone in its own. Measured on a Deck 2026-08-08: with the QAM open, X input focus inside the
//! client's ctx never moved off its window, so no `FocusOut` is ever generated. Hence an
//! explicit signal.
//!
//! **The signal.** gamescope publishes two CARDINALs on the ROOT ctx's root window (Steam mode
//! only, i.e. `gamescope -e` — which is what Gaming Mode runs):
//!
//! * `GAMESCOPE_FOCUSED_APP` — appid of the window holding **input** focus
//! * `GAMESCOPE_FOCUSED_APP_GFX` — appid of the window being **displayed**
//!
//! They are equal in normal play and diverge exactly while something else has taken input over
//! the running app. Measured, both for the Steam menu and for the QAM:
//!
//! ```text
//! app=3856846079 gfx=3856846079 ← streaming, we own input
//! app=769 gfx=3856846079 ← overlay open (769 = Steam)
//! ```
//!
//! Note `app != gfx` rather than "app is Steam": anything that takes input away from the
//! displayed app is a thing we should stop forwarding through, and comparing to our own appid
//! would need us to know it (a non-Steam shortcut's appid is assigned by Steam at creation).
//!
//! **Which display.** Not necessarily ours. Gaming Mode runs `gamescope --xwayland-count 2`:
//! Steam and the atoms live on the first server, the app is given the second, and the client's
//! own `$DISPLAY` therefore has none of these properties. So discovery walks candidates — our
//! `$DISPLAY` first (correct for a single-server gamescope), then every socket in
//! `/tmp/.X11-unix` — and keeps the first whose root actually carries both atoms. gamescope's
//! Xwayland accepts unauthenticated local connections (verified: `xprop` against it succeeds
//! with no `.Xauthority` at all), so no cookie plumbing is needed.
//!
//! Everything here is best-effort by construction: no gamescope, no X, a sandbox that cannot
//! see the other socket, or a session that restarts underneath us all end in "no signal", which
//! degrades to exactly the behaviour that shipped before this module existed.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt, EventMask, Window,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
/// How long to wait before rebuilding everything after the X connection drops. Gaming Mode
/// recreates its Xwayland servers across a session restart, so "gone" is not permanent — but it
/// is also not worth a hot retry loop.
const RECONNECT_DELAY: Duration = Duration::from_secs(3);
/// Live "an overlay owns input" flag, updated by a background thread.
///
/// Cheap to poll (one relaxed atomic load), which is what the presenter's event loop wants — it
/// checks once per iteration and only talks to the gamepad service on an edge.
pub struct OverlayFocus {
open: Arc<AtomicBool>,
}
impl OverlayFocus {
/// Start watching, or return `None` when this isn't a gamescope Steam session (the common
/// case — every desktop client) or the user opted out with `PUNKTFUNK_OVERLAY_MASK=0`.
///
/// Returning `None` is not a failure: the caller keeps its window-focus path, which is the
/// right signal everywhere the compositor actually moves focus.
pub fn start() -> Option<OverlayFocus> {
if std::env::var("PUNKTFUNK_OVERLAY_MASK").is_ok_and(|v| v == "0" || v == "false") {
tracing::info!("overlay input mask disabled by PUNKTFUNK_OVERLAY_MASK");
return None;
}
if !gamescope_session() {
return None;
}
let open = Arc::new(AtomicBool::new(false));
let flag = open.clone();
std::thread::Builder::new()
.name("punktfunk-overlay-focus".into())
.spawn(move || watch(&flag))
.map_err(|e| tracing::warn!(error = %e, "overlay focus watcher failed to start"))
.ok()?;
Some(OverlayFocus { open })
}
/// Does something other than the displayed app own input right now?
pub fn is_open(&self) -> bool {
self.open.load(Ordering::Relaxed)
}
}
/// Gaming Mode / any gamescope session — the only place this signal exists. Mirrors the same
/// env checks the shells already use to detect Gaming Mode.
fn gamescope_session() -> bool {
std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
}
/// Displays worth trying, in order: ours first (a single-server gamescope publishes the atoms on
/// the display the app is already on), then every other socket present. `/tmp/.X11-unix` is
/// listed rather than probing `:0..:N` blindly so we never connect to a display that isn't there.
fn candidate_displays() -> Vec<String> {
let mut out = Vec::new();
if let Ok(d) = std::env::var("DISPLAY") {
if !d.is_empty() {
out.push(d);
}
}
if let Ok(entries) = std::fs::read_dir("/tmp/.X11-unix") {
let mut found: Vec<String> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let n = name.strip_prefix('X')?;
n.parse::<u32>().ok().map(|n| format!(":{n}"))
})
.collect();
found.sort();
for d in found {
if !out.contains(&d) {
out.push(d);
}
}
}
out
}
/// The two atoms on a root that carries them, or `None` for a display that isn't gamescope's
/// root ctx. `only_if_exists` keeps this from interning atoms into unrelated X servers.
fn gamescope_atoms(conn: &RustConnection) -> Option<(Atom, Atom)> {
let app = conn
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP")
.ok()?
.reply()
.ok()?
.atom;
let gfx = conn
.intern_atom(true, b"GAMESCOPE_FOCUSED_APP_GFX")
.ok()?
.reply()
.ok()?
.atom;
(app != 0 && gfx != 0).then_some((app, gfx))
}
/// Read one CARDINAL appid. gamescope writes these with a length of ZERO when the appid is 0
/// (`focusedAppId != 0 ? 1 : 0`), so "present but empty" is a real state meaning "no app" — it
/// must read as `None`, not as `Some(0)` that would then compare unequal to everything.
fn read_appid(conn: &RustConnection, root: Window, atom: Atom) -> Option<u32> {
let reply = conn
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
.ok()?
.reply()
.ok()?;
// Bound rather than returned inline: the iterator borrows `reply`, and as a tail
// expression its temporary would outlive it.
let id = reply.value32()?.next();
id
}
/// The whole decision, separated from X so it can be tested: an overlay is up exactly when
/// input focus and the displayed app are both known and DIFFER.
///
/// Absence is never an overlay. A missing value means "no app focused" (gamescope's zero-length
/// write) or "this display stopped answering" — and a mask that latched on when the signal went
/// away would silently kill the controller for the rest of the session, which is a far worse
/// failure than not masking at all.
fn overlay_open_from(app: Option<u32>, gfx: Option<u32>) -> bool {
matches!((app, gfx), (Some(a), Some(g)) if a != g)
}
/// True when input focus and the displayed app have diverged — an overlay is up.
fn overlay_open(conn: &RustConnection, root: Window, app: Atom, gfx: Atom) -> bool {
overlay_open_from(read_appid(conn, root, app), read_appid(conn, root, gfx))
}
/// Connect, find the root ctx, then block on PropertyNotify for the two atoms. Returns on any X
/// error so the outer loop can rebuild after a session restart.
fn watch(flag: &Arc<AtomicBool>) {
loop {
if let Some((conn, root, app, gfx)) = connect() {
// Seed before the first event: the overlay may already be up when we start.
flag.store(overlay_open(&conn, root, app, gfx), Ordering::Relaxed);
loop {
match conn.wait_for_event() {
Ok(Event::PropertyNotify(e)) if e.atom == app || e.atom == gfx => {
let open = overlay_open(&conn, root, app, gfx);
if flag.swap(open, Ordering::Relaxed) != open {
tracing::debug!(open, "gamescope overlay focus changed");
}
}
Ok(_) => {}
Err(e) => {
tracing::info!(error = %e, "gamescope focus watcher disconnected");
break;
}
}
}
// A dropped connection tells us nothing about the controller — unmask, or a
// gamescope restart mid-overlay would leave the pad dead with nothing to revive it.
flag.store(false, Ordering::Relaxed);
}
std::thread::sleep(RECONNECT_DELAY);
}
}
/// The first candidate display whose root carries both atoms, with PropertyNotify selected.
fn connect() -> Option<(RustConnection, Window, Atom, Atom)> {
for dpy in candidate_displays() {
// `dpy`, not `display`: `display` is one of tracing's own value helpers, and a field
// named after it resolves to the helper inside the macro rather than to this string.
let Ok((conn, screen_num)) = RustConnection::connect(Some(&dpy)) else {
continue;
};
let Some((app, gfx)) = gamescope_atoms(&conn) else {
continue;
};
let root = conn.setup().roots[screen_num].root;
// Both atoms must actually be PRESENT on this root, not merely interned: a second
// gamescope Xwayland knows the atom names (they are per-server strings) but only the
// root ctx publishes the values.
if read_appid(&conn, root, gfx).is_none() {
continue;
}
// Checked rather than fire-and-forget: an event mask that silently failed to apply
// would leave the watcher blocked forever on a display that never speaks to it.
let selected = match conn.change_window_attributes(
root,
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
) {
Ok(cookie) => cookie.check().is_ok(),
Err(_) => false,
};
if !selected {
continue;
}
tracing::info!(dpy, "watching gamescope focus for overlay input masking");
return Some((conn, root, app, gfx));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The measured Deck states, both directions (2026-08-08, Steam menu and QAM alike):
/// equal appids while we own input, divergent while the overlay does.
#[test]
fn divergent_appids_are_an_overlay() {
assert!(!overlay_open_from(Some(3856846079), Some(3856846079)));
assert!(overlay_open_from(Some(769), Some(3856846079)));
}
/// gamescope writes these properties with a length of ZERO when the appid is 0, so "no app"
/// arrives as a missing value rather than `Some(0)`. Reading it as `Some(0)` would make it
/// differ from every real appid and mask the pad on an empty Gaming Mode home screen.
#[test]
fn a_missing_appid_is_never_an_overlay() {
assert!(!overlay_open_from(None, Some(3856846079)));
assert!(!overlay_open_from(Some(769), None));
assert!(!overlay_open_from(None, None));
}
/// The safety property that outranks the feature: if the signal is unreadable we forward as
/// before. A latched mask would leave a streaming session with a dead controller and no way
/// back short of restarting it.
#[test]
fn absence_fails_open_not_closed() {
assert!(!overlay_open_from(None, None));
}
}
+6 -6
View File
@@ -1174,12 +1174,12 @@ pub struct Settings {
/// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool,
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
/// why it is a device preference and never part of a settings profile. An unknown
/// name reads as the default rather than erroring — a newer client may have shipped a
/// palette this binary doesn't know.
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
/// stream depends on it, which is why it is a device preference and never part of a
/// settings profile. An unknown name reads as the default rather than erroring — a
/// newer client may have shipped a palette this binary doesn't know.
#[serde(default = "default_ui_palette")]
pub ui_palette: String,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
+126
View File
@@ -1531,6 +1531,85 @@ pub fn av1_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
d3d11
}
/// Can this client actually DECODE 4:4:4 HEVC — the question `VIDEO_CAP_444` is a promise
/// about, and the one nothing asked until a Steam Deck lost HEVC over it.
///
/// The bit used to ride the "Full chroma" toggle alone, with a comment saying the software
/// rung was the floor underneath it. M8 removed that floor: there is no CPU HEVC decoder at
/// all ([`software_decodable_codecs`]), and the host grants 4:4:4 only on HEVC. So on a
/// device with no 4:4:4 decode the toggle did not cost crispness — it cost the whole codec.
/// The Welcome resolves the chroma before a decoder exists, the native Vulkan constructor
/// then refuses the shape, VAAPI refuses it too, and the session reconnects on H.264 with
/// "HEVC decoding failed on this device" (field report 2026-08-08, Deck / VanGogh).
///
/// ⭐ Answered from the VULKAN rung alone, and that is exact rather than approximate: it is
/// the only rung in this build that implements 4:4:4 at all. `pf_vaadec::profile_for` maps
/// only `chroma_format_idc == 1` and errors `UnsupportedShape` on 3; `pf_dxvadec`'s config
/// refuses "anything but 4:2:0" by construction; the CPU rung is 8-bit 4:2:0 only. So a
/// device whose Vulkan driver offers no 4:4:4 decode profile has no 4:4:4 path in this
/// client, whatever its silicon can do. (That is why an Intel box — whose hardware HAS done
/// HEVC 4:4:4 since Ice Lake — is still a `false` here: our DXVA/VAAPI rungs do not
/// implement it, so advertising it would be a lie about US, not about the GPU.)
///
/// ⚠ Both depths are required, not either: with HDR on, the host may resolve 4:4:4 **10-bit**,
/// and a device offering `YUV444_8` but not `YUV444_10` would land in exactly the hole this
/// closes. Asking for both costs one extra capability query and removes the case entirely.
///
/// ⚠ Deliberately NOT extended to `VIDEO_CAP_10BIT`/`VIDEO_CAP_HDR`, which are advertised
/// unprobed for the same reason this one was. The asymmetry is real: all three hardware
/// rungs implement 10-bit 4:2:0 (`profile_for` maps `(H265, 1, 10)` and `(Av1, 1, 10)`;
/// pf-dxvadec carries P010), so a Vulkan-only probe there would answer `false` on boxes
/// whose VAAPI/DXVA rung decodes 10-bit perfectly and would silently withdraw HDR from
/// them — a visible regression bought against a case that has never been observed. Gating
/// 10-bit honestly needs a libva/D3D11 probe, which this path cannot afford (same reason
/// [`av1_hardware_decodable`] does not consult VAAPI).
pub fn hevc_444_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
#[cfg(any(target_os = "linux", windows))]
{
vk.is_some_and(|v| {
crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 0)
&& crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 2)
})
}
// No native Vulkan rung is compiled in off the two desktop OSes, so nothing here can
// decode 4:4:4 and the honest answer is a constant.
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = vk;
false
}
}
/// `chroma_format_idc` for 4:4:4 (H.265 7.4.3.2) — spelled once so the two depth probes
/// above and any future caller cannot disagree about the magic number.
const CHROMA_444: u8 = 3;
/// The desktop session's `video_caps` bitfield, as a pure function of the two user
/// switches that move it — so the rule can be tested without a GPU, a host or a Hello.
///
/// `want_444` is the "Full chroma" setting **already ANDed with this device's ability to
/// decode it** ([`hevc_444_hardware_decodable`]). Split that way on purpose: the caller
/// owns the expensive driver question and can log its own refusal with the user's setting
/// in hand, while the bit arithmetic — the part that was wrong — stays testable.
///
/// `MULTI_SLICE` is unconditional and is decoder truth for THIS embedder: every desktop
/// decode stack (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying
/// several slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
/// ⚠ The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges on
/// multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
///
/// HDR off means 10-bit is not advertised either, so the host never upgrades depth.
pub fn video_caps_for(hdr_enabled: bool, want_444: bool) -> u8 {
let mut caps = punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE;
if hdr_enabled {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
}
if want_444 {
caps |= punktfunk_core::quic::VIDEO_CAP_444;
}
caps
}
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
/// compute-feature probe, minus the codecs `decoder_pref` makes unreachable.
/// Advertisement-only: `resolve_codec` never auto-picks PyroWave — the session must also
@@ -2701,6 +2780,53 @@ mod tests {
use super::*;
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
/// The 4:4:4 advertisement is a PROMISE, and M8 removed the floor that used to make a
/// broken one survivable: there is no CPU HEVC decoder, and the host grants 4:4:4 on
/// HEVC only, so advertising it on a device that cannot decode it costs the entire
/// codec (field 2026-08-08, Steam Deck / VanGogh — HEVC fell back to H.264).
///
/// The device question needs a GPU; THIS is the half that does not, and it is the half
/// that was wrong — the bit used to ride `enable_444` alone.
#[test]
fn the_444_bit_needs_the_setting_and_a_device_that_can_decode_it() {
const V444: u8 = punktfunk_core::quic::VIDEO_CAP_444;
// The regression itself: setting on, device can't → the bit must NOT go out.
assert_eq!(
video_caps_for(true, false) & V444,
0,
"a 4:4:4 promise this device cannot keep costs HEVC entirely"
);
// ...and the feature still works where it can be honoured.
assert_ne!(video_caps_for(true, true) & V444, 0);
// Never advertised unasked, whatever the device can do.
assert_eq!(video_caps_for(true, false) & V444, 0);
assert_eq!(video_caps_for(false, false) & V444, 0);
// The 4:4:4 gate must not disturb the other two bits (10-bit/HDR is deliberately
// NOT probe-gated — see `hevc_444_hardware_decodable`'s docs for why).
const HDR_BITS: u8 =
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
for want_444 in [false, true] {
assert_eq!(video_caps_for(true, want_444) & HDR_BITS, HDR_BITS);
assert_eq!(video_caps_for(false, want_444) & HDR_BITS, 0);
assert_ne!(
video_caps_for(false, want_444) & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
0,
"MULTI_SLICE is unconditional for this embedder"
);
}
}
/// No presenter Vulkan device ⇒ no 4:4:4, and that is an ANSWER rather than a missing
/// one: the native Vulkan rung is the only one in this build that implements 4:4:4 at
/// all (`pf_vaadec::profile_for` errors on `chroma_format_idc == 3`, pf-dxvadec refuses
/// anything but 4:2:0, the CPU rung is 8-bit 4:2:0). The `Some` arm needs real hardware
/// and lives in the GPU suites.
#[test]
fn no_vulkan_device_means_no_444_promise() {
assert!(!hevc_444_hardware_decodable(None));
}
/// The reconnect rule, as the invariant it is: an exhausted codec must come back as
/// one this client can decode ALL THE WAY DOWN, and must never come back as itself.
///
+66 -15
View File
@@ -216,6 +216,70 @@ fn submit_queues_collide(graphics_qf: u32, decode_qf: u32) -> bool {
graphics_qf == decode_qf
}
/// The queue lock this device's decode lane submits under. One function so the
/// pre-session shape probe ([`hevc_shape_supported`]) and the real decoder cannot pick
/// different serialization for the same device.
fn queue_lock_for(vk: &VulkanDecodeDevice) -> Box<dyn pf_vkdecode::QueueLock> {
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
} else {
Box::new(NativeQueueLock::Uncontended)
}
}
/// The presenter's handles in pf-vkdecode's shape. Same reason as [`queue_lock_for`]:
/// the probe must ask about the DEVICE THE SESSION WOULD USE, not a re-derived one.
fn device_handles(vk: &VulkanDecodeDevice) -> DeviceHandles {
DeviceHandles {
get_instance_proc_addr: vk.get_instance_proc_addr,
instance: vk.instance,
physical_device: vk.physical_device,
device: vk.device,
decode_qf: vk.decode_qf,
decode_queue_index: DECODE_QUEUE_INDEX,
graphics_qf: vk.graphics_qf,
}
}
/// Can this device hardware-decode HEVC at the given picture shape? Asked BEFORE the
/// Hello, so the client never advertises a shape it would have to refuse a session over.
///
/// This is the same question, through the same code, that
/// [`NativeVulkanDecoder::new`]'s H.265 arm asks at construction — `VkH265Decoder::new`
/// then `probe_stream_support` — deliberately, so an advertisement and the rung that has
/// to honour it cannot disagree. It creates and drops a decoder object; that costs a
/// handful of driver capability queries and no session, no images and no submits.
///
/// `false` when the presenter has no Vulkan Video decode at all, which for 4:4:4 is the
/// right answer rather than a missing one — see
/// [`crate::video::hevc_444_hardware_decodable`] for why no other rung can be asked.
pub(crate) fn hevc_shape_supported(
vk: &VulkanDecodeDevice,
chroma_format_idc: u8,
bit_depth_luma_minus8: u8,
) -> bool {
if !vk.video_decode {
return false;
}
// The device-independent half first: a shape pf-vkdecode has no picture format for
// needs no driver to refuse it (and `probe_stream_support` would only re-derive it).
if pf_vkdecode::output_format_for(chroma_format_idc, bit_depth_luma_minus8).is_none() {
return false;
}
// SAFETY: the `DeviceHandles` contract exactly as `NativeVulkanDecoder::new` states
// it — these are the presenter's live instance/device, which outlive this call by
// construction (the presenter owns them for the whole process, and this runs on its
// thread while building the session's Hello). The decoder is dropped before return,
// so nothing outlives the borrow.
let dec = unsafe { pf_vkdecode::VkH265Decoder::new(&device_handles(vk), queue_lock_for(vk)) };
match dec {
Ok(d) => d
.probe_stream_support(chroma_format_idc, bit_depth_luma_minus8)
.is_ok(),
Err(_) => false,
}
}
/// [`pf_vkdecode::QueueLock`] over the device's shared [`crate::video::QueueLock`] —
/// or over nothing, when the decode queue provably has no other submitter (see the
/// module doc's queue-lock section).
@@ -934,21 +998,8 @@ impl NativeVulkanDecoder {
if !vk.video_decode {
bail!("presenter device lacks Vulkan Video decode");
}
let lock: Box<dyn pf_vkdecode::QueueLock> =
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
} else {
Box::new(NativeQueueLock::Uncontended)
};
let handles = DeviceHandles {
get_instance_proc_addr: vk.get_instance_proc_addr,
instance: vk.instance,
physical_device: vk.physical_device,
device: vk.device,
decode_qf: vk.decode_qf,
decode_queue_index: DECODE_QUEUE_INDEX,
graphics_qf: vk.graphics_qf,
};
let lock = queue_lock_for(vk);
let handles = device_handles(vk);
// The `DeviceHandles` caller contract, held for the decoder's whole lifetime
// and identical for both arms (it is the HANDLES' contract, not the codec's):
// the handles are the presenter's live instance/device, which outlives every
+53 -4
View File
@@ -246,17 +246,37 @@ const CELL_RAMP: [f64; 16] = [
-0.10, 0.08, -0.06, 0.12,
];
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
/// gain the same entry to keep the `ui_palette` key portable.
#[rustfmt::skip]
pub const PALETTES: [Palette; 12] = [
pub const PALETTES: [Palette; 13] = [
// --- dark fields (white ink) ---
Palette {
id: "violet", name: "Violet", stops: None,
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is pure
// black too: the calm mix on the form screens lifts toward nothing, so settings and
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
// luminance while keeping the backdrop a field with somewhere to go rather than a
// dead rectangle. The accent stays the brand violet — focus has to be findable on
// black.
// Named for the look, not the panel technology — black with a thin violet corona belongs
// beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored `ui_palette` value and
// the cross-client key, so renaming it would orphan saved choices and desync the clients.
id: "oled", name: "Eclipse",
stops: Some(&[
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
]),
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
},
Palette {
// Deep indigo climbing through violet into a hot magenta.
id: "nebula", name: "Nebula",
@@ -857,7 +877,7 @@ mod tests {
assert_eq!(
ids,
[
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
"bloom", "dawn", "mint", "opal",
]
);
@@ -867,7 +887,36 @@ mod tests {
.position(|p| p.light)
.expect("some are light");
assert!(PALETTES[first_light..].iter().all(|p| p.light));
assert_eq!(first_light, 6);
assert_eq!(first_light, 7);
}
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
/// under every other field's, and a ground that lifts to nothing on the form screens.
#[test]
fn oled_is_actually_black() {
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
let oled = palette("oled");
assert_eq!(
oled.ground,
(0.0, 0.0, 0.0),
"the calm lift must be nothing"
);
let cells = oled.mesh_colors();
assert!(
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
"the shaded corner has to be switched off, not dimmed"
);
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
let darkest_other = PALETTES
.iter()
.filter(|p| p.id != "oled")
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
.fold(f64::MAX, f64::min);
assert!(
mean < darkest_other / 2.0,
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
);
}
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
+158 -44
View File
@@ -258,11 +258,17 @@ impl SettingsScreen {
}
}
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
/// profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
/// explainer placeholder while there are none.
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
if self.tab != PROFILES_TAB {
return TABS[self.tab].1.to_vec();
return TABS[self.tab]
.1
.iter()
.copied()
.filter(|id| row_applies(*id, ctx.settings))
.collect();
}
if self.profiles.is_empty() {
vec![RowId::NoProfiles]
@@ -271,6 +277,16 @@ impl SettingsScreen {
}
}
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
/// take it away between frames while this screen is open.
fn clamp_cursor(&mut self, len: usize) {
if self.list.cursor >= len {
self.list.jump_to(len.saturating_sub(1));
}
}
#[cfg(test)]
pub(crate) fn tab_for_test(&self) -> usize {
self.tab
@@ -278,21 +294,22 @@ impl SettingsScreen {
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
/// value cycle), keeping each tab's own cursor.
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
let n = TABS.len() as i32;
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
}
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
/// a press on a pill names a tab outright rather than a direction to step in.
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
if tab >= TABS.len() {
return None;
}
self.tab_cursors[self.tab] = self.list.cursor;
self.tab = tab;
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
let len = self.row_ids().len();
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
// Video's follows whether the smoothness buffer is offered.
let len = self.row_ids(ctx).len();
self.list
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
Some(MenuPulse::Move)
@@ -302,10 +319,11 @@ impl SettingsScreen {
/// there is never meant for a row.
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
if let Some(tab) = self.strip.pointer(p) {
self.show_tab(tab);
self.show_tab(tab, ctx);
return true;
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.pointer(p, ids.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
@@ -325,11 +343,12 @@ impl SettingsScreen {
fx.pop();
return None;
}
MenuEvent::JumpBack => return self.switch_tab(-1),
MenuEvent::JumpForward => return self.switch_tab(1),
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
_ => {}
}
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let (msg, pulse) = self.list.menu(ev, ids.len());
self.apply_row(msg, pulse, &ids, ctx, fx)
}
@@ -344,8 +363,14 @@ impl SettingsScreen {
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
// A cursor with no row under it can only mean the list shrank between the clamp above
// and here, which nothing does today — but indexing on the assumption would turn that
// into a panic in a shipping console rather than a dropped keypress.
let Some(&focused) = ids.get(self.list.cursor) else {
return pulse;
};
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
match focused {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
@@ -378,7 +403,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
let changed = adjust(focused, delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -388,7 +413,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ids[self.list.cursor], 1, true, ctx) {
if adjust(focused, 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -397,8 +422,8 @@ impl SettingsScreen {
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids();
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
let ids = self.row_ids(ctx);
// The shoulders always change section, so that hint leads on every row.
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
hints.extend(match ids.get(self.list.cursor) {
@@ -445,7 +470,8 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let ids = self.row_ids();
let ids = self.row_ids(ctx);
self.clamp_cursor(ids.len());
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
@@ -466,6 +492,24 @@ impl SettingsScreen {
}
}
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
///
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
/// can see a line or two above them, so dimming them shows the relationship — dropping them
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
/// so the cursor is never under anything that moves.
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
match id {
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
}
}
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
_ => {}
}
let s = &ctx.settings;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
// Two rows follow a switch a line or two above them: echo cancellation only means
// anything while the mic streams, and the pad rows only while any controller is
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
// shells draw by greying a row out, and dimming (not dropping) is what shows the
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
// list instead now — see [`row_applies`] for why that one is different.
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
// is only reachable if another writer flipped the intent between the frame that built
// the list and the keypress that lands here — a boundary thud, not a stored value
// nothing will read.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
@@ -1093,9 +1139,6 @@ mod tests {
fake_home();
let mut s = SettingsScreen::with_profiles(Vec::new());
rendered(&mut s);
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids()[0], RowId::Resolution);
let first = s.list.row_rect(0).expect("the list drew its rows");
let (mut settings, pads) = ctx_parts();
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
@@ -1109,6 +1152,9 @@ mod tests {
device_name: "t",
t: 0.0,
};
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
// window: one field, one unambiguous effect to assert on.
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
let mut fx = Outbox::default();
assert!(!ctx.settings.match_window);
assert!(s.pointer(press(first), &mut ctx, &mut fx));
@@ -1232,13 +1278,12 @@ mod tests {
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
/// always done; this screen was the exception until its row list stopped being fixed.
#[test]
fn smoothness_buffer_follows_the_intent() {
fn smoothness_buffer_is_offered_only_under_smoothness() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
@@ -1251,24 +1296,93 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
let video = s.row_ids(&ctx);
assert!(
!video.contains(&RowId::SmoothBuffer),
"latency hides the buffer row: {video:?}"
);
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
// Even reached out of band it writes nothing — the list it came from is a frame old.
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
// Stepping the intent to Smoothness brings the row into the list, directly under it.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
let intent = video
.iter()
.position(|id| *id == RowId::PresentPriority)
.expect("the intent row");
assert_eq!(
video.get(intent + 1),
Some(&RowId::SmoothBuffer),
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
never has anything move out from under it"
);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
// The intent wraps back and the row leaves again — with the cursor parked on the
// intent row, which is where a user who just stepped it necessarily is.
s.list.cursor = intent;
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
let video = s.row_ids(&ctx);
assert!(!video.contains(&RowId::SmoothBuffer));
assert_eq!(
video.get(s.list.cursor),
Some(&RowId::PresentPriority),
"the cursor is still on the row the user was stepping"
);
}
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
/// than indexed with — the console must not panic because another writer changed the
/// presentation intent while its settings screen was open.
#[test]
fn a_shrinking_list_pulls_the_cursor_back() {
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
// seated with the SHRUNKEN list's intent, which is the state being tested.
fake_home();
let (mut settings, pads) = ctx_parts();
settings.present_priority = "latency".into();
settings.save();
settings.present_priority = "smooth".into();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = TABS
.iter()
.position(|(name, _)| *name == "Video")
.expect("the Video tab");
// Park on the last row while the buffer row is still there…
s.list.cursor = s.row_ids(&ctx).len() - 1;
let parked = s.list.cursor;
// …then take it away behind the screen's back, as a desktop shell would.
ctx.settings.present_priority = "latency".into();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(pulse.is_some(), "the press was routed, not dropped");
assert!(s.list.cursor < parked, "the cursor came back onto the list");
assert!(fx.nav.is_none());
}
#[test]
@@ -1392,7 +1506,7 @@ mod tests {
("p2".into(), "Game".into()),
]);
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
@@ -1438,7 +1552,7 @@ mod tests {
};
let mut s = SettingsScreen::with_profiles(Vec::new());
s.tab = PROFILES_TAB;
let ids = s.row_ids();
let ids = s.row_ids(&ctx);
assert_eq!(ids, vec![RowId::NoProfiles]);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert!(!spec.enabled);
+1 -1
View File
@@ -329,7 +329,7 @@ fn dump_console_screens() {
for _ in 0..5 {
s.handle_menu(MenuEvent::JumpForward);
}
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
s.settings.ui_palette = id.to_string();
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
}

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